skillcaddy 0.27.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/CHANGELOG.md +497 -0
- package/CONTEXT.md +149 -0
- package/LICENSE +21 -0
- package/README.md +499 -0
- package/README_CN.md +482 -0
- package/collection-metadata/github/mattpocock.json +16 -0
- package/lib/canonicalJson.js +11 -0
- package/lib/claudeStore.js +135 -0
- package/lib/collectionEnablement.js +115 -0
- package/lib/collectionSetup.js +199 -0
- package/lib/enablePlan.js +116 -0
- package/lib/enablementScope.js +8 -0
- package/lib/hermesStore.js +21 -0
- package/lib/libraryImage.js +1785 -0
- package/lib/managerInstall.js +109 -0
- package/lib/managerSourceWorkflow.js +234 -0
- package/lib/projectActions.js +24 -0
- package/lib/projectPath.js +51 -0
- package/lib/roots.js +99 -0
- package/lib/skillLinkStore.js +68 -0
- package/lib/skillMetadata.js +191 -0
- package/lib/skillStore.js +541 -0
- package/lib/sourceAcquisition.js +475 -0
- package/lib/sourceAcquisitionAdapter.js +47 -0
- package/lib/sourceAcquisitionError.js +18 -0
- package/lib/sourceAcquisitionPolicy.js +139 -0
- package/lib/sourceArchive.js +363 -0
- package/lib/sourceArchiveWorkspace.js +36 -0
- package/lib/sourceGit.js +95 -0
- package/lib/sourceGitAcquisitionAdapter.js +145 -0
- package/lib/sourceGitBatchUpdate.js +168 -0
- package/lib/sourceGitRepair.js +227 -0
- package/lib/sourceGitStaging.js +139 -0
- package/lib/sourceGitUpgradeAdapter.js +583 -0
- package/lib/sourceGitUrl.js +170 -0
- package/lib/sourceHttp.js +302 -0
- package/lib/sourceLocal.js +200 -0
- package/lib/sourceLocalAcquisitionAdapter.js +107 -0
- package/lib/sourceManager.js +242 -0
- package/lib/sourceMigration.js +172 -0
- package/lib/sourceMigrationIssue.js +6 -0
- package/lib/sourcePath.js +6 -0
- package/lib/sourcePolicy.js +53 -0
- package/lib/sourceRecord.js +295 -0
- package/lib/sourceRegistry.js +155 -0
- package/lib/sourceRemoteArchiveAcquisitionAdapter.js +147 -0
- package/lib/sourceRemoteFileAcquisitionAdapter.js +95 -0
- package/lib/sourceRemoteFilePolicy.js +62 -0
- package/lib/sourceRemoteFileWorkspace.js +55 -0
- package/lib/sourceSkillDiscovery.js +117 -0
- package/lib/sourceTree.js +185 -0
- package/lib/sourceUpgrade.js +596 -0
- package/lib/sourceUpgradeAdapter.js +41 -0
- package/lib/sourceUpgradePolicy.js +170 -0
- package/lib/sourceUpgradeTransaction.js +202 -0
- package/lib/sourceValidation.js +120 -0
- package/lib/sourceWorkspace.js +37 -0
- package/lib/specGate.js +235 -0
- package/lib/tuiActions.js +169 -0
- package/lib/tuiInstall.js +133 -0
- package/lib/tuiLayout.js +94 -0
- package/lib/version.js +17 -0
- package/lib/webManager.js +370 -0
- package/package.json +74 -0
- package/public/agentsUi.js +138 -0
- package/public/app.js +1431 -0
- package/public/claude.css +2 -0
- package/public/claudeUi.js +119 -0
- package/public/emptyState.js +12 -0
- package/public/enabled.css +303 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +199 -0
- package/public/skillcaddy_CN.png +0 -0
- package/public/skillcaddy_EN.png +0 -0
- package/public/styles.css +1611 -0
- package/scripts/cli.js +647 -0
- package/scripts/cron-sync-update-git.sh +128 -0
- package/scripts/pull-github.sh +22 -0
- package/scripts/source.js +638 -0
- package/scripts/tui.js +1058 -0
- package/server.js +196 -0
- package/skills/skillcaddy-manager/SKILL.md +93 -0
- package/skills/skillcaddy-manager/agents/openai.yaml +7 -0
- package/skills/skillcaddy-manager/references/METADATA.md +75 -0
- package/skills/skillcaddy-manager/references/OPERATIONS.md +315 -0
- package/skills/skillcaddy-manager/references/RECOMMENDATION_GUIDE.md +51 -0
- package/skills/skillcaddy-manager/references/featured-skills.json +564 -0
- package/skills/skillcaddy-manager/references/skill-platforms.json +48 -0
- package/skills/skillcaddy-manager/scripts/check-conflicts.cjs +117 -0
- package/skills/skillcaddy-manager/scripts/check-global-skills.cjs +169 -0
- package/skills/skillcaddy-manager/scripts/translate-skill-notes.cjs +297 -0
- package/skills/skillcaddy-manager/scripts/version-manager.cjs +146 -0
- package/skills/skillcaddy-manager/scripts/view-recommendations.cjs +279 -0
- package/skills/skillcaddy-manager/skillcaddy.json +9 -0
- package/skills/spec-pipeline/spec-direction-gate/SKILL.md +119 -0
- package/skills/spec-pipeline/spec-direction-gate/check-approval.py +110 -0
- package/skills/spec-pipeline/spec-draft-intake/SKILL.md +116 -0
- package/skills/spec-pipeline/spec-role-contract/SKILL.md +113 -0
- package/skills/spec-pipeline/spec-role-contract/check-roles.py +108 -0
|
@@ -0,0 +1,1785 @@
|
|
|
1
|
+
import { chmod, cp, link, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, rmdir, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
6
|
+
import { createGunzip } from 'node:zlib';
|
|
7
|
+
import { Transform } from 'node:stream';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { planSourceAcquisition, applySourceAcquisition } from './sourceAcquisition.js';
|
|
11
|
+
import { readSourceRecords } from './sourceRegistry.js';
|
|
12
|
+
import { checksumDirectory, checksumDirectoryNormalized, ensureSourceDirectory, sourcePathExists } from './sourceTree.js';
|
|
13
|
+
import { SOURCE_FOLDERS, SOURCE_INSTALLING_MARKER } from './sourcePolicy.js';
|
|
14
|
+
import { createManagedSourceWorkspace, removeManagedSourceWorkspace } from './sourceWorkspace.js';
|
|
15
|
+
import { getGlobalSkillsDir } from './skillStore.js';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { updateSkillMetadata } from './skillMetadata.js';
|
|
18
|
+
import { getHermesSkillsDir, isHermesEligibleSource } from './hermesStore.js';
|
|
19
|
+
import { scanSkillLinks } from './skillLinkStore.js';
|
|
20
|
+
import { SourceAcquisitionError } from './sourceAcquisitionError.js';
|
|
21
|
+
import { readGitHead } from './sourceGit.js';
|
|
22
|
+
import { isPathInsideOrEqual } from './sourcePath.js';
|
|
23
|
+
|
|
24
|
+
// 默认限制。形状与 `DEFAULT_ARCHIVE_LIMITS`(`lib/sourceArchive.js`)一致,
|
|
25
|
+
// `maxDepth` 提高到 40 以容忍 tar 内更深嵌套,同时保持失败语义闭环。
|
|
26
|
+
export const DEFAULT_LIBRARY_IMAGE_LIMITS = Object.freeze({
|
|
27
|
+
maxExpandedBytes: 500 * 1024 * 1024,
|
|
28
|
+
maxEntries: 10_000,
|
|
29
|
+
maxFileBytes: 100 * 1024 * 1024,
|
|
30
|
+
maxDepth: 40
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// staging 内唯一允许存在的两类条目。这是 allowlist:
|
|
34
|
+
// 任何没落在这里的类型都会被拒收,即使它不在下面的 denylist 里。
|
|
35
|
+
export const LIBRARY_IMAGE_ENTRY_TYPES = new Set(['file', 'directory']);
|
|
36
|
+
|
|
37
|
+
// denylist 只负责给已知的危险类型一个精确的错误信息;
|
|
38
|
+
// 真正的闭环由上面的 allowlist 提供。
|
|
39
|
+
export const LIBRARY_IMAGE_DENIED_ENTRY_TYPES = new Set([
|
|
40
|
+
'symlink',
|
|
41
|
+
'hardlink',
|
|
42
|
+
'fifo',
|
|
43
|
+
'block',
|
|
44
|
+
'character',
|
|
45
|
+
'socket',
|
|
46
|
+
'contiguous',
|
|
47
|
+
'unknown'
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// 跨平台强制 flag。
|
|
51
|
+
//
|
|
52
|
+
// `common` 只收录两个实现都长期支持的选项,实测于 macOS bsdtar 3.5.3:
|
|
53
|
+
// --no-acls --no-xattrs --no-same-permissions --no-same-owner
|
|
54
|
+
// `--no-same-owner` 尤其重要:GNU tar 以 root 运行时默认按归档里的
|
|
55
|
+
// uid/gid 恢复所有权,解压不可信归档时必须关掉。
|
|
56
|
+
//
|
|
57
|
+
// `bsdtar` 收录 bsdtar 专属项(GNU tar 会当作未知选项报错)。
|
|
58
|
+
// GNU-only flags verified by the #33 unpacking research.
|
|
59
|
+
export const LIBRARY_IMAGE_FLAG_POLICY = Object.freeze({
|
|
60
|
+
common: Object.freeze([
|
|
61
|
+
'--no-acls',
|
|
62
|
+
'--no-xattrs',
|
|
63
|
+
'--no-same-permissions',
|
|
64
|
+
'--no-same-owner'
|
|
65
|
+
]),
|
|
66
|
+
bsdtar: Object.freeze(['--no-mac-metadata', '--no-fflags']),
|
|
67
|
+
gnutar: Object.freeze(['--no-selinux', '--no-overwrite-dir', '--delay-directory-restore'])
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const LIBRARY_IMAGE_REQUIRED_TAR_FLAGS = LIBRARY_IMAGE_FLAG_POLICY.common;
|
|
71
|
+
|
|
72
|
+
const TAR_INVOCATION_TIMEOUT_MS = 30_000;
|
|
73
|
+
|
|
74
|
+
// tar 的 stderr 由归档内容驱动,恶意归档可以让它无限输出;
|
|
75
|
+
// 只保留前 64 KiB 用于错误信息。
|
|
76
|
+
const MAX_TAR_OUTPUT_BYTES = 64 * 1024;
|
|
77
|
+
|
|
78
|
+
const BLOCK_SIZE = 512;
|
|
79
|
+
|
|
80
|
+
// PAX / GNU 元数据块的 payload 上限。正常的 PAX 记录只有几百字节。
|
|
81
|
+
const MAX_METADATA_PAYLOAD_BYTES = 1024 * 1024;
|
|
82
|
+
|
|
83
|
+
// tar header 的 typeflag。'x' / 'g' / 'L' / 'K' 不是条目本身,
|
|
84
|
+
// 而是描述下一个条目的元数据块,由 parser 消化后不进入条目列表。
|
|
85
|
+
const TAR_TYPEFLAG_BY_BYTE = {
|
|
86
|
+
0x00: 'file', // ustar 'NUL' alternative
|
|
87
|
+
0x30: 'file', // '0'
|
|
88
|
+
0x31: 'hardlink', // '1'
|
|
89
|
+
0x32: 'symlink', // '2'
|
|
90
|
+
0x33: 'character', // '3'
|
|
91
|
+
0x34: 'block', // '4'
|
|
92
|
+
0x35: 'directory', // '5'
|
|
93
|
+
0x36: 'fifo', // '6'
|
|
94
|
+
0x37: 'contiguous', // '7' —— GNU tar 视作普通文件,这里保持更严的拒收
|
|
95
|
+
0x53: 'socket' // 'S'
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const TAR_METADATA_KIND_BY_BYTE = {
|
|
99
|
+
0x78: 'pax-extended', // 'x' —— 作用于紧随其后的一个条目
|
|
100
|
+
0x67: 'pax-global', // 'g' —— 作用于其后所有条目
|
|
101
|
+
0x4c: 'gnu-long-name', // 'L'
|
|
102
|
+
0x4b: 'gnu-long-link' // 'K'
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// 压缩容器签名。`tar -xf` 会自动识别压缩格式,但 pre-flight 解析的是裸 tar;
|
|
106
|
+
// 两者对同一个字节流的解读会完全脱节,因此压缩输入一律拒收。
|
|
107
|
+
const COMPRESSED_SIGNATURES = [
|
|
108
|
+
{ label: 'gzip', bytes: [0x1f, 0x8b] },
|
|
109
|
+
{ label: 'bzip2', bytes: [0x42, 0x5a, 0x68] },
|
|
110
|
+
{ label: 'xz', bytes: [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00] },
|
|
111
|
+
{ label: 'zstd', bytes: [0x28, 0xb5, 0x2f, 0xfd] },
|
|
112
|
+
{ label: 'lz4', bytes: [0x04, 0x22, 0x4d, 0x18] },
|
|
113
|
+
{ label: 'compress', bytes: [0x1f, 0x9d] },
|
|
114
|
+
{ label: 'zip', bytes: [0x50, 0x4b, 0x03, 0x04] }
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
function safetyError(message) {
|
|
118
|
+
return new SourceAcquisitionError('source-safety', message);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function classifyTypeflag(byte) {
|
|
122
|
+
return TAR_TYPEFLAG_BY_BYTE[byte] || 'unknown';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function classifyDirent(dirent) {
|
|
126
|
+
if (dirent.isSymbolicLink()) return 'symlink';
|
|
127
|
+
if (dirent.isFile()) return 'file';
|
|
128
|
+
if (dirent.isDirectory()) return 'directory';
|
|
129
|
+
if (dirent.isFIFO()) return 'fifo';
|
|
130
|
+
if (dirent.isSocket()) return 'socket';
|
|
131
|
+
if (dirent.isBlockDevice()) return 'block';
|
|
132
|
+
if (dirent.isCharacterDevice()) return 'character';
|
|
133
|
+
return 'unknown';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveLimits(overrides) {
|
|
137
|
+
const limits = { ...DEFAULT_LIBRARY_IMAGE_LIMITS, ...(overrides?.imageLimits || {}) };
|
|
138
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
139
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
140
|
+
throw safetyError(`invalid library image limit ${name}: ${String(value)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return limits;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A real library can contain the complete nested Git state described by
|
|
147
|
+
// ADR-0011. Keep the public extraction seam's conservative limits stable while
|
|
148
|
+
// giving the image transaction an explicit, larger budget.
|
|
149
|
+
const DEFAULT_LIBRARY_IMAGE_WORKFLOW_LIMITS = Object.freeze({
|
|
150
|
+
maxExpandedBytes: 8 * 1024 * 1024 * 1024,
|
|
151
|
+
maxEntries: 500_000,
|
|
152
|
+
maxFileBytes: 1024 * 1024 * 1024,
|
|
153
|
+
maxDepth: 40
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
function resolveWorkflowLimits(overrides) {
|
|
157
|
+
return resolveLimits({
|
|
158
|
+
...overrides,
|
|
159
|
+
imageLimits: overrides?.imageLimits || DEFAULT_LIBRARY_IMAGE_WORKFLOW_LIMITS
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function spawnTar(args, { timeoutMs = TAR_INVOCATION_TIMEOUT_MS, tarPath = 'tar' } = {}) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const env = { ...process.env, COPYFILE_DISABLE: '1' };
|
|
166
|
+
delete env.TAR_OPTIONS;
|
|
167
|
+
const child = spawn(tarPath, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
168
|
+
let stdout = '';
|
|
169
|
+
let stderr = '';
|
|
170
|
+
let settled = false;
|
|
171
|
+
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
if (settled) return;
|
|
174
|
+
settled = true;
|
|
175
|
+
child.kill('SIGKILL');
|
|
176
|
+
reject(safetyError(`tar invocation timed out after ${timeoutMs}ms: tar ${args.join(' ')}`));
|
|
177
|
+
}, timeoutMs);
|
|
178
|
+
|
|
179
|
+
const collect = (current, chunk) =>
|
|
180
|
+
current.length >= MAX_TAR_OUTPUT_BYTES
|
|
181
|
+
? current
|
|
182
|
+
: (current + chunk.toString('utf8')).slice(0, MAX_TAR_OUTPUT_BYTES);
|
|
183
|
+
|
|
184
|
+
child.stdout.on('data', (chunk) => {
|
|
185
|
+
stdout = collect(stdout, chunk);
|
|
186
|
+
});
|
|
187
|
+
child.stderr.on('data', (chunk) => {
|
|
188
|
+
stderr = collect(stderr, chunk);
|
|
189
|
+
});
|
|
190
|
+
child.on('error', (error) => {
|
|
191
|
+
if (settled) return;
|
|
192
|
+
settled = true;
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
reject(error);
|
|
195
|
+
});
|
|
196
|
+
child.on('close', (code) => {
|
|
197
|
+
if (settled) return;
|
|
198
|
+
settled = true;
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
resolve({ code, stdout, stderr });
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function readCString(buffer, offset, maxLen) {
|
|
206
|
+
let end = offset;
|
|
207
|
+
const limit = offset + maxLen;
|
|
208
|
+
while (end < limit && buffer[end] !== 0) end += 1;
|
|
209
|
+
return buffer.subarray(offset, end).toString('utf8');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 读取 tar 的数值字段。支持两种编码:
|
|
214
|
+
* - 传统 octal(ASCII,空格或 NUL 结尾);
|
|
215
|
+
* - base-256(首字节高位置 1,big-endian)——GNU / bsdtar 在数值超出 octal
|
|
216
|
+
* 宽度时使用。旧实现只认 octal 且解析失败时静默返回 0,会让 header 流错位。
|
|
217
|
+
*/
|
|
218
|
+
function readNumericField(buffer, offset, length, fieldName, blockOffset) {
|
|
219
|
+
const first = buffer[offset];
|
|
220
|
+
if ((first & 0x80) !== 0) {
|
|
221
|
+
const negative = (first & 0x40) !== 0;
|
|
222
|
+
if (negative) {
|
|
223
|
+
throw safetyError(
|
|
224
|
+
`tar header at offset ${blockOffset} declares a negative ${fieldName}`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
let value = first & 0x3f;
|
|
228
|
+
for (let index = offset + 1; index < offset + length; index += 1) {
|
|
229
|
+
value = value * 256 + buffer[index];
|
|
230
|
+
if (!Number.isSafeInteger(value)) {
|
|
231
|
+
throw safetyError(
|
|
232
|
+
`tar header at offset ${blockOffset} declares an out-of-range ${fieldName}`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const raw = buffer.subarray(offset, offset + length).toString('ascii');
|
|
240
|
+
const trimmed = raw.replace(/\0/g, ' ').trim();
|
|
241
|
+
if (trimmed === '') return 0;
|
|
242
|
+
if (!/^[0-7]+$/.test(trimmed)) {
|
|
243
|
+
throw safetyError(
|
|
244
|
+
`tar header at offset ${blockOffset} has a malformed ${fieldName} field: ${JSON.stringify(raw)}`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const value = Number.parseInt(trimmed, 8);
|
|
248
|
+
if (!Number.isSafeInteger(value)) {
|
|
249
|
+
throw safetyError(`tar header at offset ${blockOffset} declares an out-of-range ${fieldName}`);
|
|
250
|
+
}
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 校验 header 的 checksum。这是防止 parser 与系统 tar 解读脱节的关键一环:
|
|
256
|
+
* 一旦 offset 计算错位,落到数据块上的“伪 header”几乎不可能通过校验。
|
|
257
|
+
*/
|
|
258
|
+
function assertHeaderChecksum(block, blockOffset) {
|
|
259
|
+
const declared = readNumericField(block, 148, 8, 'checksum', blockOffset);
|
|
260
|
+
let unsigned = 0;
|
|
261
|
+
let signed = 0;
|
|
262
|
+
for (let index = 0; index < BLOCK_SIZE; index += 1) {
|
|
263
|
+
const byte = index >= 148 && index < 156 ? 0x20 : block[index];
|
|
264
|
+
unsigned += byte;
|
|
265
|
+
signed += byte > 127 ? byte - 256 : byte;
|
|
266
|
+
}
|
|
267
|
+
if (declared !== unsigned && declared !== signed) {
|
|
268
|
+
throw safetyError(
|
|
269
|
+
`tar header checksum mismatch at offset ${blockOffset} (declared ${declared}, computed ${unsigned})`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function parsePaxRecords(payload) {
|
|
275
|
+
const records = {};
|
|
276
|
+
let cursor = 0;
|
|
277
|
+
while (cursor < payload.length) {
|
|
278
|
+
const space = payload.indexOf(0x20, cursor);
|
|
279
|
+
if (space < 0) throw safetyError('Malformed PAX record.');
|
|
280
|
+
const lengthText = payload.subarray(cursor, space).toString('ascii');
|
|
281
|
+
if (!/^[1-9][0-9]*$/.test(lengthText)) throw safetyError('Malformed PAX record length.');
|
|
282
|
+
const declaredLength = Number(lengthText);
|
|
283
|
+
if (!Number.isSafeInteger(declaredLength) || declaredLength <= 0) {
|
|
284
|
+
throw safetyError('library image contains a malformed PAX record length');
|
|
285
|
+
}
|
|
286
|
+
const end = cursor + declaredLength;
|
|
287
|
+
if (end <= space + 1 || payload[end - 1] !== 10 || end > payload.length) {
|
|
288
|
+
throw safetyError('library image contains a truncated PAX record');
|
|
289
|
+
}
|
|
290
|
+
const body = payload.subarray(space + 1, end).toString('utf8').replace(/\n$/, '');
|
|
291
|
+
const equals = body.indexOf('=');
|
|
292
|
+
if (equals <= 0) throw safetyError('Malformed PAX record key.');
|
|
293
|
+
records[body.slice(0, equals)] = body.slice(equals + 1);
|
|
294
|
+
cursor = end;
|
|
295
|
+
}
|
|
296
|
+
return records;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function readBlock(handle, offset) {
|
|
300
|
+
const block = Buffer.alloc(BLOCK_SIZE);
|
|
301
|
+
const { bytesRead } = await handle.read(block, 0, BLOCK_SIZE, offset);
|
|
302
|
+
if (bytesRead < BLOCK_SIZE) {
|
|
303
|
+
throw safetyError(`library image is truncated at offset ${offset}`);
|
|
304
|
+
}
|
|
305
|
+
return block;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function readPayload(handle, offset, size, blockOffset) {
|
|
309
|
+
if (size > MAX_METADATA_PAYLOAD_BYTES) {
|
|
310
|
+
throw safetyError(
|
|
311
|
+
`library image metadata block at offset ${blockOffset} declares ${size} bytes (limit ${MAX_METADATA_PAYLOAD_BYTES})`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
const payload = Buffer.alloc(size);
|
|
315
|
+
if (size === 0) return payload;
|
|
316
|
+
const { bytesRead } = await handle.read(payload, 0, size, offset);
|
|
317
|
+
if (bytesRead < size) {
|
|
318
|
+
throw safetyError(`library image metadata block at offset ${blockOffset} is truncated`);
|
|
319
|
+
}
|
|
320
|
+
return payload;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function isZeroBlock(block) {
|
|
324
|
+
for (const byte of block) {
|
|
325
|
+
if (byte !== 0) return false;
|
|
326
|
+
}
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 归档结束标记之后必须全为零。真实 tar 会用零块把归档补齐到 blocking factor,
|
|
332
|
+
* 所以这一条对合法归档无害;它封掉的是「单个零块之后藏一个条目」——
|
|
333
|
+
* 系统 tar 在遇到零块时停止读取,而藏起来的条目对 pre-flight 不可见。
|
|
334
|
+
*/
|
|
335
|
+
async function assertTrailerIsZeroed(handle, offset, fileSize) {
|
|
336
|
+
let cursor = offset;
|
|
337
|
+
const scratch = Buffer.alloc(BLOCK_SIZE);
|
|
338
|
+
while (cursor < fileSize) {
|
|
339
|
+
const { bytesRead } = await handle.read(scratch, 0, BLOCK_SIZE, cursor);
|
|
340
|
+
if (bytesRead === 0) break;
|
|
341
|
+
for (let index = 0; index < bytesRead; index += 1) {
|
|
342
|
+
if (scratch[index] !== 0) {
|
|
343
|
+
throw safetyError(
|
|
344
|
+
`library image carries non-zero data after the end-of-archive marker at offset ${cursor + index}`
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
cursor += bytesRead;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function assertUncompressed(handle) {
|
|
353
|
+
const probe = Buffer.alloc(8);
|
|
354
|
+
const { bytesRead } = await handle.read(probe, 0, 8, 0);
|
|
355
|
+
for (const signature of COMPRESSED_SIGNATURES) {
|
|
356
|
+
if (bytesRead < signature.bytes.length) continue;
|
|
357
|
+
if (signature.bytes.every((byte, index) => probe[index] === byte)) {
|
|
358
|
+
throw safetyError(
|
|
359
|
+
`library image looks like a ${signature.label} container; library images must be uncompressed tar so pre-flight and tar read the same bytes`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function headerEntryPath(block, name) {
|
|
366
|
+
const magic = block.subarray(257, 263).toString('ascii');
|
|
367
|
+
if (magic !== 'ustar\0' && magic !== 'ustar ') return name;
|
|
368
|
+
const prefix = readCString(block, 345, 155);
|
|
369
|
+
return prefix ? `${prefix}/${name}` : name;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 解析整个 header 流,返回归档实际声明的条目。
|
|
374
|
+
*
|
|
375
|
+
* 相对早期版本补齐的部分(每一项都对应一个 pre-flight 与系统 tar 解读
|
|
376
|
+
* 不一致的缺口):USTAR prefix 字段、base-256 数值编码、PAX 扩展/全局头、
|
|
377
|
+
* GNU 长文件名、header checksum、结束标记之后的残留数据。
|
|
378
|
+
*/
|
|
379
|
+
async function readTarEntries(filePath, limits) {
|
|
380
|
+
const handle = await open(filePath, 'r');
|
|
381
|
+
try {
|
|
382
|
+
const { size: fileSize } = await handle.stat();
|
|
383
|
+
await assertUncompressed(handle);
|
|
384
|
+
|
|
385
|
+
const entries = [];
|
|
386
|
+
let offset = 0;
|
|
387
|
+
let totalBytes = 0;
|
|
388
|
+
let ended = false;
|
|
389
|
+
let pending = {};
|
|
390
|
+
const global = {};
|
|
391
|
+
|
|
392
|
+
while (offset + BLOCK_SIZE <= fileSize) {
|
|
393
|
+
const blockOffset = offset;
|
|
394
|
+
const block = await readBlock(handle, blockOffset);
|
|
395
|
+
if (isZeroBlock(block)) {
|
|
396
|
+
ended = true;
|
|
397
|
+
await assertTrailerIsZeroed(handle, blockOffset, fileSize);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
assertHeaderChecksum(block, blockOffset);
|
|
401
|
+
|
|
402
|
+
const typeByte = block[156];
|
|
403
|
+
const size = readNumericField(block, 124, 12, 'size', blockOffset);
|
|
404
|
+
const dataOffset = blockOffset + BLOCK_SIZE;
|
|
405
|
+
offset = dataOffset + Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE;
|
|
406
|
+
|
|
407
|
+
const metadataKind = TAR_METADATA_KIND_BY_BYTE[typeByte];
|
|
408
|
+
if (metadataKind) {
|
|
409
|
+
const payload = await readPayload(handle, dataOffset, size, blockOffset);
|
|
410
|
+
if (metadataKind === 'gnu-long-name') {
|
|
411
|
+
pending.path = payload.toString('utf8').replace(/\0.*$/, '');
|
|
412
|
+
} else if (metadataKind === 'gnu-long-link') {
|
|
413
|
+
pending.linkname = payload.toString('utf8').replace(/\0.*$/, '');
|
|
414
|
+
} else {
|
|
415
|
+
const records = parsePaxRecords(payload);
|
|
416
|
+
if (Object.keys(records).some(key => key.startsWith('GNU.sparse') || ['SCHILY.filetype', 'SCHILY.realsize', 'SCHILY.mode'].includes(key))) throw safetyError('Unsupported PAX entry interpretation.');
|
|
417
|
+
const target = metadataKind === 'pax-global' ? global : pending;
|
|
418
|
+
target.xattrs = Object.keys(records).filter(key => /^(LIBARCHIVE\.xattr\.|SCHILY\.xattr\.)/.test(key));
|
|
419
|
+
if (records.path !== undefined) target.path = records.path;
|
|
420
|
+
if (records.linkpath !== undefined) target.linkname = records.linkpath;
|
|
421
|
+
if (records.size !== undefined) {
|
|
422
|
+
const paxSize = /^[0-9]+$/.test(records.size) ? Number(records.size) : NaN;
|
|
423
|
+
if (!Number.isSafeInteger(paxSize) || paxSize < 0) {
|
|
424
|
+
throw safetyError('library image declares a malformed PAX size record');
|
|
425
|
+
}
|
|
426
|
+
target.size = paxSize;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const overrides = { ...global, ...pending };
|
|
433
|
+
pending = {};
|
|
434
|
+
|
|
435
|
+
const type = classifyTypeflag(typeByte);
|
|
436
|
+
const entryPath = overrides.path ?? headerEntryPath(block, readCString(block, 0, 100));
|
|
437
|
+
const entrySize = overrides.size ?? size;
|
|
438
|
+
const linkname = overrides.linkname ?? readCString(block, 157, 100);
|
|
439
|
+
|
|
440
|
+
if (entryPath === '') {
|
|
441
|
+
throw safetyError(`tar header at offset ${blockOffset} declares an empty entry path`);
|
|
442
|
+
}
|
|
443
|
+
if (entries.length >= limits.maxEntries) {
|
|
444
|
+
throw safetyError(`library image entry count exceeds limit ${limits.maxEntries}`);
|
|
445
|
+
}
|
|
446
|
+
if (entrySize > limits.maxFileBytes) {
|
|
447
|
+
throw safetyError(
|
|
448
|
+
`library image entry exceeds max file size ${limits.maxFileBytes}: ${entryPath} (${entrySize} bytes)`
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
totalBytes += entrySize;
|
|
452
|
+
if (totalBytes > limits.maxExpandedBytes) {
|
|
453
|
+
throw safetyError(
|
|
454
|
+
`library image expanded size exceeds limit ${limits.maxExpandedBytes}`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// PAX size controls the physical payload consumed by the extractor too.
|
|
459
|
+
offset = dataOffset + Math.ceil(entrySize / BLOCK_SIZE) * BLOCK_SIZE;
|
|
460
|
+
if (offset > fileSize) throw safetyError('Truncated tar entry payload.');
|
|
461
|
+
entries.push({
|
|
462
|
+
path: entryPath,
|
|
463
|
+
type,
|
|
464
|
+
size: entrySize,
|
|
465
|
+
dataOffset,
|
|
466
|
+
xattrs: overrides.xattrs || [],
|
|
467
|
+
uid: readNumericField(block, 108, 8, 'uid', blockOffset),
|
|
468
|
+
gid: readNumericField(block, 116, 8, 'gid', blockOffset),
|
|
469
|
+
mtime: readNumericField(block, 136, 12, 'mtime', blockOffset),
|
|
470
|
+
mode: readNumericField(block, 100, 8, 'mode', blockOffset),
|
|
471
|
+
linkname: type === 'hardlink' || type === 'symlink' ? linkname : undefined
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!ended || Object.keys(pending).length) throw safetyError('Missing tar end marker or orphaned metadata.');
|
|
476
|
+
return { entries, totalBytes };
|
|
477
|
+
} finally {
|
|
478
|
+
await handle.close();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function normalizeEntryPath(entryPath) {
|
|
483
|
+
return path.posix.normalize(entryPath.replace(/\\/g, '/')).replace(/\/+$/, '');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function isMacPackagingJunk(entryPath) {
|
|
487
|
+
const segments = entryPath.split('/');
|
|
488
|
+
const basename = segments.at(-1) || '';
|
|
489
|
+
return segments[0] === '__MACOSX' || basename === '.DS_Store' || basename.startsWith('._');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function assertEntryInsideStaging(entryPath, stagingRoot) {
|
|
493
|
+
if (/^(?:[\\/]|[A-Za-z]:)/.test(entryPath) || entryPath.replace(/\\/g, '/').split('/').includes('..') || /[\u0000-\u001f\u007f]/.test(entryPath)) {
|
|
494
|
+
throw safetyError(`library image entry path resolves outside staging or declares traversal: ${entryPath}`);
|
|
495
|
+
}
|
|
496
|
+
const normalized = normalizeEntryPath(entryPath);
|
|
497
|
+
const stagingAbsolute = path.resolve(stagingRoot);
|
|
498
|
+
const candidate = path.resolve(stagingAbsolute, normalized);
|
|
499
|
+
if (!isPathInsideOrEqual(stagingAbsolute, candidate)) {
|
|
500
|
+
throw safetyError(`library image entry path resolves outside staging: ${entryPath}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function assertEntryDepth(entry, limits) {
|
|
505
|
+
const segments = normalizeEntryPath(entry.path).split('/').filter(Boolean);
|
|
506
|
+
const depth = entry.type === 'directory' ? segments.length : segments.length - 1;
|
|
507
|
+
if (depth > limits.maxDepth) {
|
|
508
|
+
throw safetyError(
|
|
509
|
+
`library image directory depth exceeds limit ${limits.maxDepth}: ${entry.path}`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function assertEntryTypeAllowed(entry, stage) {
|
|
515
|
+
if (LIBRARY_IMAGE_ENTRY_TYPES.has(entry.type)) return;
|
|
516
|
+
const suffix = entry.linkname ? ` -> ${entry.linkname}` : '';
|
|
517
|
+
if (LIBRARY_IMAGE_DENIED_ENTRY_TYPES.has(entry.type)) {
|
|
518
|
+
throw safetyError(
|
|
519
|
+
`library image declares ${entry.type} entry in ${stage}: ${entry.path}${suffix}`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
throw safetyError(
|
|
523
|
+
`library image declares unsupported entry type ${entry.type} in ${stage}: ${entry.path}${suffix}`
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* pre-flight 认可的相对路径集合,含 tar 会隐式创建的父目录。
|
|
529
|
+
* post-flight 用它做 containment 校验:staging 里出现任何不在集合内的
|
|
530
|
+
* 路径,都说明系统 tar 写了 pre-flight 没批准的东西。
|
|
531
|
+
*/
|
|
532
|
+
function buildExpectedPaths(entries) {
|
|
533
|
+
const expected = new Set();
|
|
534
|
+
for (const entry of entries) {
|
|
535
|
+
const normalized = normalizeEntryPath(entry.path);
|
|
536
|
+
if (!normalized || normalized === '.') continue;
|
|
537
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
538
|
+
for (let index = 1; index <= segments.length; index += 1) {
|
|
539
|
+
expected.add(segments.slice(0, index).join('/'));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return expected;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function walkStaging(stagingRoot, libraryLayout = false) {
|
|
546
|
+
const results = [];
|
|
547
|
+
const queue = [stagingRoot];
|
|
548
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
549
|
+
const current = queue[index];
|
|
550
|
+
let dirents;
|
|
551
|
+
try {
|
|
552
|
+
dirents = await readdir(current, { withFileTypes: true });
|
|
553
|
+
} catch (error) {
|
|
554
|
+
// 归档可以声明 mode 0000 的目录。那会让 post-flight 无法进入,
|
|
555
|
+
// 等于有一块没被校验的 staging 区域,因此拒收。
|
|
556
|
+
if (error?.code === 'EACCES' || error?.code === 'EPERM') {
|
|
557
|
+
throw safetyError(
|
|
558
|
+
`post-flight cannot traverse staged directory ${current}; the image declares permissions that would leave part of staging unverified`
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
throw error;
|
|
562
|
+
}
|
|
563
|
+
for (const dirent of dirents) {
|
|
564
|
+
const absPath = path.join(current, dirent.name);
|
|
565
|
+
const type = classifyDirent(dirent);
|
|
566
|
+
if (!LIBRARY_IMAGE_ENTRY_TYPES.has(type) && !(libraryLayout && type === 'symlink')) {
|
|
567
|
+
throw safetyError(`post-flight walk found ${type} at staging path: ${absPath}`);
|
|
568
|
+
}
|
|
569
|
+
results.push({ absPath, type });
|
|
570
|
+
if (type === 'directory') queue.push(absPath);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return results;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* 准备 staging 目录,并记录它是否由本次调用创建。
|
|
578
|
+
*
|
|
579
|
+
* 这里刻意不用 `mkdir(..., { recursive: true })` 建最后一级:recursive 模式
|
|
580
|
+
* 对已存在的目录不报错,调用方传错路径时函数无法分辨「我建的」和「别人的」,
|
|
581
|
+
* 失败清理就会把调用方的数据一起删掉。
|
|
582
|
+
*/
|
|
583
|
+
async function prepareStaging(stagingRoot) {
|
|
584
|
+
const absolute = path.resolve(stagingRoot);
|
|
585
|
+
const parent = path.dirname(absolute);
|
|
586
|
+
if (parent !== absolute) await mkdir(parent, { recursive: true });
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
await mkdir(absolute);
|
|
590
|
+
return { stagingRoot: absolute, createdHere: true };
|
|
591
|
+
} catch (error) {
|
|
592
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const stat = await lstat(absolute);
|
|
596
|
+
if (!stat.isDirectory()) {
|
|
597
|
+
throw safetyError(`staging root is not a directory: ${absolute}`);
|
|
598
|
+
}
|
|
599
|
+
const existing = await readdir(absolute);
|
|
600
|
+
if (existing.length > 0) {
|
|
601
|
+
throw safetyError(
|
|
602
|
+
`staging root must be empty before extraction: ${absolute} holds ${existing.length} entries`
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
return { stagingRoot: absolute, createdHere: false };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* 归档可以声明不可遍历的目录(例如 mode 0644),`rm -r` 会在这种目录上拿到
|
|
610
|
+
* EACCES。清理失败等于把攻击者的内容留在 staging 里,所以这里先把目录权限
|
|
611
|
+
* 放开再重试一次。
|
|
612
|
+
*/
|
|
613
|
+
async function forceRemove(target) {
|
|
614
|
+
try {
|
|
615
|
+
await rm(target, { recursive: true, force: true });
|
|
616
|
+
return;
|
|
617
|
+
} catch (error) {
|
|
618
|
+
if (error?.code !== 'EACCES' && error?.code !== 'EPERM') throw error;
|
|
619
|
+
}
|
|
620
|
+
await relaxDirectoryModes(target);
|
|
621
|
+
await rm(target, { recursive: true, force: true });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function relaxDirectoryModes(root) {
|
|
625
|
+
const queue = [root];
|
|
626
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
627
|
+
const current = queue[index];
|
|
628
|
+
try {
|
|
629
|
+
await chmod(current, 0o700);
|
|
630
|
+
const dirents = await readdir(current, { withFileTypes: true });
|
|
631
|
+
for (const dirent of dirents) {
|
|
632
|
+
if (dirent.isDirectory() && !dirent.isSymbolicLink()) {
|
|
633
|
+
queue.push(path.join(current, dirent.name));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
} catch {
|
|
637
|
+
// best-effort:单个节点放开失败不应中断整体清理
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* 只回收本次调用产生的东西:自己建的目录整体删除,
|
|
644
|
+
* 调用方预先提供的空目录则只清空内容、保留目录本身。
|
|
645
|
+
*
|
|
646
|
+
* 返回清理失败的原因(如果有)。清理失败绝不能覆盖调用方真正要看的那个错误,
|
|
647
|
+
* 但也不能被静默丢弃 —— staging 里可能还留着攻击者的内容。
|
|
648
|
+
*/
|
|
649
|
+
async function cleanupStaging({ stagingRoot, createdHere }) {
|
|
650
|
+
try {
|
|
651
|
+
if (createdHere) {
|
|
652
|
+
await forceRemove(stagingRoot);
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
let children = [];
|
|
656
|
+
try {
|
|
657
|
+
children = await readdir(stagingRoot);
|
|
658
|
+
} catch (error) {
|
|
659
|
+
if (error?.code === 'ENOENT') return null;
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
for (const child of children) {
|
|
663
|
+
await forceRemove(path.join(stagingRoot, child));
|
|
664
|
+
}
|
|
665
|
+
return null;
|
|
666
|
+
} catch (error) {
|
|
667
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function probeTarImplementation(tarPath, timeoutMs) {
|
|
672
|
+
const { code, stdout, stderr } = await spawnTar(['--version'], { tarPath, timeoutMs });
|
|
673
|
+
if (code !== 0) {
|
|
674
|
+
throw safetyError(
|
|
675
|
+
`tar --version probe failed (exit ${code}): ${stderr.trim() || 'no stderr'}`
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
const firstLine = stdout.split('\n')[0] || '';
|
|
679
|
+
if (/bsdtar|libarchive/i.test(firstLine)) {
|
|
680
|
+
return { implementation: 'bsdtar', version: firstLine };
|
|
681
|
+
}
|
|
682
|
+
if (/GNU tar/i.test(firstLine)) {
|
|
683
|
+
return { implementation: 'gnutar', version: firstLine };
|
|
684
|
+
}
|
|
685
|
+
throw safetyError(`unknown tar implementation: ${firstLine || '<empty>'}`);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function flagsForImplementation(implementation) {
|
|
689
|
+
const policy = LIBRARY_IMAGE_FLAG_POLICY;
|
|
690
|
+
if (implementation === 'bsdtar') return [...policy.common, ...policy.bsdtar];
|
|
691
|
+
if (implementation === 'gnutar') return [...policy.common, ...policy.gnutar];
|
|
692
|
+
return [...policy.common];
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* 探测系统 tar 的实现,并在允许列表内挑选 flag 集合。
|
|
697
|
+
*
|
|
698
|
+
* 探测失败或实现无法识别时直接拒收:不认识实现就无法判断哪些 flag 安全可用,
|
|
699
|
+
* 继续解压等于放弃整套 flag 策略。
|
|
700
|
+
*/
|
|
701
|
+
export async function resolveTarInvocation(tarPath = 'tar', { timeoutMs } = {}) {
|
|
702
|
+
const probe = await probeTarImplementation(tarPath, timeoutMs);
|
|
703
|
+
return {
|
|
704
|
+
binary: tarPath,
|
|
705
|
+
implementation: probe.implementation,
|
|
706
|
+
version: probe.version,
|
|
707
|
+
flags: flagsForImplementation(probe.implementation)
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* 拼装 `tar -xf` 调用参数。导出仅供测试;运行时不直接调用。
|
|
713
|
+
*/
|
|
714
|
+
export function composeExtractArgs({ invocation, filePath, stagingRoot }) {
|
|
715
|
+
return [...invocation.flags, '-xf', filePath, '-C', stagingRoot];
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* 读取 library image 的条目与类型。
|
|
720
|
+
*
|
|
721
|
+
* @param {string} filePath tar 路径
|
|
722
|
+
* @param {{ imageLimits?: Partial<typeof DEFAULT_LIBRARY_IMAGE_LIMITS> }} [overrides]
|
|
723
|
+
* @returns {Promise<{ entries: Array<{ path: string, type: string, size: number, linkname?: string }>, totalBytes: number }>}
|
|
724
|
+
*/
|
|
725
|
+
export async function inspectLibraryImage(filePath, overrides = {}) {
|
|
726
|
+
const limits = resolveLimits(overrides);
|
|
727
|
+
const { entries, totalBytes } = await readTarEntries(filePath, limits);
|
|
728
|
+
const projected = entries.map((entry) => {
|
|
729
|
+
const projectedEntry = { path: entry.path, type: entry.type, size: entry.size };
|
|
730
|
+
if (entry.linkname) projectedEntry.linkname = entry.linkname;
|
|
731
|
+
return projectedEntry;
|
|
732
|
+
});
|
|
733
|
+
return { entries: projected, totalBytes };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* 把 library image 解到 `stagingRoot`。
|
|
738
|
+
*
|
|
739
|
+
* Pre-flight 与 post-flight 都是 load-bearing 防御层:
|
|
740
|
+
* - pre-flight 解析 tar header,校验路径形状、条目类型、深度与体积;
|
|
741
|
+
* - post-flight 在 staging 上按 allowlist 走一遍目录树,并核对写出的
|
|
742
|
+
* 每个路径都在 pre-flight 认可的集合内(defense in depth)。
|
|
743
|
+
*
|
|
744
|
+
* @param {string} filePath
|
|
745
|
+
* @param {string} stagingRoot 必须不存在或为空目录;函数只回收自己创建的内容
|
|
746
|
+
* @param {{ imageLimits?: Partial<typeof DEFAULT_LIBRARY_IMAGE_LIMITS>, tarPath?: string, timeoutMs?: number }} [overrides]
|
|
747
|
+
* @returns {Promise<Array<{ absPath: string, type: string }>>}
|
|
748
|
+
*/
|
|
749
|
+
export async function extractLibraryImage(filePath, stagingRoot, overrides = {}) {
|
|
750
|
+
const limits = resolveLimits(overrides);
|
|
751
|
+
const { entries } = await readTarEntries(filePath, limits);
|
|
752
|
+
|
|
753
|
+
for (const entry of entries) {
|
|
754
|
+
assertEntryInsideStaging(entry.path, stagingRoot);
|
|
755
|
+
if (!(overrides.libraryLayout && ['symlink', 'hardlink'].includes(entry.type))) assertEntryTypeAllowed(entry, 'pre-flight');
|
|
756
|
+
if (entry.mode & 0o7000) throw safetyError(`library image declares privileged mode: ${entry.path}`);
|
|
757
|
+
assertEntryDepth(entry, limits);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (overrides.libraryLayout) validateImageEntries(entries);
|
|
761
|
+
const staging = await prepareStaging(stagingRoot);
|
|
762
|
+
|
|
763
|
+
try {
|
|
764
|
+
const invocation = await resolveTarInvocation(overrides.tarPath, {
|
|
765
|
+
timeoutMs: overrides.timeoutMs
|
|
766
|
+
});
|
|
767
|
+
const extractArgs = composeExtractArgs({
|
|
768
|
+
invocation,
|
|
769
|
+
filePath,
|
|
770
|
+
stagingRoot: staging.stagingRoot
|
|
771
|
+
});
|
|
772
|
+
const { code, stderr } = await spawnTar(extractArgs, {
|
|
773
|
+
tarPath: invocation.binary,
|
|
774
|
+
timeoutMs: overrides.timeoutMs
|
|
775
|
+
});
|
|
776
|
+
if (code !== 0) {
|
|
777
|
+
throw safetyError(`tar extract failed (exit ${code}): ${stderr.trim() || 'no stderr'}`);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const written = await walkStaging(staging.stagingRoot, overrides.libraryLayout);
|
|
781
|
+
if (overrides.libraryLayout) await verifyImageTree(staging.stagingRoot, entries, overrides);
|
|
782
|
+
const expected = new Set([...buildExpectedPaths(entries)].map(name => name.normalize('NFC')));
|
|
783
|
+
|
|
784
|
+
// 安全性质:tar 不得写出 pre-flight 没批准的路径。
|
|
785
|
+
for (const item of written) {
|
|
786
|
+
const relative = path.relative(staging.stagingRoot, item.absPath).split(path.sep).join('/');
|
|
787
|
+
if (!expected.has(relative.normalize('NFC'))) {
|
|
788
|
+
throw safetyError(
|
|
789
|
+
`post-flight found a staged path that pre-flight never advertised: ${relative}`
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// 完整性性质:pre-flight 声明的文件不得被静默丢弃。
|
|
795
|
+
// macOS 打包垃圾(`._*` / `.DS_Store` / `__MACOSX`)由 tar 按 flag 策略消化,不参与核对。
|
|
796
|
+
const writtenRelative = new Set(
|
|
797
|
+
written.map((item) =>
|
|
798
|
+
path.relative(staging.stagingRoot, item.absPath).split(path.sep).join('/').normalize('NFC')
|
|
799
|
+
)
|
|
800
|
+
);
|
|
801
|
+
const missing = entries
|
|
802
|
+
.filter((entry) => entry.type === 'file')
|
|
803
|
+
.map((entry) => normalizeEntryPath(entry.path))
|
|
804
|
+
.filter((relative) => relative && !isMacPackagingJunk(relative))
|
|
805
|
+
.filter((relative) => !writtenRelative.has(relative.normalize('NFC')));
|
|
806
|
+
if (missing.length > 0) {
|
|
807
|
+
throw safetyError(
|
|
808
|
+
`library image advertised ${missing.length} file entries that tar did not write (first: ${missing[0]}); silent filtering would install an incomplete image`
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return written;
|
|
813
|
+
} catch (error) {
|
|
814
|
+
const cleanupError = await cleanupStaging(staging);
|
|
815
|
+
const cleanupNote = cleanupError
|
|
816
|
+
? ` (staging cleanup also failed, ${staging.stagingRoot} may still hold image content: ${cleanupError.message})`
|
|
817
|
+
: '';
|
|
818
|
+
if (error instanceof SourceAcquisitionError) {
|
|
819
|
+
if (!cleanupNote) throw error;
|
|
820
|
+
throw safetyError(`${error.message}${cleanupNote}`);
|
|
821
|
+
}
|
|
822
|
+
throw safetyError(
|
|
823
|
+
`library image extraction failed: ${
|
|
824
|
+
error instanceof Error ? error.message : String(error)
|
|
825
|
+
}${cleanupNote}`
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// The workflow uses a private, bounded uncompressed copy for both inspection and
|
|
831
|
+
// extraction. System tar never reopens the caller's mutable compressed input.
|
|
832
|
+
const execImageGit = promisify(execFile);
|
|
833
|
+
const reportedExportErrors = new WeakSet();
|
|
834
|
+
const IMAGE_DECLARATIONS = Object.freeze({
|
|
835
|
+
noAbsolutePaths: true, noTraversal: true, noSpecialFiles: true,
|
|
836
|
+
noContiguousEntries: true, noEntriesAfterEnd: true,
|
|
837
|
+
noSymlinkEscape: true, noExternalHardlinks: true, noPrivilegedModes: true
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
function imageError(category, message, exitCode = 1) {
|
|
841
|
+
return new SourceAcquisitionError(category, message, exitCode);
|
|
842
|
+
}
|
|
843
|
+
function reportImage(context, message) { context.report?.(message); }
|
|
844
|
+
function failExportCheck(context, check, message) {
|
|
845
|
+
reportImage(context, `[fail] ${check}: ${message}`);
|
|
846
|
+
const error = imageError('export-blocked', `${check}: ${message}`);
|
|
847
|
+
reportedExportErrors.add(error);
|
|
848
|
+
throw error;
|
|
849
|
+
}
|
|
850
|
+
function reportAndThrowExportFailure(context, check, error) {
|
|
851
|
+
const wrapped = wrapImageError(error, 'export-blocked');
|
|
852
|
+
reportImage(context, `[fail] ${check}: ${wrapped.message}`);
|
|
853
|
+
reportedExportErrors.add(wrapped);
|
|
854
|
+
throw wrapped;
|
|
855
|
+
}
|
|
856
|
+
function requireImagePath(filePath) {
|
|
857
|
+
if (typeof filePath !== 'string' || !filePath.endsWith('.tar.gz')) {
|
|
858
|
+
throw imageError('argument', 'Library images require a .tar.gz path.', 2);
|
|
859
|
+
}
|
|
860
|
+
return path.resolve(filePath);
|
|
861
|
+
}
|
|
862
|
+
function wrapImageError(error, category) {
|
|
863
|
+
if (error instanceof SourceAcquisitionError && error.category !== 'source-safety') {
|
|
864
|
+
if (error.category === 'source-collision') return imageError(error.category, error.message);
|
|
865
|
+
return error;
|
|
866
|
+
}
|
|
867
|
+
const filesystem = ['ENOSPC', 'EACCES', 'EPERM', 'EROFS', 'ENOENT', 'EIO', 'ENOTDIR'].includes(error.code);
|
|
868
|
+
return imageError(filesystem ? 'filesystem' : category, error.message, filesystem ? 3 : 1);
|
|
869
|
+
}
|
|
870
|
+
async function imageGit(rootDir, ...args) {
|
|
871
|
+
return (await execImageGit('git', ['-C', rootDir, ...args], { timeout: 30_000 })).stdout.trim();
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* 原件库根本身是不是 git 仓库。
|
|
875
|
+
*
|
|
876
|
+
* 旧形态下原件库和代码同目录,仓库状态检查(已提交 / 已推送)有意义。
|
|
877
|
+
* 解耦之后原件库可能是一个普通目录(`~/.skillcaddy/library`),里面没有
|
|
878
|
+
* 需要推送的提交,这时检查应当跳过而不是把导出堵死。
|
|
879
|
+
*/
|
|
880
|
+
async function isGitRepository(rootDir) {
|
|
881
|
+
try {
|
|
882
|
+
const topLevel = await imageGit(rootDir, 'rev-parse', '--show-toplevel');
|
|
883
|
+
// git 返回的是真实路径,rootDir 可能还带着 /var → /private/var 这类软链,
|
|
884
|
+
// 所以两边都要 realpath 之后再比。
|
|
885
|
+
const [realTopLevel, realRoot] = await Promise.all([
|
|
886
|
+
realpath(topLevel),
|
|
887
|
+
realpath(path.resolve(rootDir))
|
|
888
|
+
]);
|
|
889
|
+
return realTopLevel === realRoot;
|
|
890
|
+
} catch {
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* 原件库根的仓库状态。
|
|
897
|
+
*
|
|
898
|
+
* mode = 'repository':原件库根本身是 git 仓库,commit/branch/dirty/pushed 有意义。
|
|
899
|
+
* mode = 'no-repository':原件库根是普通目录,没有需要推送的提交。
|
|
900
|
+
*
|
|
901
|
+
* 两种形态都会写进镜像的 manifest.producer,导入侧按同一套规则校验。
|
|
902
|
+
*/
|
|
903
|
+
async function readProducerState(rootDir) {
|
|
904
|
+
if (!await isGitRepository(rootDir)) {
|
|
905
|
+
return { mode: 'no-repository', commit: null, branch: null, dirty: false, pushed: true };
|
|
906
|
+
}
|
|
907
|
+
return producerState(rootDir);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async function producerState(rootDir) {
|
|
911
|
+
return {
|
|
912
|
+
mode: 'repository',
|
|
913
|
+
commit: await readGitHead(rootDir),
|
|
914
|
+
branch: await imageGit(rootDir, 'symbolic-ref', 'HEAD'),
|
|
915
|
+
dirty: Boolean(await imageGit(rootDir, 'status', '--porcelain')),
|
|
916
|
+
pushed: !await imageGit(rootDir, 'log', '@{u}..', '--oneline')
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* manifest.producer 的形状校验。
|
|
922
|
+
*/
|
|
923
|
+
function isValidProducer(producer) {
|
|
924
|
+
if (!producer || typeof producer !== 'object') return false;
|
|
925
|
+
if (producer.mode === 'no-repository') {
|
|
926
|
+
return producer.commit === null && producer.branch === null &&
|
|
927
|
+
typeof producer.dirty === 'boolean' && typeof producer.pushed === 'boolean';
|
|
928
|
+
}
|
|
929
|
+
return typeof producer.commit === 'string' && typeof producer.branch === 'string' &&
|
|
930
|
+
typeof producer.dirty === 'boolean' && typeof producer.pushed === 'boolean';
|
|
931
|
+
}
|
|
932
|
+
async function imageTree(rootDir, relative = '') {
|
|
933
|
+
const items = [];
|
|
934
|
+
for (const entry of await readdir(path.join(rootDir, relative), { withFileTypes: true })) {
|
|
935
|
+
const name = path.posix.join(relative, entry.name);
|
|
936
|
+
const absolute = path.join(rootDir, name);
|
|
937
|
+
const info = await lstat(absolute);
|
|
938
|
+
items.push({ path: name, info });
|
|
939
|
+
if (info.isDirectory()) items.push(...await imageTree(rootDir, name));
|
|
940
|
+
}
|
|
941
|
+
return items;
|
|
942
|
+
}
|
|
943
|
+
function belongsToRegisteredSource(item, records) {
|
|
944
|
+
const itemPath = item.path.normalize('NFC');
|
|
945
|
+
return records.some((record) => {
|
|
946
|
+
const installPath = record.installPath.normalize('NFC');
|
|
947
|
+
return itemPath === installPath ||
|
|
948
|
+
itemPath.startsWith(`${installPath}/`) ||
|
|
949
|
+
(item.info.isDirectory() && installPath.startsWith(`${itemPath}/`));
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
async function carriedEnablements(context, records) {
|
|
953
|
+
const result = [];
|
|
954
|
+
for (const [scope, directory] of Object.entries(imageScopes(context))) {
|
|
955
|
+
for (const item of await scanSkillLinks(directory)) {
|
|
956
|
+
if (!item.isSymlink || item.alias === 'skillcaddy-manager') continue;
|
|
957
|
+
const libraryPath = path.relative(context.rootDir, item.targetPath)
|
|
958
|
+
.split(path.sep)
|
|
959
|
+
.join('/')
|
|
960
|
+
.normalize('NFC');
|
|
961
|
+
if (path.basename(libraryPath) === 'skillcaddy-manager') continue;
|
|
962
|
+
if (records.some((record) => record.skills.some((skill) =>
|
|
963
|
+
path.posix.join(record.installPath, skill).normalize('NFC') === libraryPath
|
|
964
|
+
))) {
|
|
965
|
+
result.push({ scope, libraryPath, alias: item.alias });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return result;
|
|
970
|
+
}
|
|
971
|
+
function imageScopes(context) {
|
|
972
|
+
return { global: getGlobalSkillsDir(context.globalDir), hermes: getHermesSkillsDir(context.hermesDir) };
|
|
973
|
+
}
|
|
974
|
+
async function checkExport(context) {
|
|
975
|
+
const rootDir = path.resolve(context.rootDir);
|
|
976
|
+
const managedRoot = path.join(rootDir, '.skillcaddy');
|
|
977
|
+
if (await sourcePathExists(managedRoot)) {
|
|
978
|
+
const managedInfo = await lstat(managedRoot);
|
|
979
|
+
if (!managedInfo.isDirectory() || managedInfo.isSymbolicLink() ||
|
|
980
|
+
!isPathInsideOrEqual(await realpath(rootDir), await realpath(managedRoot))) {
|
|
981
|
+
failExportCheck(context, 'managed-state', 'The .skillcaddy state directory must be a directory inside the central-library root.');
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
const items = [];
|
|
985
|
+
for (const bucket of SOURCE_FOLDERS) {
|
|
986
|
+
if (await sourcePathExists(path.join(rootDir, bucket))) {
|
|
987
|
+
const info = await lstat(path.join(rootDir, bucket));
|
|
988
|
+
if (!info.isDirectory()) failExportCheck(context, 'registered-bucket-layout', `Unsafe bucket: ${bucket}`);
|
|
989
|
+
items.push(...await imageTree(rootDir, bucket));
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (items.some(item => path.basename(item.path) === SOURCE_INSTALLING_MARKER)) {
|
|
993
|
+
failExportCheck(context, 'installing-markers', 'Installing marker found. Recover the interrupted source acquisition before export.');
|
|
994
|
+
}
|
|
995
|
+
reportImage(context, '[pass] installing markers');
|
|
996
|
+
const stagingPath = path.join(rootDir, '.skillcaddy/staging');
|
|
997
|
+
if (await sourcePathExists(stagingPath)) {
|
|
998
|
+
failExportCheck(context, 'staging-residue', 'Staging residue found. Inspect .skillcaddy/staging/ and finish or cancel the interrupted transaction.');
|
|
999
|
+
}
|
|
1000
|
+
reportImage(context, '[pass] staging residue');
|
|
1001
|
+
const records = await readSourceRecords(rootDir);
|
|
1002
|
+
for (const item of items) {
|
|
1003
|
+
if (!belongsToRegisteredSource(item, records)) {
|
|
1004
|
+
failExportCheck(context, 'registered-bucket-layout', `Unregistered bucket entry: ${item.path}. Register or move it before export.`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
reportImage(context, '[pass] registered bucket layout');
|
|
1008
|
+
for (const record of records) {
|
|
1009
|
+
if (!await sourcePathExists(path.join(rootDir, record.installPath)) ||
|
|
1010
|
+
!(await lstat(path.join(rootDir, record.installPath))).isDirectory()) {
|
|
1011
|
+
failExportCheck(context, 'registered-directories', `Missing registered directory: ${record.installPath}. Restore it before export.`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
reportImage(context, '[pass] registered directories');
|
|
1015
|
+
const hashes = {};
|
|
1016
|
+
const headRecords = [];
|
|
1017
|
+
for (const record of records) {
|
|
1018
|
+
const source = path.join(rootDir, record.installPath.normalize('NFC'));
|
|
1019
|
+
hashes[record.sourceId] = await checksumDirectory(source);
|
|
1020
|
+
if (record.type === 'git') headRecords.push({ sourceId: record.sourceId, head: await readGitHead(source) });
|
|
1021
|
+
else if (record.integrity &&
|
|
1022
|
+
hashes[record.sourceId] !== record.integrity.value &&
|
|
1023
|
+
await checksumDirectoryNormalized(source) !== record.integrity.value) {
|
|
1024
|
+
failExportCheck(context, 'source-integrity', `Integrity drift: ${record.sourceId}. Review and repair the source before export.`);
|
|
1025
|
+
}
|
|
1026
|
+
else if (!record.integrity) {
|
|
1027
|
+
reportImage(context, `[warn] integrity baseline unavailable: ${record.sourceId}`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
reportImage(context, '[pass] source integrity');
|
|
1031
|
+
let producer;
|
|
1032
|
+
if (await isGitRepository(rootDir)) {
|
|
1033
|
+
try { producer = await producerState(rootDir); }
|
|
1034
|
+
catch { failExportCheck(context, 'repository-state', 'Repository state unavailable. Commit on a branch and configure its upstream before export.'); }
|
|
1035
|
+
if (producer.dirty) failExportCheck(context, 'repository-state', 'Uncommitted changes found. Commit them before export.');
|
|
1036
|
+
if (!producer.pushed) failExportCheck(context, 'repository-state', 'Unpushed commits found. Push them before export.');
|
|
1037
|
+
reportImage(context, '[pass] repository state');
|
|
1038
|
+
} else {
|
|
1039
|
+
producer = { mode: 'no-repository', commit: null, branch: null, dirty: false, pushed: true };
|
|
1040
|
+
reportImage(context, '[warn] library root is not a git repository; repository state check skipped');
|
|
1041
|
+
}
|
|
1042
|
+
for (const item of items) {
|
|
1043
|
+
if (item.info.mode & 0o7000) failExportCheck(context, 'entry-safety', `Privileged mode: ${item.path}`);
|
|
1044
|
+
if (item.info.isSymbolicLink()) {
|
|
1045
|
+
const target = await readlink(path.join(rootDir, item.path));
|
|
1046
|
+
const resolved = path.resolve(rootDir, path.dirname(item.path), target);
|
|
1047
|
+
if (path.isAbsolute(target) || !isPathInsideOrEqual(rootDir, resolved)) failExportCheck(context, 'entry-safety', `Escaping symlink: ${item.path}`);
|
|
1048
|
+
try {
|
|
1049
|
+
if (!isPathInsideOrEqual(await realpath(rootDir), await realpath(path.join(rootDir, item.path)))) failExportCheck(context, 'entry-safety', `Escaping symlink: ${item.path}`);
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
if (error.code !== 'ENOENT') throw error;
|
|
1052
|
+
reportImage(context, `[warn] dead-symlink: ${item.path}`);
|
|
1053
|
+
}
|
|
1054
|
+
} else if (!item.info.isDirectory() && !item.info.isFile()) failExportCheck(context, 'entry-safety', `Special file: ${item.path}`);
|
|
1055
|
+
}
|
|
1056
|
+
const metadataRoot = path.join(rootDir, '.skillcaddy', 'metadata');
|
|
1057
|
+
const metadataHash = await checksumOptionalDirectory(metadataRoot);
|
|
1058
|
+
const bucketHashes = {};
|
|
1059
|
+
for (const bucket of SOURCE_FOLDERS) {
|
|
1060
|
+
bucketHashes[bucket] = await checksumOptionalDirectory(path.join(rootDir, bucket));
|
|
1061
|
+
}
|
|
1062
|
+
const enablement = await carriedEnablements(context, records);
|
|
1063
|
+
return { records, hashes, producer, headRecords, enablement, metadataHash, bucketHashes };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
export async function exportLibraryImage(context, filePath) {
|
|
1067
|
+
const destination = requireImagePath(filePath);
|
|
1068
|
+
let workspace;
|
|
1069
|
+
let temporary;
|
|
1070
|
+
try {
|
|
1071
|
+
if (await sourcePathExists(destination)) failExportCheck(context, 'destination', 'Library image already exists. Choose a new .tar.gz destination.');
|
|
1072
|
+
const before = await checkExport(context);
|
|
1073
|
+
workspace = await createManagedSourceWorkspace(context.rootDir, 'library-image-');
|
|
1074
|
+
const packing = path.join(workspace.root, 'pack');
|
|
1075
|
+
await mkdir(packing);
|
|
1076
|
+
const manifest = { schemaVersion: 1, producer: before.producer,
|
|
1077
|
+
sources: before.records.map(({ sourceId, installPath }) => ({ sourceId, installPath })),
|
|
1078
|
+
enablement: before.enablement,
|
|
1079
|
+
declarations: { ...IMAGE_DECLARATIONS, gitSourceMode: { headRecords: before.headRecords } },
|
|
1080
|
+
gitSourceMode: { mode: 'rev-parse-head-only', noIntegrityBaseline: true } };
|
|
1081
|
+
await writeFile(path.join(packing, 'library-image.json'), JSON.stringify(manifest, null, 2));
|
|
1082
|
+
const members = ['library-image.json'];
|
|
1083
|
+
for (const relative of [...SOURCE_FOLDERS, '.skillcaddy/sources', '.skillcaddy/metadata']) {
|
|
1084
|
+
if (!await sourcePathExists(path.join(context.rootDir, relative))) continue;
|
|
1085
|
+
await mkdir(path.dirname(path.join(packing, relative)), { recursive: true });
|
|
1086
|
+
await cp(path.join(context.rootDir, relative), path.join(packing, relative), { recursive: true, verbatimSymlinks: true });
|
|
1087
|
+
members.push(relative);
|
|
1088
|
+
}
|
|
1089
|
+
temporary = path.join(path.dirname(destination), `.library-image-${path.basename(workspace.root)}.tmp`);
|
|
1090
|
+
await writeFile(temporary, '', { flag: 'wx', mode: 0o600 });
|
|
1091
|
+
const invocation = await resolveTarInvocation(context.tarPath || process.env.LIBRARY_IMAGE_TAR_PATH);
|
|
1092
|
+
const result = await spawnTar([...invocation.flags, '-czf', temporary, '-C', packing, '--', ...members], { tarPath: invocation.binary, timeoutMs: 300_000 });
|
|
1093
|
+
if (result.code !== 0) failExportCheck(context, 'packing', `Packing failed: ${result.stderr}`);
|
|
1094
|
+
try {
|
|
1095
|
+
await verifyPackedImage(temporary, packing, workspace.root, context);
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
reportAndThrowExportFailure(context, 'post-pack-verification', error);
|
|
1098
|
+
}
|
|
1099
|
+
for (const record of before.records) {
|
|
1100
|
+
if (await checksumDirectory(path.join(context.rootDir, record.installPath)) !== before.hashes[record.sourceId] ||
|
|
1101
|
+
await checksumDirectory(path.join(packing, record.installPath)) !== before.hashes[record.sourceId]) {
|
|
1102
|
+
failExportCheck(context, 'post-pack-drift', `Source drift: ${record.sourceId}. Retry once the library is idle.`);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
for (const bucket of SOURCE_FOLDERS) {
|
|
1106
|
+
if (await checksumOptionalDirectory(path.join(context.rootDir, bucket)) !== before.bucketHashes[bucket]) {
|
|
1107
|
+
failExportCheck(context, 'post-pack-drift', `Source tree drift: ${bucket}. Retry once the library is idle.`);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (JSON.stringify(await readSourceRecords(context.rootDir)) !== JSON.stringify(before.records) ||
|
|
1111
|
+
JSON.stringify(await readProducerState(context.rootDir)) !== JSON.stringify(before.producer) ||
|
|
1112
|
+
await checksumOptionalDirectory(path.join(context.rootDir, '.skillcaddy', 'metadata')) !== before.metadataHash ||
|
|
1113
|
+
JSON.stringify(await carriedEnablements(context, before.records)) !== JSON.stringify(before.enablement)) {
|
|
1114
|
+
failExportCheck(context, 'post-pack-drift', 'Registry, metadata, enablement, or repository drift. Retry once the library is idle.');
|
|
1115
|
+
}
|
|
1116
|
+
reportImage(context, '[pass] post-pack drift');
|
|
1117
|
+
// Exclusive hard-link publication provides atomic visibility without rename's
|
|
1118
|
+
// overwrite race. Both paths are on the destination filesystem.
|
|
1119
|
+
try { await link(temporary, destination); }
|
|
1120
|
+
catch (error) { if (error.code === 'EEXIST') failExportCheck(context, 'destination', 'Library image destination became occupied. Choose a new path.'); throw error; }
|
|
1121
|
+
return { path: destination, manifest };
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
const wrapped = wrapImageError(error, 'export-blocked');
|
|
1124
|
+
if (!reportedExportErrors.has(wrapped)) {
|
|
1125
|
+
reportImage(context, `[fail] export: ${wrapped.message}`);
|
|
1126
|
+
}
|
|
1127
|
+
throw wrapped;
|
|
1128
|
+
}
|
|
1129
|
+
finally {
|
|
1130
|
+
if (temporary) await rm(temporary, { force: true });
|
|
1131
|
+
if (workspace) await removeManagedSourceWorkspace(workspace);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async function checksumOptionalDirectory(directory) {
|
|
1136
|
+
if (!await sourcePathExists(directory)) return null;
|
|
1137
|
+
const info = await lstat(directory);
|
|
1138
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
1139
|
+
throw imageError('export-blocked', `Metadata path is not a directory: ${directory}`);
|
|
1140
|
+
}
|
|
1141
|
+
return checksumDirectory(directory);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
async function inflateImage(filePath, destination, limits) {
|
|
1145
|
+
const input = await open(filePath, 'r');
|
|
1146
|
+
try {
|
|
1147
|
+
const signature = Buffer.alloc(2);
|
|
1148
|
+
await input.read(signature, 0, 2, 0);
|
|
1149
|
+
if (signature[0] !== 0x1f || signature[1] !== 0x8b) throw imageError('image-preflight-failed', 'Image is not gzip compressed.');
|
|
1150
|
+
let bytes = 0;
|
|
1151
|
+
const bound = limits.maxExpandedBytes + limits.maxEntries * 4096;
|
|
1152
|
+
await pipeline(input.createReadStream({ start: 0, autoClose: false }), createGunzip(), new Transform({
|
|
1153
|
+
transform(chunk, encoding, done) {
|
|
1154
|
+
bytes += chunk.length;
|
|
1155
|
+
done(bytes > bound ? imageError('image-preflight-failed', 'Expanded tar exceeds image limit.') : null, chunk);
|
|
1156
|
+
}
|
|
1157
|
+
}), createWriteStream(destination, { flags: 'wx', mode: 0o600 }));
|
|
1158
|
+
} finally { await input.close(); }
|
|
1159
|
+
}
|
|
1160
|
+
function normalizeLinkTarget(target) {
|
|
1161
|
+
return target.split('/').map((segment) => segment.normalize('NFC')).join('/');
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Walk a staged tree and settle Unicode-normalized path spellings.
|
|
1165
|
+
*
|
|
1166
|
+
* `renameToNfc` is false for the export self-check (library-image.js:1690).
|
|
1167
|
+
* That walk runs on a round-trip of the archive export just produced, in
|
|
1168
|
+
* order to confirm the bytes still verify — renaming there moves the plain
|
|
1169
|
+
* checksum away from the NFD baseline recorded moments earlier, so an
|
|
1170
|
+
* NFD-named source fails its own export. It is Linux-specific because an NFD
|
|
1171
|
+
* name and its NFC spelling are two distinct entries there, so the APFS
|
|
1172
|
+
* same-inode branch below does not absorb the difference.
|
|
1173
|
+
*
|
|
1174
|
+
* Import keeps renaming: ADR 0011 Decision 7 promises the archive stores NFC
|
|
1175
|
+
* bytes, and the receiving library should not inherit the producing
|
|
1176
|
+
* filesystem's decomposition. Collision reporting is unchanged either way —
|
|
1177
|
+
* two different inodes resolving to one NFC name is a real defect regardless.
|
|
1178
|
+
*/
|
|
1179
|
+
async function normalizeStagingPaths(root, { renameToNfc = true } = {}) {
|
|
1180
|
+
async function walk(directory) {
|
|
1181
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
1182
|
+
for (const entry of entries) {
|
|
1183
|
+
const current = path.join(directory, entry.name);
|
|
1184
|
+
const normalizedName = entry.name.normalize('NFC');
|
|
1185
|
+
let normalizedPath = current;
|
|
1186
|
+
if (normalizedName !== entry.name) {
|
|
1187
|
+
normalizedPath = path.join(directory, normalizedName);
|
|
1188
|
+
const currentInfo = await lstat(current);
|
|
1189
|
+
if (await sourcePathExists(normalizedPath)) {
|
|
1190
|
+
// APFS commonly returns decomposed names while treating their NFC
|
|
1191
|
+
// spelling as the same directory entry. That is not a collision;
|
|
1192
|
+
// two different inodes resolving to one NFC name is.
|
|
1193
|
+
const normalizedInfo = await lstat(normalizedPath);
|
|
1194
|
+
if (normalizedInfo.dev !== currentInfo.dev || normalizedInfo.ino !== currentInfo.ino) {
|
|
1195
|
+
throw imageError('image-staging-verify-failed', `Unicode-normalized path collision: ${entry.name}`);
|
|
1196
|
+
}
|
|
1197
|
+
normalizedPath = current;
|
|
1198
|
+
} else if (renameToNfc) {
|
|
1199
|
+
await rename(current, normalizedPath);
|
|
1200
|
+
} else {
|
|
1201
|
+
normalizedPath = current;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const info = await lstat(normalizedPath);
|
|
1205
|
+
if (info.isDirectory()) await walk(normalizedPath);
|
|
1206
|
+
else if (info.isSymbolicLink()) {
|
|
1207
|
+
const target = await readlink(normalizedPath);
|
|
1208
|
+
const normalizedTarget = normalizeLinkTarget(target);
|
|
1209
|
+
if (normalizedTarget !== target) {
|
|
1210
|
+
await unlink(normalizedPath);
|
|
1211
|
+
await symlink(normalizedTarget, normalizedPath);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
await walk(root);
|
|
1217
|
+
}
|
|
1218
|
+
async function verifyImageSources(rootDir, manifest, entries = []) {
|
|
1219
|
+
if (manifest?.schemaVersion !== 1 || !Array.isArray(manifest.sources) || !Array.isArray(manifest.enablement) ||
|
|
1220
|
+
manifest.gitSourceMode?.mode !== 'rev-parse-head-only' || manifest.gitSourceMode.noIntegrityBaseline !== true ||
|
|
1221
|
+
!isValidProducer(manifest.producer) ||
|
|
1222
|
+
Object.keys(IMAGE_DECLARATIONS).some(key => manifest.declarations?.[key] !== true)) {
|
|
1223
|
+
throw imageError('image-preflight-failed', 'Invalid library-image.json schema or declarations.');
|
|
1224
|
+
}
|
|
1225
|
+
validateImageEnablements(manifest.enablement);
|
|
1226
|
+
const records = await readSourceRecords(rootDir);
|
|
1227
|
+
const registryEnumeration = records
|
|
1228
|
+
.map(({ sourceId, installPath }) => ({ sourceId, installPath }))
|
|
1229
|
+
.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
|
|
1230
|
+
const manifestEnumeration = manifest.sources
|
|
1231
|
+
.map((entry) => ({ sourceId: entry?.sourceId, installPath: entry?.installPath }))
|
|
1232
|
+
.sort((left, right) => String(left.sourceId).localeCompare(String(right.sourceId)));
|
|
1233
|
+
if (
|
|
1234
|
+
manifest.sources.some((entry) => !entry || typeof entry.sourceId !== 'string' || typeof entry.installPath !== 'string') ||
|
|
1235
|
+
JSON.stringify(registryEnumeration) !== JSON.stringify(manifestEnumeration)
|
|
1236
|
+
) throw imageError('image-preflight-failed', 'Manifest source enumeration differs from the registry.');
|
|
1237
|
+
for (const record of records) {
|
|
1238
|
+
if (records.some(other => other !== record && (other.installPath === record.installPath || other.installPath.startsWith(`${record.installPath}/`)))) throw imageError('image-preflight-failed', 'Overlapping source installation paths.');
|
|
1239
|
+
}
|
|
1240
|
+
for (const bucket of SOURCE_FOLDERS) {
|
|
1241
|
+
if (!await sourcePathExists(path.join(rootDir, bucket))) continue;
|
|
1242
|
+
for (const item of await imageTree(rootDir, bucket)) {
|
|
1243
|
+
if (!belongsToRegisteredSource(item, records)) throw imageError('image-preflight-failed', `Unregistered image entry: ${item.path}`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
await assertImageLinksInsideSources(entries, records);
|
|
1247
|
+
for (const record of records) {
|
|
1248
|
+
const source = path.join(rootDir, record.installPath.normalize('NFC'));
|
|
1249
|
+
if (!await sourcePathExists(source) || !(await lstat(source)).isDirectory()) throw imageError('image-staging-verify-failed', `Missing source directory: ${record.sourceId}`);
|
|
1250
|
+
if (record.type === 'git') {
|
|
1251
|
+
const head = declaredGitHead(manifest, record.sourceId);
|
|
1252
|
+
if (!head || await readGitHead(source) !== head) throw imageError('source-validation', `Staged Git HEAD mismatch: ${record.sourceId}`);
|
|
1253
|
+
} else if (record.integrity &&
|
|
1254
|
+
await checksumDirectory(source) !== record.integrity.value &&
|
|
1255
|
+
await checksumDirectoryNormalized(source) !== record.integrity.value) {
|
|
1256
|
+
// SPEC:127 compares the plain checksumDirectory like-for-like with the baseline, but
|
|
1257
|
+
// ADR 0011:117-119 promises an NFD/NFC naming artifact never blocks import. Accept when
|
|
1258
|
+
// EITHER the plain or the NFC-normalized checksum matches the baseline (same tolerance the
|
|
1259
|
+
// export pre-flight uses in checkExport); only a difference under BOTH — real byte or
|
|
1260
|
+
// structural drift, which normalization cannot mask — is a hard failure.
|
|
1261
|
+
throw imageError('source-validation', `Staged checksum mismatch: ${record.sourceId}`);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return records;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function dependencySafeSourceOrder(records) {
|
|
1268
|
+
// Phase one rejects cross-source symlinks, and source records do not carry
|
|
1269
|
+
// another dependency edge. Every accepted source therefore has no dependents;
|
|
1270
|
+
// sourceId order provides a stable dependency-safe submission order.
|
|
1271
|
+
return [...records].sort((left, right) => left.sourceId.localeCompare(right.sourceId));
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function validateImageEnablements(enablement) {
|
|
1275
|
+
const seen = new Set();
|
|
1276
|
+
for (const triple of enablement) {
|
|
1277
|
+
if (!triple || typeof triple !== 'object' || Array.isArray(triple) ||
|
|
1278
|
+
Object.keys(triple).some((key) => !['scope', 'libraryPath', 'alias'].includes(key)) ||
|
|
1279
|
+
!['global', 'hermes'].includes(triple.scope) ||
|
|
1280
|
+
typeof triple.alias !== 'string' || !triple.alias ||
|
|
1281
|
+
triple.alias === '.' || triple.alias === '..' ||
|
|
1282
|
+
/[\\/\u0000-\u001f\u007f]/.test(triple.alias) ||
|
|
1283
|
+
typeof triple.libraryPath !== 'string' || !triple.libraryPath ||
|
|
1284
|
+
path.posix.isAbsolute(triple.libraryPath) || triple.libraryPath.includes('\\') ||
|
|
1285
|
+
/[\u0000-\u001f\u007f]/.test(triple.libraryPath) ||
|
|
1286
|
+
triple.libraryPath.split('/').some((segment) => !segment || segment === '.' || segment === '..') ||
|
|
1287
|
+
path.posix.normalize(triple.libraryPath) !== triple.libraryPath ||
|
|
1288
|
+
!SOURCE_FOLDERS.includes(triple.libraryPath.split('/')[0])) {
|
|
1289
|
+
throw imageError('image-preflight-failed', 'Manifest contains a malformed enablement triple.');
|
|
1290
|
+
}
|
|
1291
|
+
const key = `${triple.scope}\0${triple.alias}`;
|
|
1292
|
+
if (seen.has(key)) throw imageError('image-preflight-failed', `Manifest repeats enablement alias: ${triple.scope}/${triple.alias}`);
|
|
1293
|
+
seen.add(key);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function assertImageLinksInsideSources(entries, records) {
|
|
1298
|
+
for (const entry of entries) {
|
|
1299
|
+
if (entry.type !== 'symlink') continue;
|
|
1300
|
+
const relativePath = normalizeEntryPath(entry.path).normalize('NFC');
|
|
1301
|
+
const record = records.find((candidate) => {
|
|
1302
|
+
const installPath = candidate.installPath.normalize('NFC');
|
|
1303
|
+
return relativePath === installPath || relativePath.startsWith(`${installPath}/`);
|
|
1304
|
+
});
|
|
1305
|
+
if (!record) {
|
|
1306
|
+
throw imageError('image-staging-verify-failed', `Symlink is outside a registered source: ${entry.path}`);
|
|
1307
|
+
}
|
|
1308
|
+
const sourceRoot = record.installPath.normalize('NFC');
|
|
1309
|
+
const target = normalizeEntryPath(
|
|
1310
|
+
path.posix.join(path.posix.dirname(relativePath), normalizeLinkTarget(entry.linkname))
|
|
1311
|
+
).normalize('NFC');
|
|
1312
|
+
if (target !== sourceRoot && !target.startsWith(`${sourceRoot}/`)) {
|
|
1313
|
+
throw imageError('image-staging-verify-failed', `Symlink escapes its source: ${entry.path}`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
export async function importLibraryImage(context, filePath, options = {}) {
|
|
1319
|
+
const input = requireImagePath(filePath);
|
|
1320
|
+
let workspace;
|
|
1321
|
+
let preserve = false;
|
|
1322
|
+
const skillcaddyRoot = path.join(path.resolve(context.rootDir), '.skillcaddy');
|
|
1323
|
+
const skillcaddyExisted = await sourcePathExists(skillcaddyRoot);
|
|
1324
|
+
try {
|
|
1325
|
+
const inputSha256 = await hashImageFile(input);
|
|
1326
|
+
const resumable = await findResumableImageWorkspace(context.rootDir, inputSha256);
|
|
1327
|
+
workspace = resumable || await createManagedSourceWorkspace(context.rootDir, 'library-image-');
|
|
1328
|
+
const rawTarPath = path.join(workspace.root, 'image.tar');
|
|
1329
|
+
const staged = path.join(workspace.root, 'library');
|
|
1330
|
+
const limits = resolveWorkflowLimits(context);
|
|
1331
|
+
let entries;
|
|
1332
|
+
if (resumable) {
|
|
1333
|
+
const transaction = await readTransactionMarker(workspace.root);
|
|
1334
|
+
if (!transaction?.rawTarSha256 || await hashImageFile(rawTarPath) !== transaction.rawTarSha256) {
|
|
1335
|
+
throw imageError('image-staging-verify-failed', `Preserved image staging is incomplete: ${workspace.root}. Delete it and retry.`);
|
|
1336
|
+
}
|
|
1337
|
+
({ entries } = await readTarEntries(rawTarPath, limits));
|
|
1338
|
+
if (entries[0]?.path !== 'library-image.json' || entries[0]?.type !== 'file') {
|
|
1339
|
+
throw imageError('image-preflight-failed', 'Preserved library-image.json is not the first archive entry.');
|
|
1340
|
+
}
|
|
1341
|
+
// Verify before normalizing: the integrity baseline records the producing
|
|
1342
|
+
// filesystem's spelling, and renaming an NFD entry to NFC here moves the
|
|
1343
|
+
// directory away from it. See the non-resumable branch below.
|
|
1344
|
+
await verifyResumedImageStaging(staged, entries, context);
|
|
1345
|
+
await normalizeStagingPaths(staged);
|
|
1346
|
+
} else {
|
|
1347
|
+
await inflateImage(input, rawTarPath, limits);
|
|
1348
|
+
({ entries } = await readTarEntries(rawTarPath, limits));
|
|
1349
|
+
if (entries[0]?.path !== 'library-image.json' || entries[0]?.type !== 'file') {
|
|
1350
|
+
throw imageError('image-preflight-failed', 'library-image.json must be the first archive entry.');
|
|
1351
|
+
}
|
|
1352
|
+
await extractLibraryImage(rawTarPath, staged, {
|
|
1353
|
+
...context,
|
|
1354
|
+
imageLimits: limits,
|
|
1355
|
+
libraryLayout: true,
|
|
1356
|
+
tarPath: context.tarPath || process.env.LIBRARY_IMAGE_TAR_PATH
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
const manifest = JSON.parse(await readFile(path.join(staged, 'library-image.json'), 'utf8'));
|
|
1360
|
+
const records = dependencySafeSourceOrder(
|
|
1361
|
+
await verifyImageSources(staged, manifest, entries)
|
|
1362
|
+
);
|
|
1363
|
+
// Normalize only after verification. At this point the staged tree still
|
|
1364
|
+
// carries the producer's spelling, which is what the integrity baseline
|
|
1365
|
+
// describes. Normalizing first made an NFD-named source fail import on
|
|
1366
|
+
// Linux: the rename happens there, because on APFS the NFC path already
|
|
1367
|
+
// resolves to the same inode and the walk keeps the original name. The
|
|
1368
|
+
// rename turns the directory's plain checksum into the normalized one, and
|
|
1369
|
+
// then neither matches the NFD baseline.
|
|
1370
|
+
//
|
|
1371
|
+
// ADR 0011 Decision 7 still holds — the receiving library ends up in NFC
|
|
1372
|
+
// form. It is just settled after the comparison that decides whether the
|
|
1373
|
+
// archive is trustworthy at all, instead of before it.
|
|
1374
|
+
await normalizeStagingPaths(staged);
|
|
1375
|
+
if (resumable) preserve = true;
|
|
1376
|
+
const rawTarSha256 = await hashImageFile(rawTarPath);
|
|
1377
|
+
if (!resumable) {
|
|
1378
|
+
await writeFile(
|
|
1379
|
+
path.join(workspace.root, 'transaction.json'),
|
|
1380
|
+
JSON.stringify({ kind: 'library-image-import', archiveSha256: inputSha256, rawTarSha256 }),
|
|
1381
|
+
{ flag: 'wx', mode: 0o600 }
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
reportImage(context, '[pass] image unpack and source verification');
|
|
1385
|
+
const scopeSnapshot = await snapshotImageScopes(context);
|
|
1386
|
+
const enablement = await planImageEnablements(context, manifest.enablement, records, staged);
|
|
1387
|
+
const result = { sources: [], enablement };
|
|
1388
|
+
for (const record of records) reportImage(context, `[source] ${record.sourceId} -> ${record.installPath}`);
|
|
1389
|
+
for (const item of enablement) reportImage(context, `[${item.disposition}] ${item.scope}/${item.alias}`);
|
|
1390
|
+
if (options.dryRun) {
|
|
1391
|
+
for (const record of records) {
|
|
1392
|
+
const plan = await planSourceAcquisition(context, buildImageAcquisitionRequest(record, staged, manifest));
|
|
1393
|
+
result.sources.push({ sourceId: record.sourceId, status: plan.status });
|
|
1394
|
+
reportImage(context, `[${plan.status}] ${record.sourceId}`);
|
|
1395
|
+
}
|
|
1396
|
+
return result;
|
|
1397
|
+
}
|
|
1398
|
+
if (!options.yes && !await options.confirm?.({ sources: records, enablement })) return { ...result, cancelled: true };
|
|
1399
|
+
await assertImageScopes(context, scopeSnapshot);
|
|
1400
|
+
const committed = [];
|
|
1401
|
+
for (const [index, record] of records.entries()) {
|
|
1402
|
+
try {
|
|
1403
|
+
const receiverRecord = (await readSourceRecords(context.rootDir))
|
|
1404
|
+
.find((item) => item.sourceId === record.sourceId);
|
|
1405
|
+
const request = buildImageAcquisitionRequest(record, staged, manifest);
|
|
1406
|
+
const plan = await planSourceAcquisition(context, request);
|
|
1407
|
+
const submitted = await applySourceAcquisition(context, plan);
|
|
1408
|
+
if (receiverRecord && JSON.stringify(receiverRecord) !== JSON.stringify(record)) {
|
|
1409
|
+
reportImage(context, `[not-applied] receiver source sidecar wins: ${record.sourceId}`);
|
|
1410
|
+
}
|
|
1411
|
+
result.sources.push(submitted);
|
|
1412
|
+
committed.push(record.sourceId);
|
|
1413
|
+
reportImage(context, `[${submitted.status}] ${record.sourceId}`);
|
|
1414
|
+
} catch (error) {
|
|
1415
|
+
preserve = true;
|
|
1416
|
+
const detail = `committed: ${committed.join(', ') || '(none)'}\nuncommitted: ${record.sourceId}\nunattempted: ${records.slice(index + 1).map(item => item.sourceId).join(', ') || '(none)'}\nStaging preserved: ${workspace.root}. Re-run image import to resume; delete this staging directory to cancel.`;
|
|
1417
|
+
throw imageError(error.category || 'image-source-mismatch', `${error.message}\n${detail}`);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
try {
|
|
1421
|
+
await restoreImageMetadata(context, staged, records);
|
|
1422
|
+
result.enablement = await applyImageEnablements(context, manifest.enablement, records, scopeSnapshot);
|
|
1423
|
+
await ensureSourceDirectory(context.rootDir, '.skillcaddy');
|
|
1424
|
+
const breadcrumb = path.join(context.rootDir, '.skillcaddy/library-image-import.json');
|
|
1425
|
+
if (await sourcePathExists(breadcrumb) && !(await lstat(breadcrumb)).isFile()) throw imageError('source-safety', 'Provenance breadcrumb is not a regular file.');
|
|
1426
|
+
await writeFile(breadcrumb, JSON.stringify(manifest.producer, null, 2));
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
preserve = true;
|
|
1429
|
+
throw error;
|
|
1430
|
+
}
|
|
1431
|
+
preserve = false;
|
|
1432
|
+
await clearResumedImageWorkspaces(workspace, inputSha256);
|
|
1433
|
+
return result;
|
|
1434
|
+
} catch (error) { throw wrapImageError(error, 'image-preflight-failed'); }
|
|
1435
|
+
finally {
|
|
1436
|
+
if (workspace && !preserve) await removeManagedSourceWorkspace(workspace);
|
|
1437
|
+
if (!preserve) await removeEmptyImageStagingParent(context.rootDir);
|
|
1438
|
+
if (!preserve && !skillcaddyExisted) await removeEmptyImageStateRoot(skillcaddyRoot);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function buildImageAcquisitionRequest(record, staged, manifest) {
|
|
1443
|
+
const head = record.type === 'git'
|
|
1444
|
+
? declaredGitHead(manifest, record.sourceId)
|
|
1445
|
+
: undefined;
|
|
1446
|
+
return {
|
|
1447
|
+
image: {
|
|
1448
|
+
record,
|
|
1449
|
+
contentRoot: path.join(staged, record.installPath.normalize('NFC')),
|
|
1450
|
+
...(head ? { head } : {})
|
|
1451
|
+
}
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function declaredGitHead(manifest, sourceId) {
|
|
1456
|
+
return manifest.declarations.gitSourceMode?.headRecords
|
|
1457
|
+
?.find((item) => item.sourceId === sourceId)?.head;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function isImageMember(name) {
|
|
1461
|
+
return name === 'library-image.json' || SOURCE_FOLDERS.some(bucket => name === bucket || name.startsWith(`${bucket}/`)) ||
|
|
1462
|
+
name === '.skillcaddy' || ['.skillcaddy/sources', '.skillcaddy/metadata'].some(root => name === root || name.startsWith(`${root}/`));
|
|
1463
|
+
}
|
|
1464
|
+
function validateImageEntries(entries) {
|
|
1465
|
+
const byPath = new Map();
|
|
1466
|
+
const normalizedPaths = new Map();
|
|
1467
|
+
for (const entry of entries) {
|
|
1468
|
+
const name = normalizeEntryPath(entry.path);
|
|
1469
|
+
if (!isImageMember(name) || entry.path.includes('\\')) throw safetyError(`Unexpected image member: ${entry.path}`);
|
|
1470
|
+
const normalizedName = name.normalize('NFC');
|
|
1471
|
+
const normalizedPrevious = normalizedPaths.get(normalizedName);
|
|
1472
|
+
if (normalizedPrevious && normalizedPrevious.path !== entry.path) {
|
|
1473
|
+
throw safetyError(`Unicode-normalized path collision: ${entry.path}`);
|
|
1474
|
+
}
|
|
1475
|
+
normalizedPaths.set(normalizedName, entry);
|
|
1476
|
+
if (path.basename(name) === SOURCE_INSTALLING_MARKER) throw safetyError(`Image contains an installing marker: ${name}`);
|
|
1477
|
+
const previous = byPath.get(name);
|
|
1478
|
+
if (previous && (name === 'library-image.json' || previous.type !== entry.type || ['symlink', 'hardlink'].includes(entry.type))) throw safetyError(`Ambiguous duplicate image member: ${name}`);
|
|
1479
|
+
byPath.set(name, entry);
|
|
1480
|
+
if (['symlink', 'hardlink'].includes(entry.type)) {
|
|
1481
|
+
if (name.startsWith('.skillcaddy/')) throw safetyError(`Registry or metadata link: ${name}`);
|
|
1482
|
+
if (!entry.linkname || /^(?:[\\/]|[A-Za-z]:)/.test(entry.linkname) || /[\\\u0000-\u001f\u007f]/.test(entry.linkname) || entry.linkname.includes('\\')) throw safetyError(`Unsafe ${entry.type} target: ${name}`);
|
|
1483
|
+
const target = entry.type === 'symlink' ? path.posix.normalize(path.posix.join(path.posix.dirname(name), entry.linkname)) : entry.linkname;
|
|
1484
|
+
if (!SOURCE_FOLDERS.some(bucket => target.startsWith(`${bucket}/`)) || (entry.type === 'hardlink' && entry.linkname.split('/').includes('..'))) throw safetyError(`Escaping ${entry.type}: ${name}`);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
// Do not allow tar to traverse any link, including a safe in-library link,
|
|
1488
|
+
// while writing another member. This closes order-dependent alias attacks.
|
|
1489
|
+
for (const [name, entry] of byPath) {
|
|
1490
|
+
const segments = name.split('/');
|
|
1491
|
+
for (let index = 1; index < segments.length; index++) {
|
|
1492
|
+
const ancestor = byPath.get(segments.slice(0, index).join('/'));
|
|
1493
|
+
if (ancestor && ancestor.type !== 'directory') throw safetyError(`Entry traverses a non-directory: ${name}`);
|
|
1494
|
+
}
|
|
1495
|
+
if (entry.type === 'hardlink' && byPath.get(normalizeEntryPath(entry.linkname))?.type !== 'file') throw safetyError(`Hardlink must target an image regular file: ${name}`);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
async function verifyImageTree(rootDir, entries, context) {
|
|
1499
|
+
const root = await realpath(rootDir);
|
|
1500
|
+
const expected = new Map(entries.map(entry => [normalizeEntryPath(entry.path).normalize('NFC'), entry]));
|
|
1501
|
+
const inodes = new Map();
|
|
1502
|
+
for (const item of await imageTree(rootDir)) {
|
|
1503
|
+
const absolute = path.join(rootDir, item.path);
|
|
1504
|
+
try {
|
|
1505
|
+
if (!isPathInsideOrEqual(root, await realpath(absolute))) throw imageError('image-staging-verify-failed', `Escaping realpath: ${item.path}`);
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
if (error.code !== 'ENOENT' || !item.info.isSymbolicLink()) throw error;
|
|
1508
|
+
reportImage(context, `[warn] dead-symlink: ${item.path}`);
|
|
1509
|
+
}
|
|
1510
|
+
const declared = expected.get(item.path.normalize('NFC'));
|
|
1511
|
+
if (declared) {
|
|
1512
|
+
if (declared.type === 'directory' ? !item.info.isDirectory() : ['file', 'hardlink'].includes(declared.type) && !item.info.isFile()) throw imageError('image-staging-verify-failed', `Staged inode type mismatch: ${item.path}`);
|
|
1513
|
+
if (declared.xattrs.length) reportImage(context, `[warn] xattrs: ${item.path} (archive attributes not restored)`);
|
|
1514
|
+
for (const [category, actual, original] of [
|
|
1515
|
+
['uid', item.info.uid, declared.uid], ['gid', item.info.gid, declared.gid],
|
|
1516
|
+
['mtime', Math.floor(item.info.mtimeMs / 1000), declared.mtime],
|
|
1517
|
+
['mode', item.info.mode & 0o777, declared.mode & 0o777]
|
|
1518
|
+
]) if (actual !== original) reportImage(context, `[warn] ${category}: ${item.path} (${original} -> ${actual})`);
|
|
1519
|
+
if (item.path !== normalizeEntryPath(declared.path)) reportImage(context, `[warn] nfd-roundtrip: ${item.path}`);
|
|
1520
|
+
if (declared.type === 'symlink' && (!item.info.isSymbolicLink() || (await readlink(absolute)).normalize('NFC') !== normalizeLinkTarget(declared.linkname).normalize('NFC'))) throw imageError('image-staging-verify-failed', `Symlink mismatch: ${item.path}`);
|
|
1521
|
+
}
|
|
1522
|
+
if (item.info.mode & 0o7000) throw imageError('image-staging-verify-failed', `Privileged staged mode: ${item.path}`);
|
|
1523
|
+
if (item.info.isFile()) {
|
|
1524
|
+
const key = `${item.info.dev}:${item.info.ino}`;
|
|
1525
|
+
const inode = inodes.get(key) || { count: 0, links: item.info.nlink };
|
|
1526
|
+
inode.count++; inodes.set(key, inode);
|
|
1527
|
+
if (item.info.nlink > 1) reportImage(context, `[warn] nlink: ${item.path} (${item.info.nlink})`);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
if ([...inodes.values()].some(inode => inode.count !== inode.links)) throw imageError('image-staging-verify-failed', 'Staged hardlink points outside the image.');
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* Did a scope directory's entry set change between the two snapshots?
|
|
1535
|
+
*
|
|
1536
|
+
* Undefined-vs-undefined (a scope that did not exist in either snapshot) is not
|
|
1537
|
+
* a change; the caller already treats a missing scope as "nothing to compare".
|
|
1538
|
+
*/
|
|
1539
|
+
function entrySetChanged(before, after) {
|
|
1540
|
+
if (before === after) return false;
|
|
1541
|
+
if (!before || !after) return false;
|
|
1542
|
+
if (before.length !== after.length) return true;
|
|
1543
|
+
return before.some((name, index) => name !== after[index]);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
async function snapshotImageScopes(context) {
|
|
1547
|
+
const home = homedir();
|
|
1548
|
+
const snapshot = {};
|
|
1549
|
+
for (const [scope, directory] of Object.entries(imageScopes(context))) {
|
|
1550
|
+
const info = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
1551
|
+
snapshot[scope] = {
|
|
1552
|
+
relative: path.relative(home, directory),
|
|
1553
|
+
identity: info ? `${info.dev}:${info.ino}` : null,
|
|
1554
|
+
// A scope directory replaced between planning and confirmation keeps the
|
|
1555
|
+
// same inode on Linux: measured on ubuntu-latest, rm + mkdir at one path
|
|
1556
|
+
// returns the identical dev:ino (2049:9180294 before and after), so the
|
|
1557
|
+
// identity check alone cannot see it. macOS increments, which is why
|
|
1558
|
+
// this was invisible there.
|
|
1559
|
+
//
|
|
1560
|
+
// ctime is not a substitute: it also advances when the directory's
|
|
1561
|
+
// contents change, so an unrelated symlink appearing or disappearing
|
|
1562
|
+
// would read as "the scope was replaced". The entry set is what actually
|
|
1563
|
+
// distinguishes the two — a replaced directory starts empty where the
|
|
1564
|
+
// planned one had links.
|
|
1565
|
+
entries: info && info.isDirectory()
|
|
1566
|
+
? (await readdir(directory).catch(() => [])).map(name => name.normalize('NFC')).sort()
|
|
1567
|
+
: null,
|
|
1568
|
+
realpath: info ? await realpath(directory).catch(() => null) : null
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
return snapshot;
|
|
1572
|
+
}
|
|
1573
|
+
async function assertImageScopes(context, before) {
|
|
1574
|
+
const after = await snapshotImageScopes(context);
|
|
1575
|
+
for (const scope of Object.keys(before)) {
|
|
1576
|
+
// An explicit directory left behind by a HOME change is dealt with per
|
|
1577
|
+
// triple, not mistaken for a change to the configured scope layout.
|
|
1578
|
+
if (after[scope].relative.startsWith('..')) continue;
|
|
1579
|
+
if (after[scope].relative !== before[scope].relative ||
|
|
1580
|
+
(before[scope].identity && after[scope].identity !== before[scope].identity) ||
|
|
1581
|
+
(before[scope].realpath && after[scope].realpath !== before[scope].realpath) ||
|
|
1582
|
+
entrySetChanged(before[scope].entries, after[scope].entries)) {
|
|
1583
|
+
throw imageError('stale-plan', `Scope directory changed: ${scope}. Re-run image import to review a new plan.`);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
async function pathRemainsInside(root, target) {
|
|
1588
|
+
if (!isPathInsideOrEqual(path.resolve(root), path.resolve(target))) return false;
|
|
1589
|
+
let ancestor = target;
|
|
1590
|
+
while (!await sourcePathExists(ancestor)) ancestor = path.dirname(ancestor);
|
|
1591
|
+
return isPathInsideOrEqual(await realpath(root), await realpath(ancestor));
|
|
1592
|
+
}
|
|
1593
|
+
async function planImageEnablements(context, triples, records, contentRoot = context.rootDir) {
|
|
1594
|
+
const scopes = imageScopes(context);
|
|
1595
|
+
const home = homedir();
|
|
1596
|
+
const result = [];
|
|
1597
|
+
for (const triple of triples) {
|
|
1598
|
+
const { scope, alias } = triple || {};
|
|
1599
|
+
const libraryPath = typeof triple?.libraryPath === 'string'
|
|
1600
|
+
? normalizeEntryPath(triple.libraryPath).normalize('NFC')
|
|
1601
|
+
: triple?.libraryPath;
|
|
1602
|
+
if (alias === 'skillcaddy-manager' || (typeof libraryPath === 'string' && path.posix.basename(libraryPath) === 'skillcaddy-manager')) continue;
|
|
1603
|
+
const item = { scope, alias, libraryPath, disposition: 'unsatisfiable' };
|
|
1604
|
+
result.push(item);
|
|
1605
|
+
if (!Object.hasOwn(scopes, scope) || typeof alias !== 'string' || !alias || alias === '.' || alias === '..' || /[\\/\u0000-\u001f\u007f]/.test(alias) || typeof libraryPath !== 'string') continue;
|
|
1606
|
+
const record = records.find((record) => record.skills.some((skill) =>
|
|
1607
|
+
path.posix.join(record.installPath, skill).normalize('NFC') === libraryPath
|
|
1608
|
+
));
|
|
1609
|
+
if (!record) continue;
|
|
1610
|
+
if (scope === 'hermes' && !isHermesEligibleSource(record.bucket)) { item.disposition = 'ineligible'; continue; }
|
|
1611
|
+
const target = path.join(scopes[scope], alias);
|
|
1612
|
+
if (!await pathRemainsInside(home, scopes[scope])) continue;
|
|
1613
|
+
const skillPath = path.join(contentRoot, libraryPath);
|
|
1614
|
+
if (!await sourcePathExists(skillPath) || !await pathRemainsInside(contentRoot, skillPath)) continue;
|
|
1615
|
+
const installedTarget = path.resolve(context.rootDir, libraryPath);
|
|
1616
|
+
let info;
|
|
1617
|
+
try { info = await lstat(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
1618
|
+
if (!info) item.disposition = 'create';
|
|
1619
|
+
else if (!info.isSymbolicLink()) item.disposition = 'conflict:not-a-symlink';
|
|
1620
|
+
else item.disposition = path.resolve(path.dirname(target), await readlink(target)) === installedTarget ? 'unchanged' : 'conflict:other-target';
|
|
1621
|
+
}
|
|
1622
|
+
return result;
|
|
1623
|
+
}
|
|
1624
|
+
async function applyImageEnablements(context, triples, records, snapshot) {
|
|
1625
|
+
await assertImageScopes(context, snapshot);
|
|
1626
|
+
const result = await planImageEnablements(context, triples, records);
|
|
1627
|
+
for (const item of result) {
|
|
1628
|
+
if (item.disposition === 'create') {
|
|
1629
|
+
const directory = imageScopes(context)[item.scope];
|
|
1630
|
+
// ensureSourceDirectory refuses symlink ancestors, even inside HOME.
|
|
1631
|
+
const relative = path.relative(homedir(), directory);
|
|
1632
|
+
await ensureSourceDirectory(homedir(), ...relative.split(path.sep));
|
|
1633
|
+
try { await symlink(path.resolve(context.rootDir, item.libraryPath), path.join(directory, item.alias)); }
|
|
1634
|
+
catch (error) {
|
|
1635
|
+
if (error.code !== 'EEXIST') throw error;
|
|
1636
|
+
Object.assign(item, (await planImageEnablements(context, [item], records))[0]);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
reportImage(context, `[${item.disposition}] ${item.scope}/${item.alias}`);
|
|
1640
|
+
}
|
|
1641
|
+
return result;
|
|
1642
|
+
}
|
|
1643
|
+
async function restoreImageMetadata(context, staged, records) {
|
|
1644
|
+
for (const record of records) {
|
|
1645
|
+
for (const skill of record.skills) {
|
|
1646
|
+
const libraryPath = path.posix.join(record.installPath, skill).normalize('NFC');
|
|
1647
|
+
const relative = `.skillcaddy/metadata/${libraryPath}/skillcaddy.json`;
|
|
1648
|
+
if (!await sourcePathExists(path.join(staged, relative))) continue;
|
|
1649
|
+
if (await sourcePathExists(path.join(context.rootDir, relative))) {
|
|
1650
|
+
reportImage(context, `[not-applied] receiver metadata sidecar wins: ${libraryPath}`);
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
const metadata = JSON.parse(await readFile(path.join(staged, relative), 'utf8'));
|
|
1654
|
+
await ensureSourceDirectory(context.rootDir, ...path.posix.dirname(relative).split('/'));
|
|
1655
|
+
try { await updateSkillMetadata(context.rootDir, { ...metadata, skillPath: path.join(context.rootDir, libraryPath), createOnly: true }); }
|
|
1656
|
+
catch (error) {
|
|
1657
|
+
if (error.code !== 'EEXIST') throw error;
|
|
1658
|
+
reportImage(context, `[not-applied] receiver metadata sidecar wins: ${libraryPath}`);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
async function hashImageFile(filePath) {
|
|
1665
|
+
const hash = createHash('sha256');
|
|
1666
|
+
for await (const chunk of createReadStream(filePath)) hash.update(chunk);
|
|
1667
|
+
return hash.digest('hex');
|
|
1668
|
+
}
|
|
1669
|
+
async function readTransactionMarker(root) {
|
|
1670
|
+
const marker = path.join(root, 'transaction.json');
|
|
1671
|
+
try {
|
|
1672
|
+
if (!(await lstat(marker)).isFile()) return null;
|
|
1673
|
+
return JSON.parse(await readFile(marker, 'utf8'));
|
|
1674
|
+
} catch (error) {
|
|
1675
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) return null;
|
|
1676
|
+
throw error;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
async function findResumableImageWorkspace(rootDir, archiveSha256) {
|
|
1680
|
+
const parent = await ensureSourceDirectory(rootDir, '.skillcaddy', 'staging');
|
|
1681
|
+
const entries = await readdir(parent, { withFileTypes: true });
|
|
1682
|
+
for (const entry of entries) {
|
|
1683
|
+
if (!entry.isDirectory() || !/^library-image-[A-Za-z0-9]{6}$/.test(entry.name)) continue;
|
|
1684
|
+
const root = path.join(parent, entry.name);
|
|
1685
|
+
const transaction = await readTransactionMarker(root);
|
|
1686
|
+
if (transaction?.kind === 'library-image-import' && transaction.archiveSha256 === archiveSha256) {
|
|
1687
|
+
return { parent, root };
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
return null;
|
|
1691
|
+
}
|
|
1692
|
+
async function verifyResumedImageStaging(staged, entries, context) {
|
|
1693
|
+
validateImageEntries(entries);
|
|
1694
|
+
const written = await walkStaging(staged, true);
|
|
1695
|
+
const expected = new Set([...buildExpectedPaths(entries)].map((name) => name.normalize('NFC')));
|
|
1696
|
+
for (const item of written) {
|
|
1697
|
+
const relative = path.relative(staged, item.absPath).split(path.sep).join('/').normalize('NFC');
|
|
1698
|
+
if (!expected.has(relative)) throw imageError('image-staging-verify-failed', `Preserved staging contains an unexpected path: ${relative}`);
|
|
1699
|
+
}
|
|
1700
|
+
const missing = entries
|
|
1701
|
+
.filter((entry) => entry.type === 'file')
|
|
1702
|
+
.map((entry) => normalizeEntryPath(entry.path))
|
|
1703
|
+
.filter((relative) => relative && !isMacPackagingJunk(relative))
|
|
1704
|
+
.filter((relative) => !expected.has(relative.normalize('NFC')) || !written.some((item) => path.relative(staged, item.absPath).split(path.sep).join('/').normalize('NFC') === relative.normalize('NFC')));
|
|
1705
|
+
if (missing.length) throw imageError('image-staging-verify-failed', `Preserved image staging is missing: ${missing[0]}`);
|
|
1706
|
+
await verifyImageTree(staged, entries, context);
|
|
1707
|
+
}
|
|
1708
|
+
async function clearResumedImageWorkspaces(workspace, archiveSha256) {
|
|
1709
|
+
for (const entry of await readdir(workspace.parent, { withFileTypes: true })) {
|
|
1710
|
+
const root = path.join(workspace.parent, entry.name);
|
|
1711
|
+
if (root === workspace.root || !entry.isDirectory() || !/^library-image-[A-Za-z0-9]{6}$/.test(entry.name)) continue;
|
|
1712
|
+
const marker = path.join(root, 'transaction.json');
|
|
1713
|
+
if (!await sourcePathExists(marker) || !(await lstat(marker)).isFile()) continue;
|
|
1714
|
+
let transaction;
|
|
1715
|
+
try { transaction = JSON.parse(await readFile(marker, 'utf8')); } catch { continue; }
|
|
1716
|
+
if (transaction.kind === 'library-image-import' && transaction.archiveSha256 === archiveSha256) await forceRemove(root);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
async function removeEmptyImageStagingParent(rootDir) {
|
|
1721
|
+
await removeEmptyDirectory(path.join(path.resolve(rootDir), '.skillcaddy', 'staging'));
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
async function removeEmptyImageStateRoot(root) {
|
|
1725
|
+
await removeEmptyDirectory(root);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
async function removeEmptyDirectory(directory) {
|
|
1729
|
+
try {
|
|
1730
|
+
const info = await lstat(directory);
|
|
1731
|
+
if (!info.isDirectory() || info.isSymbolicLink() || (await readdir(directory)).length > 0) return;
|
|
1732
|
+
await rmdir(directory);
|
|
1733
|
+
} catch (error) {
|
|
1734
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
async function verifyPackedImage(archive, packing, workspace, context) {
|
|
1739
|
+
const rawTarPath = path.join(workspace, 'packed.tar');
|
|
1740
|
+
const digest = await hashImageFile(archive);
|
|
1741
|
+
const limits = resolveWorkflowLimits(context);
|
|
1742
|
+
await inflateImage(archive, rawTarPath, limits);
|
|
1743
|
+
const { entries } = await readTarEntries(rawTarPath, limits);
|
|
1744
|
+
validateImageEntries(entries);
|
|
1745
|
+
if (entries[0]?.path !== 'library-image.json') throw imageError('export-blocked', 'Packed manifest is not first.');
|
|
1746
|
+
const advertised = new Set(entries.map(entry => normalizeEntryPath(entry.path)));
|
|
1747
|
+
for (const item of await imageTree(packing)) {
|
|
1748
|
+
if (!item.info.isDirectory() && !advertised.has(item.path)) throw imageError('export-blocked', `Packing omitted library bytes: ${item.path}`);
|
|
1749
|
+
}
|
|
1750
|
+
const handle = await open(rawTarPath, 'r');
|
|
1751
|
+
try {
|
|
1752
|
+
for (const entry of entries) {
|
|
1753
|
+
if (entry.mode & 0o7000) throw imageError('export-blocked', `Privileged packed mode: ${entry.path}`);
|
|
1754
|
+
assertEntryInsideStaging(entry.path, packing);
|
|
1755
|
+
if (entry.type === 'file') {
|
|
1756
|
+
const hash = createHash('sha256');
|
|
1757
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
1758
|
+
let remaining = entry.size;
|
|
1759
|
+
let offset = entry.dataOffset;
|
|
1760
|
+
while (remaining) {
|
|
1761
|
+
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, remaining), offset);
|
|
1762
|
+
if (!bytesRead) throw imageError('export-blocked', 'Packed tar was truncated during verification.');
|
|
1763
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
1764
|
+
remaining -= bytesRead; offset += bytesRead;
|
|
1765
|
+
}
|
|
1766
|
+
if (hash.digest('hex') !== await hashImageFile(path.join(packing, entry.path))) throw imageError('export-blocked', `Packed bytes differ: ${entry.path}`);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
} finally { await handle.close(); }
|
|
1770
|
+
const roundtrip = path.join(workspace, 'roundtrip');
|
|
1771
|
+
try {
|
|
1772
|
+
await extractLibraryImage(rawTarPath, roundtrip, {
|
|
1773
|
+
...context,
|
|
1774
|
+
imageLimits: limits,
|
|
1775
|
+
libraryLayout: true,
|
|
1776
|
+
tarPath: context.tarPath || process.env.LIBRARY_IMAGE_TAR_PATH
|
|
1777
|
+
});
|
|
1778
|
+
await normalizeStagingPaths(roundtrip, { renameToNfc: false });
|
|
1779
|
+
const roundtripManifest = JSON.parse(await readFile(path.join(roundtrip, 'library-image.json'), 'utf8'));
|
|
1780
|
+
await verifyImageSources(roundtrip, roundtripManifest, entries);
|
|
1781
|
+
} finally {
|
|
1782
|
+
await forceRemove(roundtrip);
|
|
1783
|
+
}
|
|
1784
|
+
if (await hashImageFile(archive) !== digest) throw imageError('export-blocked', 'Archive changed during the post-pack gate.');
|
|
1785
|
+
}
|