@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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/bin/skills-hub.mjs +26 -0
- package/package.json +44 -0
- package/src/adapters/index.mjs +832 -0
- package/src/artifact.mjs +376 -0
- package/src/atomic-fs.mjs +166 -0
- package/src/attestation.mjs +136 -0
- package/src/canonical-json.mjs +147 -0
- package/src/cli.mjs +208 -0
- package/src/commands/check.mjs +295 -0
- package/src/commands/context.mjs +235 -0
- package/src/commands/install.mjs +430 -0
- package/src/commands/locks.mjs +197 -0
- package/src/commands/output.mjs +127 -0
- package/src/commands/query.mjs +266 -0
- package/src/commands/recover.mjs +438 -0
- package/src/commands/registry.mjs +123 -0
- package/src/commands/resolve.mjs +171 -0
- package/src/commands/snapshot-access.mjs +91 -0
- package/src/commands/sync-lock.mjs +189 -0
- package/src/crc32c.mjs +27 -0
- package/src/exit-codes.mjs +265 -0
- package/src/fault-inject.mjs +379 -0
- package/src/install.mjs +732 -0
- package/src/journal.mjs +435 -0
- package/src/ledger.mjs +671 -0
- package/src/lock.mjs +98 -0
- package/src/lockfile.mjs +0 -0
- package/src/pack.mjs +792 -0
- package/src/packer.mjs +351 -0
- package/src/plan.mjs +519 -0
- package/src/recover.mjs +1345 -0
- package/src/safe-fs.mjs +252 -0
- package/src/sigstore.mjs +480 -0
- package/src/snapshot.mjs +528 -0
- package/src/stats.mjs +59 -0
- package/src/target.mjs +738 -0
- package/src/telemetry.mjs +393 -0
- package/src/tree-digest.mjs +103 -0
- package/src/trust-roots/README.md +31 -0
- package/src/trust-roots/sigstore-public-good.json +126 -0
- package/src/trust.mjs +563 -0
- package/src/untar.mjs +570 -0
- package/src/upload.mjs +268 -0
- package/src/vendor.mjs +465 -0
package/src/artifact.mjs
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// 制品链:资产验证 → 隔离解包 → 重算树摘要 → manifest 绑定
|
|
2
|
+
// 规范:02-registry.md §6 第 7 步、01-artifacts.md §4/§5/§5.3/§6、04-install.md §7
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import {
|
|
5
|
+
mkdtempSync, mkdirSync, openSync, writeSync, fsyncSync, closeSync, futimesSync,
|
|
6
|
+
utimesSync, chmodSync, fchmodSync, fstatSync, lstatSync, readFileSync, existsSync, statSync, rmSync, constants,
|
|
7
|
+
} from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { fsyncDir } from './atomic-fs.mjs';
|
|
11
|
+
import { treeDigest } from './tree-digest.mjs';
|
|
12
|
+
import { untarGz, assertArtifactPath, TarViolation } from './untar.mjs';
|
|
13
|
+
import {
|
|
14
|
+
IntegrityError, WireError,
|
|
15
|
+
parseWireJson, assertExactKeys, assertString, assertStringArray, assertUint, assertTreeDigest,
|
|
16
|
+
} from './trust.mjs';
|
|
17
|
+
|
|
18
|
+
export const SKILL_MANIFEST_SCHEMA = 'geoly.skills.skill/1';
|
|
19
|
+
export const PACK_MANIFEST_SCHEMA = 'geoly.skills.pack/1';
|
|
20
|
+
|
|
21
|
+
const viol = (v, m) => { throw new IntegrityError(v, m); };
|
|
22
|
+
|
|
23
|
+
// ── 资产 ────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* §6 第 7 步的第一件事:验 `asset.sha256`(顺带验 `asset.size`)。
|
|
27
|
+
* 🔴 没有期望值就直接抛 —— API 上不存在「不给期望值就跳过」的口子。
|
|
28
|
+
*/
|
|
29
|
+
export function assertAssetBytes(bytes, record) {
|
|
30
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
31
|
+
const expected = record?.asset?.sha256;
|
|
32
|
+
if (typeof expected !== 'string') viol('E_NO_EXPECTED_DIGEST', 'record.asset.sha256 缺失,拒绝校验资产');
|
|
33
|
+
const got = 'sha256:' + createHash('sha256').update(buf).digest('hex');
|
|
34
|
+
if (got !== expected) viol('E_ASSET_SHA256', `资产 sha256 是 ${got},快照说应为 ${expected}`);
|
|
35
|
+
// 🔴 `asset.size` **必填**,不是「有就查」。
|
|
36
|
+
// snapshot parser 本来就要求它存在,所以这里写成可选只在**直接调本 API** 时有区别 ——
|
|
37
|
+
// 也就是说,它唯一的作用是给绕过尺寸一致性校验留一个口子。
|
|
38
|
+
// 同 E_NO_EXPECTED_DIGEST 的立场:API 上不存在「不给期望值就跳过」。
|
|
39
|
+
if (typeof record.asset.size !== 'number') {
|
|
40
|
+
viol('E_NO_EXPECTED_SIZE', 'record.asset.size 缺失,拒绝校验资产');
|
|
41
|
+
}
|
|
42
|
+
if (buf.length !== record.asset.size) {
|
|
43
|
+
viol('E_ASSET_SIZE', `资产 ${buf.length} 字节,快照说应为 ${record.asset.size}`);
|
|
44
|
+
}
|
|
45
|
+
return got;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── 隔离临时目录 ────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 🔴 解到**隔离临时目录**(不是 target,不是 stage)——04-install.md §7 第 2 步。
|
|
52
|
+
* `mkdtemp` 生成的目录名不可预测且 mode 0700:别人预先占位、或在解包途中
|
|
53
|
+
* 往里塞 symlink,都需要先能写进这个目录。
|
|
54
|
+
*/
|
|
55
|
+
export function createIsolatedDir(parent = tmpdir()) {
|
|
56
|
+
const d = mkdtempSync(join(parent, 'geoly-unpack-'));
|
|
57
|
+
chmodSync(d, 0o700);
|
|
58
|
+
return d;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 把内存里的条目写进隔离目录。
|
|
63
|
+
*
|
|
64
|
+
* 🔴 每个中间目录都由**本函数自己**创建(`mkdir` 不带 recursive,`EEXIST` 即违规),
|
|
65
|
+
* 每个文件用 `O_CREAT|O_EXCL|O_NOFOLLOW` 打开。
|
|
66
|
+
* 为什么不用「先 lstat 再写」:那是 TOCTOU —— 检查与写之间有窗口。
|
|
67
|
+
* 让内核在 `open` 这一次系统调用里同时完成「必须不存在」与「不许跟随符号链接」。
|
|
68
|
+
*/
|
|
69
|
+
export function writeEntries(destDir, entries) {
|
|
70
|
+
const madeDirs = [];
|
|
71
|
+
const known = new Set([destDir]);
|
|
72
|
+
|
|
73
|
+
const ensureDir = (abs) => {
|
|
74
|
+
if (known.has(abs)) return;
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(abs, 0o755);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// 🔴 EEXIST 在这里就是异常:目录是我们刚建的、由我们独占,
|
|
79
|
+
// 里面不该有别人先放好的东西
|
|
80
|
+
viol('E_DEST_DIRTY', `隔离目录里已存在 ${abs}(${e.code}):拒绝写入`);
|
|
81
|
+
}
|
|
82
|
+
// 建完立刻 lstat 确认它确实是目录而不是被换成了符号链接。
|
|
83
|
+
// ⚠️ 这**收窄**竞态窗口,不消除它:Node 没有 openat/mkdirat,
|
|
84
|
+
// 中间目录的 check-then-use 无法在纯 Node 里做成原子的。残余风险见交付汇报。
|
|
85
|
+
const st = lstatSync(abs);
|
|
86
|
+
if (!st.isDirectory() || st.isSymbolicLink()) viol('E_DEST_DIRTY', `${abs} 建出来之后不是普通目录`);
|
|
87
|
+
chmodSync(abs, 0o755); // 绕开 umask,把 mode 钉死(§6.2:目录 mode 一律 0755)
|
|
88
|
+
known.add(abs);
|
|
89
|
+
madeDirs.push(abs);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
93
|
+
for (const e of entries) {
|
|
94
|
+
const segs = e.path.split('/');
|
|
95
|
+
for (let i = 1; i < segs.length; i++) ensureDir(join(destDir, ...segs.slice(0, i)));
|
|
96
|
+
const abs = join(destDir, ...segs);
|
|
97
|
+
let fd;
|
|
98
|
+
try {
|
|
99
|
+
fd = openSync(abs, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW, e.mode);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
viol('E_DEST_DIRTY', `无法以 O_EXCL|O_NOFOLLOW 创建 ${e.path}(${err.code})`);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
writeSync(fd, e.data, 0, e.data.length, 0);
|
|
105
|
+
// 🔴 mode 用 fchmod 而不是 chmod:`open` 受 umask 影响(umask 077 会把
|
|
106
|
+
// 0644 变成 0600),必须钉死;而按**路径**再 chmod 一次等于重新解析路径,
|
|
107
|
+
// 那正是 Codex 指出的 TOCTOU 窗口。用已经拿到的 fd 就没有这次重解析。
|
|
108
|
+
fchmodSync(fd, e.mode);
|
|
109
|
+
fsyncSync(fd);
|
|
110
|
+
// §6.2:mtime / atime 解包时一律置 0(epoch),归档里的时间戳不参与也不保留
|
|
111
|
+
futimesSync(fd, 0, 0);
|
|
112
|
+
const st = fstatSync(fd);
|
|
113
|
+
if (!st.isFile() || st.nlink !== 1) viol('E_DEST_DIRTY', `${e.path} 写完之后不是 nlink=1 的普通文件`);
|
|
114
|
+
if ((st.mode & 0o777) !== e.mode) viol('E_DEST_DIRTY', `${e.path} 的 mode 没能钉成 0${e.mode.toString(8)}`);
|
|
115
|
+
} finally {
|
|
116
|
+
closeSync(fd);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 目录时间与 fsync 放到最后:先设时间会被后续写子项覆盖
|
|
121
|
+
for (let i = madeDirs.length - 1; i >= 0; i--) { utimesSync(madeDirs[i], 0, 0); fsyncDir(madeDirs[i]); }
|
|
122
|
+
fsyncDir(destDir);
|
|
123
|
+
return { dirs: madeDirs.length, files: entries.length };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 🔴 `0755` 仅当 capability 声明了 `shell`(01-artifacts.md §5 载荷规则表)。
|
|
128
|
+
* mode 进树摘要正是因为它关联这条 capability —— 只校验摘要不校验这条绑定,
|
|
129
|
+
* 等于让一个没声明 shell 的制品带着可执行位装进去。
|
|
130
|
+
*/
|
|
131
|
+
export function assertModeCapabilityBinding(entries, record) {
|
|
132
|
+
const caps = Array.isArray(record?.capabilities) ? record.capabilities : [];
|
|
133
|
+
if (caps.includes('shell')) return;
|
|
134
|
+
const bad = entries.filter(e => e.mode === 0o755).map(e => e.path);
|
|
135
|
+
if (bad.length) {
|
|
136
|
+
viol('E_MODE_CAPABILITY',
|
|
137
|
+
`制品未声明 shell capability,却含 0755 文件:${bad.slice(0, 5).join(', ')}${bad.length > 5 ? ` 等 ${bad.length} 个` : ''}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* §6 第 7 步全流程:
|
|
143
|
+
* 验 asset.sha256 → 隔离临时目录解包 → 重算 tree_digest → 校验归档内逻辑路径与 mode。
|
|
144
|
+
*
|
|
145
|
+
* 🔴 顺序不可调换:**解包永远发生在 sha256 验证之后**。
|
|
146
|
+
* 先解包再验摘要等于把未经验证的字节喂给解析器,解析器的任何缺陷都直接可达。
|
|
147
|
+
*/
|
|
148
|
+
export function verifyAndExtract({ bytes, record, parent = tmpdir() }) {
|
|
149
|
+
assertAssetBytes(bytes, record);
|
|
150
|
+
|
|
151
|
+
const { entries, totals } = untarGz(bytes);
|
|
152
|
+
if (entries.length === 0) viol('E_EMPTY_ARTIFACT', '制品没有任何文件');
|
|
153
|
+
|
|
154
|
+
// 纵深防御:不信任 untar 的路径判定,再判一遍。任一侧将来放松,另一侧还在。
|
|
155
|
+
for (const e of entries) assertArtifactPath(e.path, `payload:${e.path}`);
|
|
156
|
+
assertModeCapabilityBinding(entries, record);
|
|
157
|
+
|
|
158
|
+
const dir = createIsolatedDir(parent);
|
|
159
|
+
// 🔴 失败路径必须自己收尸。这之前只有成功路径会把 dir 交给调用方,
|
|
160
|
+
// 写盘失败或树摘要不符时目录就留在 /tmp 里没人管 —— 一个合法 gzip 配上错的
|
|
161
|
+
// tree_digest 就能让每次调用都完整写一遍盘再抛错,反复调用会堆出一地
|
|
162
|
+
// geoly-unpack-*。调用方拿不到 dir,也就不可能替我们删。
|
|
163
|
+
let ok = false;
|
|
164
|
+
try {
|
|
165
|
+
writeEntries(dir, entries);
|
|
166
|
+
|
|
167
|
+
// 解完重算树摘要(§7 第 4 步)
|
|
168
|
+
const got = treeDigest(dir);
|
|
169
|
+
if (got !== record.tree_digest) {
|
|
170
|
+
viol('E_TREE_DIGEST', `解包后重算 ${got},快照说应为 ${record.tree_digest}`);
|
|
171
|
+
}
|
|
172
|
+
ok = true;
|
|
173
|
+
return { dir, entries, totals, treeDigest: got };
|
|
174
|
+
} finally {
|
|
175
|
+
// 只删我们自己用 mkdtemp 建的那一个;成功时所有权移交调用方,不能删
|
|
176
|
+
if (!ok) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* 清理尽力而为 */ } }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── manifest 绑定(01-artifacts.md §5.3) ──────────────────────────────────
|
|
181
|
+
|
|
182
|
+
const SKILL_MANIFEST_KEYS = {
|
|
183
|
+
required: ['schema', 'kind', 'namespace', 'name', 'version', 'description', 'license',
|
|
184
|
+
'clients', 'capabilities', 'replaces', 'conflicts', 'provenance'],
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const PACK_MANIFEST_KEYS = {
|
|
188
|
+
required: ['schema', 'kind', 'namespace', 'name', 'version', 'description', 'license',
|
|
189
|
+
'members', 'bundled', 'conflicts', 'contract_paths', 'compatibility'],
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** 最小 YAML frontmatter 子集:`---` 包围、单行 `key: value`。其余一律拒绝。 */
|
|
193
|
+
export function parseFrontmatter(text) {
|
|
194
|
+
if (!text.startsWith('---\n')) throw new WireError('E_FRONTMATTER', 'SKILL.md 必须以 --- 开头的 YAML frontmatter 起始');
|
|
195
|
+
const end = text.indexOf('\n---\n', 3);
|
|
196
|
+
if (end === -1) throw new WireError('E_FRONTMATTER', 'SKILL.md 的 frontmatter 没有闭合的 ---');
|
|
197
|
+
const body = text.slice(4, end + 1);
|
|
198
|
+
const out = {};
|
|
199
|
+
for (const raw of body.split('\n')) {
|
|
200
|
+
if (raw.trim() === '') continue;
|
|
201
|
+
if (raw.includes('\t')) throw new WireError('E_FRONTMATTER', 'frontmatter 含 TAB');
|
|
202
|
+
if (raw === '---' || raw.startsWith('---')) throw new WireError('E_FRONTMATTER', 'frontmatter 里出现多文档分隔符');
|
|
203
|
+
const m = /^([A-Za-z0-9_-]+): (.*)$/.exec(raw);
|
|
204
|
+
if (!m) throw new WireError('E_FRONTMATTER', `frontmatter 只支持单行 key: value,无法解析:${JSON.stringify(raw)}`);
|
|
205
|
+
let v = m[2];
|
|
206
|
+
// 拒绝 YAML 锚点 / 别名 / 合并键 —— 它们能让同一份文本解出不同结构
|
|
207
|
+
if (/^[&*]/.test(v) || m[1] === '<<') throw new WireError('E_FRONTMATTER', 'frontmatter 禁止 YAML 锚点/别名');
|
|
208
|
+
if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) || (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) {
|
|
209
|
+
v = v.slice(1, -1);
|
|
210
|
+
}
|
|
211
|
+
if (Object.hasOwn(out, m[1])) throw new WireError('E_FRONTMATTER', `frontmatter 重复 key ${m[1]}`);
|
|
212
|
+
out[m[1]] = v;
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readPayloadJson(payloadDir, file) {
|
|
218
|
+
const p = join(payloadDir, file);
|
|
219
|
+
if (!existsSync(p) || !statSync(p).isFile()) {
|
|
220
|
+
viol('E_MANIFEST_MISSING', `载荷根缺少 ${file}(01-artifacts.md §5.1/§5.2)`);
|
|
221
|
+
}
|
|
222
|
+
return { bytes: readFileSync(p), path: p };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 🔴 §5.3:manifest ↔ ArtifactId 的**六项全等**(skill 再加第七项)。
|
|
227
|
+
* v1 只强制了「三处 name 一致」,能发布出「路径 x@1.0.0、载荷声明 x@2.0.0」的制品。
|
|
228
|
+
*
|
|
229
|
+
* 这一步必须在**第 7 步**做 —— manifest 在资产内部,第 5 步(解析快照)时
|
|
230
|
+
* 根本还没下载到(v3 在第 5 步要求校验它,顺序上不可能)。
|
|
231
|
+
*/
|
|
232
|
+
export function assertManifestBinding(record, payloadDir) {
|
|
233
|
+
const isSkill = record.kind === 'skill';
|
|
234
|
+
const file = isSkill ? 'skill.json' : 'pack.json';
|
|
235
|
+
const { bytes } = readPayloadJson(payloadDir, file);
|
|
236
|
+
const doc = parseWireJson(bytes, file);
|
|
237
|
+
|
|
238
|
+
// 🔴 「skill.json 里没有 digest 字段,永远不会有。摘要只存在于 registry snapshot,
|
|
239
|
+
// 投稿者声明的一律不读。」——给它一个专门的违规码,别混在「未知字段」里。
|
|
240
|
+
if (Object.hasOwn(doc, 'digest')) {
|
|
241
|
+
viol('E_MANIFEST_DIGEST', `${file} 出现 digest 字段:投稿者声明的摘要一律不读(01-artifacts.md §5.1)`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!isSkill) {
|
|
245
|
+
// pack.json(03-packs.md §2)。这里只做**绑定所需**的严格校验:
|
|
246
|
+
// schema、必填键集、成员锁定必须是精确版本+摘要。
|
|
247
|
+
// ⚠️ 成员图的解析、degraded 判定、conflicts 匹配等属于 packs 模块的职责,不在本模块。
|
|
248
|
+
assertExactKeys(doc, PACK_MANIFEST_KEYS, file);
|
|
249
|
+
if (doc.schema !== PACK_MANIFEST_SCHEMA) {
|
|
250
|
+
throw new WireError('E_SCHEMA', `${file}.schema 必须是 ${PACK_MANIFEST_SCHEMA},得到 ${JSON.stringify(doc.schema)}`);
|
|
251
|
+
}
|
|
252
|
+
assertString(doc.description, `${file}.description`);
|
|
253
|
+
assertString(doc.license, `${file}.license`);
|
|
254
|
+
assertStringArray(doc.conflicts, `${file}.conflicts`);
|
|
255
|
+
assertStringArray(doc.contract_paths, `${file}.contract_paths`);
|
|
256
|
+
for (const listName of ['members', 'bundled']) {
|
|
257
|
+
const list = doc[listName];
|
|
258
|
+
if (!Array.isArray(list)) throw new WireError('E_WIRE_TYPE', `${file}.${listName} 必须是数组`);
|
|
259
|
+
list.forEach((m, i) => {
|
|
260
|
+
const w = `${file}.${listName}[${i}]`;
|
|
261
|
+
assertExactKeys(m, { required: ['id', 'tree_digest', 'role'], optional: ['order'] }, w);
|
|
262
|
+
// 🔴 成员锁定:精确版本 + 摘要,不接受 semver range
|
|
263
|
+
// (range 意味着「装的时候才知道装到什么」)
|
|
264
|
+
if (!/^(skill|pack):[a-z0-9-]+\/[a-z0-9-]+@[^*^~ ]+$/.test(assertString(m.id, `${w}.id`))) {
|
|
265
|
+
throw new WireError('E_PACK_MEMBER_ID', `${w}.id 必须是精确的 ArtifactId,不接受 range:${m.id}`);
|
|
266
|
+
}
|
|
267
|
+
assertTreeDigest(m.tree_digest, `${w}.tree_digest`);
|
|
268
|
+
assertString(m.role, `${w}.role`);
|
|
269
|
+
if (Object.hasOwn(m, 'order')) assertUint(m.order, `${w}.order`);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (isSkill) {
|
|
275
|
+
assertExactKeys(doc, SKILL_MANIFEST_KEYS, file);
|
|
276
|
+
if (doc.schema !== SKILL_MANIFEST_SCHEMA) {
|
|
277
|
+
throw new WireError('E_SCHEMA', `${file}.schema 必须是 ${SKILL_MANIFEST_SCHEMA},得到 ${JSON.stringify(doc.schema)}`);
|
|
278
|
+
}
|
|
279
|
+
assertString(doc.description, `${file}.description`);
|
|
280
|
+
assertString(doc.license, `${file}.license`);
|
|
281
|
+
assertStringArray(doc.clients, `${file}.clients`);
|
|
282
|
+
assertStringArray(doc.capabilities, `${file}.capabilities`);
|
|
283
|
+
assertStringArray(doc.replaces, `${file}.replaces`);
|
|
284
|
+
assertStringArray(doc.conflicts, `${file}.conflicts`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ①—⑤:仓库路径、kind、namespace、name、version
|
|
288
|
+
const wantPath = `artifacts/${record.kind}s/${record.namespace}/${record.name}/${record.version}`;
|
|
289
|
+
const checks = [
|
|
290
|
+
['① 仓库路径', record.path, wantPath],
|
|
291
|
+
['② kind', doc.kind, record.kind],
|
|
292
|
+
['③ namespace', doc.namespace, record.namespace],
|
|
293
|
+
['④ name', doc.name, record.name],
|
|
294
|
+
['⑤ version', doc.version, record.version],
|
|
295
|
+
// ⑥ snapshot record 的 id / ns / name / version / kind
|
|
296
|
+
['⑥ id', record.id, `${record.kind}:${record.namespace}/${record.name}@${record.version}`],
|
|
297
|
+
];
|
|
298
|
+
for (const [label, got, want] of checks) {
|
|
299
|
+
if (got !== want) viol('E_MANIFEST_BINDING', `${label} 不一致:${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ⑦ skill 的第七项:SKILL.md frontmatter 的 name
|
|
303
|
+
let frontmatter = null;
|
|
304
|
+
if (isSkill) {
|
|
305
|
+
const sp = join(payloadDir, 'SKILL.md');
|
|
306
|
+
if (!existsSync(sp) || !statSync(sp).isFile()) viol('E_MANIFEST_MISSING', '载荷根缺少 SKILL.md(§5.1)');
|
|
307
|
+
frontmatter = parseFrontmatter(readFileSync(sp, 'utf8'));
|
|
308
|
+
if (frontmatter.name !== record.name) {
|
|
309
|
+
viol('E_MANIFEST_BINDING', `⑦ SKILL.md frontmatter 的 name 是 ${JSON.stringify(frontmatter.name)},应为 ${record.name}`);
|
|
310
|
+
}
|
|
311
|
+
if (typeof frontmatter.description !== 'string' || frontmatter.description === '') {
|
|
312
|
+
viol('E_MANIFEST_BINDING', 'SKILL.md frontmatter 缺少 description(§5.1)');
|
|
313
|
+
}
|
|
314
|
+
// 🔴 版本只放 skill.json;SKILL.md frontmatter 只承担运行时语义(§5.1 末段)
|
|
315
|
+
if (Object.hasOwn(frontmatter, 'version')) {
|
|
316
|
+
viol('E_MANIFEST_BINDING', 'SKILL.md frontmatter 不得带 version —— 版本只放 skill.json(§5.1)');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { manifest: doc, frontmatter };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 第 7 步的组合入口:验资产 → 解包 → 树摘要 → manifest 绑定。
|
|
325
|
+
*
|
|
326
|
+
* 🔴 成功时 `dir` 的所有权移交调用方,**调用方必须调 `dispose()`**。
|
|
327
|
+
* 返回值里带 `dispose` 而不是只在文档里写一句「记得删」——
|
|
328
|
+
* 实测「靠调用方记得」是不成立的:我们自己的测试就漏了一地,
|
|
329
|
+
* 一轮全量跑之后 `$TMPDIR` 里有 3807 个 `geoly-unpack-*`。
|
|
330
|
+
*
|
|
331
|
+
* 能用 `withVerifiedArtifact()` 就用它,那个结构上不可能忘。
|
|
332
|
+
*/
|
|
333
|
+
export function verifyArtifact({ bytes, record, parent = tmpdir() }) {
|
|
334
|
+
const r = verifyAndExtract({ bytes, record, parent });
|
|
335
|
+
// 🔴 `dispose` 必须在**任何可能抛错的步骤之前**就能用。
|
|
336
|
+
// 早先它构造在 assertManifestBinding 之后 —— 绑定失败时 r.dir 就没人收尸了,
|
|
337
|
+
// 而调用方连 dir 都拿不到(异常里没有它),想清也清不了。
|
|
338
|
+
// 与 verifyAndExtract 里那段「失败路径必须自己收尸」是同一条,我漏了这一处。
|
|
339
|
+
let disposed = false;
|
|
340
|
+
const dispose = () => {
|
|
341
|
+
if (disposed) return;
|
|
342
|
+
disposed = true;
|
|
343
|
+
try { rmSync(r.dir, { recursive: true, force: true }); } catch { /* 尽力而为 */ }
|
|
344
|
+
};
|
|
345
|
+
let b;
|
|
346
|
+
try {
|
|
347
|
+
b = assertManifestBinding(record, r.dir);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
dispose();
|
|
350
|
+
throw err;
|
|
351
|
+
}
|
|
352
|
+
return { ...r, ...b, dispose };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 作用域版:`fn` 无论正常返回还是抛错,隔离目录都会被清掉。
|
|
357
|
+
*
|
|
358
|
+
* 🔴 **这是首选入口。** 把「记得清理」从一条纪律变成一个结构性质:
|
|
359
|
+
* 调用方拿不到不清理的写法。异步 `fn` 也支持。
|
|
360
|
+
*/
|
|
361
|
+
export function withVerifiedArtifact({ bytes, record, parent = tmpdir() }, fn) {
|
|
362
|
+
const art = verifyArtifact({ bytes, record, parent });
|
|
363
|
+
let promise = false;
|
|
364
|
+
try {
|
|
365
|
+
const out = fn(art);
|
|
366
|
+
if (out && typeof out.then === 'function') {
|
|
367
|
+
promise = true;
|
|
368
|
+
return out.finally(() => art.dispose());
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
} finally {
|
|
372
|
+
if (!promise) art.dispose();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export { TarViolation };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// 原子写与目录链 fsync —— 规范见 11-wire-contract.md §5、04-install.md §5.2.1
|
|
2
|
+
//
|
|
3
|
+
// 🔴 本模块是**全部**状态写入的唯一出口。故障注入框架(src/fault-inject.mjs)
|
|
4
|
+
// 在这里埋具名注入点并维护「持久性影子」,因此**绕过本模块的裸 fs 写入
|
|
5
|
+
// 对崩溃测试不可见**。事务内核必须只走这里。
|
|
6
|
+
import {
|
|
7
|
+
openSync, closeSync, fsyncSync, writeFileSync, renameSync, mkdirSync,
|
|
8
|
+
existsSync, statSync, lstatSync, readdirSync, unlinkSync, rmdirSync, readFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
fp, durableDir, durableFile, pendingCreate, pendingData, pendingRename, pendingUnlink,
|
|
13
|
+
shadowActive,
|
|
14
|
+
} from './fault-inject.mjs';
|
|
15
|
+
|
|
16
|
+
/** fsync 一个目录(POSIX:需以只读方式打开目录 fd) */
|
|
17
|
+
export function fsyncDir(dir) {
|
|
18
|
+
fp('fsync-dir:pre', { dir });
|
|
19
|
+
const fd = openSync(dir, 'r');
|
|
20
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
21
|
+
durableDir(dir);
|
|
22
|
+
fp('fsync-dir:post', { dir });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 原子写:临时文件 → fsync → rename → fsync 父目录。
|
|
27
|
+
* 🔴 §11 §5:禁止原地覆写。
|
|
28
|
+
* 🔴 失败即停机;**不得声称「磁盘未变」** —— rename 之后、父目录 fsync 报错时文件可能已存在。
|
|
29
|
+
*/
|
|
30
|
+
// 🔴 `.${Date.now()}-${pid}.tmp` 在同一毫秒内会撞名,`wx` 直接 EEXIST。
|
|
31
|
+
// 事务的 journal 每个段都要重写一次,一毫秒里写两次是常态 —— 故障注入
|
|
32
|
+
// 框架第一次跑就打出来了。加进程内单调计数器,同进程必不重复。
|
|
33
|
+
let tmpSeq = 0;
|
|
34
|
+
|
|
35
|
+
export function writeAtomic(path, data) {
|
|
36
|
+
const dir = dirname(path);
|
|
37
|
+
const tmp = join(dir, `.${Date.now().toString(36)}-${process.pid}-${(tmpSeq++).toString(36)}.tmp`);
|
|
38
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
39
|
+
const fd = openSync(tmp, 'wx', 0o644);
|
|
40
|
+
try {
|
|
41
|
+
// 🔴 探针必须在 try 里:放在 try 之前时,throw 模式会漏掉这个 fd。
|
|
42
|
+
pendingCreate(tmp);
|
|
43
|
+
fp('atomic-write:post-open-tmp', { path });
|
|
44
|
+
fp('atomic-write:pre-write', { path, syscall: 'write' });
|
|
45
|
+
writeFileSync(fd, buf);
|
|
46
|
+
pendingData(tmp);
|
|
47
|
+
fp('atomic-write:post-write', { path });
|
|
48
|
+
fp('atomic-write:pre-fsync-file', { path, syscall: 'fsync' });
|
|
49
|
+
fsyncSync(fd);
|
|
50
|
+
durableFile(tmp);
|
|
51
|
+
fp('atomic-write:post-fsync-file', { path });
|
|
52
|
+
} finally { closeSync(fd); }
|
|
53
|
+
fp('atomic-write:pre-rename', { path, syscall: 'rename' });
|
|
54
|
+
// 掉电模型需要「这次 rename 没发生」时把被覆盖的旧目标写回去。
|
|
55
|
+
// 🔴 只在影子激活时读 —— 否则生产热路径每写一次状态文件就多读一遍旧文件。
|
|
56
|
+
const overwritten = shadowActive() && existsSync(path) ? readFileSync(path) : undefined;
|
|
57
|
+
renameSync(tmp, path);
|
|
58
|
+
pendingRename(tmp, path, overwritten);
|
|
59
|
+
fp('atomic-write:post-rename', { path });
|
|
60
|
+
fsyncDir(dir);
|
|
61
|
+
fp('atomic-write:post-fsync-dir', { path });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 逐层创建目录并 fsync 到已存在的祖先为止(§5.2.1)。
|
|
66
|
+
* 反例:只 fsync 叶子时,断电可能「叶子里的文件在、而叶子本身的目录项不在」。
|
|
67
|
+
*/
|
|
68
|
+
export function mkdirChainFsync(target) {
|
|
69
|
+
const missing = [];
|
|
70
|
+
let cur = target;
|
|
71
|
+
while (!existsSync(cur)) { missing.unshift(cur); cur = dirname(cur); }
|
|
72
|
+
if (missing.length === 0) return;
|
|
73
|
+
for (const d of missing) { mkdirSync(d, 0o755); pendingCreate(d); }
|
|
74
|
+
fp('mkdir-chain:post-mkdir', { target, created: missing.length });
|
|
75
|
+
// 从最深处往上 fsync:每个新建目录 + 其父
|
|
76
|
+
for (let i = missing.length - 1; i >= 0; i--) {
|
|
77
|
+
fsyncDir(missing[i]);
|
|
78
|
+
fp('mkdir-chain:mid-fsync', { dir: missing[i] });
|
|
79
|
+
}
|
|
80
|
+
fsyncDir(cur); // 已存在的最近祖先
|
|
81
|
+
fp('mkdir-chain:post-fsync', { target });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** rmtree 之后 fsync 父目录(§5.4 幂等规则) */
|
|
85
|
+
export function fsyncParentAfter(path) {
|
|
86
|
+
const p = dirname(path);
|
|
87
|
+
if (existsSync(p)) fsyncDir(p);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 整棵目录的 rename + 两侧父目录 fsync —— §5.3 的 ② 与 ④。
|
|
92
|
+
* 🔴 两侧都要 fsync:只 fsync 目标侧时,断电可能留下「源与目标都在」,
|
|
93
|
+
* 正是 §5.4 幂等表里判 corrupt 的分支 ②。
|
|
94
|
+
*/
|
|
95
|
+
export function renameDirFsync(from, to) {
|
|
96
|
+
fp('rename-dir:pre', { from, to, syscall: 'rename' });
|
|
97
|
+
renameSync(from, to);
|
|
98
|
+
pendingRename(from, to);
|
|
99
|
+
fp('rename-dir:post-rename', { from, to });
|
|
100
|
+
fsyncDir(dirname(to));
|
|
101
|
+
fp('rename-dir:post-fsync-dst', { from, to });
|
|
102
|
+
const src = dirname(from);
|
|
103
|
+
if (src !== dirname(to)) {
|
|
104
|
+
// 🔴 源父目录不见了不是「跳过」的理由 —— 那说明有第三方在动我们的状态目录。
|
|
105
|
+
// fail-closed(§5.4 的 I/O 统一规则)。
|
|
106
|
+
if (!existsSync(src)) throw new Error(`rename-dir:源父目录 ${src} 已不存在,无法 fsync`);
|
|
107
|
+
fsyncDir(src);
|
|
108
|
+
}
|
|
109
|
+
fp('rename-dir:post-fsync-src', { from, to });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 递归删除 + fsync 父目录。
|
|
114
|
+
* §5.4:`rmtree` 天然幂等(不存在就跳过),但 🔴 **成功后必须 fsync 父目录**。
|
|
115
|
+
*
|
|
116
|
+
* 🔴 **自己写递归,不用 `rmSync({recursive:true})`** —— 后者是一次调用,
|
|
117
|
+
* 「删到一半崩」根本注入不进去。而 §5.4 明说「`retired/<name>` 的递归删除
|
|
118
|
+
* 本身是**多次**操作」,那正是要打的窗口。`rmtree:mid` 就埋在每删掉一项之后。
|
|
119
|
+
*/
|
|
120
|
+
export function rmtreeFsync(path) {
|
|
121
|
+
fp('rmtree:pre', { path });
|
|
122
|
+
// 🔴 `existsSync` 是 fail-open 的:broken symlink、EACCES 都返回 false,
|
|
123
|
+
// 于是「看不见」被当成「不存在」,清理被标记完成而目标其实还在。
|
|
124
|
+
// 判据必须是 lstat 的 errno:只有 ENOENT/ENOTDIR 才是真的没有。
|
|
125
|
+
try {
|
|
126
|
+
lstatSync(path);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') {
|
|
129
|
+
fp('rmtree:post', { path, skipped: true });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
throw err; // EACCES 之类:看不见就不能声称删干净了
|
|
133
|
+
}
|
|
134
|
+
rmtreeStepwise(path);
|
|
135
|
+
fp('rmtree:post', { path });
|
|
136
|
+
const p = dirname(path);
|
|
137
|
+
if (existsSync(p)) fsyncDir(p);
|
|
138
|
+
fp('rmtree:post-fsync-parent', { path });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 🔴 **逐项**登记删除效果(Codex 第二轮 #10)。
|
|
143
|
+
* 以前是整棵删完才登记一次、且 undo 是空函数,于是「删到一半掉电」这个窗口
|
|
144
|
+
* 在 powerfail 模型里等于没建模 —— 那正是 §5.6 阶段 C 最危险的一格。
|
|
145
|
+
* 现在每删掉一个条目就存下它的前像,undo 逆序把它放回去。
|
|
146
|
+
*/
|
|
147
|
+
function rmtreeStepwise(path) {
|
|
148
|
+
const st = lstatSync(path);
|
|
149
|
+
if (!st.isDirectory()) {
|
|
150
|
+
const preimage = shadowActive() ? readFileSync(path) : null;
|
|
151
|
+
const mode = st.mode & 0o777;
|
|
152
|
+
unlinkSync(path);
|
|
153
|
+
pendingUnlink(path, preimage, mode);
|
|
154
|
+
fp('rmtree:mid', { removed: path });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
for (const name of readdirSync(path).sort()) rmtreeStepwise(join(path, name));
|
|
158
|
+
rmdirSync(path);
|
|
159
|
+
pendingUnlink(path, null, 0o755, true);
|
|
160
|
+
fp('rmtree:mid', { removed: path });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 判断两个路径是否同设备(stage 与 target 必须同设备,否则 rename 会 EXDEV) */
|
|
164
|
+
export function sameDevice(a, b) {
|
|
165
|
+
return statSync(a).dev === statSync(b).dev;
|
|
166
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// attestation(02-registry.md §1.1)—— DSSE envelope + in-toto statement
|
|
2
|
+
//
|
|
3
|
+
// 🔴 **安装链路不读它。** 它只服务取证:绑定「快照的 sha256 ↔ 源 commit」,
|
|
4
|
+
// 单向依赖(source commit → snapshot → attestation),不构成自引用。
|
|
5
|
+
// P0-1 把 release commit SHA 从快照里删掉了(那会自引用),代价是失去
|
|
6
|
+
// 「哪条流水线生成了它」的强审计;补回来的方式不是塞回快照,而是另发这个签名对象。
|
|
7
|
+
//
|
|
8
|
+
// 本模块**不导出**任何会被安装链路调用的名字,函数名里带 ForForensics 就是提醒。
|
|
9
|
+
import {
|
|
10
|
+
WireError, IntegrityError, REPO,
|
|
11
|
+
parseWireJson, assertExactKeys, assertUint, assertString, assertTreeDigest,
|
|
12
|
+
} from './trust.mjs';
|
|
13
|
+
|
|
14
|
+
export const DSSE_PAYLOAD_TYPE = 'application/vnd.in-toto+json';
|
|
15
|
+
export const PREDICATE_TYPE = 'https://geoly.ai/skills-hub/release/v1';
|
|
16
|
+
export const BUILD_TYPE = 'geoly-skills/release/v1';
|
|
17
|
+
export const STATEMENT_TYPE = 'https://in-toto.io/Statement/v1';
|
|
18
|
+
|
|
19
|
+
const RE_COMMIT = /^[0-9a-f]{40}$/;
|
|
20
|
+
const RE_HEX64 = /^[0-9a-f]{64}$/;
|
|
21
|
+
const RE_B64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
22
|
+
|
|
23
|
+
const ENVELOPE_KEYS = { required: ['payload', 'payloadType', 'signatures'] };
|
|
24
|
+
const SIG_KEYS = { required: ['sig'], optional: ['keyid'] };
|
|
25
|
+
const STATEMENT_KEYS = { required: ['_type', 'subject', 'predicateType', 'predicate'] };
|
|
26
|
+
const SUBJECT_KEYS = { required: ['name', 'digest'] };
|
|
27
|
+
const PREDICATE_KEYS = { required: ['buildType', 'sourceRepo', 'sourceCommit', 'workflowRef', 'promotionPr'] };
|
|
28
|
+
|
|
29
|
+
/** DSSE 的 PAE 预认证编码 —— 验签器要签的就是这串字节 */
|
|
30
|
+
export function pae(payloadType, payload) {
|
|
31
|
+
const t = Buffer.from(payloadType, 'utf8');
|
|
32
|
+
const p = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
33
|
+
return Buffer.concat([
|
|
34
|
+
Buffer.from(`DSSEv1 ${t.length} `, 'utf8'), t,
|
|
35
|
+
Buffer.from(` ${p.length} `, 'utf8'), p,
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 严格 base64:拒绝空白、换行、非标准字母表、非 canonical 填充 */
|
|
40
|
+
function decodeB64Strict(s, where) {
|
|
41
|
+
assertString(s, where);
|
|
42
|
+
if (!RE_B64.test(s) || s.length % 4 !== 0) throw new WireError('E_B64', `${where} 不是严格 base64`);
|
|
43
|
+
const buf = Buffer.from(s, 'base64');
|
|
44
|
+
if (buf.toString('base64') !== s) throw new WireError('E_B64', `${where} 的 base64 不是 canonical 形式`);
|
|
45
|
+
return buf;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 解析并严格校验一个 attestation。
|
|
50
|
+
*
|
|
51
|
+
* 🔴 **只用于取证**(02-registry.md §1.1 的契约表:「验证者 = 取证工具与人;
|
|
52
|
+
* CLI 的安装链路不读」)。把它接进安装链路等于把一个不参与信任的对象变成信任输入。
|
|
53
|
+
*
|
|
54
|
+
* @param {Buffer} bytes DSSE envelope 的 JSON 字节
|
|
55
|
+
* @param {{expectSnapshotSha256?:string, expectSnapshotN?:number}} expect
|
|
56
|
+
*/
|
|
57
|
+
export function parseAttestationForForensics(bytes, { expectSnapshotSha256, expectSnapshotN } = {}) {
|
|
58
|
+
const env = parseWireJson(bytes, 'attestation');
|
|
59
|
+
assertExactKeys(env, ENVELOPE_KEYS, 'attestation');
|
|
60
|
+
if (env.payloadType !== DSSE_PAYLOAD_TYPE) {
|
|
61
|
+
throw new WireError('E_DSSE_PAYLOAD_TYPE', `payloadType 必须是 ${DSSE_PAYLOAD_TYPE},得到 ${JSON.stringify(env.payloadType)}`);
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(env.signatures) || env.signatures.length === 0) {
|
|
64
|
+
throw new WireError('E_DSSE_SIGNATURES', 'signatures 必须是非空数组');
|
|
65
|
+
}
|
|
66
|
+
env.signatures.forEach((s, i) => {
|
|
67
|
+
assertExactKeys(s, SIG_KEYS, `attestation.signatures[${i}]`);
|
|
68
|
+
decodeB64Strict(s.sig, `attestation.signatures[${i}].sig`);
|
|
69
|
+
if (Object.hasOwn(s, 'keyid')) assertString(s.keyid, `attestation.signatures[${i}].keyid`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const payload = decodeB64Strict(env.payload, 'attestation.payload');
|
|
73
|
+
const stmt = parseWireJson(payload, 'attestation.payload');
|
|
74
|
+
assertExactKeys(stmt, STATEMENT_KEYS, 'attestation.payload');
|
|
75
|
+
if (stmt._type !== STATEMENT_TYPE) {
|
|
76
|
+
throw new WireError('E_STATEMENT_TYPE', `_type 必须是 ${STATEMENT_TYPE},得到 ${JSON.stringify(stmt._type)}`);
|
|
77
|
+
}
|
|
78
|
+
if (stmt.predicateType !== PREDICATE_TYPE) {
|
|
79
|
+
throw new WireError('E_PREDICATE_TYPE', `predicateType 必须是固定字符串 ${PREDICATE_TYPE}(变更即升版本)`);
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(stmt.subject) || stmt.subject.length !== 1) {
|
|
82
|
+
throw new WireError('E_SUBJECT_COUNT', 'subject 必须恰好一项(一个 attestation 只绑一张快照)');
|
|
83
|
+
}
|
|
84
|
+
const sub = stmt.subject[0];
|
|
85
|
+
assertExactKeys(sub, SUBJECT_KEYS, 'attestation.subject[0]');
|
|
86
|
+
assertString(sub.name, 'attestation.subject[0].name');
|
|
87
|
+
assertExactKeys(sub.digest, { required: ['sha256'] }, 'attestation.subject[0].digest');
|
|
88
|
+
if (!RE_HEX64.test(assertString(sub.digest.sha256, 'attestation.subject[0].digest.sha256'))) {
|
|
89
|
+
throw new WireError('E_SUBJECT_DIGEST', 'subject[0].digest.sha256 必须是 64 位小写 hex');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const p = stmt.predicate;
|
|
93
|
+
assertExactKeys(p, PREDICATE_KEYS, 'attestation.predicate');
|
|
94
|
+
if (p.buildType !== BUILD_TYPE) throw new WireError('E_BUILD_TYPE', `buildType 必须是 ${BUILD_TYPE}`);
|
|
95
|
+
if (p.sourceRepo !== REPO) throw new WireError('E_SOURCE_REPO', `sourceRepo 必须是 ${REPO},得到 ${p.sourceRepo}`);
|
|
96
|
+
if (!RE_COMMIT.test(assertString(p.sourceCommit, 'attestation.predicate.sourceCommit'))) {
|
|
97
|
+
throw new WireError('E_SOURCE_COMMIT', `sourceCommit 必须是 40 位小写 hex,得到 ${p.sourceCommit}`);
|
|
98
|
+
}
|
|
99
|
+
assertUint(p.promotionPr, 'attestation.predicate.promotionPr');
|
|
100
|
+
|
|
101
|
+
// 🔴 workflowRef 必须是**不可变标识**:`.github/workflows/release.yml@<40 位 sha>`。
|
|
102
|
+
// **不接受 `@refs/heads/main`** —— 分支引用本身可变,写它等于没写。
|
|
103
|
+
const wf = assertString(p.workflowRef, 'attestation.predicate.workflowRef');
|
|
104
|
+
if (/@refs\/(heads|tags)\//.test(wf)) {
|
|
105
|
+
throw new WireError('E_WORKFLOW_REF_MUTABLE',
|
|
106
|
+
`workflowRef 用了可变的分支/标签引用(${wf}):必须钉 40 位 commit sha`);
|
|
107
|
+
}
|
|
108
|
+
const m = /^\.github\/workflows\/release\.yml@([0-9a-f]{40})$/.exec(wf);
|
|
109
|
+
if (!m) {
|
|
110
|
+
throw new WireError('E_WORKFLOW_REF', `workflowRef 必须形如 .github/workflows/release.yml@<40 位小写 hex>,得到 ${wf}`);
|
|
111
|
+
}
|
|
112
|
+
// 规格给的示例里两者相同;这里强制相等,否则「哪条流水线」与「哪个源 commit」可以各说各话。
|
|
113
|
+
if (m[1] !== p.sourceCommit) {
|
|
114
|
+
throw new WireError('E_WORKFLOW_REF_COMMIT',
|
|
115
|
+
`workflowRef 钉的 commit(${m[1]}) 与 sourceCommit(${p.sourceCommit}) 不一致`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 与 timestamp 交叉核对(契约表:subject[].digest.sha256 必须与 timestamp.snapshot_sha256 一致)
|
|
119
|
+
if (expectSnapshotSha256 !== undefined) {
|
|
120
|
+
const want = expectSnapshotSha256.replace(/^sha256:/, '');
|
|
121
|
+
if (sub.digest.sha256 !== want) {
|
|
122
|
+
throw new IntegrityError('E_ATTEST_SUBJECT_MISMATCH',
|
|
123
|
+
`attestation 的 subject 摘要 ${sub.digest.sha256} 与 timestamp.snapshot_sha256 ${want} 不一致`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (expectSnapshotN !== undefined && sub.name !== `hub-${expectSnapshotN}.json`) {
|
|
127
|
+
throw new IntegrityError('E_ATTEST_SUBJECT_NAME',
|
|
128
|
+
`attestation 的 subject.name 是 ${sub.name},期望 hub-${expectSnapshotN}.json`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { envelope: env, statement: stmt, predicate: p, subject: sub, pae: pae(env.payloadType, payload) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// `assertTreeDigest` 在本模块用不到,但 re-export 会诱使调用方从这里拿安装用的校验器。
|
|
135
|
+
// 故意不 re-export —— 保持「attestation 不在安装链路上」这条边界清晰。
|
|
136
|
+
void assertTreeDigest;
|