HarmonyOS 7 fflate:离线模型包路径穿越拦截与原子回滚

发布时间:2026/10/7 8:05:14
HarmonyOS 7 fflate:离线模型包路径穿越拦截与原子回滚 一、导入成功之后模型目录里多了一份不该出现的文件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.gsbox12 个文件清单声明解压后 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.etsBundleImportPage只订阅快照并发送“选择、取消、重试”意图ZIP 回调、文件句柄和暂存目录都由导入器拥有。这样页面退到后台时资源释放的位置不会散在多个组件生命周期里。三、第一道门禁不是正则而是规范化后的相对路径最先补的是EntryPathGuard。仅仅拒绝字符串中的../不够反斜杠、重复分隔符、绝对路径、盘符前缀和空字节都可能绕过简单判断。解决问题的代码先统一分隔符再逐段折叠.遇到..时直接拒绝而不是尝试“算出最终路径”。// EntryPathGuard.etsexportclassEntryPathGuard{staticnormalize(rawName:string):string{constnamerawName.replaceAll(\\,/).trim()if(name.length0||name.startsWith(/)||name.includes(\u0000)){thrownewError(ZIP_PATH_INVALID)}if(/^[A-Za-z]:/.test(name)){thrownewError(ZIP_DRIVE_PREFIX)}constsafe:string[][]for(constpartofname.split(/)){if(part||part.)continueif(part..)thrownewError(ZIP_PATH_TRAVERSAL)safe.push(part)}if(safe.length0)thrownewError(ZIP_EMPTY_ENTRY)returnsafe.join(/)}}这里故意不接受任何“向上再回来”的路径即便数学上最后仍落在根目录。归档格式没有必要表达这种语义放宽只会增加审计成本。目录条目和文件条目共用同一规则页面显示的错误固定为ZIP_PATH_TRAVERSALHiLog 记录entry7 name../../preferences/tokens.json stateQUARANTINED。这段函数必须在创建文件句柄之前执行。若先openSync再检查失败分支就需要处理已经生成的空文件。重复导入同一恶意包时任务代次会增加但错误条目和暂存残留都应保持为 0这也是后来自动化验收里很有用的一条断言。四、配额要跟着产出字节走而不是相信清单manifest.json的 286.3 MB 只是预检参考不能作为安全事实。ZIP 中央目录里的原始大小同样来自输入。真正可靠的数字是解压回调交付给应用的chunk.length。第二段代码把条目流、全局配额和取消信号收进同一个租约任何文件越过单文件上限或总量越过 320 MB都会终止当前流并进入统一清理。// BundleImporter.ets核心片段import{Unzip,AsyncUnzipInflate,UnzipFile}fromfflateprivateopenArchive(task:ImportTask):Unzip{constunzipnewUnzip((entry:UnzipFile){constrelativeEntryPathGuard.normalize(entry.name)constleasethis.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)returnif(error)returnthis.fail(task,ZIP_CORRUPTED,error.message)this.quota.consume(relative,chunk.byteLength,320*1024*1024)lease.write(chunk)task.outputBytesthis.quota.totalBytesthis.emit(task,EXTRACTING)if(final){lease.close()this.activeStreams.delete(relative)this.finished.add(relative)}}entry.start()})unzip.register(AsyncUnzipInflate)returnunzip}cancel(task:ImportTask):void{task.cancelledtruethis.activeStreams.forEach(streamstream.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伪代码省略平台错误类型转换asynccommit(task:ImportTask,manifest:ModelManifest):Promisevoid{this.assertGeneration(task.generation)task.stateVERIFYINGconstreportawaitthis.verifier.verifyAll(task.tempDir,manifest)if(report.passed!12)thrownewError(HASH_MISMATCH)constversionDir${this.modelsRoot}/${manifest.version}-${task.generation}this.fs.renameSync(task.tempDir,versionDir)task.stateCOMMITTINGconstpointerTmp${this.modelsRoot}/current.json.tmpthis.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.stateREADY}版本目录先完成重命名指针文件再经tmp → current.json切换。旧目录不在关键路径中删除而是下一次冷启动确认当前指针可读后再回收。重复调用commit时如果current.json已指向同一个 taskId就直接返回READY这让进程死亡后的恢复不必猜上一次执行到了哪一步。哈希校验也必须覆盖清单本身之外的边界包里多出的文件算失败清单声明却未出现的文件也算失败。最终合法包报告是entries12/12 sha25612/12 extra0 missing0随后才进入提交。六、从一行异常日志反推完整状态调试时我没有只看页面百分比而是给每条日志附上 taskId、generation、state、entry 与累计字节。恶意包的关键日志如下20:18:07.114 ModelVault ZIP-2018 PREFLIGHT input82.4MB entries12 budget320MB 20:18:07.462 ModelVault ZIP-2018 EXTRACTING progress19% output61.7MB entry7 20:18:07.466 ModelVault ZIP-2018 QUARANTINED errorZIP_PATH_TRAVERSAL name../../preferences/tokens.json 20:18:07.493 ModelVault ZIP-2018 ROLLBACK tempResidual0 openHandles0合法包重试则是 generation 19output286.3MB peakMemory46.8MB验证 12/12通过后指针切换耗时 14 ms。日志里没有打印真实沙箱绝对路径也没有把清单内容整段输出避免诊断信息本身泄露用户目录。页面订阅的是不可变ImportSnapshot。退到后台时停止 100 ms 的 UI 合并计时器但解压任务是否继续由产品策略决定本 Demo 选择取消并清理因为模型导入没有后台完成承诺。aboutToDisappear只发取消意图真正的句柄关闭仍由导入器完成避免页面与服务重复释放。七、运行结果失败包隔离合法包一次提交最终手机页保留了两次运行记录。第一条是 generation 1819% 时命中ZIP_PATH_TRAVERSAL第 7 个条目被隔离临时残留 0。第二条仍使用任务号ZIP-2018但 generation 19286.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 适配层而不是退回主线程同步展开。无论底层换成什么库路径守卫、产出配额、代次隔离和原子指针四个约束都不应丢。回头看这次真正修掉的不是一个../而是“把解压成功误当成导入成功”的工程假设。只有当每个输入名被收束、每个产出字节被计量、每个回调绑定代次、每次提交都可重放离线模型包才算进入可维护的产品链路。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询