@vincentt-xr/cli 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/MIGRATION.md +30 -0
  2. package/dist/cli/index.js +86 -12
  3. package/dist/domain/api.js +16 -12
  4. package/dist/domain/command.js +9 -0
  5. package/dist/dx/command.d.ts +74 -18
  6. package/dist/dx/command.js +140 -86
  7. package/dist/dx/consent.d.ts +25 -6
  8. package/dist/dx/consent.js +64 -35
  9. package/dist/dx/enablement.d.ts +63 -11
  10. package/dist/dx/enablement.js +93 -21
  11. package/dist/dx/report.d.ts +4 -24
  12. package/dist/dx/report.js +32 -25
  13. package/dist/dx/reportIo.js +6 -4
  14. package/dist/dx/send.js +6 -4
  15. package/dist/lib/backend.js +5 -10
  16. package/dist/lib/exitCodes.d.ts +6 -0
  17. package/dist/lib/exitCodes.js +6 -0
  18. package/dist/lib/terminalText.d.ts +6 -0
  19. package/dist/lib/terminalText.js +10 -0
  20. package/dist/lib/version.d.ts +28 -2
  21. package/dist/lib/version.js +30 -3
  22. package/dist/lib/whoami.js +3 -0
  23. package/dist/library/assetsCommand.js +4 -0
  24. package/dist/library/client.js +6 -8
  25. package/dist/login/authBackend.js +18 -15
  26. package/dist/login/loginCommand.js +31 -4
  27. package/dist/login/pkceLogin.d.ts +10 -0
  28. package/dist/login/pkceLogin.js +24 -1
  29. package/dist/platform/releaseFetch.js +36 -69
  30. package/dist/preview/api.js +9 -12
  31. package/dist/preview/command.d.ts +3 -2
  32. package/dist/preview/command.js +10 -5
  33. package/dist/preview/copy.d.ts +13 -1
  34. package/dist/preview/copy.js +31 -3
  35. package/dist/preview/proxy.js +5 -39
  36. package/dist/preview/runner.d.ts +31 -9
  37. package/dist/preview/runner.js +43 -15
  38. package/dist/publish/api.js +14 -14
  39. package/dist/publish/command.d.ts +3 -0
  40. package/dist/publish/command.js +74 -7
  41. package/dist/publish/copy.d.ts +15 -0
  42. package/dist/publish/copy.js +109 -19
  43. package/dist/publish/keyScan.d.ts +25 -0
  44. package/dist/publish/keyScan.js +88 -0
  45. package/dist/publish/manifest.d.ts +60 -19
  46. package/dist/publish/manifest.js +290 -71
  47. package/dist/publish/upload.d.ts +18 -0
  48. package/dist/publish/upload.js +38 -0
  49. package/dist/publish/weight.d.ts +23 -0
  50. package/dist/publish/weight.js +113 -0
  51. package/dist/shared/config.d.ts +6 -6
  52. package/dist/shared/credentialNames.d.ts +17 -0
  53. package/dist/shared/credentialNames.js +72 -0
  54. package/dist/shared/http.d.ts +34 -0
  55. package/dist/shared/http.js +98 -2
  56. package/dist/shared/upgradeError.d.ts +62 -0
  57. package/dist/shared/upgradeError.js +215 -0
  58. package/dist/tunnel/caps.d.ts +19 -0
  59. package/dist/tunnel/caps.js +52 -0
  60. package/dist/tunnel/cbor.d.ts +39 -12
  61. package/dist/tunnel/cbor.js +84 -10
  62. package/dist/tunnel/client.d.ts +56 -2
  63. package/dist/tunnel/client.js +160 -44
  64. package/dist/tunnel/frame.d.ts +1 -1
  65. package/dist/tunnel/frame.js +8 -4
  66. package/dist/tunnel/index.d.ts +3 -2
  67. package/dist/tunnel/index.js +3 -2
  68. package/package.json +4 -3
@@ -5,8 +5,10 @@
5
5
  // every single PUT while both repos' unit suites stay green (§2.7's first
6
6
  // contract trap).
7
7
  import { createHash } from "node:crypto";
8
- import { createReadStream, promises as fs } from "node:fs";
8
+ import { constants as fsConstants, promises as fs } from "node:fs";
9
9
  import path from "node:path";
10
+ import { PUBLISH_CREDENTIAL_NAMES, PUBLISH_STRIP, foldName, isCredentialName, } from "../shared/credentialNames.js";
11
+ import { createKeyScanner, credentialJson } from "./keyScan.js";
10
12
  import { normalizePath, validatePathSet } from "./paths.js";
11
13
  export class ManifestError extends Error {
12
14
  reason;
@@ -27,6 +29,13 @@ const ALLOWED_ROOT_DIR = ".well-known";
27
29
  const ALLOWED_ROOT_FILE = ".nojekyll";
28
30
  const LFS_POINTER_MAX_BYTES = 1024;
29
31
  const LFS_FIRST_LINE = "version https://git-lfs.github.com/spec/v1";
32
+ /** The early pass reads this much of every file. */
33
+ const HEAD_READ_BYTES = 4096;
34
+ /** A `.json` at most this size is read whole and parsed. */
35
+ const CREDENTIAL_JSON_MAX_BYTES = 65_536;
36
+ const READ_CONCURRENCY = 16;
37
+ const HASH_CHUNK_BYTES = 64 * 1024;
38
+ const OPEN_NOFOLLOW = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0);
30
39
  function isInside(child, parent) {
31
40
  const rel = path.relative(parent, child);
32
41
  return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
@@ -34,20 +43,35 @@ function isInside(child, parent) {
34
43
  function hasDottedSegment(rel) {
35
44
  return rel.split(/[\\/]/).some((seg) => seg.startsWith("."));
36
45
  }
46
+ function isPublishCredentialName(name) {
47
+ return isCredentialName(foldName(name), PUBLISH_CREDENTIAL_NAMES, PUBLISH_STRIP);
48
+ }
49
+ function isJsonName(rel) {
50
+ return rel.toLowerCase().endsWith(".json");
51
+ }
37
52
  /**
38
- * The leak walk. Unlike `walkDist` it records what it does not upload: a dotted
39
- * name is an offender (a folder once, with a trailing slash, never descended),
40
- * and every symlink is resolved and judged against the real repo root.
53
+ * The one walk. It records what it does not upload: a dotted name is an
54
+ * offender (a folder once, with a trailing slash, never descended), every
55
+ * symlink is resolved and judged against the real repo root, and a name on the
56
+ * credential list is recorded apart (a folder once, never descended).
41
57
  */
42
- async function walkOutput(outReal, rootReal) {
43
- const files = [];
44
- const offenders = [];
58
+ async function walkOutput(outReal, rootReal, hooks) {
59
+ const walk = { files: [], offenders: [], credentialNames: [] };
60
+ let seq = 0;
61
+ const relOf = (full) => path.relative(outReal, full).split(path.sep).join("/");
62
+ // A read error's message carries the raw absolute path. It becomes a refusal
63
+ // naming the output-relative path, which the copy escapes.
64
+ const unreadable = (rel) => new ManifestError("file_unreadable", rel, "a path could not be read");
45
65
  const visit = async (dir) => {
46
- const entries = await fs.readdir(dir, { withFileTypes: true });
66
+ await hooks.beforeReadDir?.(dir);
67
+ const dirRel = dir === outReal ? "" : `${relOf(dir)}/`;
68
+ const entries = await fs
69
+ .readdir(dir, { withFileTypes: true })
70
+ .catch(() => Promise.reject(unreadable(dirRel)));
47
71
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
48
72
  for (const entry of entries) {
49
73
  const full = path.join(dir, entry.name);
50
- const rel = path.relative(outReal, full).split(path.sep).join("/");
74
+ const rel = relOf(full);
51
75
  const atRoot = dir === outReal;
52
76
  const lower = entry.name.toLowerCase();
53
77
  if (entry.name.startsWith(".")) {
@@ -55,17 +79,17 @@ async function walkOutput(outReal, rootReal) {
55
79
  ((lower === ALLOWED_ROOT_DIR && entry.isDirectory()) ||
56
80
  (lower === ALLOWED_ROOT_FILE && entry.isFile()));
57
81
  if (!allowed) {
58
- offenders.push({ path: entry.isDirectory() ? `${rel}/` : rel });
82
+ walk.offenders.push({ path: entry.isDirectory() ? `${rel}/` : rel });
59
83
  continue;
60
84
  }
61
85
  }
62
86
  if (entry.isSymbolicLink()) {
63
87
  const target = await symlinkTarget(full);
64
88
  if (!isInside(target, rootReal)) {
65
- offenders.push({ path: rel, target });
89
+ walk.offenders.push({ path: rel, target });
66
90
  }
67
91
  else if (hasDottedSegment(path.relative(rootReal, target))) {
68
- offenders.push({
92
+ walk.offenders.push({
69
93
  path: rel,
70
94
  target: path.relative(rootReal, target).split(path.sep).join("/"),
71
95
  });
@@ -73,17 +97,56 @@ async function walkOutput(outReal, rootReal) {
73
97
  // A symlink that resolves cleanly inside is not uploaded, as before.
74
98
  continue;
75
99
  }
100
+ const nameMatched = isPublishCredentialName(entry.name);
76
101
  if (entry.isDirectory()) {
102
+ if (nameMatched) {
103
+ walk.credentialNames.push({
104
+ seq: seq++,
105
+ offender: { path: `${rel}/`, why: "name" },
106
+ });
107
+ continue;
108
+ }
77
109
  await visit(full);
78
110
  }
79
111
  else if (entry.isFile()) {
80
- const stat = await fs.stat(full);
81
- files.push({ rel, size: stat.size });
112
+ const stat = await fs.lstat(full).catch(() => Promise.reject(unreadable(rel)));
113
+ const at = seq++;
114
+ if (nameMatched) {
115
+ walk.credentialNames.push({ seq: at, offender: { path: rel, why: "name" } });
116
+ }
117
+ walk.files.push({
118
+ rel,
119
+ abs: full,
120
+ size: stat.size,
121
+ dev: stat.dev,
122
+ ino: stat.ino,
123
+ seq: at,
124
+ nameMatched,
125
+ });
82
126
  }
83
127
  }
84
128
  };
85
129
  await visit(outReal);
86
- return { files, offenders };
130
+ return walk;
131
+ }
132
+ /**
133
+ * Files whose real parent folder is not inside the real output. The walk reads
134
+ * a folder by path, so a folder swapped for a symlink mid-walk is walked through
135
+ * the link; this catches it after the fact. A parent that cannot be resolved
136
+ * counts as escaped: it is never assumed inside.
137
+ */
138
+ async function escapedFiles(files, outReal) {
139
+ const parents = new Map();
140
+ const inside = (dir) => {
141
+ let p = parents.get(dir);
142
+ if (p === undefined) {
143
+ p = fs.realpath(dir).then((real) => real === outReal || isInside(real, outReal), () => false);
144
+ parents.set(dir, p);
145
+ }
146
+ return p;
147
+ };
148
+ const verdicts = await mapLimit(files, READ_CONCURRENCY, (f) => inside(path.dirname(f.abs)));
149
+ return files.filter((_, i) => !verdicts[i]).map((f) => ({ path: f.rel }));
87
150
  }
88
151
  /** Where a symlink lands: its realpath, or its lexical target when it dangles. */
89
152
  async function symlinkTarget(link) {
@@ -96,19 +159,93 @@ async function symlinkTarget(link) {
96
159
  }
97
160
  }
98
161
  /** A Git LFS pointer file: at most 1 KB, the spec's exact first line, and an oid line. */
99
- export async function isLfsPointer(absPath, size) {
162
+ export function isLfsPointer(head, size) {
100
163
  if (size > LFS_POINTER_MAX_BYTES)
101
164
  return false;
102
- const head = await fs.readFile(absPath, "utf8");
103
- const lines = head.split("\n");
165
+ const lines = Buffer.from(head.buffer, head.byteOffset, head.byteLength)
166
+ .toString("utf8")
167
+ .split("\n");
104
168
  if (lines[0] !== LFS_FIRST_LINE)
105
169
  return false;
106
170
  return lines.some((l) => l.startsWith("oid sha256:"));
107
171
  }
172
+ class UnreadableFile extends Error {
173
+ }
174
+ /**
175
+ * Open a walked file without following a link, and confirm it is still the
176
+ * regular file the walk saw: the same device and inode before and after the
177
+ * open. Anything else throws UnreadableFile.
178
+ */
179
+ async function openWalked(file) {
180
+ let handle;
181
+ try {
182
+ const before = await fs.lstat(file.abs);
183
+ if (!before.isFile() || before.dev !== file.dev || before.ino !== file.ino) {
184
+ throw new UnreadableFile();
185
+ }
186
+ handle = await fs.open(file.abs, OPEN_NOFOLLOW);
187
+ const after = await handle.stat();
188
+ if (!after.isFile() || after.dev !== file.dev || after.ino !== file.ino) {
189
+ throw new UnreadableFile();
190
+ }
191
+ return handle;
192
+ }
193
+ catch {
194
+ await handle?.close().catch(() => undefined);
195
+ throw new UnreadableFile();
196
+ }
197
+ }
198
+ /** Exactly `length` bytes from offset 0; a short read is unreadable. */
199
+ async function readExactly(handle, length) {
200
+ const buf = Buffer.alloc(length);
201
+ let got = 0;
202
+ while (got < length) {
203
+ const { bytesRead } = await handle.read(buf, got, length - got, got);
204
+ if (bytesRead === 0)
205
+ throw new UnreadableFile();
206
+ got += bytesRead;
207
+ }
208
+ return buf;
209
+ }
108
210
  /**
109
- * The leak and LFS checks, in that order, before anything is hashed or sent.
110
- * Throws ManifestError; returns nothing on a clean output.
211
+ * The early pass's read: the first `min(size, 4096)` bytes, or the whole file
212
+ * for a `.json` of at most 64 KB. Opens without following links.
111
213
  */
214
+ export async function readHead(file) {
215
+ const whole = isJsonName(file.rel) && file.size <= CREDENTIAL_JSON_MAX_BYTES;
216
+ const handle = await openWalked(file);
217
+ try {
218
+ return await readExactly(handle, whole ? file.size : Math.min(file.size, HEAD_READ_BYTES));
219
+ }
220
+ catch {
221
+ throw new UnreadableFile();
222
+ }
223
+ finally {
224
+ await handle.close().catch(() => undefined);
225
+ }
226
+ }
227
+ /** The content rules over a head (or whole small JSON). */
228
+ function contentWhy(rel, size, bytes) {
229
+ const scanner = createKeyScanner();
230
+ scanner.push(bytes.subarray(0, HEAD_READ_BYTES).toString("latin1"));
231
+ if (scanner.end())
232
+ return "private_key";
233
+ if (isJsonName(rel) && size <= CREDENTIAL_JSON_MAX_BYTES)
234
+ return credentialJson(bytes);
235
+ return undefined;
236
+ }
237
+ async function mapLimit(items, limit, fn) {
238
+ const out = new Array(items.length);
239
+ let next = 0;
240
+ const worker = async () => {
241
+ while (next < items.length) {
242
+ const i = next++;
243
+ out[i] = await fn(items[i]);
244
+ }
245
+ };
246
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
247
+ return out;
248
+ }
112
249
  /**
113
250
  * Folders an output must never be, or sit inside: the repository's history and
114
251
  * the Vincentt binding. Compared case-insensitively, as a default macOS volume
@@ -121,11 +258,11 @@ function forbiddenSegmentIn(rel) {
121
258
  .find((seg) => FORBIDDEN_OUTPUT_SEGMENTS.has(seg.toLowerCase()));
122
259
  }
123
260
  /**
124
- * The leak and LFS checks, in that order, before anything is hashed or sent.
125
- * Returns the output-relative file list the checks passed, which is exactly the
126
- * list the manifest is built from: what is checked is what is uploaded.
261
+ * The leak, credential and LFS checks, in that order, before anything is hashed
262
+ * or sent. Returns the file list the checks passed, which is exactly the list
263
+ * the manifest is built from: what is checked is what is uploaded.
127
264
  */
128
- export async function checkOutput(distDir, repoRoot) {
265
+ export async function checkOutput(distDir, repoRoot, hooks = {}) {
129
266
  const rootReal = await fs.realpath(repoRoot).catch(() => path.resolve(repoRoot));
130
267
  const lexical = path.resolve(distDir);
131
268
  const lexicalRoot = path.resolve(repoRoot);
@@ -143,7 +280,7 @@ export async function checkOutput(distDir, repoRoot) {
143
280
  if (err.code === "ENOENT") {
144
281
  throw new ManifestError("no_dist", distDir, `no dist at ${distDir}`);
145
282
  }
146
- throw err;
283
+ throw new ManifestError("file_unreadable", "", "the output could not be read");
147
284
  }
148
285
  if (outReal === rootReal) {
149
286
  throw new ManifestError("output_is_repo_root", distDir, `${distDir} resolves to the repo root`);
@@ -156,24 +293,63 @@ export async function checkOutput(distDir, repoRoot) {
156
293
  if (hiddenParent !== undefined) {
157
294
  throw new ManifestError("output_in_hidden", hiddenParent, `${distDir} is inside ${hiddenParent}`);
158
295
  }
159
- if (!(await fs.stat(outReal)).isDirectory()) {
296
+ const outStat = await fs.stat(outReal).catch(() => {
297
+ throw new ManifestError("file_unreadable", "", "the output could not be read");
298
+ });
299
+ if (!outStat.isDirectory()) {
160
300
  throw new ManifestError("no_dist", distDir, `no dist at ${distDir}`);
161
301
  }
162
- const walk = await walkOutput(outReal, rootReal);
302
+ const walk = await walkOutput(outReal, rootReal, hooks);
163
303
  const stats = {
164
304
  files: walk.files.length,
165
305
  bytes: walk.files.reduce((n, f) => n + f.size, 0),
166
306
  };
307
+ const escaped = await escapedFiles(walk.files, outReal);
308
+ if (escaped.length > 0) {
309
+ throw new ManifestError("path_escaped", escaped[0].path, "files resolved outside the output", { paths: escaped, stats });
310
+ }
167
311
  if (walk.offenders.length > 0) {
168
312
  throw new ManifestError("secret_path", walk.offenders[0].path, "hidden paths in the output", {
169
313
  paths: walk.offenders,
170
314
  stats,
171
315
  });
172
316
  }
317
+ const heads = await mapLimit(walk.files, READ_CONCURRENCY, async (f) => {
318
+ try {
319
+ const head = await readHead(f);
320
+ return {
321
+ why: f.nameMatched ? undefined : contentWhy(f.rel, f.size, head),
322
+ lfs: isLfsPointer(head, f.size),
323
+ };
324
+ }
325
+ catch {
326
+ return { unreadable: true };
327
+ }
328
+ });
329
+ const credentials = [...walk.credentialNames];
173
330
  const pointers = [];
174
- for (const f of walk.files) {
175
- if (await isLfsPointer(path.join(outReal, f.rel), f.size))
331
+ walk.files.forEach((f, i) => {
332
+ const h = heads[i];
333
+ if ("unreadable" in h)
334
+ return;
335
+ if (h.why !== undefined)
336
+ credentials.push({ seq: f.seq, offender: { path: f.rel, why: h.why } });
337
+ if (h.lfs)
176
338
  pointers.push({ path: f.rel });
339
+ });
340
+ if (credentials.length > 0) {
341
+ const paths = credentials.sort((a, b) => a.seq - b.seq).map((c) => c.offender);
342
+ throw new ManifestError("credential_file", paths[0].path, "credential files in the output", {
343
+ paths,
344
+ stats,
345
+ });
346
+ }
347
+ // A file the checks could not read fails closed; it is never assumed clean.
348
+ const unreadable = walk.files.find((_, i) => "unreadable" in heads[i]);
349
+ if (unreadable !== undefined) {
350
+ throw new ManifestError("file_unreadable", unreadable.rel, "a file could not be read", {
351
+ stats,
352
+ });
177
353
  }
178
354
  if (pointers.length > 0) {
179
355
  throw new ManifestError("lfs_pointer", pointers[0].path, "Git LFS pointers in the output", {
@@ -181,69 +357,112 @@ export async function checkOutput(distDir, repoRoot) {
181
357
  stats,
182
358
  });
183
359
  }
184
- return walk.files;
360
+ return walk.files.map(({ rel, abs, size, dev, ino }) => ({ rel, abs, size, dev, ino }));
185
361
  }
186
- /** Recursively list files under `dir` as dir-relative POSIX paths. Symlinks are not followed. */
187
- export async function walkDist(dir, base = dir) {
188
- let entries;
362
+ /**
363
+ * Hash a walked file and re-run the content rules over the exact bytes hashed.
364
+ * The file is opened without following links and must still be the regular
365
+ * file the walk saw. Every read error throws `file_unreadable`; only a JSON
366
+ * parse failure is "not a match".
367
+ */
368
+ export async function hashAndScan(absPath, walked) {
369
+ const unreadable = () => new ManifestError("file_unreadable", walked.rel, "a file changed while it was read");
370
+ let handle;
189
371
  try {
190
- entries = await fs.readdir(dir, { withFileTypes: true });
372
+ handle = await openWalked({ ...walked, abs: absPath });
191
373
  }
192
- catch (err) {
193
- if (err.code === "ENOENT") {
194
- throw new ManifestError("no_dist", dir, `no dist at ${dir}`);
195
- }
196
- throw err;
197
- }
198
- const out = [];
199
- for (const entry of entries) {
200
- const full = path.join(dir, entry.name);
201
- if (entry.isDirectory())
202
- out.push(...(await walkDist(full, base)));
203
- else if (entry.isFile()) {
204
- out.push(path.relative(base, full).split(path.sep).join("/"));
374
+ catch {
375
+ throw unreadable();
376
+ }
377
+ try {
378
+ const { size } = await handle.stat();
379
+ const hash = createHash("sha256");
380
+ const scanner = createKeyScanner();
381
+ const keepJson = isJsonName(walked.rel) && size <= CREDENTIAL_JSON_MAX_BYTES;
382
+ const kept = [];
383
+ const chunk = Buffer.alloc(HASH_CHUNK_BYTES);
384
+ let read = 0;
385
+ for (;;) {
386
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, read);
387
+ if (bytesRead === 0)
388
+ break;
389
+ const piece = chunk.subarray(0, bytesRead);
390
+ hash.update(piece);
391
+ scanner.push(piece.toString("latin1"));
392
+ if (keepJson)
393
+ kept.push(Buffer.from(piece));
394
+ read += bytesRead;
395
+ if (read > size)
396
+ throw unreadable();
205
397
  }
398
+ if (read !== size)
399
+ throw unreadable();
400
+ let why = scanner.end() ? "private_key" : undefined;
401
+ if (why === undefined && keepJson)
402
+ why = credentialJson(Buffer.concat(kept));
403
+ return { sha256: hash.digest("base64"), size, why };
404
+ }
405
+ catch (err) {
406
+ if (err instanceof ManifestError)
407
+ throw err;
408
+ throw unreadable();
409
+ }
410
+ finally {
411
+ await handle.close().catch(() => undefined);
206
412
  }
207
- return out;
208
- }
209
- /** sha256 of a file, base64 std — the exact form `x-amz-checksum-sha256` wants. */
210
- export async function hashFile(absPath) {
211
- const hash = createHash("sha256");
212
- const stream = createReadStream(absPath);
213
- for await (const chunk of stream)
214
- hash.update(chunk);
215
- return hash.digest("base64");
216
413
  }
217
414
  /**
218
415
  * Build the manifest for a dist directory. Every path is normalized before it is
219
416
  * hashed, so the string this returns is the string the API validates, signs, and
220
417
  * later lists — there is no second form anywhere.
221
418
  */
222
- export async function buildManifest(distDir, opts = {}) {
223
- // With a repo root, the list the leak and LFS checks passed IS the upload
224
- // list. A second walk could see a file created after the checks ran.
225
- const raw = opts.repoRoot !== undefined
226
- ? (await checkOutput(distDir, opts.repoRoot)).map((f) => f.rel)
227
- : await walkDist(distDir);
419
+ export async function buildManifest(distDir, opts) {
420
+ // The list the checks passed IS the upload list. A second walk could see a
421
+ // file created after the checks ran.
422
+ const walked = await checkOutput(distDir, opts.repoRoot, {
423
+ beforeReadDir: opts.beforeReadDir,
424
+ });
228
425
  const normalized = [];
229
- for (const rel of raw) {
230
- const result = normalizePath(rel);
426
+ for (const file of walked) {
427
+ const result = normalizePath(file.rel);
231
428
  if (!result.ok) {
232
- throw new ManifestError(result.reason, rel, `${rel}: ${result.reason}`);
429
+ throw new ManifestError(result.reason, file.rel, `${file.rel}: ${result.reason}`);
233
430
  }
234
- normalized.push({ rel, norm: result.path });
431
+ normalized.push({ file, norm: result.path });
235
432
  }
236
433
  const pathError = validatePathSet(normalized.map((n) => n.norm));
237
434
  if (pathError) {
238
435
  throw new ManifestError(pathError.reason, pathError.path, `${pathError.path}: ${pathError.reason}`);
239
436
  }
437
+ await opts.beforeHash?.(walked);
438
+ const stats = {
439
+ files: walked.length,
440
+ bytes: walked.reduce((n, f) => n + f.size, 0),
441
+ };
240
442
  const files = [];
443
+ const credentials = [];
241
444
  let totalBytes = 0;
242
- for (const { rel, norm } of normalized) {
243
- const abs = path.join(distDir, rel);
244
- const stat = await fs.stat(abs);
245
- files.push({ path: norm, size: stat.size, sha256: await hashFile(abs) });
246
- totalBytes += stat.size;
445
+ for (const { file, norm } of normalized) {
446
+ let hashed;
447
+ try {
448
+ hashed = await hashAndScan(file.abs, file);
449
+ }
450
+ catch (err) {
451
+ if (err instanceof ManifestError) {
452
+ throw new ManifestError(err.reason, err.offendingPath, err.message, { stats });
453
+ }
454
+ throw err;
455
+ }
456
+ if (hashed.why !== undefined)
457
+ credentials.push({ path: file.rel, why: hashed.why });
458
+ files.push({ path: norm, size: hashed.size, sha256: hashed.sha256 });
459
+ totalBytes += hashed.size;
460
+ }
461
+ if (credentials.length > 0) {
462
+ throw new ManifestError("credential_file", credentials[0].path, "credential files in the output", {
463
+ paths: credentials,
464
+ stats,
465
+ });
247
466
  }
248
467
  files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
249
468
  return { files, totalBytes };
@@ -22,6 +22,24 @@ export declare class UploadExpired extends Error {
22
22
  readonly path: string;
23
23
  constructor(path: string);
24
24
  }
25
+ /**
26
+ * The api returned an upload target for a path this run's manifest does not
27
+ * hold. Nothing is sent: only files that passed this run's checks are uploaded.
28
+ */
29
+ export declare class UploadTargetRejected extends Error {
30
+ readonly path: string;
31
+ constructor(path: string);
32
+ }
33
+ /**
34
+ * A target's signed checksum is not the one this run's manifest holds for that
35
+ * path. The PUT reopens the file by path, so the signed checksum is what makes
36
+ * "checked is uploaded" hold; a target that would sign anything else is refused
37
+ * before any byte is sent.
38
+ */
39
+ export declare class UploadChecksumMismatch extends Error {
40
+ readonly path: string;
41
+ constructor(path: string);
42
+ }
25
43
  export interface UploadProgress {
26
44
  filesDone: number;
27
45
  filesTotal: number;
@@ -46,6 +46,37 @@ export class UploadExpired extends Error {
46
46
  this.name = "UploadExpired";
47
47
  }
48
48
  }
49
+ /**
50
+ * The api returned an upload target for a path this run's manifest does not
51
+ * hold. Nothing is sent: only files that passed this run's checks are uploaded.
52
+ */
53
+ export class UploadTargetRejected extends Error {
54
+ path;
55
+ constructor(path) {
56
+ super(`the upload target ${path} is not in this build`);
57
+ this.path = path;
58
+ this.name = "UploadTargetRejected";
59
+ }
60
+ }
61
+ /**
62
+ * A target's signed checksum is not the one this run's manifest holds for that
63
+ * path. The PUT reopens the file by path, so the signed checksum is what makes
64
+ * "checked is uploaded" hold; a target that would sign anything else is refused
65
+ * before any byte is sent.
66
+ */
67
+ export class UploadChecksumMismatch extends Error {
68
+ path;
69
+ constructor(path) {
70
+ super(`the upload target ${path} does not carry this build's checksum`);
71
+ this.path = path;
72
+ this.name = "UploadChecksumMismatch";
73
+ }
74
+ }
75
+ const CHECKSUM_HEADER = "x-amz-checksum-sha256";
76
+ function signedChecksum(headers) {
77
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === CHECKSUM_HEADER);
78
+ return key === undefined ? undefined : headers[key];
79
+ }
49
80
  const DEFAULT_CONCURRENCY = 6;
50
81
  const DEFAULT_MAX_ATTEMPTS = 5;
51
82
  const BACKOFF_BASE_MS = 500;
@@ -99,6 +130,13 @@ export function classifyS3Response(status, body) {
99
130
  export async function uploadAll(opts) {
100
131
  const fetchImpl = opts.fetchImpl ?? fetch;
101
132
  const sizes = new Map(opts.manifest.map((f) => [f.path, f.size]));
133
+ const foreign = opts.targets.find((t) => !sizes.has(t.path));
134
+ if (foreign !== undefined)
135
+ throw new UploadTargetRejected(foreign.path);
136
+ const sums = new Map(opts.manifest.map((f) => [f.path, f.sha256]));
137
+ const mismatched = opts.targets.find((t) => signedChecksum(t.headers) !== sums.get(t.path));
138
+ if (mismatched !== undefined)
139
+ throw new UploadChecksumMismatch(mismatched.path);
102
140
  const bytesTotal = opts.targets.reduce((n, t) => n + (sizes.get(t.path) ?? 0), 0);
103
141
  const queue = [...opts.targets];
104
142
  const retry = {
@@ -0,0 +1,23 @@
1
+ import type { ManifestEntry } from "./manifest.js";
2
+ export declare const HEAVY_MODEL_BYTES = 10000000;
3
+ export declare const HEAVY_IMAGE_PX = 2048;
4
+ export declare const IMAGE_HEAD_BYTES = 524288;
5
+ export type HeavyFinding = {
6
+ path: string;
7
+ kind: "model";
8
+ bytes: number;
9
+ } | {
10
+ path: string;
11
+ kind: "image";
12
+ width: number;
13
+ height: number;
14
+ };
15
+ export type ReadHead = (path: string, n: number) => Promise<Uint8Array>;
16
+ export declare function heavyFileFindings(files: readonly ManifestEntry[], readHead: ReadHead): Promise<HeavyFinding[]>;
17
+ type Size = {
18
+ width: number;
19
+ height: number;
20
+ };
21
+ /** Pixel size from a file header, or undefined for an unknown or truncated one. */
22
+ export declare function imageSize(head: Uint8Array): Size | undefined;
23
+ export {};