본문으로 건너뛰기
Y

dsh-tool-archive

yuehancn/dsh-tool-archive

Pack a directory into a deterministic tar, read an archive's contents without extracting it, unpack while refusing every member that could reach outside the destination, and verify what came out against what went in by SHA-256. No external dependency — the tar format is written and read in this plugin. Extraction is deliberately more permissive on reading than on writing, because a reader's job is to report what an archive says, while a writer must never emit what it cannot read back. A tar has no central index, so the walk tracks whether it reached the end-of-archive marker: without that, a half-file checksums fine and reads as complete.

설치

dsh plugin --profile web add github:yuehancn/dsh-tool-archive

README

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\\shareabsolute
.. 组件(含埋在中段)../x、a/../../btraversal
归一化后为空.、./、././.empty
空组件a//bempty-component
含 NULa\0bnul
符号链接 / 硬链接flag 2 / 1拒绝,并报出目标
设备 / FIFOflag 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 读它拿不到有用的内容。结果:链接以零字节文件的形式进入归档,带着空字符串的哈希。

归档看起来完全健康,里面却有一个和磁盘上不是一回事的文件。这是静默数据丢失。

修复有两层:

  1. 不信任 stat 标志。readlink 独立查一次,任一个信号命中就跳过并报出目标。
  2. 列目录的大小和读出来的字节数必须一致。不一致说明文件在遍历途中变了、或者是链接解析到了别处 —— 打包出去会写一个「头部声明的大小和内容不符」的成员,每个读取器都会相信它,没有一个能发现它。所以直接丢弃并说明原因。

顺带修正了 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.mjs245八进制字段、ustar name/prefix 拆分、checksum、块遍历、截断检测、路径守卫、digest、清单 diff
test-integration.mjs233四个工具经真实 context、编译后的 schema 形状、注入 seam、打包/解包/验证往返、逃逸攻击
test-e2e.mjs120真目录真字节、二进制载荷、空文件、空目录、CJK 文件名、系统 GNU tar 互操作、恶意归档
regression-published.mjs38不改一字地从磁盘 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.相对路径解析基准
archiveDirarchive-output归档读写目录
extractDirarchive-extract解包根目录
excludenode_modules/.git/.DS_Store/Thumbs.db末段名跳过
maxFileBytes0单文件上限;0 = 不限
maxEntries20000超出会告警(不静默截断)
maxTotalBytes2 GiB解包总量上限,也是读入内存的上限
maxNameBytes1000成员名字节上限
hashtrue打包时算 SHA-256
noOverwritefalse解包时拒绝覆盖
status/pack/unpack/verifytrue分别开关四个工具

说清楚三件它不做的事

  • 不写 GNU long-name / PAX 扩展。 名字长到纯 ustar 放不下就报错。要打极深路径的包,这个插件不合适。
  • 不解符号链接和硬链接。 不是「暂不支持」,是刻意拒绝:链接的目标在本机是另一个意思,解出来就是把树的出口交给归档作者。
  • 不压缩。 输出是纯 .tar。压缩交给别的工具,这个插件的职责是把「哪些字节、按什么顺序、用什么名字」说准。

许可

MIT

관련 플러그인