@mpgd/cli 0.30.1 → 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.
@@ -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
+ };