@geoly-ai/skills-hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin/skills-hub.mjs +26 -0
  4. package/package.json +44 -0
  5. package/src/adapters/index.mjs +832 -0
  6. package/src/artifact.mjs +376 -0
  7. package/src/atomic-fs.mjs +166 -0
  8. package/src/attestation.mjs +136 -0
  9. package/src/canonical-json.mjs +147 -0
  10. package/src/cli.mjs +208 -0
  11. package/src/commands/check.mjs +295 -0
  12. package/src/commands/context.mjs +235 -0
  13. package/src/commands/install.mjs +430 -0
  14. package/src/commands/locks.mjs +197 -0
  15. package/src/commands/output.mjs +127 -0
  16. package/src/commands/query.mjs +266 -0
  17. package/src/commands/recover.mjs +438 -0
  18. package/src/commands/registry.mjs +123 -0
  19. package/src/commands/resolve.mjs +171 -0
  20. package/src/commands/snapshot-access.mjs +91 -0
  21. package/src/commands/sync-lock.mjs +189 -0
  22. package/src/crc32c.mjs +27 -0
  23. package/src/exit-codes.mjs +265 -0
  24. package/src/fault-inject.mjs +379 -0
  25. package/src/install.mjs +732 -0
  26. package/src/journal.mjs +435 -0
  27. package/src/ledger.mjs +671 -0
  28. package/src/lock.mjs +98 -0
  29. package/src/lockfile.mjs +0 -0
  30. package/src/pack.mjs +792 -0
  31. package/src/packer.mjs +351 -0
  32. package/src/plan.mjs +519 -0
  33. package/src/recover.mjs +1345 -0
  34. package/src/safe-fs.mjs +252 -0
  35. package/src/sigstore.mjs +480 -0
  36. package/src/snapshot.mjs +528 -0
  37. package/src/stats.mjs +59 -0
  38. package/src/target.mjs +738 -0
  39. package/src/telemetry.mjs +393 -0
  40. package/src/tree-digest.mjs +103 -0
  41. package/src/trust-roots/README.md +31 -0
  42. package/src/trust-roots/sigstore-public-good.json +126 -0
  43. package/src/trust.mjs +563 -0
  44. package/src/untar.mjs +570 -0
  45. package/src/upload.mjs +268 -0
  46. package/src/vendor.mjs +465 -0
package/src/untar.mjs ADDED
@@ -0,0 +1,570 @@
1
+ // 受限 tar.gz 解析器 —— 规范:01-artifacts.md §4(路径 grammar)、§5(载荷规则与上限)、
2
+ // §6.2(摘要不覆盖的东西必须在别处消灭)、02-registry.md §4.1(canonical tar.gz)、
3
+ // 04-install.md §7(解包策略层)。
4
+ //
5
+ // 🔴 这不是一个 tar reader,是一个 **canonical 形状识别器**。
6
+ //
7
+ // 通用 tar 库的默认行为恰恰是我们要拒绝的那些:跟随 PAX longname、忽略未知
8
+ // typeflag、按 mtime 还原时间、把 `../` 交给调用方去防。本模块反过来做:
9
+ // 只接受「ustar 普通文件条目、uid/gid/mtime/dev 全 0、uname/gname 空、
10
+ // mode ∈ {0644,0755}、路径符合 §4」的条目,**其余一律拒绝,不做任何兼容**。
11
+ // 白名单比黑名单短得多,也就没有「忘了拒绝某个 typeflag」这一类洞。
12
+ //
13
+ // 🔴 **本模块不落盘**:返回内存里的条目,由 artifact.mjs 写到隔离临时目录。
14
+ // 解析器没有任何写盘能力 —— 即使路径判定漏了一项,它也写不出去。
15
+ //
16
+ // ⚠️ 与规范的分歧:04-install.md §7 要求「不自己写 tar reader,用成熟库 + 策略层」。
17
+ // 本实现选择了自写受限解析器(零运行时依赖)。取舍与理由见交付汇报,
18
+ // 需要上层拍板;§7 同时给了退路:「若没有库满足,则需在库之外先做一遍原始 tar 头扫描」,
19
+ // 本模块就是那一遍扫描的完整版。
20
+ import { InflateRaw, deflateRawSync, constants as zlibConstants } from 'node:zlib';
21
+ import { parseSafeRelPath } from './safe-fs.mjs';
22
+
23
+ // ── 上限(01-artifacts.md §5) ──────────────────────────────────────────────
24
+ export const MAX_FILE_BYTES = 2 * 1024 * 1024; // 单文件 2 MiB
25
+ export const MAX_TOTAL_BYTES = 16 * 1024 * 1024; // 解压后总计 16 MiB
26
+ export const MAX_FILES = 2000;
27
+ export const MAX_DEPTH = 12;
28
+ export const MAX_RATIO = 200; // 压缩比 200:1
29
+ // 🔴 canonical deflate 参数(02-registry.md §4.1 的 level 9)。全部显式钉死:
30
+ // windowBits/memLevel/strategy 只要有一个走默认,换个 Node 就可能算出别的长度。
31
+ const CANON_DEFLATE = Object.freeze({
32
+ level: 9, windowBits: 15, memLevel: 8, strategy: zlibConstants.Z_DEFAULT_STRATEGY,
33
+ });
34
+ // gzip 定长封套:10 字节头 + 8 字节尾
35
+ const GZIP_ENVELOPE = 18;
36
+ // body 相对 canonical 重压缩结果允许的上浮。**不是** 逐字节相等:
37
+ // zlib 的输出跨版本/跨架构不保证一致(本机 Node 22 / 24 实测逐字节一致,但那不构成
38
+ // 跨平台保证),逐字节比会把「换台机器装不上」变成常态。
39
+ //
40
+ // ⚠️ **它证明的是「body 没有明显膨胀」,不是「body 一定是 level 9」。**
41
+ // Codex 第四轮的反例:对**不可压缩**内容,level 0 与 level 9 的体积差远小于 10%
42
+ // (实测 raw 206336 → level0 206366 / level9 205007),伪造 XFL=2 就能过这一关。
43
+ // 别把这个常量当成 level 9 的证明去依赖 —— 真正靠它挡住的是
44
+ // stored-block 填充与明显的 level 降级。压缩比的安全性不依赖它,
45
+ // 那由 canonical 分母单独保证。
46
+ const CANON_SLACK = (n) => Math.floor(n * 1.1) + 64;
47
+ const BLOCK = 512;
48
+ const USTAR_NAME = 100;
49
+ const USTAR_PREFIX = 155;
50
+ // tar 本身的块开销:每条 512 头 + 至多 511 填充,再加两个结束块
51
+ const MAX_TAR_BYTES = MAX_TOTAL_BYTES + MAX_FILES * BLOCK * 2 + 4 * BLOCK;
52
+ // 🔴 输入侧也要有上限。`maxOutputLength` 只挡住**输出**:攻击者可以送一个几十 MB、
53
+ // 几乎不产生任何输出的 body(几百万个零输出 stored block),让我们先完整 inflate
54
+ // 一遍、再重压缩一遍,全程持有 buf/out/canonBody 三份内存。Codex 第四轮实测:
55
+ // 50 MB 输入 60 ms —— 单发不致命,但它是纯粹的白送,且并发时按输入大小线性放大。
56
+ // 合法 gzip 至多比它的解压结果大一丁点(deflate stored 每 65535 字节多 5 字节,
57
+ // 实测 18 MB 不可压缩内容膨胀 0.031%),所以 1% + 1 KiB 的余量足够宽。
58
+ const MAX_GZ_BYTES = Math.ceil(MAX_TAR_BYTES * 1.01) + 1024;
59
+
60
+ export class TarViolation extends Error {
61
+ constructor(violation, msg, where) {
62
+ super(`[${violation}] ${msg}${where ? `(${where})` : ''}`);
63
+ this.name = 'TarViolation';
64
+ this.violation = violation;
65
+ this.where = where;
66
+ this.code = 2; // 完整性失败
67
+ }
68
+ }
69
+ const viol = (v, m, w) => { throw new TarViolation(v, m, w); };
70
+
71
+ /**
72
+ * 🔴 **攻击者控制的字节进诊断消息之前必须先转义。**
73
+ *
74
+ * 路径判定是分层的:charset(只允许 `[A-Za-z0-9._-]`)在 `..`/绝对路径/反斜杠/
75
+ * 空 segment/深度这几项**之后**才跑,所以那几条消息拿到的 `path` 还是原始字节 ——
76
+ * 里面可以塞 ANSI 转义序列(实测 `\x1b]0;…\x07` 能改终端标题、`\x1b[2J` 能清屏),
77
+ * 而这些消息会被打进终端和日志。用 `JSON.stringify` 把控制字符转成 `\uXXXX`。
78
+ *
79
+ * 不靠「把 charset 检查提前」来解决:那只是让当前这一版恰好安全,
80
+ * 下次有人加一条在 charset 之前的判定又会漏。**在出口处统一转义**才是不变量。
81
+ */
82
+ const q = (x) => JSON.stringify(String(x));
83
+
84
+ // ── gzip ────────────────────────────────────────────────────────────────────
85
+
86
+ const CRC_TABLE = (() => {
87
+ const t = new Uint32Array(256);
88
+ for (let i = 0; i < 256; i++) {
89
+ let c = i;
90
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
91
+ t[i] = c >>> 0;
92
+ }
93
+ return t;
94
+ })();
95
+
96
+ export function crc32(buf) {
97
+ let c = 0xffffffff;
98
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
99
+ return (c ^ 0xffffffff) >>> 0;
100
+ }
101
+
102
+ /**
103
+ * canonical gzip 解封(02-registry.md §4.1:`mtime=0`、`OS=255`、level 9、无 `FNAME`/`FCOMMENT`)。
104
+ *
105
+ * 🔴 FLG 必须**整字节为 0**:不只拒 FNAME/FCOMMENT,连 FEXTRA / FHCRC / FTEXT /
106
+ * 保留位一并拒。它们都是「规范没定义的组合」,按 11-wire-contract.md §7 一律拒绝。
107
+ */
108
+ export function gunzipCanonical(gz) {
109
+ const buf = Buffer.isBuffer(gz) ? gz : Buffer.from(gz);
110
+ if (buf.length < 18) viol('E_TRUNCATED', `gzip 只有 ${buf.length} 字节,装不下头+尾`);
111
+ // 最先判、最便宜的一道闸:还没做任何解压就先把超大输入挡在外面
112
+ if (buf.length > MAX_GZ_BYTES) {
113
+ viol('E_GZIP_SIZE',
114
+ `gzip 输入 ${buf.length} 字节,超过 ${MAX_GZ_BYTES} 上限`
115
+ + `(解压后至多 ${MAX_TAR_BYTES} 字节,合法压缩流不可能比它大这么多)`);
116
+ }
117
+ if (buf[0] !== 0x1f || buf[1] !== 0x8b) viol('E_GZIP_HEADER', 'gzip magic 不是 1f 8b');
118
+ if (buf[2] !== 8) viol('E_GZIP_HEADER', `CM=${buf[2]},只接受 8(deflate)`);
119
+ if (buf[3] !== 0) {
120
+ const names = ['FTEXT', 'FHCRC', 'FEXTRA', 'FNAME', 'FCOMMENT', 'RES1', 'RES2', 'RES3'];
121
+ const set = names.filter((_, i) => buf[3] & (1 << i));
122
+ viol('E_GZIP_HEADER', `FLG 必须为 0,出现 ${set.join('/')}`);
123
+ }
124
+ const mtime = buf.readUInt32LE(4);
125
+ if (mtime !== 0) viol('E_GZIP_HEADER', `gzip MTIME 必须是 0,得到 ${mtime}`);
126
+ // XFL:canonical 要求 level 9,zlib 在 level 9 时写 XFL=2。
127
+ // ⚠️ XFL 只是**提示位**,攻击者可以随手填 2 而 body 其实是 level 0 —— Codex 第三轮实测:
128
+ // level 0 的 body 2053 字节、level 9 只要 72 字节,改个 XFL 就照单全收。
129
+ // 所以它只是便宜的前置筛子,真正证明 level 9 的是下面的 canonical 重压缩比对。
130
+ if (buf[8] !== 2) viol('E_GZIP_HEADER', `gzip XFL 必须是 2(level 9 的取值),得到 ${buf[8]}`);
131
+ if (buf[9] !== 255) viol('E_GZIP_HEADER', `gzip OS 必须是 255(unknown),得到 ${buf[9]}`);
132
+
133
+ const body = buf.subarray(10, buf.length - 8);
134
+ const wantCrc = buf.readUInt32LE(buf.length - 8);
135
+ const wantIsize = buf.readUInt32LE(buf.length - 4);
136
+
137
+ // 🔴 必须知道 deflate 流**实际消耗了多少输入字节**。
138
+ //
139
+ // `inflateRawSync` 会**静默忽略**流结束之后的尾随字节,`gunzipSync` 也会忽略
140
+ // member 之后的垃圾。Codex 第二轮实测:在一个 2 MiB 全零的合法归档后面追加
141
+ // 16 KB 垃圾 + 一份拷贝的 8 字节尾,CRC 与 ISIZE 都对得上,于是
142
+ // ① 夹带的 16 KB 被完全接受;
143
+ // ② 分母被垫大,**压缩比从 988:1 掉到 113:1,直接绕过 200:1 的解压炸弹上限**。
144
+ // 所以改用流对象拿 `bytesWritten`(已消耗的输入长度),要求它**恰好等于 body 长度**。
145
+ const engine = new InflateRaw({ maxOutputLength: MAX_TAR_BYTES });
146
+ if (typeof engine._processChunk !== 'function') {
147
+ // 拿不到「消耗了多少输入」就无法证明没有夹带 → fail-closed,不降级
148
+ viol('E_GZIP_ENGINE', '本 Node 的 zlib 无法报告已消耗的输入长度,拒绝解包');
149
+ }
150
+ let out;
151
+ try {
152
+ out = engine._processChunk(body, zlibConstants.Z_FINISH);
153
+ } catch (e) {
154
+ if (/BUFFER_TOO_LARGE|maxOutputLength/i.test(String(e.code) + e.message)) {
155
+ viol('E_TOTAL_SIZE', `解压超过 ${MAX_TAR_BYTES} 字节上限(解压炸弹)`);
156
+ }
157
+ viol('E_GZIP_DEFLATE', `deflate 流损坏:${e.message}`);
158
+ } finally {
159
+ try { engine.close(); } catch { /* 已经关了 */ }
160
+ }
161
+ const consumed = engine.bytesWritten;
162
+ if (consumed !== body.length) {
163
+ const rest = body.subarray(consumed);
164
+ // 多 member:紧跟着的是 8 字节 member 尾 + 下一个 gzip 头
165
+ if (rest.length >= 10 && rest[8] === 0x1f && rest[9] === 0x8b) {
166
+ viol('E_GZIP_MULTIMEMBER', `gzip 含多个 member,只接受一个(第一个 member 之后还有 ${rest.length} 字节)`);
167
+ }
168
+ viol('E_GZIP_TRAILER', `deflate 流之后还有 ${rest.length} 字节尾随数据(夹带,且会垫大压缩比的分母)`);
169
+ }
170
+
171
+ // 🔴 CRC / ISIZE 先验:它们是 O(n) 的便宜校验,而下面的 canonical 重压缩最坏要
172
+ // 350 ms。先证明这份数据没坏,再为它花那笔 CPU —— 损坏输入不该付重压缩的钱。
173
+ if (crc32(out) !== wantCrc) viol('E_GZIP_CRC', 'gzip CRC32 不符');
174
+ if ((out.length >>> 0) !== wantIsize) viol('E_GZIP_ISIZE', `gzip ISIZE 不符(${out.length >>> 0} vs ${wantIsize})`);
175
+
176
+ // 🔴🔴 分母还能在**流内部**被垫大 —— 上一轮只堵住了流外部。
177
+ //
178
+ // Codex 第三轮实测的绕过:deflate 允许 `00 00 00 ff ff`(BFINAL=0、BTYPE=stored、
179
+ // LEN=0)这种**零输出的空块**。往合法流前面插 1675 个,这些字节
180
+ // ① 属于同一条合法 deflate 流,`bytesWritten` 照样等于 body.length,
181
+ // 上一轮的 E_GZIP_TRAILER 完全看不见它们;
182
+ // ② 把 gzip 文件从 10,495 字节前的规模垫到 10,495 字节,
183
+ // **压缩比从 989.9:1 压到 199.97:1,正好蹭过 200:1**。
184
+ //
185
+ // 结论:**任何由攻击者提供的长度都不能当分母。** 分母必须是「这份内容最少能压到
186
+ // 多少字节」——也就是我们自己按 canonical 参数重压一遍的结果。攻击者控制不了它。
187
+ let canonBody;
188
+ try {
189
+ canonBody = deflateRawSync(out, CANON_DEFLATE);
190
+ } catch (e) {
191
+ // 拿不到 canonical 基准就证明不了分母没被垫大 → fail-closed,不退回用文件自称的长度。
192
+ // 退回等于把刚堵上的绕过重新打开,而且只在压缩器出问题时才打开 —— 最难发现的那种。
193
+ viol('E_GZIP_ENGINE', `无法按 canonical 参数重压缩以核算压缩比,拒绝解包:${e.message}`);
194
+ }
195
+ const canonGzLen = canonBody.length + GZIP_ENVELOPE;
196
+ // 取 min:投稿方若用了比 zlib 更强的压缩器(zopfli 等),真实文件可能比我们重压的还小,
197
+ // 那就用它的真实长度,不给它凭空放宽。
198
+ const denom = Math.min(buf.length, canonGzLen);
199
+ if (denom > 0 && out.length / denom > MAX_RATIO) {
200
+ viol('E_RATIO',
201
+ `压缩比 ${(out.length / denom).toFixed(1)}:1 超过 ${MAX_RATIO}:1`
202
+ + `(按 canonical 重压缩后的 ${denom} 字节算;文件自称 ${buf.length} 字节)`);
203
+ }
204
+
205
+ // 🔴 body 不得明显大于 canonical 重压缩的结果。这是 stored-block 填充的第二道闸,
206
+ // 也挡得住明显的 level 降级;但它**不是 level 9 的证明**(见 CANON_SLACK 的注释:
207
+ // 不可压缩内容上 level 0 与 level 9 差不到 10%,伪造 XFL 能过)。
208
+ if (body.length > CANON_SLACK(canonBody.length)) {
209
+ viol('E_GZIP_NONCANONICAL',
210
+ `deflate body ${body.length} 字节,而按 canonical level 9 重压只需 ${canonBody.length} 字节`
211
+ + `(上限 ${CANON_SLACK(canonBody.length)})。多半塞了零输出的空块,或压缩等级明显低于 9`);
212
+ }
213
+ return out;
214
+ }
215
+
216
+ // ── tar 头部字段 ────────────────────────────────────────────────────────────
217
+
218
+ const isZero = (b) => { for (let i = 0; i < b.length; i++) if (b[i] !== 0) return false; return true; };
219
+
220
+ /** NUL 结尾字符串;🔴 NUL 之后必须全零,否则是夹带(Codex 评审提出) */
221
+ function cstrField(blk, off, len, name, idx) {
222
+ const b = blk.subarray(off, off + len);
223
+ const z = b.indexOf(0);
224
+ if (z === -1) return b.toString('latin1'); // 满字段无终止符,ustar 允许
225
+ for (let i = z; i < b.length; i++) {
226
+ if (b[i] !== 0) viol('E_FIELD_PAD', `${name} 字段 NUL 之后有非零字节(夹带)`, `entry #${idx}`);
227
+ }
228
+ return b.subarray(0, z).toString('latin1');
229
+ }
230
+
231
+ function assertZeroField(blk, off, len, name, code, idx) {
232
+ const b = blk.subarray(off, off + len);
233
+ if (!isZero(b)) viol(code, `${name} 必须全为 0(01-artifacts.md §6.2),得到 ${JSON.stringify(b.toString('latin1').replace(/\0+$/, ''))}`, `entry #${idx}`);
234
+ }
235
+
236
+ /**
237
+ * 严格八进制数字域:`(len-1)` 位 `[0-7]` + 一个 NUL。
238
+ *
239
+ * 🔴 拒 GNU base-256(首字节高位置 1)—— 那是能表达 >8GB 的扩展编码,
240
+ * 也是绕过长度校验的经典手法。
241
+ *
242
+ * 🔴🔴 **这个布局会拒掉真实 tar 工具的产出,是有意的,不要"修"。**
243
+ *
244
+ * POSIX 允许数字域用空格或 NUL 终止,于是同一个值有多种字节写法;而
245
+ * `asset.sha256` 绑的是字节(ERRATA E-3)。canonical 只能有一种,这里定死
246
+ * 「补零到 len-1 位 + NUL」。实测本机 `bsdtar 3.5.3`(macOS 系统 tar,
247
+ * `--format ustar`)写的是另一套:
248
+ *
249
+ * ```
250
+ * mode '000644 \0' ← 6 位 + 空格 + NUL,本实现报 E_OCTAL
251
+ * size '00000000003 ' ← 11 位 + 空格(无 NUL),本实现报 E_OCTAL
252
+ * chksum '012066\0 ' ← 与本实现一致
253
+ * typeflag '0' ← 与本实现一致
254
+ * ```
255
+ *
256
+ * 也就是说**系统 tar 打的包一律装不上**。这与 ERRATA E-5/E-6 的结论一致:
257
+ * packer 必须自己写字节、不得 shell out 到系统 `tar`(E-6 还给了另一个理由 ——
258
+ * macOS 的 tar 会偷偷注入 AppleDouble 成员)。放宽这里等于把 E-3 重新打开。
259
+ */
260
+ function octalField(blk, off, len, name, idx) {
261
+ const b = blk.subarray(off, off + len);
262
+ if (b[0] & 0x80) viol('E_BASE256', `${name} 用了 GNU base-256 数字编码`, `entry #${idx}`);
263
+ const body = b.subarray(0, len - 1);
264
+ if (b[len - 1] !== 0) viol('E_OCTAL', `${name} 必须以 NUL 结尾(canonical 八进制布局)`, `entry #${idx}`);
265
+ for (let i = 0; i < body.length; i++) {
266
+ if (body[i] < 0x30 || body[i] > 0x37) {
267
+ viol('E_OCTAL', `${name} 不是 ${len - 1} 位八进制 ASCII`, `entry #${idx}`);
268
+ }
269
+ }
270
+ return parseInt(body.toString('latin1'), 8);
271
+ }
272
+
273
+ /**
274
+ * chksum 域:**只**接受 POSIX 规范布局「6 位八进制 + NUL + 空格」。
275
+ *
276
+ * 🔴 曾经也接受「7 位 + NUL」。那是同一个校验和的第二种字节写法 ——
277
+ * 与 typeflag NUL、devmajor 全 NUL 同一类问题:一个逻辑条目多个合法字节形式,
278
+ * 而 `asset.sha256` 绑的是字节(ERRATA E-3 的理由)。canonical 定死一种。
279
+ * 6 位八进制上限 0o777777 = 262143,而一个 512 字节头的无符号字节和上限是
280
+ * 512 × 255 = 130560,永远装得下,收窄没有表达力代价。
281
+ */
282
+ function checksumField(blk, idx) {
283
+ const s = blk.subarray(148, 156).toString('latin1');
284
+ if (!/^[0-7]{6}\0 $/.test(s)) {
285
+ return viol('E_CHECKSUM',
286
+ 'chksum 字段布局不合法:canonical 只接受 6 位八进制 + NUL + 空格', `entry #${idx}`);
287
+ }
288
+ return parseInt(s.slice(0, 6), 8);
289
+ }
290
+
291
+ const TYPEFLAG_NAMES = {
292
+ 0x31: 'hardlink(1)', 0x32: 'symlink(2)', 0x33: 'chardev(3)', 0x34: 'blockdev(4)',
293
+ 0x35: 'directory(5)', 0x36: 'fifo(6)', 0x37: 'contiguous(7)',
294
+ 0x78: 'PAX 扩展头(x)', 0x67: 'PAX 全局头(g)',
295
+ 0x4c: 'GNU longname(L)', 0x4b: 'GNU longlink(K)', 0x53: 'GNU sparse(S)',
296
+ 0x44: 'GNU dumpdir(D)', 0x4d: 'GNU multivolume(M)', 0x56: 'GNU volume label(V)',
297
+ 0x4e: 'GNU longnames(N)',
298
+ };
299
+
300
+ // ── 路径(01-artifacts.md §4) ──────────────────────────────────────────────
301
+
302
+ const RE_SEGMENT = /^[A-Za-z0-9._-]+$/;
303
+ const RESERVED = new Set([
304
+ 'CON', 'PRN', 'AUX', 'NUL',
305
+ ...Array.from({ length: 9 }, (_, i) => `COM${i + 1}`),
306
+ ...Array.from({ length: 9 }, (_, i) => `LPT${i + 1}`),
307
+ ]);
308
+
309
+ /**
310
+ * §4.1 + §4.2 全量判定。每一条一个独立的违规码 —— 报出**是哪一项**。
311
+ * 归一化/大小写折叠等跨条目的规则由调用方(`parseTar`)累积判定。
312
+ */
313
+ export function assertArtifactPath(path, where) {
314
+ const p = q(path);
315
+ if (path === '') viol('E_PATH_EMPTY', '路径为空', where);
316
+ if (path.startsWith('/')) viol('E_PATH_ABS', `绝对路径:${p}`, where);
317
+ if (path.includes('\\')) viol('E_PATH_BACKSLASH', `路径含反斜杠:${p}`, where);
318
+ if (path.includes('\0')) viol('E_PATH_NUL', '路径含 NUL', where);
319
+ const segs = path.split('/');
320
+ if (segs.length > MAX_DEPTH) viol('E_DEPTH', `路径深度 ${segs.length} 超过 ${MAX_DEPTH}:${p}`, where);
321
+ for (const s of segs) {
322
+ if (s === '') viol('E_PATH_EMPTY_SEGMENT', `空 segment(//、首/尾斜杠):${p}`, where);
323
+ // 🔴 `..` 按 segment 判,不做字符串 includes —— `a..b` 是合法文件名
324
+ if (s === '.' || s === '..') viol('E_PATH_DOTDOT', `segment 是 ${s}:${p}`, where);
325
+ if (!RE_SEGMENT.test(s)) viol('E_PATH_CHARSET', `segment 不是 ASCII-only [A-Za-z0-9._-]:${JSON.stringify(s)}`, where);
326
+ // 🔴 ERRATA E-6:macOS 系统 tar 会为携带 xattr 注入 AppleDouble(`._*`)成员,
327
+ // 而 §5 明确拒绝 xattr。这类条目本来也会被下一行的 E_PATH_LEADING 挡住,
328
+ // 但报「segment 以 . 开头」对打包的人毫无帮助 —— 他从 `tar -tvf` 根本看不到
329
+ // 这个成员,只会以为校验器有 bug。所以先于通用规则报出专门的码 + 处方。
330
+ if (s.startsWith('._')) {
331
+ viol('E_APPLEDOUBLE',
332
+ `${JSON.stringify(s)} 是 macOS AppleDouble 成员(系统 tar 为携带 xattr 自动注入,`
333
+ + `且 tar -tvf 不会列出它)。§5 拒绝 xattr。请用 COPYFILE_DISABLE=1 重新打包,`
334
+ + `或改用自己写字节、不 shell out 到系统 tar 的 packer(ERRATA E-5/E-6)`, where);
335
+ }
336
+ if (s[0] === '.' || s[0] === '-') viol('E_PATH_LEADING', `segment 以 ${s[0]} 开头:${q(s)}`, where);
337
+ if (s.endsWith('.')) viol('E_PATH_TRAILING_DOT', `segment 以 . 结尾:${q(s)}`, where);
338
+ const stem = (s.includes('.') ? s.slice(0, s.indexOf('.')) : s).toUpperCase();
339
+ if (RESERVED.has(stem)) viol('E_PATH_RESERVED', `segment 是保留设备名:${q(s)}`, where);
340
+ }
341
+ // 纵深防御:再过一遍地基模块。两边都判到才算过 —— 任一侧将来放松,另一侧还在。
342
+ // 🔴 它抛的是普通 Error(没有 violation),得转成违规码,否则「报出具体违规项」
343
+ // 这条契约会在**恰好是本层漏判**的那种情况下失效。
344
+ try {
345
+ parseSafeRelPath(path, { maxDepth: MAX_DEPTH });
346
+ } catch (e) {
347
+ viol('E_PATH_SAFE_FS', `safe-fs 的独立判定拒绝了这条路径(本层漏判):${e.message}`, where);
348
+ }
349
+ return segs;
350
+ }
351
+
352
+ /**
353
+ * §4.3 USTAR 可编码性 + **唯一编码**。
354
+ *
355
+ * 🔴 不只是「name ≤ 100 且 prefix ≤ 155」:同一条路径若能被切成多种 (prefix,name),
356
+ * 攻击者就能用非规范切法造出两个字节不同、解出来同路径的归档 ——
357
+ * 而 `asset.sha256` 绑的是字节。所以这里定死唯一切法(prefix 取最长的合法切点,
358
+ * 与 GNU tar 一致),观察到的切法必须与之相等。
359
+ */
360
+ export function canonicalUstarSplit(path) {
361
+ const bytes = Buffer.byteLength(path, 'utf8');
362
+ if (bytes <= USTAR_NAME) return { prefix: '', name: path };
363
+ let best = null;
364
+ for (let i = 0; i < path.length; i++) {
365
+ if (path[i] !== '/') continue;
366
+ const prefix = path.slice(0, i);
367
+ const name = path.slice(i + 1);
368
+ if (name === '' || prefix === '') continue;
369
+ if (Buffer.byteLength(prefix, 'utf8') > USTAR_PREFIX) break; // 再往后只会更长
370
+ if (Buffer.byteLength(name, 'utf8') > USTAR_NAME) continue;
371
+ best = { prefix, name }; // 取最长的合法 prefix
372
+ }
373
+ return best;
374
+ }
375
+
376
+ // ── 主解析 ──────────────────────────────────────────────────────────────────
377
+
378
+ /**
379
+ * 解析 canonical tar(已 gunzip 的字节)。
380
+ * @returns {{entries: Array<{path:string,mode:number,data:Buffer}>, totals:{files:number,bytes:number}}}
381
+ */
382
+ export function parseTar(tar) {
383
+ if (tar.length === 0) viol('E_TRUNCATED', 'tar 为空');
384
+ if (tar.length % BLOCK !== 0) viol('E_TRUNCATED', `tar 长度 ${tar.length} 不是 512 的整数倍`);
385
+
386
+ const entries = [];
387
+ const seenPath = new Set();
388
+ const seenFold = new Set();
389
+ // 折叠后的目录名 -> 它第一次出现时的实际拼写。用来抓「A/x.md 与 a/y.md」这种
390
+ // 两条路径各自不重名、但在大小写不敏感的文件系统上指向同一个目录的情况。
391
+ const dirCase = new Map();
392
+ let totalBytes = 0;
393
+ let prevPathBuf = null;
394
+ let off = 0;
395
+ let sawEof = false;
396
+ let idx = 0;
397
+
398
+ while (off < tar.length) {
399
+ const blk = tar.subarray(off, off + BLOCK);
400
+
401
+ if (isZero(blk)) {
402
+ // 结束标记 = 恰好两个全零块,之后**什么都不能有**
403
+ const second = tar.subarray(off + BLOCK, off + 2 * BLOCK);
404
+ if (second.length !== BLOCK || !isZero(second)) viol('E_NO_EOF_MARKER', '结束标记不是两个连续的 512 全零块');
405
+ const rest = tar.subarray(off + 2 * BLOCK);
406
+ if (rest.length > 0) {
407
+ if (!isZero(rest)) viol('E_TRAILING', `结束标记之后还有 ${rest.length} 字节非零数据(tar smuggling)`);
408
+ viol('E_TRAILING_BLOCKS', `结束标记之后还有 ${rest.length / BLOCK} 个多余的全零块(非 canonical)`);
409
+ }
410
+ sawEof = true;
411
+ break;
412
+ }
413
+
414
+ idx++;
415
+ const where = `entry #${idx}`;
416
+
417
+ // ① 头部 checksum:先验它,后面的字段才值得读
418
+ const want = checksumField(blk, idx);
419
+ let sum = 0;
420
+ for (let i = 0; i < BLOCK; i++) sum += i >= 148 && i < 156 ? 0x20 : blk[i];
421
+ if (sum !== want) viol('E_CHECKSUM', `头部 checksum 不符(算出 ${sum},头里写 ${want})`, where);
422
+
423
+ // ② magic / version:只接受 POSIX ustar,拒 v7 老 tar 与 GNU 变体("ustar \0")
424
+ const magic = blk.subarray(257, 263);
425
+ const version = blk.subarray(263, 265);
426
+ if (magic.toString('latin1') !== 'ustar\0') {
427
+ viol('E_MAGIC', `magic 不是 ustar+NUL,得到 ${JSON.stringify(magic.toString('latin1'))}`, where);
428
+ }
429
+ if (version.toString('latin1') !== '00') {
430
+ viol('E_MAGIC', `ustar version 不是 "00",得到 ${JSON.stringify(version.toString('latin1'))}`, where);
431
+ }
432
+
433
+ // ③ typeflag:只接受普通文件。🔴 逐类报出是哪一种,不笼统说「类型不对」
434
+ // 🔴 只接受 '0'(0x30)。v7 老 tar 用 NUL(0x00) 表示普通文件,那是**同一条逻辑
435
+ // 条目的第二种字节编码** —— 接受它就等于把 E-3 刚消灭的「一个逻辑制品多个
436
+ // 合法字节形式」放回来(`asset.sha256` 绑的是字节)。magic 已经强制 POSIX
437
+ // ustar,ustar 下 '0' 是唯一规范值,所以这里没有兼容性代价。
438
+ const tf = blk[156];
439
+ if (tf !== 0x30) {
440
+ if (tf === 0x00) {
441
+ viol('E_TYPEFLAG', 'typeflag 是 NUL(v7 老 tar 的普通文件写法);'
442
+ + 'ustar 下普通文件的唯一规范值是 ASCII "0",两种编码并存会破坏 ERRATA E-3 的唯一性', where);
443
+ }
444
+ const known = TYPEFLAG_NAMES[tf];
445
+ viol('E_TYPEFLAG', known
446
+ ? `拒绝 ${known} 条目(01-artifacts.md §5 只允许普通文件;§4.1 只写文件条目、不写目录条目)`
447
+ : `拒绝未知 typeflag 0x${tf.toString(16).padStart(2, '0')}`, where);
448
+ }
449
+
450
+ // ④ 01-artifacts.md §6.2:摘要不覆盖的东西,规范强制其取值
451
+ assertZeroField(blk, 157, 100, 'linkname', 'E_LINKNAME', idx);
452
+ assertZeroField(blk, 265, 32, 'uname', 'E_UNAME', idx);
453
+ assertZeroField(blk, 297, 32, 'gname', 'E_GNAME', idx);
454
+ assertZeroField(blk, 500, 12, '头部保留区(500..511)', 'E_HEADER_PAD', idx);
455
+
456
+ const mode = octalField(blk, 100, 8, 'mode', idx);
457
+ const uid = octalField(blk, 108, 8, 'uid', idx);
458
+ const gid = octalField(blk, 116, 8, 'gid', idx);
459
+ const size = octalField(blk, 124, 12, 'size', idx);
460
+ const mtime = octalField(blk, 136, 12, 'mtime', idx);
461
+ // 🔴 不给 devmajor/devminor 开 `allowAllNul`:全 NUL 与 "0000000\0" 都表示 0,
462
+ // 两种都收下就是同一条逻辑条目的两种字节编码(同上 typeflag 的理由)。
463
+ // canonical 形式定死为八进制零;packer 自己写字节(E-5 推论),没有兼容代价。
464
+ const devmajor = octalField(blk, 329, 8, 'devmajor', idx);
465
+ const devminor = octalField(blk, 337, 8, 'devminor', idx);
466
+
467
+ if (uid !== 0) viol('E_UID', `uid 必须是 0,得到 ${uid}`, where);
468
+ if (gid !== 0) viol('E_GID', `gid 必须是 0,得到 ${gid}`, where);
469
+ if (mtime !== 0) viol('E_MTIME', `mtime 必须是 0,得到 ${mtime}`, where);
470
+ if (devmajor !== 0 || devminor !== 0) viol('E_DEV', `devmajor/devminor 必须是 0,得到 ${devmajor}/${devminor}`, where);
471
+ // 🔴 mode 白名单:0o4755 之类带 setuid/setgid/sticky 的值会在这里落网
472
+ if (mode !== 0o644 && mode !== 0o755) {
473
+ viol('E_MODE', `mode 只允许 0644 / 0755,得到 0${mode.toString(8)}`, where);
474
+ }
475
+
476
+ // ⑤ 路径
477
+ const name = cstrField(blk, 0, USTAR_NAME, 'name', idx);
478
+ const prefix = cstrField(blk, 345, USTAR_PREFIX, 'prefix', idx);
479
+ if (name === '') viol('E_PATH_EMPTY', 'name 字段为空', where);
480
+ const path = prefix === '' ? name : `${prefix}/${name}`;
481
+ assertArtifactPath(path, where);
482
+
483
+ const canon = canonicalUstarSplit(path);
484
+ if (canon === null) {
485
+ viol('E_PATH_USTAR', `路径无法被 ustar 的 prefix(155)+name(100) 切分:${q(path)}`, where);
486
+ }
487
+ if (canon.prefix !== prefix || canon.name !== name) {
488
+ viol('E_PATH_USTAR_SPLIT',
489
+ `非 canonical 的 ustar 切分:头里是 prefix=${JSON.stringify(prefix)}/name=${JSON.stringify(name)},` +
490
+ `canonical 是 prefix=${JSON.stringify(canon.prefix)}/name=${JSON.stringify(canon.name)}`, where);
491
+ }
492
+
493
+ if (seenPath.has(path)) viol('E_DUP_PATH', `重复路径 ${q(path)}`, where);
494
+ const fold = path.toLowerCase();
495
+ if (seenFold.has(fold)) viol('E_CASE_COLLIDE', `大小写折叠后与已有条目重名:${q(path)}`, where);
496
+
497
+ // 🔴 同一个名字不能既是文件又是目录 —— 这样的归档在任何文件系统上都落不了地。
498
+ //
499
+ // 归档里只写文件条目、不写目录条目(§4.1),目录是由路径隐含的。于是
500
+ // `a` 与 `a/b.md` 两条都「各自合法」:路径 grammar 过、不重复、大小写不撞、
501
+ // 顺序也对(`a` 必然排在 `a/b.md` 前面,因为它是前缀)。
502
+ //
503
+ // 不在这里判的话,它会一路走到 artifact.mjs 的 writeEntries,在 `mkdir a` 撞上
504
+ // 刚写好的文件 `a` 时报 **E_DEST_DIRTY**——那个码的意思是「隔离目录被别人动过」,
505
+ // 是安全事件。把「归档自相矛盾」误报成「隔离目录被污染」会把排查引到完全错的方向,
506
+ // 而且那时已经往磁盘写过东西了。**结构问题要在解析器里、落盘之前判掉。**
507
+ const segsOf = path.split('/');
508
+ for (let i = 1; i < segsOf.length; i++) {
509
+ const ancestor = segsOf.slice(0, i).join('/');
510
+ const aFold = ancestor.toLowerCase();
511
+ if (seenPath.has(ancestor)) {
512
+ viol('E_PATH_FILE_DIR_COLLIDE',
513
+ `${q(path)} 要求 ${q(ancestor)} 是目录,但它已经作为文件条目出现过`, where);
514
+ }
515
+ if (seenFold.has(aFold)) {
516
+ viol('E_PATH_FILE_DIR_COLLIDE',
517
+ `${q(path)} 要求 ${q(ancestor)} 是目录,但大小写折叠后它已经作为文件条目出现过`
518
+ + `(macOS 上会互相覆盖)`, where);
519
+ }
520
+ // 🔴 目录名之间的大小写冲突。§4.2 的折叠规则原文只说「与已有**条目**折叠后重名」,
521
+ // 照字面实现只比整条路径,于是 `A/x.md` 与 `a/y.md` 两条都能过 ——
522
+ // 可它们在 macOS 上是同一个目录。后果比「报错码不好看」严重:
523
+ // **同一份 asset 在 Linux 上装得上、在 macOS 上装不上**(mkdir 撞 EEXIST,
524
+ // 还会被报成 E_DEST_DIRTY「隔离目录被污染」)。
525
+ // §4.2 折叠规则的立意就是消灭这种平台相关性,所以按立意补到目录上。
526
+ const prevCase = dirCase.get(aFold);
527
+ if (prevCase !== undefined && prevCase !== ancestor) {
528
+ viol('E_CASE_COLLIDE',
529
+ `目录 ${q(ancestor)} 与先前出现的 ${q(prevCase)} 大小写折叠后是同一个目录`
530
+ + `(大小写不敏感的文件系统上会合并成一个)`, where);
531
+ }
532
+ dirCase.set(aFold, ancestor);
533
+ }
534
+
535
+ seenPath.add(path);
536
+ seenFold.add(fold);
537
+
538
+ // ⑥ 顺序:与树摘要相同的 path 字节序,严格升序(02-registry.md §4.1,参与确定性)
539
+ const pathBuf = Buffer.from(path, 'utf8');
540
+ if (prevPathBuf !== null && Buffer.compare(prevPathBuf, pathBuf) >= 0) {
541
+ viol('E_ORDER',
542
+ `条目顺序不是 path 字节序严格升序:${q(prevPathBuf.toString())} 之后出现 ${q(path)}`, where);
543
+ }
544
+ prevPathBuf = pathBuf;
545
+
546
+ // ⑦ 上限与数据区
547
+ if (size > MAX_FILE_BYTES) viol('E_FILE_SIZE', `单文件 ${size} 字节超过 ${MAX_FILE_BYTES}:${q(path)}`, where);
548
+ totalBytes += size;
549
+ if (totalBytes > MAX_TOTAL_BYTES) viol('E_TOTAL_SIZE', `解压总计超过 ${MAX_TOTAL_BYTES} 字节`, where);
550
+ if (entries.length + 1 > MAX_FILES) viol('E_FILE_COUNT', `文件数超过 ${MAX_FILES}`, where);
551
+
552
+ const dataStart = off + BLOCK;
553
+ const padded = Math.ceil(size / BLOCK) * BLOCK;
554
+ if (dataStart + padded > tar.length) viol('E_TRUNCATED', `${q(path)} 的数据区被截断`, where);
555
+ const data = tar.subarray(dataStart, dataStart + size);
556
+ const pad = tar.subarray(dataStart + size, dataStart + padded);
557
+ if (!isZero(pad)) viol('E_DATA_PAD', `${q(path)} 数据区尾部的 512 对齐填充非全零(夹带)`, where);
558
+
559
+ entries.push({ path, mode, data: Buffer.from(data) });
560
+ off = dataStart + padded;
561
+ }
562
+
563
+ if (!sawEof) viol('E_NO_EOF_MARKER', '归档缺少两个 512 全零块的结束标记');
564
+ return { entries, totals: { files: entries.length, bytes: totalBytes } };
565
+ }
566
+
567
+ /** gunzip + parseTar 的组合入口 */
568
+ export function untarGz(gzBytes) {
569
+ return parseTar(gunzipCanonical(gzBytes));
570
+ }