@mpgd/cli 0.31.0 → 0.32.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/dist/asset-pack-build.d.ts +32 -0
- package/dist/asset-pack-build.js +411 -0
- package/dist/asset-pack-verify.d.ts +76 -0
- package/dist/asset-pack-verify.js +920 -0
- package/dist/asset-pack-zip.d.ts +19 -0
- package/dist/asset-pack-zip.js +81 -0
- package/dist/bin.js +6 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +283 -1
- package/package.json +10 -10
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, open, opendir, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { verifyZipV1Archive, ZipDecodeError, } from '@mpgd/phaser-assets/archive-validation';
|
|
5
|
+
import { defaultArchiveWorkerLimits } from '@mpgd/phaser-assets/archives';
|
|
6
|
+
import { validatePhaserPackDeliveryManifest, } from '@mpgd/phaser-assets/pack-format';
|
|
7
|
+
const DEFAULT_MANIFEST_BYTE_CAP = 32 * 1024 * 1024;
|
|
8
|
+
const DEFAULT_VERIFY_TIMEOUT_MS = 120000;
|
|
9
|
+
/** The watchdog arms one millisecond after the deadline. Leave one
|
|
10
|
+
* millisecond of headroom so the largest accepted timeout plus that offset
|
|
11
|
+
* still fits the platform timer range instead of clamping to 1 ms. */
|
|
12
|
+
const TIMER_RANGE_MS = 2 ** 31 - 1;
|
|
13
|
+
const MAX_VERIFY_TIMEOUT_MS = TIMER_RANGE_MS - 1;
|
|
14
|
+
const DEFAULT_MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
|
|
15
|
+
const DEFAULT_MAX_ENTRY_BYTES = 256 * 1024 * 1024;
|
|
16
|
+
const DEFAULT_MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
|
|
17
|
+
const STREAM_CHUNK_BYTES = 1024 * 1024;
|
|
18
|
+
/** Inventory walking stops beyond this many files so an accidental root
|
|
19
|
+
* choice cannot turn verification into an unbounded traversal; the report
|
|
20
|
+
* marks the count truncated instead of failing silently. */
|
|
21
|
+
const INVENTORY_FILE_CAP = 1000000;
|
|
22
|
+
/** Same manifest artifact colliding under case folding means the deployment
|
|
23
|
+
* is ambiguous on case-insensitive hosts. */
|
|
24
|
+
/** Case-fold the collision key unconditionally: the verifier's host is not
|
|
25
|
+
* the deployment's consumers, and case-duplicate artifacts are ambiguous
|
|
26
|
+
* for any case-insensitive host or CDN regardless of where this runs.
|
|
27
|
+
* The fold mirrors the 1:1 upcase tables of the case-insensitive
|
|
28
|
+
* filesystems deployments actually target (NTFS, APFS): each code point
|
|
29
|
+
* maps through its single-code-point uppercase form, so Greek σ and ς
|
|
30
|
+
* collide (both upcase to Σ) exactly as on those hosts, while mappings
|
|
31
|
+
* that only exist as multi-character expansions — ß to SS, fi to FI —
|
|
32
|
+
* stay distinct, matching a 1:1 table and keeping valid NTFS/APFS
|
|
33
|
+
* deployments from being falsely rejected. Full Unicode case folding
|
|
34
|
+
* (ext4/F2FS casefold directories) is a different equivalence, recorded
|
|
35
|
+
* in `notVerified` rather than approximated here. */
|
|
36
|
+
const caseKeyOf = (resolvedPath) => {
|
|
37
|
+
let key = '';
|
|
38
|
+
for (const character of resolvedPath) {
|
|
39
|
+
const upper = character.toUpperCase();
|
|
40
|
+
// Code points, not UTF-16 units: an astral-plane letter whose
|
|
41
|
+
// uppercase form is also one code point (a surrogate pair of length
|
|
42
|
+
// two) folds like any other, while multi-code-point expansions such
|
|
43
|
+
// as ß to SS keep the original.
|
|
44
|
+
key += [...upper].length === 1 ? upper : character;
|
|
45
|
+
}
|
|
46
|
+
return key;
|
|
47
|
+
};
|
|
48
|
+
const NOT_VERIFIED = [
|
|
49
|
+
'CDN cache state and origin preservation',
|
|
50
|
+
'HTTP CORS and Content-Type responses',
|
|
51
|
+
'Application image display and device compatibility',
|
|
52
|
+
'Server access control',
|
|
53
|
+
'Atomic multi-file deployment swaps',
|
|
54
|
+
'Full Unicode case-fold ambiguity (ext4/F2FS casefold directories): '
|
|
55
|
+
+ 'collision detection folds like NTFS and APFS, under which '
|
|
56
|
+
+ '"Straße.png" and "STRASSE.png" remain distinct files',
|
|
57
|
+
];
|
|
58
|
+
const failWith = (failures, stage, code, message, packId) => {
|
|
59
|
+
failures.push(packId === undefined ? { stage, code, message } : { stage, code, message, packId });
|
|
60
|
+
};
|
|
61
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
62
|
+
const positiveIntegerOption = (failures, name, value) => {
|
|
63
|
+
if (value === undefined) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
67
|
+
failWith(failures, 'args', 'invalid-option', `${name} must be a positive integer`);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
/** Thrown by I/O helpers when the whole-verification budget expires
|
|
73
|
+
* mid-read; the failure itself is recorded exactly once by the deadline
|
|
74
|
+
* control before this sentinel propagates. */
|
|
75
|
+
class VerifyDeadlineError extends Error {
|
|
76
|
+
}
|
|
77
|
+
/** Streaming SHA-256 over an already-opened descriptor: never buffers the
|
|
78
|
+
* whole file, the hash reads every actual byte rather than trusting stat
|
|
79
|
+
* or Content-Length alone, and the bytes hashed are the same inode the
|
|
80
|
+
* path checks validated — a component swapped for a symlink after the
|
|
81
|
+
* check cannot redirect this read. */
|
|
82
|
+
const streamSha256 = async (path, handle, hasher, deadline) => {
|
|
83
|
+
let bytes = 0;
|
|
84
|
+
const stream = handle.createReadStream({
|
|
85
|
+
highWaterMark: STREAM_CHUNK_BYTES,
|
|
86
|
+
// closeBounded owns closure under the deadline; an implicit close
|
|
87
|
+
// after the read would not be raced and could stall the exit path.
|
|
88
|
+
autoClose: false,
|
|
89
|
+
});
|
|
90
|
+
const timer = deadline.armStream(stream);
|
|
91
|
+
try {
|
|
92
|
+
for await (const chunk of stream) {
|
|
93
|
+
const view = chunk;
|
|
94
|
+
bytes += view.byteLength;
|
|
95
|
+
hasher.update(view);
|
|
96
|
+
// Hashing a large chunk is synchronous; sample after it so a final
|
|
97
|
+
// chunk that crosses the budget cannot be accepted merely because the
|
|
98
|
+
// stream itself completed and its watchdog was cleared.
|
|
99
|
+
deadline.sample();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error instanceof VerifyDeadlineError) {
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
// A file changing mid-verification (size or content) surfaces as a
|
|
107
|
+
// read failure rather than a digest mismatch.
|
|
108
|
+
throw new Error(`Could not read ${path}: ${errorText(error)}`);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
}
|
|
113
|
+
deadline.sample();
|
|
114
|
+
return { bytes, sha256: hasher.digest('hex') };
|
|
115
|
+
};
|
|
116
|
+
const resolveArtifactPath = async (root, realRoot, artifactPath, deadline) => {
|
|
117
|
+
if (artifactPath.length === 0) {
|
|
118
|
+
throw new Error('Artifact path is empty');
|
|
119
|
+
}
|
|
120
|
+
if (isAbsolute(artifactPath)
|
|
121
|
+
|| /^[a-zA-Z]:[/\\]/u.test(artifactPath)
|
|
122
|
+
|| artifactPath.startsWith('\\\\')) {
|
|
123
|
+
throw new Error(`Artifact path must be relative: ${artifactPath}`);
|
|
124
|
+
}
|
|
125
|
+
let current = root;
|
|
126
|
+
for (const segment of artifactPath.split(/[\\/]/u)) {
|
|
127
|
+
if (segment === '' || segment === '.') {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (segment === '..') {
|
|
131
|
+
throw new Error(`Artifact path escapes the root: ${artifactPath}`);
|
|
132
|
+
}
|
|
133
|
+
current = join(current, segment);
|
|
134
|
+
let stat;
|
|
135
|
+
try {
|
|
136
|
+
// bigint identity stays exact on filesystems whose device or inode
|
|
137
|
+
// numbers exceed Number.MAX_SAFE_INTEGER.
|
|
138
|
+
stat = await deadline.race(lstat(current, { bigint: true }));
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (error instanceof VerifyDeadlineError) {
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
throw new Error(`Artifact path is missing: ${artifactPath}`);
|
|
145
|
+
}
|
|
146
|
+
if (stat.isSymbolicLink()) {
|
|
147
|
+
throw new Error(`Artifact path must not contain symbolic links: ${artifactPath}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const stat = await deadline.race(lstat(current, { bigint: true }));
|
|
151
|
+
if (!stat.isFile()) {
|
|
152
|
+
throw new Error(`Artifact path is not a regular file: ${artifactPath}`);
|
|
153
|
+
}
|
|
154
|
+
const rel = relative(realRoot, await deadline.race(realpath(current)));
|
|
155
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
156
|
+
throw new Error(`Artifact path escapes the root: ${artifactPath}`);
|
|
157
|
+
}
|
|
158
|
+
// The lstat identity travels with the path so the later open() can bind
|
|
159
|
+
// to this exact inode; a component swapped for a symlink between the
|
|
160
|
+
// check and the open surfaces as a mismatch instead of a redirect.
|
|
161
|
+
return { path: current, dev: stat.dev, ino: stat.ino };
|
|
162
|
+
};
|
|
163
|
+
/** Count every regular file under the root for host-limit accounting.
|
|
164
|
+
* Symlinks are skipped: they are not deployed objects, and manifest
|
|
165
|
+
* artifacts reject them independently. */
|
|
166
|
+
const inventoryRoot = async (root, deadline) => {
|
|
167
|
+
let files = 0;
|
|
168
|
+
let bytes = 0;
|
|
169
|
+
let largest = 0;
|
|
170
|
+
let truncated = false;
|
|
171
|
+
// One shared, bounded pending set instead of recursion: a single
|
|
172
|
+
// directory handle is open at a time, and directory-heavy deployments
|
|
173
|
+
// (empty directories never reach the file cap) cannot queue without
|
|
174
|
+
// limit — the walk reports truncation instead of exhausting memory.
|
|
175
|
+
const pending = [root];
|
|
176
|
+
while (!truncated && pending.length > 0) {
|
|
177
|
+
const directory = pending.pop();
|
|
178
|
+
if (directory === undefined) {
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
// opendir streams entries in bounded batches, so a single directory
|
|
182
|
+
// holding more than the cap cannot materialize every dirent first;
|
|
183
|
+
// each batch read is raced against the deadline like every other
|
|
184
|
+
// blocking wait.
|
|
185
|
+
// Retain the directory identity before opening it. A queued directory
|
|
186
|
+
// can be replaced by a symlink or a different directory while another
|
|
187
|
+
// entry is being walked; fail closed instead of letting opendir follow a
|
|
188
|
+
// replacement outside the verified root.
|
|
189
|
+
const expectedDirectory = await deadline.race(lstat(directory, { bigint: true }));
|
|
190
|
+
if (expectedDirectory.isSymbolicLink() || !expectedDirectory.isDirectory()) {
|
|
191
|
+
throw new Error(`Directory changed during inventory: ${directory}`);
|
|
192
|
+
}
|
|
193
|
+
const dir = await deadline.race(opendir(directory));
|
|
194
|
+
let stalled = false;
|
|
195
|
+
try {
|
|
196
|
+
const openedDirectory = await deadline.race(lstat(directory, { bigint: true }));
|
|
197
|
+
if (openedDirectory.isSymbolicLink()
|
|
198
|
+
|| !openedDirectory.isDirectory()
|
|
199
|
+
|| openedDirectory.dev !== expectedDirectory.dev
|
|
200
|
+
|| openedDirectory.ino !== expectedDirectory.ino) {
|
|
201
|
+
throw new Error(`Directory changed during inventory: ${directory}`);
|
|
202
|
+
}
|
|
203
|
+
for (;;) {
|
|
204
|
+
const entry = await deadline.race(dir.read());
|
|
205
|
+
if (entry === null) {
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
deadline.sample();
|
|
209
|
+
const child = join(directory, entry.name);
|
|
210
|
+
// Every entry is classified by a fresh lstat, never by the
|
|
211
|
+
// possibly stale dirent — not even to skip obvious specials — so
|
|
212
|
+
// an entry whose type changed between the readdir and this call
|
|
213
|
+
// (symlink swapped in, special replaced by a file) can neither
|
|
214
|
+
// steer the walk outside the root nor be skipped from the
|
|
215
|
+
// host-limit counts.
|
|
216
|
+
const stat = await deadline.race(lstat(child));
|
|
217
|
+
if (stat.isSymbolicLink()) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (stat.isDirectory()) {
|
|
221
|
+
pending.push(child);
|
|
222
|
+
if (pending.length > INVENTORY_FILE_CAP) {
|
|
223
|
+
truncated = true;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (!stat.isFile()) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
// Truncation is only marked for an entry that actually counts:
|
|
232
|
+
// a root of exactly the cap whose next entry is a skipped
|
|
233
|
+
// symlink or device does not claim to be truncated.
|
|
234
|
+
if (files >= INVENTORY_FILE_CAP) {
|
|
235
|
+
truncated = true;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
files++;
|
|
239
|
+
bytes += stat.size;
|
|
240
|
+
if (stat.size > largest) {
|
|
241
|
+
largest = stat.size;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (error instanceof VerifyDeadlineError) {
|
|
247
|
+
stalled = true;
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
await closeBounded(() => dir.close(), stalled, deadline);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { files, bytes, largest, truncated };
|
|
256
|
+
};
|
|
257
|
+
const readManifestCapped = async (manifestPath, cap, deadline) => {
|
|
258
|
+
// Open through the deadline: a FIFO with no writer blocks inside
|
|
259
|
+
// open(2) before any stream event exists, and destroying a stream
|
|
260
|
+
// cannot cancel that pending open.
|
|
261
|
+
const handle = await deadline.race(open(resolve(manifestPath), 'r'));
|
|
262
|
+
const chunks = [];
|
|
263
|
+
let received = 0;
|
|
264
|
+
let stalled = false;
|
|
265
|
+
try {
|
|
266
|
+
const info = await deadline.race(handle.stat());
|
|
267
|
+
if (info.isFile()) {
|
|
268
|
+
// Read regular files through the already-opened descriptor so every
|
|
269
|
+
// individual wait is raced against the same deadline. A stream can
|
|
270
|
+
// report EOF while buffered data is still being finalized differently
|
|
271
|
+
// across filesystems; explicit reads make the bytes returned here
|
|
272
|
+
// exactly the bytes consumed from the descriptor.
|
|
273
|
+
if (info.size > BigInt(cap)) {
|
|
274
|
+
throw new Error(`Delivery manifest exceeds ${cap} bytes`);
|
|
275
|
+
}
|
|
276
|
+
const expectedBytes = Number(info.size);
|
|
277
|
+
const buffer = Buffer.allocUnsafe(Math.min(STREAM_CHUNK_BYTES, cap));
|
|
278
|
+
while (received < expectedBytes) {
|
|
279
|
+
const readLength = Math.min(buffer.byteLength, expectedBytes - received);
|
|
280
|
+
const read = handle.read(buffer, 0, readLength, received);
|
|
281
|
+
const { bytesRead } = await deadline.race(read);
|
|
282
|
+
if (bytesRead === 0) {
|
|
283
|
+
throw new Error(`Delivery manifest changed while reading (expected ${expectedBytes} bytes, received ${received})`);
|
|
284
|
+
}
|
|
285
|
+
received += bytesRead;
|
|
286
|
+
chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
|
|
287
|
+
deadline.sample();
|
|
288
|
+
}
|
|
289
|
+
const tail = Buffer.allocUnsafe(1);
|
|
290
|
+
const { bytesRead: tailBytes } = await deadline.race(handle.read(tail, 0, 1, received));
|
|
291
|
+
if (tailBytes !== 0) {
|
|
292
|
+
throw new Error(`Delivery manifest changed while reading (expected ${expectedBytes} bytes, received more)`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
// FIFOs and other non-regular sources retain the stream watchdog: a
|
|
297
|
+
// pending read must be destroyed after the deadline so a direct
|
|
298
|
+
// FileHandle.read cannot leave the smoke process alive behind a
|
|
299
|
+
// rejected race.
|
|
300
|
+
const stream = handle.createReadStream({
|
|
301
|
+
highWaterMark: STREAM_CHUNK_BYTES,
|
|
302
|
+
autoClose: false,
|
|
303
|
+
});
|
|
304
|
+
const timer = deadline.armStream(stream);
|
|
305
|
+
try {
|
|
306
|
+
for await (const chunk of stream) {
|
|
307
|
+
const view = chunk;
|
|
308
|
+
received += view.byteLength;
|
|
309
|
+
if (received > cap) {
|
|
310
|
+
stream.destroy();
|
|
311
|
+
throw new Error(`Delivery manifest exceeds ${cap} bytes`);
|
|
312
|
+
}
|
|
313
|
+
chunks.push(Buffer.from(view));
|
|
314
|
+
deadline.sample();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
if (error instanceof VerifyDeadlineError) {
|
|
324
|
+
stalled = true;
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
if (received > cap) {
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
throw new Error(`Could not read the delivery manifest: ${errorText(error)}`);
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
await closeBounded(() => handle.close(), stalled, deadline);
|
|
334
|
+
}
|
|
335
|
+
return Buffer.concat(chunks);
|
|
336
|
+
};
|
|
337
|
+
/** Read exactly `bytes` bytes into one pre-allocated buffer from the same
|
|
338
|
+
* descriptor the identity check validated — a file changing size between
|
|
339
|
+
* the stat and this read fails instead of verifying different bytes, and
|
|
340
|
+
* a valid archive near the cap never holds chunk copies beside the
|
|
341
|
+
* result. */
|
|
342
|
+
const readExact = async (path, handle, bytes, deadline) => {
|
|
343
|
+
const buffer = Buffer.alloc(bytes);
|
|
344
|
+
let received = 0;
|
|
345
|
+
while (received < bytes) {
|
|
346
|
+
const { bytesRead } = await deadline.race(handle.read(buffer, received, bytes - received, received));
|
|
347
|
+
if (bytesRead === 0) {
|
|
348
|
+
throw new Error(`Archive shrank while being read: ${path}`);
|
|
349
|
+
}
|
|
350
|
+
received += bytesRead;
|
|
351
|
+
}
|
|
352
|
+
// One byte past the declared size must be EOF: an archive appended to
|
|
353
|
+
// after the descriptor stat cannot verify its prefix and silently
|
|
354
|
+
// pass while no longer matching its declared size.
|
|
355
|
+
const tail = Buffer.alloc(1);
|
|
356
|
+
const { bytesRead: tailBytes } = await deadline.race(handle.read(tail, 0, 1, bytes));
|
|
357
|
+
if (tailBytes !== 0) {
|
|
358
|
+
throw new Error(`Archive grew while being read: ${path}`);
|
|
359
|
+
}
|
|
360
|
+
return buffer;
|
|
361
|
+
};
|
|
362
|
+
const expectedOf = (pack) => ({
|
|
363
|
+
formatVersion: 1,
|
|
364
|
+
archive: {
|
|
365
|
+
bytes: pack.archive?.bytes ?? 0,
|
|
366
|
+
sha256: pack.archive?.sha256 ?? '',
|
|
367
|
+
},
|
|
368
|
+
entries: pack.assets.flatMap((asset) => asset.files.map((file) => ({
|
|
369
|
+
path: file.path,
|
|
370
|
+
method: file.method ?? 'store',
|
|
371
|
+
bytes: file.bytes,
|
|
372
|
+
sha256: file.sha256,
|
|
373
|
+
}))),
|
|
374
|
+
});
|
|
375
|
+
/** The ZIP entry path bound mirrors runtime delivery: the default worker
|
|
376
|
+
* limit raised to this pack's longest declared entry path, so a manifest
|
|
377
|
+
* the builder, validator and runtime accept never fails here. */
|
|
378
|
+
const longestDeclaredPathOf = (pack) => {
|
|
379
|
+
let longest = defaultArchiveWorkerLimits().maxPathLength;
|
|
380
|
+
for (const asset of pack.assets) {
|
|
381
|
+
for (const file of asset.files) {
|
|
382
|
+
if (file.path.length > longest) {
|
|
383
|
+
longest = file.path.length;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return longest;
|
|
388
|
+
};
|
|
389
|
+
/** Core decode limits follow the manifest's own declarations: the archive
|
|
390
|
+
* byte limit is the declared archive size (already verified by hash), the
|
|
391
|
+
* expanded limits are the manifest's file byte sums, and the deadline is
|
|
392
|
+
* the unspent remainder of the whole-verification budget. */
|
|
393
|
+
const coreLimitsOf = (pack, maxEntryBytes, maxExpandedBytes, remainingMs) => {
|
|
394
|
+
const expanded = pack.assets.reduce((sum, asset) => sum + asset.files.reduce((n, file) => n + file.bytes, 0), 0);
|
|
395
|
+
return {
|
|
396
|
+
archiveBytes: pack.archive?.bytes ?? 0,
|
|
397
|
+
// Declared sizes remain the operative limits — tighter than the caps —
|
|
398
|
+
// but never exceed the independent bounds, whatever the manifest says.
|
|
399
|
+
entryBytes: Math.min(maxEntryBytes, Math.max(1, pack.assets.reduce((max, asset) => asset.files.reduce((inner, file) => Math.max(inner, file.bytes), max), 1))),
|
|
400
|
+
totalExpandedBytes: Math.min(maxExpandedBytes, Math.max(1, expanded)),
|
|
401
|
+
entryCount: Math.max(1, pack.archive?.entryCount ?? 0),
|
|
402
|
+
maxPathLength: longestDeclaredPathOf(pack),
|
|
403
|
+
// The monotonic clock yields fractional milliseconds; the core's
|
|
404
|
+
// limit checks require integers.
|
|
405
|
+
decodeDeadlineMs: Math.max(1, Math.floor(remainingMs)),
|
|
406
|
+
};
|
|
407
|
+
};
|
|
408
|
+
/** Verify a delivery manifest's referenced artifacts against a deployment
|
|
409
|
+
* root: read-only, deterministic, no network and no extraction. Every
|
|
410
|
+
* referenced byte is read and hashed; zip archives are additionally decoded
|
|
411
|
+
* through the shared pure core. Verification continues after the first
|
|
412
|
+
* failure so one report can carry every distinct problem. */
|
|
413
|
+
export async function verifyAssetPackDelivery(options) {
|
|
414
|
+
const failures = [];
|
|
415
|
+
const manifestByteCap = options.manifestByteCap ?? DEFAULT_MANIFEST_BYTE_CAP;
|
|
416
|
+
const verifyTimeoutMs = options.verifyTimeoutMs ?? DEFAULT_VERIFY_TIMEOUT_MS;
|
|
417
|
+
const maxArchiveBytes = options.maxArchiveBytes ?? DEFAULT_MAX_ARCHIVE_BYTES;
|
|
418
|
+
const maxEntryBytes = options.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
|
|
419
|
+
const maxExpandedBytes = options.maxExpandedBytes ?? DEFAULT_MAX_EXPANDED_BYTES;
|
|
420
|
+
const hostLimits = options.hostLimits;
|
|
421
|
+
const usedLimits = {
|
|
422
|
+
...(hostLimits?.maxObjectBytes === undefined ? {} : { maxObjectBytes: hostLimits.maxObjectBytes }),
|
|
423
|
+
...(hostLimits?.maxFiles === undefined ? {} : { maxFiles: hostLimits.maxFiles }),
|
|
424
|
+
...(hostLimits?.maxTotalBytes === undefined ? {} : { maxTotalBytes: hostLimits.maxTotalBytes }),
|
|
425
|
+
};
|
|
426
|
+
if (verifyTimeoutMs > MAX_VERIFY_TIMEOUT_MS) {
|
|
427
|
+
failWith(failures, 'args', 'invalid-option', `verifyTimeoutMs must be at most ${MAX_VERIFY_TIMEOUT_MS} ms (the platform timer range minus the watchdog headroom)`);
|
|
428
|
+
}
|
|
429
|
+
const argsValid = [
|
|
430
|
+
positiveIntegerOption(failures, 'manifestByteCap', manifestByteCap),
|
|
431
|
+
positiveIntegerOption(failures, 'verifyTimeoutMs', verifyTimeoutMs),
|
|
432
|
+
verifyTimeoutMs <= MAX_VERIFY_TIMEOUT_MS,
|
|
433
|
+
positiveIntegerOption(failures, 'maxArchiveBytes', maxArchiveBytes),
|
|
434
|
+
positiveIntegerOption(failures, 'maxEntryBytes', maxEntryBytes),
|
|
435
|
+
positiveIntegerOption(failures, 'maxExpandedBytes', maxExpandedBytes),
|
|
436
|
+
...Object.entries(hostLimits ?? {}).map(([name, value]) => positiveIntegerOption(failures, `--${name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)}`, value)),
|
|
437
|
+
].every((valid) => valid);
|
|
438
|
+
// Monotonic: a wall-clock correction mid-verification must not extend
|
|
439
|
+
// or shrink the budget.
|
|
440
|
+
const startedAt = performance.now();
|
|
441
|
+
/** The one whole-verification budget; the pure core receives only the
|
|
442
|
+
* unspent remainder so a late archive cannot restart the clock. */
|
|
443
|
+
const deadlineBreached = () => {
|
|
444
|
+
if (performance.now() - startedAt <= verifyTimeoutMs) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
if (!failures.some((entry) => entry.code === 'deadline')) {
|
|
448
|
+
failWith(failures, 'limits', 'deadline', `Delivery verification exceeded its ${verifyTimeoutMs} ms budget`);
|
|
449
|
+
}
|
|
450
|
+
return true;
|
|
451
|
+
};
|
|
452
|
+
/** The shared budget control: every blocking filesystem wait below goes
|
|
453
|
+
* through it, so no single stalled call can outlive the budget. */
|
|
454
|
+
const deadline = {
|
|
455
|
+
breach: deadlineBreached,
|
|
456
|
+
remainingMs: () => verifyTimeoutMs - (performance.now() - startedAt),
|
|
457
|
+
sample: () => {
|
|
458
|
+
if (deadlineBreached()) {
|
|
459
|
+
throw new VerifyDeadlineError();
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
// Fires one millisecond past the deadline instant — the breach
|
|
463
|
+
// predicate treats the deadline itself as still within budget — so a
|
|
464
|
+
// source that never delivers another chunk (FIFO, stalled mount)
|
|
465
|
+
// cannot outlive the budget; the recorded failure travels on the
|
|
466
|
+
// destroy error.
|
|
467
|
+
armStream: (stream) => setTimeout(() => {
|
|
468
|
+
if (deadlineBreached()) {
|
|
469
|
+
stream.destroy(new VerifyDeadlineError());
|
|
470
|
+
}
|
|
471
|
+
}, Math.max(1, verifyTimeoutMs - (performance.now() - startedAt) + 1)),
|
|
472
|
+
race: async (operation) => {
|
|
473
|
+
let timer;
|
|
474
|
+
try {
|
|
475
|
+
return await Promise.race([
|
|
476
|
+
operation,
|
|
477
|
+
new Promise((_, reject) => {
|
|
478
|
+
timer = setTimeout(() => {
|
|
479
|
+
if (deadlineBreached()) {
|
|
480
|
+
reject(new VerifyDeadlineError());
|
|
481
|
+
}
|
|
482
|
+
}, Math.max(1, verifyTimeoutMs - (performance.now() - startedAt) + 1));
|
|
483
|
+
}),
|
|
484
|
+
]);
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
clearTimeout(timer);
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
// ---- Stage: manifest -------------------------------------------------
|
|
492
|
+
// Invalid arguments stop all filesystem work: the deadline itself may
|
|
493
|
+
// be the invalid value (an oversized timeout clamps the platform timer
|
|
494
|
+
// to 1 ms, disarming the watchdog), so I/O must not start on it.
|
|
495
|
+
let manifestBytes;
|
|
496
|
+
let manifest;
|
|
497
|
+
if (argsValid) {
|
|
498
|
+
try {
|
|
499
|
+
manifestBytes = await readManifestCapped(options.manifestPath, manifestByteCap, deadline);
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
if (!(error instanceof VerifyDeadlineError)) {
|
|
503
|
+
failWith(failures, 'manifest', 'manifest-unreadable', errorText(error));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (manifestBytes !== undefined) {
|
|
508
|
+
try {
|
|
509
|
+
manifest = validatePhaserPackDeliveryManifest(JSON.parse(manifestBytes.toString('utf8')));
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
failWith(failures, 'manifest', 'manifest-invalid', errorText(error));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/** What the chosen manifest requires, independent of verification
|
|
516
|
+
* outcomes: distinct artifact objects and their declared bytes. */
|
|
517
|
+
let declaredTotals = { files: 0, bytes: 0 };
|
|
518
|
+
if (manifest !== undefined) {
|
|
519
|
+
for (const pack of manifest.packs) {
|
|
520
|
+
if (pack.delivery === 'zip') {
|
|
521
|
+
declaredTotals = {
|
|
522
|
+
files: declaredTotals.files + 1,
|
|
523
|
+
bytes: declaredTotals.bytes + (pack.archive?.bytes ?? 0),
|
|
524
|
+
};
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
for (const asset of pack.assets) {
|
|
528
|
+
declaredTotals = {
|
|
529
|
+
files: declaredTotals.files + asset.files.length,
|
|
530
|
+
bytes: declaredTotals.bytes
|
|
531
|
+
+ asset.files.reduce((sum, file) => sum + file.bytes, 0),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// Referenced objects are a subset of the root inventory, so declared
|
|
537
|
+
// totals are a lower bound: a manifest that already exceeds a host
|
|
538
|
+
// limit fails before any artifact I/O spends the budget.
|
|
539
|
+
if (manifest !== undefined && hostLimits?.maxFiles !== undefined
|
|
540
|
+
&& declaredTotals.files > hostLimits.maxFiles) {
|
|
541
|
+
failWith(failures, 'limits', 'max-files', `Manifest already references ${declaredTotals.files} files, `
|
|
542
|
+
+ `over the limit ${hostLimits.maxFiles}`);
|
|
543
|
+
}
|
|
544
|
+
if (manifest !== undefined && hostLimits?.maxTotalBytes !== undefined
|
|
545
|
+
&& declaredTotals.bytes > hostLimits.maxTotalBytes) {
|
|
546
|
+
failWith(failures, 'limits', 'max-total-bytes', `Manifest already references ${declaredTotals.bytes} bytes, `
|
|
547
|
+
+ `over the limit ${hostLimits.maxTotalBytes}`);
|
|
548
|
+
}
|
|
549
|
+
const root = resolve(options.root);
|
|
550
|
+
let rootIsDirectory = false;
|
|
551
|
+
let rootMissing = false;
|
|
552
|
+
if (argsValid) {
|
|
553
|
+
try {
|
|
554
|
+
// The deployment root may itself be a symlink (release directories
|
|
555
|
+
// often are); follow it, unlike the per-component artifact checks.
|
|
556
|
+
rootIsDirectory = (await deadline.race(stat(root))).isDirectory();
|
|
557
|
+
// An existing non-directory (a regular file, say) must fail like a
|
|
558
|
+
// missing root — skipping every stage without a failure would
|
|
559
|
+
// certify an unchecked deployment.
|
|
560
|
+
rootMissing = !rootIsDirectory;
|
|
561
|
+
}
|
|
562
|
+
catch (error) {
|
|
563
|
+
// A stalled stat is a deadline problem, not a missing root: the
|
|
564
|
+
// deadline failure is already recorded, so the report must not add
|
|
565
|
+
// a false path diagnosis.
|
|
566
|
+
rootMissing = !(error instanceof VerifyDeadlineError);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (rootMissing) {
|
|
570
|
+
failWith(failures, 'paths', 'root-missing', `Verification root is not a directory: ${options.root}`);
|
|
571
|
+
}
|
|
572
|
+
const archives = [];
|
|
573
|
+
const seenResolved = new Map();
|
|
574
|
+
let realRoot = root;
|
|
575
|
+
let rootUsable = rootIsDirectory;
|
|
576
|
+
if (rootIsDirectory) {
|
|
577
|
+
try {
|
|
578
|
+
realRoot = await deadline.race(realpath(root));
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
// A root that stops resolving mid-verification (a release symlink
|
|
582
|
+
// swapped between the stat and this call, or the spent budget)
|
|
583
|
+
// becomes a structured failure — never an escaping rejection that
|
|
584
|
+
// would cost the caller its report.
|
|
585
|
+
rootUsable = false;
|
|
586
|
+
if (!(error instanceof VerifyDeadlineError)) {
|
|
587
|
+
failWith(failures, 'paths', 'root-unresolvable', `Could not resolve the verification root: ${errorText(error)}`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/** Verified on-disk objects keyed by resolved path. The manifest
|
|
592
|
+
* validator already rejects duplicate references, so this is
|
|
593
|
+
* defense in depth for distinct manifest paths that alias the same
|
|
594
|
+
* file on case-insensitive filesystems: they re-check the new
|
|
595
|
+
* declaration without re-reading or re-counting the object. */
|
|
596
|
+
const verifiedArtifacts = new Map();
|
|
597
|
+
const declaredHostLimitExceeded = failures.some((failure) => failure.stage === 'limits'
|
|
598
|
+
&& (failure.code === 'max-files' || failure.code === 'max-total-bytes'));
|
|
599
|
+
if (manifest !== undefined && rootUsable && argsValid && !declaredHostLimitExceeded) {
|
|
600
|
+
try {
|
|
601
|
+
for (const pack of manifest.packs) {
|
|
602
|
+
if (deadline.breach()) {
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
if (pack.delivery === 'files') {
|
|
606
|
+
for (const asset of pack.assets) {
|
|
607
|
+
for (const file of asset.files) {
|
|
608
|
+
if (deadline.breach()) {
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
await verifyReferencedFile(failures, pack, file, root, realRoot, seenResolved, verifiedArtifacts, deadline, hostLimits?.maxObjectBytes);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
await verifyZipPack(failures, pack, root, realRoot, seenResolved, verifiedArtifacts, archives, deadline, maxArchiveBytes, maxEntryBytes, maxExpandedBytes, hostLimits?.maxObjectBytes);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
// The budget expired mid-I/O; the deadline failure is already
|
|
621
|
+
// recorded, so stop verifying instead of reporting a read error.
|
|
622
|
+
if (!(error instanceof VerifyDeadlineError)) {
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// ---- Stage: limits ---------------------------------------------------
|
|
628
|
+
let inventory;
|
|
629
|
+
const limitsRequested = hostLimits !== undefined
|
|
630
|
+
&& Object.values(hostLimits).some((value) => value !== undefined);
|
|
631
|
+
if (limitsRequested && rootUsable && argsValid && !deadline.breach()) {
|
|
632
|
+
try {
|
|
633
|
+
inventory = await inventoryRoot(realRoot, deadline);
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
if (!(error instanceof VerifyDeadlineError)) {
|
|
637
|
+
failWith(failures, 'limits', 'inventory-unreadable', errorText(error));
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (inventory !== undefined) {
|
|
641
|
+
// Truncated inventories still run the checks: the counts are lower
|
|
642
|
+
// bounds, so any breach they show is real even though the exact
|
|
643
|
+
// totals past the cap stay unknown.
|
|
644
|
+
const maxFiles = hostLimits?.maxFiles;
|
|
645
|
+
const maxTotalBytes = hostLimits?.maxTotalBytes;
|
|
646
|
+
const maxObjectBytes = hostLimits?.maxObjectBytes;
|
|
647
|
+
if (maxFiles !== undefined && inventory.files > maxFiles) {
|
|
648
|
+
failWith(failures, 'limits', 'max-files', `Root inventory has ${inventory.files} files, over the limit ${maxFiles}`);
|
|
649
|
+
}
|
|
650
|
+
if (maxTotalBytes !== undefined && inventory.bytes > maxTotalBytes) {
|
|
651
|
+
failWith(failures, 'limits', 'max-total-bytes', `Root inventory holds ${inventory.bytes} bytes, over the limit ${maxTotalBytes}`);
|
|
652
|
+
}
|
|
653
|
+
// Object size covers the whole deployment root — unreferenced stale
|
|
654
|
+
// revisions included — exactly like the count and total limits.
|
|
655
|
+
if (maxObjectBytes !== undefined && inventory.largest > maxObjectBytes) {
|
|
656
|
+
failWith(failures, 'limits', 'max-object-bytes', `Root inventory holds an object of ${inventory.largest} bytes, `
|
|
657
|
+
+ `over the limit ${maxObjectBytes}`);
|
|
658
|
+
}
|
|
659
|
+
if (inventory.truncated) {
|
|
660
|
+
// The walk stopped at the cap, so the limits above were only
|
|
661
|
+
// checked against a lower bound; refuse to certify the deployment.
|
|
662
|
+
failWith(failures, 'limits', 'inventory-truncated', `Root inventory exceeded ${INVENTORY_FILE_CAP} files; `
|
|
663
|
+
+ 'host limits could not be fully verified');
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
ok: failures.length === 0,
|
|
669
|
+
manifest: {
|
|
670
|
+
sha256: manifestBytes === undefined ? '' : createHash('sha256').update(manifestBytes).digest('hex'),
|
|
671
|
+
bytes: manifestBytes?.byteLength ?? 0,
|
|
672
|
+
format: manifest?.format ?? '',
|
|
673
|
+
version: manifest?.version ?? 0,
|
|
674
|
+
packs: manifest?.packs.length ?? 0,
|
|
675
|
+
},
|
|
676
|
+
referenced: declaredTotals,
|
|
677
|
+
...(inventory === undefined ? {} : { inventory }),
|
|
678
|
+
archives,
|
|
679
|
+
...(limitsRequested ? { limits: usedLimits } : {}),
|
|
680
|
+
failures: [...failures],
|
|
681
|
+
notVerified: NOT_VERIFIED,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
/** Resolve one manifest artifact path under the root and register it
|
|
685
|
+
* against case-folded collisions — the single security-critical
|
|
686
|
+
* resolve/register path shared by files and zip packs. */
|
|
687
|
+
const resolveAndRegister = async (failures, pack, root, realRoot, artifactPath, seenResolved, deadline) => {
|
|
688
|
+
let resolved;
|
|
689
|
+
try {
|
|
690
|
+
resolved = await resolveArtifactPath(root, realRoot, artifactPath, deadline);
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
if (error instanceof VerifyDeadlineError) {
|
|
694
|
+
// The budget expired mid-resolution; the deadline failure is
|
|
695
|
+
// already recorded, so propagate the sentinel instead of adding a
|
|
696
|
+
// misleading path failure.
|
|
697
|
+
throw error;
|
|
698
|
+
}
|
|
699
|
+
failWith(failures, 'paths', 'path-invalid', errorText(error), pack.packId);
|
|
700
|
+
return undefined;
|
|
701
|
+
}
|
|
702
|
+
const caseKey = caseKeyOf(resolved.path);
|
|
703
|
+
const prior = seenResolved.get(caseKey);
|
|
704
|
+
if (prior !== undefined) {
|
|
705
|
+
if (prior !== resolved.path) {
|
|
706
|
+
failWith(failures, 'paths', 'path-collision', `Manifest paths ${prior} and ${resolved.path} resolve to the same file`, pack.packId);
|
|
707
|
+
return undefined;
|
|
708
|
+
}
|
|
709
|
+
// Duplicate reference to an already-registered object: the caller
|
|
710
|
+
// re-checks the new declaration but the object counts once.
|
|
711
|
+
return resolved;
|
|
712
|
+
}
|
|
713
|
+
seenResolved.set(caseKey, resolved.path);
|
|
714
|
+
return resolved;
|
|
715
|
+
};
|
|
716
|
+
/** Close a filesystem handle under the deadline: a close following an
|
|
717
|
+
* already-timed-out operation queues behind uncancellable work and is
|
|
718
|
+
* fired and forgotten (the CLI's forced exit releases whatever the queue
|
|
719
|
+
* still holds), while even a clean-path close is raced, because a stalled
|
|
720
|
+
* close on a network or FUSE mount would otherwise stop the report from
|
|
721
|
+
* ever returning. */
|
|
722
|
+
const closeBounded = async (close, stalled, deadline) => {
|
|
723
|
+
if (stalled) {
|
|
724
|
+
void close().catch(() => { });
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
await deadline.race(close());
|
|
728
|
+
};
|
|
729
|
+
/** Bind an opened descriptor to the identity the path checks validated:
|
|
730
|
+
* the same inode, or the component was swapped between check and open. */
|
|
731
|
+
const bindsToValidated = (info, resolved) => info.dev === resolved.dev && info.ino === resolved.ino;
|
|
732
|
+
const verifyReferencedFile = async (failures, pack, file, root, realRoot, seenResolved, verifiedArtifacts, deadline, maxObjectBytes) => {
|
|
733
|
+
const resolved = await resolveAndRegister(failures, pack, root, realRoot, file.path, seenResolved, deadline);
|
|
734
|
+
if (resolved === undefined) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const resolvedPath = resolved.path;
|
|
738
|
+
try {
|
|
739
|
+
const cached = verifiedArtifacts.get(resolvedPath);
|
|
740
|
+
if (cached !== undefined) {
|
|
741
|
+
if (cached.bytes !== file.bytes) {
|
|
742
|
+
failWith(failures, 'files', 'size-mismatch', `File ${file.path} is ${cached.bytes} bytes, manifest declares ${file.bytes}`, pack.packId);
|
|
743
|
+
}
|
|
744
|
+
else if (cached.sha256 !== file.sha256) {
|
|
745
|
+
failWith(failures, 'files', 'hash-mismatch', `File ${file.path} does not match its manifest digest`, pack.packId);
|
|
746
|
+
}
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const handle = await deadline.race(open(resolvedPath, 'r'));
|
|
750
|
+
let stalled = false;
|
|
751
|
+
let hashed;
|
|
752
|
+
try {
|
|
753
|
+
const info = await deadline.race(handle.stat({ bigint: true }));
|
|
754
|
+
if (!bindsToValidated(info, resolved)) {
|
|
755
|
+
failWith(failures, 'paths', 'path-invalid', `Artifact path changed during verification: ${file.path}`, pack.packId);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
// Size from the bound descriptor fails closed before the hash, so a
|
|
759
|
+
// huge replacement cannot consume the budget streaming bytes that
|
|
760
|
+
// were already known not to match.
|
|
761
|
+
if (info.size !== BigInt(file.bytes)) {
|
|
762
|
+
failWith(failures, 'files', 'size-mismatch', `File ${file.path} is ${info.size} bytes, manifest declares ${file.bytes}`, pack.packId);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
// The object cap applies before streaming too: a valid but oversized
|
|
766
|
+
// artifact must fail as max-object-bytes here rather than consuming
|
|
767
|
+
// the whole budget and surfacing only a deadline failure.
|
|
768
|
+
if (maxObjectBytes !== undefined && info.size > BigInt(maxObjectBytes)) {
|
|
769
|
+
failWith(failures, 'limits', 'max-object-bytes', `File ${file.path} is ${info.size} bytes, over the limit ${maxObjectBytes}`, pack.packId);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
hashed = await streamSha256(resolvedPath, handle, createHash('sha256'), deadline);
|
|
773
|
+
// Keep the deadline sentinel distinct from a later size/hash result
|
|
774
|
+
// even when the final synchronous hash update crossed the boundary.
|
|
775
|
+
deadline.sample();
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
if (error instanceof VerifyDeadlineError) {
|
|
779
|
+
stalled = true;
|
|
780
|
+
}
|
|
781
|
+
throw error;
|
|
782
|
+
}
|
|
783
|
+
finally {
|
|
784
|
+
await closeBounded(() => handle.close(), stalled, deadline);
|
|
785
|
+
}
|
|
786
|
+
if (hashed.bytes !== file.bytes) {
|
|
787
|
+
failWith(failures, 'files', 'size-mismatch', `File ${file.path} is ${hashed.bytes} bytes, manifest declares ${file.bytes}`, pack.packId);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (hashed.sha256 !== file.sha256) {
|
|
791
|
+
failWith(failures, 'files', 'hash-mismatch', `File ${file.path} does not match its manifest digest`, pack.packId);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
verifiedArtifacts.set(resolvedPath, hashed);
|
|
795
|
+
}
|
|
796
|
+
catch (error) {
|
|
797
|
+
if (error instanceof VerifyDeadlineError) {
|
|
798
|
+
throw error;
|
|
799
|
+
}
|
|
800
|
+
failWith(failures, 'files', 'unreadable', errorText(error), pack.packId);
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
const verifyZipPack = async (failures, pack, root, realRoot, seenResolved, verifiedArtifacts, archives, deadline, maxArchiveBytes, maxEntryBytes, maxExpandedBytes, maxObjectBytes) => {
|
|
804
|
+
const archivePath = pack.archive?.path;
|
|
805
|
+
if (archivePath === undefined) {
|
|
806
|
+
failWith(failures, 'archive', 'archive-missing', `Zip pack ${pack.packId} lacks an archive path`, pack.packId);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const resolved = await resolveAndRegister(failures, pack, root, realRoot, archivePath, seenResolved, deadline);
|
|
810
|
+
if (resolved === undefined) {
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const resolvedPath = resolved.path;
|
|
814
|
+
const declaredBytes = pack.archive?.bytes ?? 0;
|
|
815
|
+
if (declaredBytes > maxArchiveBytes) {
|
|
816
|
+
failWith(failures, 'limits', 'max-archive-bytes', `Archive ${archivePath} declares ${declaredBytes} bytes, over the limit ${maxArchiveBytes}`, pack.packId);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
// Decompression bounds are independent of the manifest's own numbers: a
|
|
820
|
+
// self-consistent manifest can still declare gigabytes of expansion, and
|
|
821
|
+
// these caps reject that before any allocation, not after.
|
|
822
|
+
const declaredEntryBytes = pack.assets.reduce((max, asset) => asset.files.reduce((inner, file) => Math.max(inner, file.bytes), max), 0);
|
|
823
|
+
const declaredExpandedBytes = pack.assets.reduce((sum, asset) => sum + asset.files.reduce((inner, file) => inner + file.bytes, 0), 0);
|
|
824
|
+
if (declaredEntryBytes > maxEntryBytes) {
|
|
825
|
+
failWith(failures, 'limits', 'max-entry-bytes', `Pack ${pack.packId} declares an entry of ${declaredEntryBytes} bytes, `
|
|
826
|
+
+ `over the limit ${maxEntryBytes}`, pack.packId);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (declaredExpandedBytes > maxExpandedBytes) {
|
|
830
|
+
failWith(failures, 'limits', 'max-expanded-bytes', `Pack ${pack.packId} declares ${declaredExpandedBytes} expanded bytes, `
|
|
831
|
+
+ `over the limit ${maxExpandedBytes}`, pack.packId);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
let archiveBytes;
|
|
835
|
+
try {
|
|
836
|
+
// One descriptor serves the size check, the digest and the decode, so
|
|
837
|
+
// every check sees the same inode the path checks validated.
|
|
838
|
+
const handle = await deadline.race(open(resolvedPath, 'r'));
|
|
839
|
+
let stalled = false;
|
|
840
|
+
try {
|
|
841
|
+
const { size, dev, ino } = await deadline.race(handle.stat({ bigint: true }));
|
|
842
|
+
if (!bindsToValidated({ dev, ino }, resolved)) {
|
|
843
|
+
failWith(failures, 'paths', 'path-invalid', `Artifact path changed during verification: ${archivePath}`, pack.packId);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const cached = verifiedArtifacts.get(resolvedPath);
|
|
847
|
+
if (cached === undefined) {
|
|
848
|
+
if (pack.archive !== undefined && size !== BigInt(pack.archive.bytes)) {
|
|
849
|
+
failWith(failures, 'archive', 'size-mismatch', `Archive ${archivePath} is ${size} bytes, manifest declares ${pack.archive.bytes}`, pack.packId);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (size > BigInt(maxArchiveBytes)) {
|
|
853
|
+
failWith(failures, 'limits', 'max-archive-bytes', `Archive ${archivePath} is ${size} bytes, over the limit ${maxArchiveBytes}`, pack.packId);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (maxObjectBytes !== undefined && size > BigInt(maxObjectBytes)) {
|
|
857
|
+
failWith(failures, 'limits', 'max-object-bytes', `Archive ${archivePath} is ${size} bytes, over the limit ${maxObjectBytes}`, pack.packId);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
archiveBytes = await readExact(resolvedPath, handle, declaredBytes, deadline);
|
|
861
|
+
const hasher = createHash('sha256');
|
|
862
|
+
hasher.update(archiveBytes);
|
|
863
|
+
const actual = { bytes: archiveBytes.byteLength, sha256: hasher.digest('hex') };
|
|
864
|
+
if (pack.archive !== undefined && actual.sha256 !== pack.archive.sha256) {
|
|
865
|
+
failWith(failures, 'archive', 'hash-mismatch', `Archive ${archivePath} does not match its manifest digest`, pack.packId);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
verifiedArtifacts.set(resolvedPath, actual);
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
if (pack.archive !== undefined && cached.bytes !== pack.archive.bytes) {
|
|
872
|
+
failWith(failures, 'archive', 'size-mismatch', `Archive ${archivePath} is ${cached.bytes} bytes, manifest declares ${pack.archive.bytes}`, pack.packId);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
if (pack.archive !== undefined && cached.sha256 !== pack.archive.sha256) {
|
|
876
|
+
failWith(failures, 'archive', 'hash-mismatch', `Archive ${archivePath} does not match its manifest digest`, pack.packId);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
// The object is already verified and counted; re-read only what the
|
|
880
|
+
// decode needs.
|
|
881
|
+
archiveBytes = await readExact(resolvedPath, handle, declaredBytes, deadline);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
catch (error) {
|
|
885
|
+
if (error instanceof VerifyDeadlineError) {
|
|
886
|
+
stalled = true;
|
|
887
|
+
}
|
|
888
|
+
throw error;
|
|
889
|
+
}
|
|
890
|
+
finally {
|
|
891
|
+
await closeBounded(() => handle.close(), stalled, deadline);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
if (error instanceof VerifyDeadlineError) {
|
|
896
|
+
throw error;
|
|
897
|
+
}
|
|
898
|
+
failWith(failures, 'archive', 'unreadable', errorText(error), pack.packId);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
try {
|
|
902
|
+
// The core only samples its budget around awaited digests, so a
|
|
903
|
+
// stalled one can outlive it; the shared deadline races the whole
|
|
904
|
+
// decode, letting the CLI report and take its forced-exit path.
|
|
905
|
+
const decode = verifyZipV1Archive(archiveBytes, expectedOf(pack), coreLimitsOf(pack, maxEntryBytes, maxExpandedBytes, deadline.remainingMs()));
|
|
906
|
+
const stats = await deadline.race(decode);
|
|
907
|
+
archives.push({
|
|
908
|
+
packId: pack.packId,
|
|
909
|
+
entries: stats.entries,
|
|
910
|
+
expandedBytes: stats.expandedBytes,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
catch (error) {
|
|
914
|
+
if (error instanceof VerifyDeadlineError) {
|
|
915
|
+
throw error;
|
|
916
|
+
}
|
|
917
|
+
const code = error instanceof ZipDecodeError ? error.code : 'decode';
|
|
918
|
+
failWith(failures, 'archive', `zip-${code}`, `Archive ${archivePath} failed verification: ${errorText(error)}`, pack.packId);
|
|
919
|
+
}
|
|
920
|
+
};
|