DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

yuehancn /

yuehancn/dsh-tool-archive

Verified

DeepSeek Harness plugin

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@bcef4427

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 读它拿不到有用的内容。结果:链接以零字节文件的形式进入归档,带着空字符串的哈希。

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

修复有两层:

  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.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

—/ 5

No ratings yet

Verified DSH bundle

Commit bcef44271769

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout