一个三维模型能在预览器里打开,并不说明交付素材可以安全进入工程。3DGS 素材往往伴随图片、清单、缩略图和导出结果文件;当导入工具按清单里的相对路径逐项复制时,危险并不一定在模型的渲染阶段出现。有时只是一段 ../,就足以让“应该指向包内图片”的字段变成指向包外文件的路径。这篇只做导入前的清单引用校验,不写未经核实的重建 SDK 函数,也不把自定义 JSON 说成 Spatial Recon Kit 的官方文件格式。示例项目 SceneGate 是为 3DGS 素材接收环节设计的辅助工具,脚本运行在开发机的 Node.js 环境,HarmonyOS 应用端只是展示预检结果。它不替代解压工具、杀毒工具,也不能证明模型文件本身可信。本轮构造任务 SC-1817,清单共有 4 条资源引用;其中 3 条可以留在约定的素材根目录内,1 条 ../private/keys.txt 被拒绝。最终状态固定为 BLOCKED_PATH,操作按钮为“检查素材清单”。数字和状态只为说明脚本分支,绝不表示对真实 3DGS 资源包的检测报告。## 一、先看清导入工具究竟信任了什么接收流程通常可以拆成几个环节:用户选择来源包,导入器读取清单,查找对应资源,检查文件类型,然后才考虑后续重建或预览。如果把“清单里的字符串”当成可信文件路径,检查就从第二步失去边界。路径拼接 API 负责组装路径,不负责判断来源有没有权利越过根目录。例如根目录设置在 workspace/assets,清单写 photos/a01.jpg 是合理候选;写 ../private/keys.txt 就不能获得相同待遇。简单字符串 startsWith(root) 更不可靠:/work/assets_backup 也以 /work/assets 开头,却不是它的子目录。Windows 上又存在盘符、反斜杠和网络共享路径,同一份清单不能靠某个平台的偶然解析习惯来保证安全。Demo 采用一条故意收紧的工程约定:manifest 中的路径一律是 POSIX 风格的非空相对路径,分隔符只能是 /,不允许 .、..、空段、反斜杠、冒号、百分号或零字节。这样会拒绝部分原本可以规范化的合法写法,但提升了来源定位和日志审计的一致性。如果业务确实需要复杂路径格式,应该写出明确的解码、规范化及安全测试标准,而不是偷偷放宽判断。## 二、拒绝要发生在任何文件写入之前下面第一段代码放在 tools/verify-scene.mjs,解决“对一个清单路径求出允许的候选目标”的问题。Node.js path.resolve() 会得到规范化目标,path.relative() 用来判断目标是否离开根目录。两者都是 Node 自身的标准库方法,与 HarmonyOS 端侧接口没有混用。jsimport path from 'node:path';import { realpath, stat } from 'node:fs/promises';export function resolveAssetCandidate(rootDir, name) { if (typeof name !== 'string' || name.length === 0 || /[\\:%\0]/.test(name) || name.startsWith('/')) { return null; } const segments = name.split('/'); if (segments.some(part => part === '' || part === '.' || part === '..')) { return null; } const root = path.resolve(rootDir); const target = path.resolve(root, name); const rel = path.relative(root, target); if (!rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) return null; return target;}这里必须区分两层结论:resolveAssetCandidate() 返回路径,只能说明字符串规范和词法意义上的目录范围符合约定,并不能证明这个路径对应的文件存在,更不能排除符号链接把访问转到根目录之外。path.isAbsolute() 在判断里是辅助条件,不是单独的防穿越方案。任何前置的 URL 解码操作都需要在校验之前完成并设置唯一的解码次数,否则编码后的分隔符会制造另一条绕过路线;本例干脆拒绝 %。再强调一遍:本文不是教应用直接展开来源不明的 ZIP。压缩条目名、目录链接、文件类型和解压写入目标应该在解压器自身单独检查。这里只管清单 JSON 引用,不能把这个函数扩大宣传成“Zip Slip 已全部解决”。### 1. 文件存在也不意味着位于合法目录一个更难察觉的反例是:photos/link.jpg 看起来完全符合语法,但 photos 或文件本身可能是指向外部位置的符号链接。如果检查完字符串就读取文件,词法路径仍然可能被文件系统重定向。第二段在文件确实存在时,用 realpath() 取得实际路径,再核对实际根目录。生产工具还应在读取文件时处理竞争条件,尽可能采用安全文件句柄和受控目录方式。jsexport async function verifyAssetFile(rootDir, name) { const candidate = resolveAssetCandidate(rootDir, name); if (!candidate) return { ok: false, reason: 'BLOCKED_PATH' }; try { const realRoot = await realpath(rootDir); const realTarget = await realpath(candidate); const rel = path.relative(realRoot, realTarget); if (!rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) { return { ok: false, reason: 'BLOCKED_PATH' }; } const info = await stat(realTarget); if (!info.isFile()) return { ok: false, reason: 'NOT_FILE' }; return { ok: true, reason: 'ACCEPTED' }; } catch { return { ok: false, reason: 'MISSING_FILE' }; }}````stat()` 判断的是目标是否普通文件,避免把目录当成图片。对于需要保证“审核后到读取前未被替换”的高风险系统,仅靠 `realpath()` 也不够;这是时间检查和使用之间的竞争问题。本文的 Demo 是离线收件预检,不宣称具备抵抗恶意同时写入的完整沙箱能力。如果交付方拥有写入权限,应把检查和复制安排在受控、只读、隔离的工作目录里。![](https://i-blog.csdnimg.cn/direct/f82651eb0ba54d369c9d546f2c0d7bcb.png)## 三、让被拒绝的那一项可定位场景里的路径从何而来,也要能解释清楚。这份自定义清单只允许出现 `resources` 数组,每项含 `name` 和 `kind`。合法样例分别是 `photos/front.jpg`、`photos/left.jpg`、`models/preview.bin`;第四项写成 `../private/keys.txt`。假设前三个文件存在且都位于根目录内,函数会保留三项、拒绝一项,但整个批次仍不可开始实际导入。下面第三段把单文件验证与整批状态连接。它让用户看到的是“哪条引用被拒绝”,而不是含糊的“读取失败”。代码中的日志是按构造清单推导的演示输出,不能当成 IDE 真实运行截图。循环的错误信息里只保留相对名称,避免泄露开发机完整路径。jsexport async function auditSceneManifest(rootDir, manifest, taskId) { const refs = Array.isArray(manifest.resources) ? manifest.resources : []; let allowed = 0; const rejected = []; for (const entry of refs) { if (!entry || typeof entry.name !== ‘string’) { rejected.push(‘’); continue; } const r = await verifyAssetFile(rootDir, entry.name); if (r.ok) allowed++; else rejected.push(entry.name); } const state = rejected.length === 0 ? ‘READY_TO_IMPORT’ : ‘BLOCKED_PATH’; console.info([SceneGate] ${taskId} refs=${refs.length} allowed=${allowed} blocked=${rejected.length}); console.info([SceneGate] ${taskId} state=${state}); return { taskId, refs: refs.length, allowed, rejected, state };}```这里有一个刻意的保守取舍:只要 rejected.length 不为零,状态就停在 BLOCKED_PATH,即使其余三项都合法,也不自动导入部分素材。原因是 3DGS 预览或后处理可能依赖文件之间的完整对应关系;静默跳过一张照片会制造更难排查的缺口。生产版本还应验证 kind 白名单、资源扩展名、文件大小、条目数量、重复名称及内容摘要。若这些规则尚未配置好,就不要把“路径安全”包装成完整素材质量审查。## 四、用失败样例决定页面应该展示什么对任务 SC-1817,构造输入总数固定是 4,允许项数 3,拒绝项数 1。页面可以列出 ../private/keys.txt,但默认不提供任何可点击的文件路径。屏幕上展示“已拦截 1 项”“当前状态 BLOCKED_PATH”,并禁用“开始导入”,另设“检查素材清单”以查看原因。这里最重要的不是把错误标红,而是不能让操作员在看见错误时顺手跳过校验。日志中可以使用两行固定样例:[SceneGate] SC-1817 refs=4 allowed=3 blocked=1 和 [SceneGate] SC-1817 state=BLOCKED_PATH。为了让排查链路能被审核人重现,文件列表的排序应以清单次序为准,至少保留被拒条目的索引和理由,避免每次运行日志不一样。实际执行时,日志应该输出到本地受控文件;图片里的 HiLog 是演示排版,不意味着 Node 脚本运行在 HarmonyOS 模拟器进程中。可以把测试分成五组:正常的 photos/front.jpg、穿越上级的 ../private/keys.txt、含反斜杠的 photos\front.jpg、绝对路径 /tmp/a.jpg、被符号链接重定向的合法名字。还应该加入根目录空路径、URL 字符串、重复分隔符和不存在的文件。每个失败分支不仅断言状态码,还要检查“无实际导入副作用”,否则过滤只是表面上的成功。## 五、与真正的重建能力保持接口边界Spatial Recon Kit 和 ArkGraphics 3D 提供面向三维内容的能力,但本文没有调用这些能力,也没有规定其内部文件格式。清单结构、任务号、界面数字、示意资源名均由这个示例自己定义。它位于“外部素材进入工程”这个边界层,目标是让自动化流水线拒绝不可信的路径引用,不负责空间点云质量、模型加载兼容性或渲染性能。安全判断还必须承认文件系统的限制:一次检查通过不等于文件在之后的时间里不会变化。批量导入前,应重新校验已解压的实际条目,再复制到最终不可写目录;必要时结合摘要和数字签名建立来源可信链。用项目级 path.relative() 实现的校验,是值得保留的第一道门槛,而不是完成安全评估的最后一步。把拦截位置放在写入前,就能避免“已经导入了部分文件,再发现异常再回滚”的复杂状态。对开发工具而言,能把拒绝理由准确交给用户,比隐蔽地吞掉异常更可靠。例子中的 BLOCKED_PATH 正说明了这一点:四项里有一项不可信,整个导入动作就应该停住。资料核对: 华为 Spatial Recon Kit / 3DGS 场景能力入口(https://developer.huawei.com/consumer/cn/features/spatialization);Node.js Path 官方文档(https://nodejs.org/api/path.html);Node.js File System 官方文档(https://nodejs.org/api/fs.html)。本篇仅在开发机端展示 Node.js 清单校验设计,不表示真实鸿蒙设备重建、模型导入或 AppGallery 上架审查已经执行。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐