HarmonyOS 7 FilePicker:3DGS模型包路径穿越拦截与清单校验【鸿蒙心迹】
一、模型能打开,不等于这个 ZIP 值得信任
SplatDepot 是一个离线 3DGS 场景导入 Demo。现场工程师通过 DocumentViewPicker 选择 .gsbundle,应用解压 scene.ply、纹理、相机轨迹和 manifest.json,验证后交给渲染模块。第一版只做了“能否解压”和“有没有 manifest”两项检查,直到安全测试包里出现 ../shared/config.json,我们才意识到解压目录之外的文件可能被覆盖。
更麻烦的是,失败后 staging 目录还留下了已经写出的 126 MiB 数据。用户重新选择正常包,旧纹理被新 manifest 引用,画面居然能渲染,只是局部颜色错误。这个现象很像模型质量问题,实际上是导入事务没有边界。

本轮把任务定为 GSB-2240,输入文件 museum_west_v12.gsbundle。最终成功 attempt 为 2:包大小 184.6 MiB,37 个条目,解压后 612.8 MiB,manifest v3,哈希通过 37/37,当前 attempt 非法路径 0,上一失败 attempt 拦截 2 条并回收 1 个目录,最终状态 READY。
二、选择器返回的是访问入口,不是可信路径
页面通过 FilePicker 让用户选择一个模型包,随后立即复制到本任务私有 inbox。这样后续校验不再依赖外部 URI 的持续可读性,也不会在用户替换原文件时读到前后不一致的数据。工程目录如下:
entry/src/main/ets/
├── pages/BundleImportPage.ets
├── import/BundleGate.ets
├── import/ManifestVerifier.ets
├── storage/StagingArea.ets
└── model/BundleTask.ets
第一段代码处理选择与任务初始化。目标文件名从 URI 展示信息中取得,但最终落盘名由任务 ID 与 attempt 生成,绝不把外部文件名直接拼到沙箱路径中。
import { filePicker } from '@kit.CoreFileKit'
interface ImportTask {
id: string
attempt: number
sourceUri: string
inboxPath: string
}
async function chooseBundle(): Promise<ImportTask> {
const picker = new filePicker.DocumentViewPicker()
const result = await picker.select({
maxSelectNumber: 1,
fileSuffixFilters: ['模型包|.gsbundle']
})
if (result.length !== 1) throw new Error('BUNDLE_NOT_SELECTED')
const task: ImportTask = {
id: 'GSB-2240', attempt: 2, sourceUri: result[0],
inboxPath: `${getContext().filesDir}/inbox/GSB-2240-2.bundle`
}
await copyUriToPrivateFile(task.sourceUri, task.inboxPath)
return task
}
Picker 被取消是正常分支,页面应回到 IDLE,不能记成导入失败。复制阶段需要设置 184.6 MiB 的上限并检查剩余空间;仅凭扩展名筛选无法证明内容是 ZIP。页面退出时可以取消还未开始的复制,但已经打开的文件句柄必须在 finally 中关闭。重复点击导入按钮会创建两个 staging,因此状态进入 COPYING 后立刻禁用按钮。
三、路径检查必须发生在 file.start 之前
fflate 的流式 Unzip 会逐个给出条目。安全关键点不是写文件时再判断,而是在条目开始解压前规范化名字。绝对路径、反斜杠、空段、.、..、NUL 字符和盘符形式一律拒绝;规范化结果还要确认位于 staging 根目录之下。
function normalizeEntryName(raw: string): string {
if (raw.includes('\u0000') || raw.includes('\\') || raw.startsWith('/')) {
throw new Error('UNSAFE_ENTRY_PATH')
}
const parts = raw.split('/')
if (parts.some((part: string) => part === '' || part === '.' || part === '..')) {
throw new Error('UNSAFE_ENTRY_PATH')
}
if (/^[A-Za-z]:/.test(parts[0])) throw new Error('UNSAFE_ENTRY_PATH')
return parts.join('/')
}
function resolveInside(root: string, entry: string): string {
const safe = normalizeEntryName(entry)
const target = `${root}/${safe}`
if (!target.startsWith(`${root}/`)) throw new Error('PATH_ESCAPE')
return target
}
startsWith 只是最后一道防线,前面的段级检查才是主要规则。生产实现还使用规范化后的真实路径 API,避免符号链接和编码差异绕过字符串判断;staging 根目录不允许已有符号链接。目录条目与文件条目分别处理,单条目上限 256 MiB,总展开上限 700 MiB,压缩比异常也会终止,避免小包膨胀耗尽磁盘。
四、流式解压既要控内存,也要能整体失败
第二次 attempt 使用 fflate 的 Unzip,只有条目名称、安全预算和清单声明全部通过,才调用 file.start()。每个条目的数据分块写入私有 staging,同时累加实际展开字节与 SHA-256。任一条目失败,控制器停止接收后续数据,并把整个 attempt 标记为不可提交。
import { Unzip, UnzipInflate } from 'fflate'
async function extractToStaging(task: ImportTask, bytes: Uint8Array): Promise<void> {
const root = `${getContext().filesDir}/staging/${task.id}-${task.attempt}`
const gate = new BundleGate(700 * 1024 * 1024)
const unzip = new Unzip((entry) => {
const target = resolveInside(root, entry.name)
gate.acceptHeader(entry.name)
const sink = openStagingSink(target)
entry.ondata = (error, chunk, final) => {
if (error) throw error
gate.acceptBytes(entry.name, chunk.length)
sink.write(chunk)
if (final) sink.close()
}
entry.start()
})
unzip.register(UnzipInflate)
try {
unzip.push(bytes, true)
await gate.waitForAllEntries()
} catch (error) {
await removeDirectory(root)
throw error
}
}
示例为了突出回调结构使用了完整 Uint8Array,实际 184.6 MiB 文件按块读取并多次 push,最后一块才传 true;否则内存峰值仍然过高。entry.ondata 的异常不能只在回调里 throw 后遗忘,工程版会汇聚到 gate 的 Promise。写入过程中页面状态是 EXTRACTING,即使 37 个条目已经全部出现,也要等所有 sink 关闭后才能进入 VERIFYING。
五、manifest 是承诺,磁盘内容才是事实
manifest.json v3 列出 37 个相对路径、字节数与 SHA-256。验证器比较三件事:清单是否有重复路径;磁盘是否多出或缺少条目;每个文件的大小和哈希是否匹配。scene.ply 还要做最小头部检查,纹理维度与清单元数据相符后才能提交。
提交不是把 staging 留在那里直接用,而是将 GSB-2240-2 原子改名为版本目录 museum_west_v12,再更新一个很小的 active 指针。若指针更新失败,旧版本仍可用,新目录进入待清理队列。导入过程中渲染器始终读取旧 active,不会看到半套文件。

安全演练的 attempt 1 包含 ../shared/config.json 与 /data/local/tmp/debug.txt 两个非法路径,均在 entry.start() 前被拦截,staging 目录回收完成。用户重新选择清理后的包,attempt 2 的 37 个条目全部通过,哈希 37/37,展开 612.8 MiB,耗时 4.82 s,状态进入 READY。

手机页显示任务 GSB-2240、文件 museum_west_v12.gsbundle、Attempt 2、184.6 MiB、条目 37、展开 612.8 MiB、manifest v3、哈希 37/37、当前非法路径 0、历史拦截 2、回收目录 1、进度 100%、耗时 4.82 s 和 READY。按钮“加载 3DGS 场景”只有 READY 后可用,避免渲染线程提前占用 staging 文件。
六、失败目录的回收规则不能靠“下次启动再说”
SplatDepot 在 catch 中只删除当前任务的 staging,不清空整个模型库。删除失败时记录 cleanupPending,下次冷启动先扫描带任务前缀且没有 commit 标记的目录;超过 24 小时才回收,避免误删仍在提交的任务。已经成为 active 的版本永不由 staging 清理器处理。
页面进入后台时,如果仍在 COPY 或 EXTRACT,任务可以继续,但 UI 回调只更新持久化进度;页面销毁后不再写 @State。应用进程被终止后,staging 中没有 commit 标记的目录一律不能被渲染模块读取。再次进入页面时,要么从已校验的 checkpoint 继续,要么整体重来,不能根据“文件看起来都在”直接宣布成功。
七、边界做完整后,导入才是一项能力
路径穿越只是压缩包风险里最显眼的一种。实际产品还要限制条目数量、文件名长度、单文件大小、总展开量、压缩比、重复名字与大小写冲突;对 Unicode 归一化后同名的条目也应拒绝。哈希能证明内容与 manifest 一致,却不能证明模型可信,签名与来源授权要在更上一层解决。
这次复盘给我最大的提醒是:FilePicker 解决“用户选了什么”,fflate 解决“怎样展开”,manifest 解决“包内承诺了什么”,staging 事务解决“何时对业务可见”。四层混在一个 importBundle() 中,任何错误都会变成残留文件或半成功状态;拆开后,每层都有明确输入、失败原因、释放点和重复调用策略。对于体积远大于普通配置包的 3DGS 资产,这些边界不是附加安全项,而是导入功能本身。
更多推荐





所有评论(0)