dsh-tool-archive
DeepSeek Harness 的归档工具箱:把目录打成确定性的 tar、不解包就能读清单、解包时逐个成员做路径校验、以及用 SHA-256 验证「解出来的东西 == 打进去的东西」。
零外部依赖 —— tar 格式在这个插件里自己写、自己读。
| 工具 | 做什么 |
|---|---|
archive_status |
报告写哪种 tar、拒绝哪些成员类型、路径要满足什么条件、输出去哪 |
archive_pack |
把目录打成 tar,返回每个文件的 SHA-256 清单 + 整树 digest |
archive_unpack |
列内容 / 解包;绝对路径、..、符号链接、硬链接、设备节点一律拒绝 |
archive_verify |
不解包就核对:给目录比目录,给 digest 比 digest |
为什么需要它
「解个包」是 shell 里的一行,也是模型能被要求做的最危险的操作之一。一个 tar 条目的名字可以是 ../../.ssh/authorized_keys、可以是绝对路径、可以是指向树外的符号链接、可以是指向别的文件的硬链接。任何一个都把「解包」变成「往这台机器的任何地方写」。
而 tar 没有中央索引:名字、大小、类型散落在与数据交错的 512 字节头里,所以「这个包里有什么」是一次遍历而不是一次查表 —— 这就是 archive_status 存在的原因,也是它能不落盘就回答的原因。
快速开始
- id: tool-archive
name: dsh-tool-archive
config:
workDir: C:/work/project
archiveDir: C:/work/project/archive-output
extractDir: C:/work/project/archive-extract
// 打包
await archive_pack({ source: "src" })
// → { file, bytes, files, directories, contentBytes, overheadPercent,
// format: "ustar", treeDigest, manifest: [...], skipped: [] }
// 不落盘先看看里面有什么
await archive_unpack({ file: "pack.tar", listOnly: true })
// 解包(危险成员会被拒;strict 默认 true,一旦有拒绝就整体不写)
await archive_unpack({ file: "pack.tar", dest: "run-1" })
// 验证
await archive_verify({ file: "pack.tar", against: "src" })
// → { ok: true, treeDigest, unchanged: 5, added: [], removed: [], changed: [] }
确定性:同一个树 → 同一个哈希
这是这个插件最容易被低估的部分,也是它和「随手 tar 一下」最大的区别。
目录遍历的顺序是文件系统给的,不是内容给的。readdir 这次把 b 排在 a 前面,下次可能反过来;uid/gid/mtime 取决于哪台机器、哪个时刻打的包;权限位取决于 umask。任何一项进入哈希,同一个树就会产出不同的归档,于是清单比对就失去了意义 —— 你无法区分「内容变了」和「只是换了台机器」。
所以打包时:
- 成员按路径排序(不是按名字,嵌套树也要稳定)
- uid / gid 固定为
0 - 文件模式固定
0644,目录固定0755 treeDigest= 对「类型 + 路径 + 大小 + 内容哈希」这一串做 SHA-256
结果是:同一份内容,无论在哪台机器、以什么顺序创建,digest 都一样,归档字节也逐字节相同。测试里直接断言了这一点:两个用相反顺序创建的目录,包里字节完全相同。
treeDigest 描述的是内容,不是打包过程。所以改一个文件的权限不会让 digest 变化 —— 刻意如此,否则它就没法用来跨机器比对。
路径校验:唯一的边界
所有到达文件系统的成员名字都要过 resolveWithin / resolveEntry,这是这个插件的安全边界。
拒绝条件:
| 情况 | 例子 | 判定 |
|---|---|---|
| 绝对路径 | /etc/passwd、C:/Windows/x、\\\\srv\\share |
absolute |
.. 组件(含埋在中段) |
../x、a/../../b |
traversal |
| 归一化后为空 | .、./、././. |
empty |
| 空组件 | a//b |
empty-component |
| 含 NUL | a\0b |
nul |
| 符号链接 / 硬链接 | flag 2 / 1 |
拒绝,并报出目标 |
| 设备 / FIFO | flag 3 / 4 / 6 |
拒绝,并报出类型 |
| 未知类型标记 | flag Z |
拒绝 |
| 头部 checksum 不符 | — | 拒绝(大小不可信) |
两条容易被忽略的实现细节:
一、输出路径只取最后一段。 path.resolve(dir, name) 会尊重绝对路径的第二参 —— resolve("/out", "C:/Windows/x.png") 真的返回 Windows 路径,写文件能逃出配置目录。所以路由分量在 resolve 看到之前就被丢干净,输入输出两侧都丢。
二、././. 的归一化要循环到不动点。 单次 replace(/^\.\//, "") 把 ././. 变成 ./.,再走一次才变成 .。少了这一步,././. 会通过空名检查并解析成解包根目录本身。
三、. / 开头的正常名字不能误伤。 ./a.txt、./././x.txt 归一化之后是安全的普通名字,必须放行 —— 很多正常工具产出的包就长这样。测试里把它和真正的逃逸攻击并列断言,钉住这个区分。
读取比写入宽容,是有意的
写出去的只有 ustar(POSIX.1-1988):512 字节块、6 位八进制 checksum、长路径拆进 name + prefix 两个字段、不发 GNU long-name / PAX 扩展。
读的时候接受三种方言:标准 ustar(magic 与 version 分开写)、缺 version 字节的、以及完全没有 magic 但按传统偏移排布的。不合规范的头部会记警告但继续读,因为「别人用老工具打的包」是正常输入。
但有一条不宽容:任何名字长到纯 ustar 头放不下就报错,绝不为了塞进去而截断或用扩展。截断一个名字等于把文件写到别的地方去。
三个真 bug(测试抓出来的)
1. 截断的归档读起来「没问题」
while (offset + BLOCK <= buffer.length) 在剩余字节不足一个完整块时直接退出循环。已经读到的成员个个 checksum 正确,于是循环安静结束、调用方被告知归档完好。
一个 3072 字节的包砍到 1027 字节:ok: true。半个文件报「成功」,比报错更糟 —— 报错会有人去看,成功不会。
修复是跟踪是否真的走到了归档结束标记。没走到就说明归档被截断了,报出「读到了 N 个成员后归档就没了,没有结束标记」;尾部剩下的不足 512 字节也一并说明。
测试在每一个块边界上切一刀,逐个断言:凡是被判为「已结束」的切口,它读到的最后一个成员必须是完整的;凡是没走到结束标记的,必须发出声音。恰好只有一个切口(trailer 的第一个零块)是合法的结束点 —— GNU tar 接受单个零块作为结束,所以那个切口产出的确实是一个正确的、只含前若干成员的包。
2. 符号链接被当成普通文件打进包里
Windows 上 lstat 对一个符号链接可能报成普通文件,而 readFile 读它拿不到有用的内容。结果:链接以零字节文件的形式进入归档,带着空字符串的哈希。
归档看起来完全健康,里面却有一个和磁盘上不是一回事的文件。这是静默数据丢失。
修复有两层:
- 不信任 stat 标志。
readlink独立查一次,任一个信号命中就跳过并报出目标。 - 列目录的大小和读出来的字节数必须一致。不一致说明文件在遍历途中变了、或者是链接解析到了别处 —— 打包出去会写一个「头部声明的大小和内容不符」的成员,每个读取器都会相信它,没有一个能发现它。所以直接丢弃并说明原因。
顺带修正了 contentBytes:它原本来自遍历,会把被丢弃的成员也算进「归档持有的内容」。改成从 manifest 求和。
3. 大小写冲突会静默吞掉一个文件
在大小写不敏感的文件系统(NTFS、HFS+、默认配置的 APFS)上,README.md 和 readme.md 是同一个文件。而一个在大小写敏感系统上打出来的包,可以合法地同时含有这两个名字 —— 在这里解包时会合成一个,后写的覆盖先写的,调用方拿到一棵少了一个文件的树,全程没有任何报错。
检测放在 planExtraction,因为这两个名字在归档里同时存在,也就是文件系统已经丢掉的信息还留着的地方。检出即告警,并说明会互相覆盖。
(顺带一提:一开始我把这个检测放在打包侧,它永远不可能触发 —— 因为本地 readdir 根本列不出两个只差大小写的名字。测试跑出来是「没命中」,那个失败是对的。)
--force-local:一个会伪装成「环境不支持」的坑
测试里用系统 GNU tar 独立验证产物,这是唯一的外部检查 —— 自己验自己一定会漏。但如果直接写:
execFileSync("tar", ["-tf", "C:/work/x.tar"])
GNU tar 会把 C: 当成远程主机去开 rsh 连接,报 Cannot connect to C: resolve failed。这个错误看起来像「本机没有 tar」,于是检查被安静跳过 —— 唯一的外部验证就这么没了。
必须加 --force-local。另外路径里的反斜杠对原生 Windows 二进制是转义字符,C:\…\out 传进去会变成 C\:… 并报 Cannot open,所以路径要先统一成正斜杠。
测试
node _test/run-all.mjs
636 条断言,4 个套件全绿。
| 套件 | 断言 | 覆盖 |
|---|---|---|
test-logic.mjs |
245 | 八进制字段、ustar name/prefix 拆分、checksum、块遍历、截断检测、路径守卫、digest、清单 diff |
test-integration.mjs |
233 | 四个工具经真实 context、编译后的 schema 形状、注入 seam、打包/解包/验证往返、逃逸攻击 |
test-e2e.mjs |
120 | 真目录真字节、二进制载荷、空文件、空目录、CJK 文件名、系统 GNU tar 互操作、恶意归档 |
regression-published.mjs |
38 | 不改一字地从磁盘 import 入口:package.json 元数据、真 schemastery 默认值、真 dsh-tools 编译 schema、register → execute 端到端 |
前三个套件把插件的 import 剥掉、用 new Function 重建,好让白盒断言能碰到内部函数 —— 但那样从来没有真正加载过这个包。第四个套件什么都不剥:import 磁盘上那份 lib/index.js,让真的 @deepseek-ai/dsh-tools 编译 schema,再真的注册、真的执行一次。它排在最后:如果它红了,说明内部逻辑是对的而产物不对。
夹具的 tar 由 harness 里一个独立、朴素、不支持 prefix 的写入器生成,不用被测代码。自己造夹具给自己读,会掩盖写入器本身的 bug;两者一致才有意义。
e2e 的恶意归档用文件系统断言而不是只看返回对象:被拒绝的成员必须没有留下任何痕迹。一个「报了拒绝但仍然写了文件」的实现,只断言对象是发现不了的。
配置
| 键 | 默认 | 说明 |
|---|---|---|
workDir |
. |
相对路径解析基准 |
archiveDir |
archive-output |
归档读写目录 |
extractDir |
archive-extract |
解包根目录 |
exclude |
node_modules/.git/.DS_Store/Thumbs.db |
末段名跳过 |
maxFileBytes |
0 |
单文件上限;0 = 不限 |
maxEntries |
20000 |
超出会告警(不静默截断) |
maxTotalBytes |
2 GiB |
解包总量上限,也是读入内存的上限 |
maxNameBytes |
1000 |
成员名字节上限 |
hash |
true |
打包时算 SHA-256 |
noOverwrite |
false |
解包时拒绝覆盖 |
status/pack/unpack/verify |
true |
分别开关四个工具 |
说清楚三件它不做的事
- 不写 GNU long-name / PAX 扩展。 名字长到纯 ustar 放不下就报错。要打极深路径的包,这个插件不合适。
- 不解符号链接和硬链接。 不是「暂不支持」,是刻意拒绝:链接的目标在本机是另一个意思,解出来就是把树的出口交给归档作者。
- 不压缩。 输出是纯
.tar。压缩交给别的工具,这个插件的职责是把「哪些字节、按什么顺序、用什么名字」说准。
许可
MIT
No comments yet. Be the first to write one.