一、导入成功之后,模型目录里多了一份不该出现的文件

ModelVault 原本只是内部验收用的 3D 场景包导入器:用户选择 room_v7.gsbox,应用解压模型、校验清单,再把目录切换为当前版本。测试包只有 82.4 MB,第一次接入 fflate 时十几秒就能完成,页面也顺利显示“可加载”。真正的问题出现在一次恶意包测试:ZIP 的第 7 个条目叫 ../../preferences/tokens.json。解压器没有报错,路径拼接也没有越界异常,文件却落到了暂存目录之外。

这不是“压缩库不安全”这么简单。fflate 0.8.3 负责解释 ZIP 和输出字节,目标路径、磁盘配额、事务边界都属于业务代码。直接把 entry.name 拼到沙箱目录,等于把归档文件的输入当成了文件系统指令。随后我又造了一个 8 MB 压缩、解开后超过 600 MB 的重复数据包,发现页面进度还停在 19%,磁盘空间已经被吃掉大半。两个问题放在一起,才确定导入不能只是“解压后算哈希”,而要做成一条有状态、有预算、失败可清理的提交链。

本次 Demo 固定任务号 ZIP-2018,输入包 room_v7.gsbox,12 个文件,清单声明解压后 286.3 MB,硬上限 320 MB。完整状态是 PREFLIGHT → EXTRACTING → VERIFYING → COMMITTING → READY,任一门禁失败都进入 QUARANTINED。这几个值同时出现在日志、页面和验收图中,避免调试时拿“差不多的测试包”解释结果。

二、先划清库的边界:流式输出不等于安全落盘

选 fflate 的原因很朴素:它是纯 JavaScript/TypeScript 库,支持 ZIP 流式解压,也能为具体文件流注册异步 Inflate。项目只引入 Unzip 与 AsyncUnzipInflate,没有把整包一次性展开到内存。82.4 MB 的输入按 256 KB 喂给解压器,峰值内存最终稳定在 46.8 MB。

但流式 API 只解决“何时拿到字节”。下面四件事必须由 BundleImporter 自己保证:条目名经过规范化后仍在暂存根目录内;累计产出不得超过 320 MB;落盘后的每个文件必须与 manifest.json 的 SHA-256 对上;校验完成前不能覆盖线上目录。只要其中一项模糊,流式处理反而会让半成品更早散落到磁盘。

项目结构也按这四个责任拆开,而不是把所有回调堆在页面里:

ModelVault/
├── entry/src/main/ets/pages/BundleImportPage.ets
├── entry/src/main/ets/importer/BundleImporter.ets
├── entry/src/main/ets/importer/EntryPathGuard.ets
├── entry/src/main/ets/importer/QuotaLedger.ets
├── entry/src/main/ets/importer/ManifestVerifier.ets
└── entry/src/main/ets/storage/AtomicModelStore.ets

BundleImportPage 只订阅快照并发送“选择、取消、重试”意图;ZIP 回调、文件句柄和暂存目录都由导入器拥有。这样页面退到后台时,资源释放的位置不会散在多个组件生命周期里。

三、第一道门禁不是正则,而是规范化后的相对路径

最先补的是 EntryPathGuard。仅仅拒绝字符串中的 ../ 不够:反斜杠、重复分隔符、绝对路径、盘符前缀和空字节都可能绕过简单判断。解决问题的代码先统一分隔符,再逐段折叠 .;遇到 .. 时直接拒绝,而不是尝试“算出最终路径”。

// EntryPathGuard.ets
export class EntryPathGuard {
  static normalize(rawName: string): string {
    const name = rawName.replaceAll('\\', '/').trim()
    if (name.length === 0 || name.startsWith('/') || name.includes('\u0000')) {
      throw new Error('ZIP_PATH_INVALID')
    }
    if (/^[A-Za-z]:/.test(name)) {
      throw new Error('ZIP_DRIVE_PREFIX')
    }

    const safe: string[] = []
    for (const part of name.split('/')) {
      if (part === '' || part === '.') continue
      if (part === '..') throw new Error('ZIP_PATH_TRAVERSAL')
      safe.push(part)
    }
    if (safe.length === 0) throw new Error('ZIP_EMPTY_ENTRY')
    return safe.join('/')
  }
}

这里故意不接受任何“向上再回来”的路径,即便数学上最后仍落在根目录。归档格式没有必要表达这种语义,放宽只会增加审计成本。目录条目和文件条目共用同一规则;页面显示的错误固定为 ZIP_PATH_TRAVERSAL,HiLog 记录 entry=7 name=../../preferences/tokens.json state=QUARANTINED。

这段函数必须在创建文件句柄之前执行。若先 openSync 再检查,失败分支就需要处理已经生成的空文件。重复导入同一恶意包时,任务代次会增加,但错误条目和暂存残留都应保持为 0;这也是后来自动化验收里很有用的一条断言。

四、配额要跟着产出字节走,而不是相信清单

manifest.json 的 286.3 MB 只是预检参考,不能作为安全事实。ZIP 中央目录里的原始大小同样来自输入。真正可靠的数字,是解压回调交付给应用的 chunk.length。第二段代码把条目流、全局配额和取消信号收进同一个租约;任何文件越过单文件上限,或总量越过 320 MB,都会终止当前流并进入统一清理。

// BundleImporter.ets(核心片段)
import { Unzip, AsyncUnzipInflate, UnzipFile } from 'fflate'

private openArchive(task: ImportTask): Unzip {
  const unzip = new Unzip((entry: UnzipFile) => {
    const relative = EntryPathGuard.normalize(entry.name)
    const lease = this.files.openTemp(relative, task.generation)
    this.activeStreams.set(relative, entry)

    entry.ondata = (error: Error | null, chunk: Uint8Array, final: boolean) => {
      if (task.cancelled || task.generation !== this.generation) return
      if (error) return this.fail(task, 'ZIP_CORRUPTED', error.message)

      this.quota.consume(relative, chunk.byteLength, 320 * 1024 * 1024)
      lease.write(chunk)
      task.outputBytes = this.quota.totalBytes
      this.emit(task, 'EXTRACTING')

      if (final) {
        lease.close()
        this.activeStreams.delete(relative)
        this.finished.add(relative)
      }
    }
    entry.start()
  })
  unzip.register(AsyncUnzipInflate)
  return unzip
}

cancel(task: ImportTask): void {
  task.cancelled = true
  this.activeStreams.forEach(stream => stream.terminate())
  this.activeStreams.clear()
  this.files.rollbackTemp(task.generation)
}

配额台账先累加再写入,越界的那一块不会落盘。consume 内部还维护每个条目的 160 MB 上限,防止一个文件独占全部预算。回调不能直接修改 ArkUI 组件;导入器每 100 ms 合并一次快照,页面进度才不会随着数百个数据块频繁刷新。

取消也不是把按钮置灰。terminate() 后仍要关闭已打开的句柄、删除代次对应的临时目录,并拒绝旧回调更新新任务。任务 ZIP-2018 的 generation 是 18,重试后 generation 变成 19;任何携带 18 的回调都只做资源收口,不再写状态。

五、校验通过也不能原地覆盖

恶意包被拦住后,第二个合法包完成了 12/12 条目解压,但第一次提交仍暴露了问题:代码先删除旧模型目录,再重命名暂存目录。两步之间如果应用被杀,用户既没有旧版本,也没有新版本。最终方案把版本目录当成不可变对象,只原子替换一个很小的 current.json 指针。

// AtomicModelStore.ets(伪代码省略平台错误类型转换)
async commit(task: ImportTask, manifest: ModelManifest): Promise<void> {
  this.assertGeneration(task.generation)
  task.state = 'VERIFYING'
  const report = await this.verifier.verifyAll(task.tempDir, manifest)
  if (report.passed !== 12) throw new Error('HASH_MISMATCH')

  const versionDir = `${this.modelsRoot}/${manifest.version}-${task.generation}`
  this.fs.renameSync(task.tempDir, versionDir)

  task.state = 'COMMITTING'
  const pointerTmp = `${this.modelsRoot}/current.json.tmp`
  this.fs.writeTextSync(pointerTmp, JSON.stringify({
    version: manifest.version,
    path: versionDir,
    taskId: task.id
  }))
  this.fs.fsyncSync(pointerTmp)
  this.fs.renameSync(pointerTmp, `${this.modelsRoot}/current.json`)
  task.state = 'READY'
}

版本目录先完成重命名,指针文件再经 tmp → current.json 切换。旧目录不在关键路径中删除,而是下一次冷启动确认当前指针可读后再回收。重复调用 commit 时,如果 current.json 已指向同一个 taskId,就直接返回 READY;这让进程死亡后的恢复不必猜上一次执行到了哪一步。

哈希校验也必须覆盖清单本身之外的边界:包里多出的文件算失败,清单声明却未出现的文件也算失败。最终合法包报告是 entries=12/12 sha256=12/12 extra=0 missing=0,随后才进入提交。

六、从一行异常日志反推完整状态

调试时我没有只看页面百分比,而是给每条日志附上 taskId、generation、state、entry 与累计字节。恶意包的关键日志如下:

20:18:07.114  ModelVault  ZIP-2018  PREFLIGHT   input=82.4MB entries=12 budget=320MB
20:18:07.462  ModelVault  ZIP-2018  EXTRACTING progress=19% output=61.7MB entry=7
20:18:07.466  ModelVault  ZIP-2018  QUARANTINED error=ZIP_PATH_TRAVERSAL name=../../preferences/tokens.json
20:18:07.493  ModelVault  ZIP-2018  ROLLBACK    tempResidual=0 openHandles=0

合法包重试则是 generation 19:output=286.3MB peakMemory=46.8MB,验证 12/12,通过后指针切换耗时 14 ms。日志里没有打印真实沙箱绝对路径,也没有把清单内容整段输出,避免诊断信息本身泄露用户目录。

页面订阅的是不可变 ImportSnapshot。退到后台时停止 100 ms 的 UI 合并计时器,但解压任务是否继续由产品策略决定;本 Demo 选择取消并清理,因为模型导入没有后台完成承诺。aboutToDisappear 只发取消意图,真正的句柄关闭仍由导入器完成,避免页面与服务重复释放。

七、运行结果:失败包隔离,合法包一次提交

最终手机页保留了两次运行记录。第一条是 generation 18:19% 时命中 ZIP_PATH_TRAVERSAL,第 7 个条目被隔离,临时残留 0。第二条仍使用任务号 ZIP-2018,但 generation 19:286.3 MB 解压完成、SHA-256 12/12、额外文件 0、缺失文件 0,状态 READY。峰值内存 46.8 MB,没有因为输入包 82.4 MB 就把整包和全部输出同时留在内存。

这个结果比“能打开模型”更重要:异常包没有写出根目录,超额包会在产出字节越界的那一刻停止,合法包只有在完整校验后才切换指针。按钮“取消导入”在 VERIFYING 和 COMMITTING 阶段会被禁用,其中提交阶段只有 14 ms;这样既不给用户虚假的可取消承诺,也不会制造半提交状态。

八、这条导入链的边界

这套实现并不把 ZIP 当成可信分发协议。包的来源校验、签名和密钥轮换仍应在更外层完成;本文的 SHA-256 只证明内容与当前清单一致,不证明清单来自谁。320 MB 也不是通用答案,它必须小于应用沙箱可用空间,并为旧版本、临时文件和系统波动留余量。

另一个边界是 fflate 的异步能力依赖执行环境。项目上线前要在目标 HarmonyOS 设备上验证 Worker/线程行为;如果构建链或运行时不支持某个异步实现,应把解压移入 TaskPool/Worker 适配层,而不是退回主线程同步展开。无论底层换成什么库,路径守卫、产出配额、代次隔离和原子指针四个约束都不应丢。

回头看,这次真正修掉的不是一个 ../,而是“把解压成功误当成导入成功”的工程假设。只有当每个输入名被收束、每个产出字节被计量、每个回调绑定代次、每次提交都可重放,离线模型包才算进入可维护的产品链路。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐