nerdsystem 0.0.11 → 0.0.13

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,896 @@
1
+ /**
2
+ * Source snapshots: the deterministic tar.gz of an app folder that is stored with every deploy and
3
+ * every `nerd save`, and restored by `nerd pull` (docs/source-snapshots.md). The builder imports this
4
+ * file from the CLI's dist, so it depends on Node and `./gates/text.js` only.
5
+ */
6
+ import { execFileSync } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { Readable } from "node:stream";
11
+ import { pipeline } from "node:stream/promises";
12
+ import { createGzip, gunzipSync } from "node:zlib";
13
+ import { isSnapshotExcluded, isSnapshotForced, isSnapshotForcedDir, MAX_SNAPSHOT_FILE_BYTES, MAX_SNAPSHOT_HEADER_BYTES, MAX_SOURCE_BYTES, MAX_SOURCE_FILES, MAX_SOURCE_UNPACKED, SKIP_DIR, SNAPSHOT_MANIFEST, snapshotForcedWarning, validateSnapshotEntry, } from "./gates/text.js";
14
+ export { MAX_SNAPSHOT_FILE_BYTES, MAX_SOURCE_BYTES, MAX_SOURCE_FILES, MAX_SOURCE_UNPACKED, SNAPSHOT_MANIFEST, validateSnapshotEntry } from "./gates/text.js";
15
+ export class SnapshotError extends Error {
16
+ code;
17
+ constructor(message, code) {
18
+ super(message);
19
+ this.code = code;
20
+ this.name = "SnapshotError";
21
+ }
22
+ }
23
+ const MB = 1024 * 1024;
24
+ const BLOCK = 512;
25
+ const MODE_FILE = 0o644;
26
+ const MODE_EXEC = 0o755;
27
+ /** Warnings printed per forced file before the rest fold into one line. */
28
+ const FORCED_WARNING_LIMIT = 20;
29
+ export function formatMb(bytes) {
30
+ const mb = bytes / MB;
31
+ return mb >= 10 ? `${Math.round(mb)} MB` : `${Math.max(0.1, Math.round(mb * 10) / 10)} MB`;
32
+ }
33
+ function sha256(data) {
34
+ return createHash("sha256").update(data).digest("hex");
35
+ }
36
+ function byteOrder(a, b) {
37
+ return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
38
+ }
39
+ function gitEnv() {
40
+ const env = { ...process.env };
41
+ for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"])
42
+ delete env[k];
43
+ return env;
44
+ }
45
+ function gitRun(root, args) {
46
+ try {
47
+ return execFileSync("git", args, { cwd: root, env: gitEnv(), stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * MB, windowsHide: true });
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ function gitLsFiles(root, args) {
54
+ const out = gitRun(root, ["ls-files", "-z", ...args]);
55
+ if (out === null)
56
+ return null;
57
+ return out.toString("utf8").split("\0").filter((p) => p.length > 0);
58
+ }
59
+ /** `{ sha, branch }` of the checkout at `root`, or null when there is no git, no repo, or no commit yet. */
60
+ export function gitInfo(root) {
61
+ if (!existsSync(path.join(root, ".git")))
62
+ return null;
63
+ const sha = gitRun(root, ["rev-parse", "HEAD"])?.toString("utf8").trim();
64
+ if (!sha || !/^[0-9a-f]{40,64}$/.test(sha))
65
+ return null;
66
+ const branch = gitRun(root, ["rev-parse", "--abbrev-ref", "HEAD"])?.toString("utf8").trim() || "HEAD";
67
+ return { sha, branch };
68
+ }
69
+ function toAbs(root, posix) {
70
+ return path.join(root, ...posix.split("/"));
71
+ }
72
+ /** Every regular file under an ignored directory the snapshot must keep (hard excludes still apply). */
73
+ function walkForced(root, dirRel, out) {
74
+ let entries;
75
+ try {
76
+ entries = readdirSync(toAbs(root, dirRel), { withFileTypes: true });
77
+ }
78
+ catch {
79
+ return;
80
+ }
81
+ entries.sort((a, b) => byteOrder(a.name, b.name));
82
+ for (const entry of entries) {
83
+ const rel = `${dirRel}/${entry.name}`;
84
+ const kind = direntKind(entry, toAbs(root, rel));
85
+ if (kind === "dir") {
86
+ if (!isSnapshotExcluded(`${rel}/`))
87
+ walkForced(root, rel, out);
88
+ }
89
+ else if (kind === "file" && isSnapshotForced(rel)) {
90
+ out.push(rel);
91
+ }
92
+ }
93
+ }
94
+ function direntKind(entry, abs) {
95
+ if (entry.isSymbolicLink())
96
+ return "other";
97
+ if (entry.isDirectory())
98
+ return "dir";
99
+ if (entry.isFile())
100
+ return "file";
101
+ try {
102
+ const st = lstatSync(abs);
103
+ return st.isDirectory() ? "dir" : st.isFile() ? "file" : "other";
104
+ }
105
+ catch {
106
+ return "other";
107
+ }
108
+ }
109
+ /** Candidates from git: tracked plus untracked-not-ignored, and the ignored files a rebuild needs. */
110
+ function listWithGit(root) {
111
+ if (!existsSync(path.join(root, ".git")))
112
+ return null;
113
+ const listed = gitLsFiles(root, ["--cached", "--others", "--exclude-standard"]);
114
+ if (listed === null)
115
+ return null;
116
+ // `--directory` collapses a wholly ignored directory into one `dir/` entry, so node_modules is one
117
+ // line instead of fifty thousand; the directories a rebuild needs are walked on the filesystem.
118
+ const ignored = gitLsFiles(root, ["--others", "--ignored", "--exclude-standard", "--directory"]) ?? [];
119
+ const forced = [];
120
+ for (const entry of ignored) {
121
+ if (entry.endsWith("/")) {
122
+ const dir = entry.replace(/\/+$/, "");
123
+ if (isSnapshotForcedDir(dir))
124
+ walkForced(root, dir, forced);
125
+ }
126
+ else if (isSnapshotForced(entry)) {
127
+ forced.push(entry);
128
+ }
129
+ }
130
+ return { paths: listed, forced };
131
+ }
132
+ function globToRegex(glob) {
133
+ let out = "";
134
+ for (let i = 0; i < glob.length; i++) {
135
+ const ch = glob[i];
136
+ if (ch === "*") {
137
+ if (glob[i + 1] === "*") {
138
+ out += ".*";
139
+ i++;
140
+ if (glob[i + 1] === "/")
141
+ i++;
142
+ }
143
+ else
144
+ out += "[^/]*";
145
+ }
146
+ else if (ch === "?")
147
+ out += "[^/]";
148
+ else if (/[.+^${}()|[\]\\]/.test(ch))
149
+ out += `\\${ch}`;
150
+ else
151
+ out += ch;
152
+ }
153
+ return out;
154
+ }
155
+ export function parseGitignore(text, baseRel) {
156
+ const base = baseRel ? `${globToRegex(baseRel)}/` : "";
157
+ const rules = [];
158
+ for (const raw of text.split(/\r?\n/)) {
159
+ let line = raw.replace(/(?<!\\)\s+$/, "");
160
+ if (line === "" || line.startsWith("#") || line.startsWith("!"))
161
+ continue;
162
+ let dirOnly = false;
163
+ if (line.endsWith("/")) {
164
+ dirOnly = true;
165
+ line = line.replace(/\/+$/, "");
166
+ }
167
+ if (line.startsWith("**/"))
168
+ line = line.slice(3);
169
+ let anchored = false;
170
+ if (line.startsWith("/")) {
171
+ anchored = true;
172
+ line = line.replace(/^\/+/, "");
173
+ }
174
+ else if (line.includes("/"))
175
+ anchored = true;
176
+ if (line === "")
177
+ continue;
178
+ const body = globToRegex(line.replace(/\\(.)/g, "$1"));
179
+ const re = anchored ? new RegExp(`^${base}${body}(/|$)`) : new RegExp(`^${base}(.*/)?${body}(/|$)`);
180
+ rules.push({ re, dirOnly });
181
+ }
182
+ return rules;
183
+ }
184
+ function ignoredBy(rules, rel, isDir) {
185
+ return rules.some((r) => (!r.dirOnly || isDir) && r.re.test(rel));
186
+ }
187
+ /** Candidates from the filesystem when git is not there. */
188
+ function listByWalking(root, skipped) {
189
+ const paths = [];
190
+ const forced = [];
191
+ const walk = (dirRel, inherited, ancestorIgnored) => {
192
+ let entries;
193
+ try {
194
+ entries = readdirSync(dirRel ? toAbs(root, dirRel) : root, { withFileTypes: true });
195
+ }
196
+ catch {
197
+ return;
198
+ }
199
+ entries.sort((a, b) => byteOrder(a.name, b.name));
200
+ let rules = inherited;
201
+ const ignoreFile = path.join(dirRel ? toAbs(root, dirRel) : root, ".gitignore");
202
+ if (entries.some((e) => e.name === ".gitignore" && !e.isDirectory())) {
203
+ try {
204
+ rules = [...inherited, ...parseGitignore(readFileSync(ignoreFile, "utf8"), dirRel)];
205
+ }
206
+ catch {
207
+ // unreadable .gitignore: ignore nothing extra
208
+ }
209
+ }
210
+ for (const entry of entries) {
211
+ const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
212
+ const kind = direntKind(entry, toAbs(root, rel));
213
+ if (kind === "dir") {
214
+ if (isSnapshotExcluded(`${rel}/`))
215
+ continue;
216
+ const underForced = isSnapshotForcedDir(rel);
217
+ if (!underForced && SKIP_DIR.test(`${rel}/`))
218
+ continue;
219
+ const ignored = ancestorIgnored || ignoredBy(rules, rel, true);
220
+ if (ignored && !underForced)
221
+ continue;
222
+ walk(rel, rules, ignored);
223
+ }
224
+ else if (kind === "file") {
225
+ if (isSnapshotExcluded(rel))
226
+ continue;
227
+ const ignored = ancestorIgnored || ignoredBy(rules, rel, false);
228
+ if (ignored) {
229
+ if (isSnapshotForced(rel))
230
+ forced.push(rel);
231
+ }
232
+ else
233
+ paths.push(rel);
234
+ }
235
+ else {
236
+ let size = 0;
237
+ try {
238
+ size = lstatSync(toAbs(root, rel)).size;
239
+ }
240
+ catch {
241
+ // gone already
242
+ }
243
+ skipped.push({ path: rel, size, reason: "not a regular file" });
244
+ }
245
+ }
246
+ };
247
+ walk("", [], false);
248
+ return { paths, forced };
249
+ }
250
+ /**
251
+ * The files a snapshot of `root` holds, sorted by path in byte order. Uses git when `.git` exists and
252
+ * git runs, else walks the folder. Every candidate is stat'ed; missing and non-regular paths are
253
+ * dropped, the hard exclude list is applied last, and a file over 50 MB is left out with a warning.
254
+ */
255
+ export function listSnapshotFiles(root, opts = {}) {
256
+ root = path.resolve(root);
257
+ const skipped = [];
258
+ const warnings = [];
259
+ const fromGit = opts.useGit === false ? null : listWithGit(root);
260
+ const listedBy = fromGit ? "git" : "walk";
261
+ const { paths, forced } = fromGit ?? listByWalking(root, skipped);
262
+ const forcedSet = new Set();
263
+ const candidates = new Map();
264
+ for (const p of paths)
265
+ candidates.set(p, false);
266
+ for (const p of forced)
267
+ if (!candidates.has(p))
268
+ candidates.set(p, true);
269
+ const files = [];
270
+ for (const [rel, isForced] of candidates) {
271
+ if (rel.startsWith("../") || rel === ".." || rel.startsWith("/"))
272
+ continue;
273
+ if (isSnapshotExcluded(rel)) {
274
+ skipped.push({ path: rel, size: 0, reason: "never saved: local databases, env files, keys, and caches stay on this computer" });
275
+ continue;
276
+ }
277
+ const abs = toAbs(root, rel);
278
+ let st;
279
+ try {
280
+ st = lstatSync(abs);
281
+ }
282
+ catch {
283
+ skipped.push({ path: rel, size: 0, reason: "missing on disk" });
284
+ continue;
285
+ }
286
+ if (!st.isFile()) {
287
+ skipped.push({ path: rel, size: st.size, reason: st.isSymbolicLink() ? "symbolic link" : "not a regular file" });
288
+ continue;
289
+ }
290
+ if (st.size > MAX_SNAPSHOT_FILE_BYTES) {
291
+ skipped.push({ path: rel, size: st.size, reason: `over ${MAX_SNAPSHOT_FILE_BYTES / MB} MB` });
292
+ warnings.push(`\`${rel}\` is ${formatMb(st.size)} and was left out of the saved copy; files over ${MAX_SNAPSHOT_FILE_BYTES / MB} MB are not saved. Keep videos and large files in file storage.`);
293
+ continue;
294
+ }
295
+ files.push({ path: rel, abs, size: st.size, mode: st.mode & 0o111 ? MODE_EXEC : MODE_FILE });
296
+ if (isForced)
297
+ forcedSet.add(rel);
298
+ }
299
+ files.sort((a, b) => byteOrder(a.path, b.path));
300
+ const forcedKept = files.map((f) => f.path).filter((p) => forcedSet.has(p));
301
+ forcedKept.slice(0, FORCED_WARNING_LIMIT).forEach((p) => warnings.push(snapshotForcedWarning(p)));
302
+ if (forcedKept.length > FORCED_WARNING_LIMIT)
303
+ warnings.push(`${forcedKept.length - FORCED_WARNING_LIMIT} more files are in .gitignore but a rebuild needs them; they are in the saved copy`);
304
+ return { files, warnings, skipped, forced: forcedKept, listedBy };
305
+ }
306
+ // ---------------------------------------------------------------------------------------------
307
+ // Tar writer (ustar, deterministic)
308
+ // ---------------------------------------------------------------------------------------------
309
+ function octalField(n, width) {
310
+ const b = Buffer.alloc(width);
311
+ b.write(n.toString(8).padStart(width - 1, "0"), 0, "ascii");
312
+ return b;
313
+ }
314
+ function tarHeader(e) {
315
+ const h = Buffer.alloc(BLOCK);
316
+ const name = Buffer.from(e.name, "utf8");
317
+ name.copy(h, 0, 0, Math.min(100, name.length));
318
+ octalField(e.mode ?? MODE_FILE, 8).copy(h, 100);
319
+ octalField(0, 8).copy(h, 108);
320
+ octalField(0, 8).copy(h, 116);
321
+ octalField(e.data?.length ?? 0, 12).copy(h, 124);
322
+ octalField(0, 12).copy(h, 136);
323
+ h.fill(0x20, 148, 156);
324
+ h.write(e.type === "" ? "\0" : e.type, 156, 1, "latin1");
325
+ if (e.linkname)
326
+ Buffer.from(e.linkname, "utf8").copy(h, 157, 0, 100);
327
+ h.write("ustar\0", 257, "ascii");
328
+ h.write("00", 263, "ascii");
329
+ octalField(0, 8).copy(h, 329);
330
+ octalField(0, 8).copy(h, 337);
331
+ if (e.prefix)
332
+ Buffer.from(e.prefix, "utf8").copy(h, 345, 0, 155);
333
+ let sum = 0;
334
+ for (const byte of h)
335
+ sum += byte;
336
+ h.write(sum.toString(8).padStart(6, "0"), 148, "ascii");
337
+ h[154] = 0;
338
+ h[155] = 0x20;
339
+ return h;
340
+ }
341
+ function padding(size) {
342
+ const rest = size % BLOCK;
343
+ return rest === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK - rest);
344
+ }
345
+ /** The bytes of one entry: an optional PAX header for a long path, the ustar header, data, padding. */
346
+ export function tarEntryBytes(e) {
347
+ const out = [];
348
+ const data = e.data ?? Buffer.alloc(0);
349
+ const nameBytes = Buffer.from(e.name, "utf8");
350
+ let headerName = e.name;
351
+ if (nameBytes.length > 100 && !e.prefix) {
352
+ const record = paxRecord("path", e.name);
353
+ const shortName = truncateName(nameBytes);
354
+ out.push(tarHeader({ name: `PaxHeader/${shortName}`.slice(0, 100), type: "x", data: record, mode: MODE_FILE }), record, padding(record.length));
355
+ headerName = shortName;
356
+ }
357
+ out.push(tarHeader({ ...e, name: headerName, data }));
358
+ if (data.length > 0)
359
+ out.push(data, padding(data.length));
360
+ return out;
361
+ }
362
+ /** The first 100 bytes of a long name, cut back to a whole path segment so it is never a partial one. */
363
+ function truncateName(nameBytes) {
364
+ const cut = nameBytes.subarray(0, 100);
365
+ const slash = cut.lastIndexOf(0x2f);
366
+ return (slash > 0 ? cut.subarray(0, slash) : cut).toString("utf8").replace(/�+$/, "");
367
+ }
368
+ function paxRecord(key, value) {
369
+ const body = Buffer.from(` ${key}=${value}\n`, "utf8");
370
+ let len = body.length + 1;
371
+ while (String(len).length + body.length !== len)
372
+ len = String(len).length + body.length;
373
+ return Buffer.concat([Buffer.from(String(len), "ascii"), body]);
374
+ }
375
+ /** A whole tar from raw entries, in the given order, with the two end blocks. Tests build hostile tars with it. */
376
+ export function buildTar(entries) {
377
+ const parts = [];
378
+ for (const e of entries)
379
+ parts.push(...tarEntryBytes(e));
380
+ parts.push(Buffer.alloc(BLOCK * 2));
381
+ return Buffer.concat(parts);
382
+ }
383
+ /** gzip level 6 with a fixed header (mtime 0, OS byte 3) so the same tar always gives the same bytes. */
384
+ export async function gzipDeterministic(chunks, maxBytes = Infinity) {
385
+ const out = [];
386
+ let total = 0;
387
+ const gzip = createGzip({ level: 6 });
388
+ let over = false;
389
+ try {
390
+ await pipeline(Readable.from(chunks), gzip, async (source) => {
391
+ for await (const chunk of source) {
392
+ total += chunk.length;
393
+ if (total > maxBytes) {
394
+ over = true;
395
+ throw new SnapshotError("compressed snapshot is over the cap", "too_large");
396
+ }
397
+ out.push(chunk);
398
+ }
399
+ });
400
+ }
401
+ catch (err) {
402
+ // pipeline may report its own abort error once the consumer stops early; the cap is the cause.
403
+ if (over)
404
+ throw new SnapshotError("compressed snapshot is over the cap", "too_large");
405
+ throw err;
406
+ }
407
+ const gz = Buffer.concat(out);
408
+ gz.writeUInt32LE(0, 4);
409
+ gz[9] = 3;
410
+ return gz;
411
+ }
412
+ function tarSizeOf(name, size) {
413
+ const nameBytes = Buffer.byteLength(name, "utf8");
414
+ let total = BLOCK + size + padding(size).length;
415
+ if (nameBytes > 100) {
416
+ const record = paxRecord("path", name).length;
417
+ total += BLOCK + record + padding(record).length;
418
+ }
419
+ return total;
420
+ }
421
+ function tooLargeResult(reason, files, base, tarBytes) {
422
+ const bytes = files.reduce((n, f) => n + f.size, 0);
423
+ const largest = [...files]
424
+ .sort((a, b) => b.size - a.size || byteOrder(a.path, b.path))
425
+ .slice(0, 5)
426
+ .map((f) => ({ path: f.path, size: f.size }));
427
+ const names = largest
428
+ .slice(0, 2)
429
+ .map((f) => `${f.path} (${formatMb(f.size)})`)
430
+ .join(", ");
431
+ const message = reason === "files"
432
+ ? `Code not saved: the app folder has ${files.length.toLocaleString("en-US")} files; the most a snapshot holds is ${MAX_SOURCE_FILES.toLocaleString("en-US")}. Add generated folders to .gitignore.`
433
+ : `Code not saved: the app folder is ${formatMb(bytes)}; the largest files are ${names}. Keep videos and large files in file storage, or add them to .gitignore.`;
434
+ return { ...base, bytes, tarBytes, tooLarge: { reason, bytes, fileCount: files.length, largest, message } };
435
+ }
436
+ /**
437
+ * Packs the app at `root` into a deterministic tar.gz. Returns `tooLarge` instead of `gz` when a
438
+ * cap is hit (a user-caused skip: the deploy proceeds without a snapshot). Throws `SnapshotError`
439
+ * with code `changed` when a file changed under it; the caller should try once more.
440
+ */
441
+ export async function packSnapshot(root, opts = {}) {
442
+ root = path.resolve(root);
443
+ const selection = listSnapshotFiles(root, opts);
444
+ const { files } = selection;
445
+ const git = opts.git === undefined ? gitInfo(root) : opts.git;
446
+ const base = {
447
+ fileCount: files.length,
448
+ manifest: { version: 1, createdAt: opts.createdAt ?? new Date().toISOString(), cli: opts.cli ?? "unknown", node: opts.node ?? process.version, git, files: [] },
449
+ warnings: selection.warnings,
450
+ skipped: selection.skipped,
451
+ forced: selection.forced,
452
+ listedBy: selection.listedBy,
453
+ };
454
+ let tarBytes = BLOCK * 2;
455
+ for (const f of files)
456
+ tarBytes += tarSizeOf(f.path, f.size);
457
+ if (files.length > MAX_SOURCE_FILES)
458
+ return tooLargeResult("files", files, base, tarBytes);
459
+ if (tarBytes > MAX_SOURCE_UNPACKED)
460
+ return tooLargeResult("unpacked", files, base, tarBytes);
461
+ // Pass one: hash every file so the manifest, the first entry, can list them all.
462
+ const manifestFiles = files.map((f) => {
463
+ const data = readFileSync(f.abs);
464
+ return { path: f.path, size: data.length, sha256: sha256(data), mode: f.mode };
465
+ });
466
+ const manifest = { ...base.manifest, files: manifestFiles };
467
+ const manifestData = Buffer.from(JSON.stringify(manifest, null, 2), "utf8");
468
+ tarBytes += tarSizeOf(SNAPSHOT_MANIFEST, manifestData.length);
469
+ if (tarBytes > MAX_SOURCE_UNPACKED)
470
+ return tooLargeResult("unpacked", files, base, tarBytes);
471
+ // Pass two: stream the tar through gzip, hashing the tar bytes (manifest excluded) on the way.
472
+ const tree = createHash("sha256");
473
+ let streamed = 0;
474
+ async function* chunks() {
475
+ for (const chunk of tarEntryBytes({ name: SNAPSHOT_MANIFEST, type: "0", data: manifestData, mode: MODE_FILE })) {
476
+ streamed += chunk.length;
477
+ yield chunk;
478
+ }
479
+ for (let i = 0; i < files.length; i++) {
480
+ const f = files[i];
481
+ const listed = manifestFiles[i];
482
+ const data = readFileSync(f.abs);
483
+ if (data.length !== listed.size || sha256(data) !== listed.sha256)
484
+ throw new SnapshotError(`\`${f.path}\` changed while the snapshot was being packed. Try again.`, "changed");
485
+ for (const chunk of tarEntryBytes({ name: f.path, type: "0", data, mode: f.mode })) {
486
+ tree.update(chunk);
487
+ streamed += chunk.length;
488
+ yield chunk;
489
+ }
490
+ }
491
+ const end = Buffer.alloc(BLOCK * 2);
492
+ tree.update(end);
493
+ streamed += end.length;
494
+ yield end;
495
+ }
496
+ let gz;
497
+ try {
498
+ gz = await gzipDeterministic(chunks(), MAX_SOURCE_BYTES);
499
+ }
500
+ catch (err) {
501
+ if (err instanceof SnapshotError && err.code === "too_large")
502
+ return tooLargeResult("compressed", files, base, tarBytes);
503
+ throw err;
504
+ }
505
+ if (streamed !== tarBytes)
506
+ throw new SnapshotError("snapshot size did not add up; try again", "changed");
507
+ return { ...base, manifest, gz, sha256: sha256(gz), treeHash: tree.digest("hex"), bytes: gz.length, tarBytes };
508
+ }
509
+ function parseOctal(h, offset, width, what) {
510
+ if (h[offset] & 0x80)
511
+ throw new SnapshotError(`tar ${what} uses base-256 encoding, which snapshots never do`, "invalid_snapshot");
512
+ const text = h.toString("ascii", offset, offset + width).replace(/\0.*$/s, "").trim();
513
+ if (text === "")
514
+ return 0;
515
+ if (!/^[0-7]+$/.test(text))
516
+ throw new SnapshotError(`tar ${what} field is not octal`, "invalid_snapshot");
517
+ return parseInt(text, 8);
518
+ }
519
+ function cString(h, offset, width) {
520
+ const end = h.indexOf(0, offset);
521
+ return h.toString("utf8", offset, end === -1 || end > offset + width ? offset + width : end);
522
+ }
523
+ /** Raw entries in order; PAX and long-name headers are yielded as entries and resolved by the caller. */
524
+ export function* parseTar(tar) {
525
+ let offset = 0;
526
+ while (offset + BLOCK <= tar.length) {
527
+ const h = tar.subarray(offset, offset + BLOCK);
528
+ if (h.every((b) => b === 0))
529
+ return;
530
+ let unsigned = 0;
531
+ let signed = 0;
532
+ for (let i = 0; i < BLOCK; i++) {
533
+ const b = i >= 148 && i < 156 ? 0x20 : h[i];
534
+ unsigned += b;
535
+ signed += i >= 148 && i < 156 ? 0x20 : (b << 24) >> 24;
536
+ }
537
+ const stored = parseOctal(h, 148, 8, "checksum");
538
+ if (stored !== unsigned && stored !== signed)
539
+ throw new SnapshotError("tar header checksum does not match; the snapshot is damaged", "invalid_snapshot");
540
+ const size = parseOctal(h, 124, 12, "size");
541
+ const mode = parseOctal(h, 100, 8, "mode");
542
+ const type = h.toString("latin1", 156, 157);
543
+ const magic = h.toString("ascii", 257, 262);
544
+ const prefix = magic === "ustar" ? cString(h, 345, 155) : "";
545
+ const base = cString(h, 0, 100);
546
+ const name = prefix ? `${prefix}/${base}` : base;
547
+ const dataStart = offset + BLOCK;
548
+ const dataEnd = dataStart + size;
549
+ if (dataEnd > tar.length)
550
+ throw new SnapshotError(`entry "${name.slice(0, 120)}" runs past the end of the archive`, "invalid_snapshot");
551
+ yield { name, type, size, mode, data: tar.subarray(dataStart, dataEnd) };
552
+ offset = dataEnd + padding(size).length;
553
+ }
554
+ }
555
+ function parsePax(data) {
556
+ const out = new Map();
557
+ let pos = 0;
558
+ while (pos < data.length) {
559
+ const space = data.indexOf(0x20, pos);
560
+ if (space === -1)
561
+ throw new SnapshotError("malformed PAX header", "invalid_snapshot");
562
+ const len = Number(data.toString("ascii", pos, space));
563
+ if (!Number.isInteger(len) || len <= space - pos + 1 || pos + len > data.length || data[pos + len - 1] !== 0x0a)
564
+ throw new SnapshotError("malformed PAX header", "invalid_snapshot");
565
+ const record = data.toString("utf8", space + 1, pos + len - 1);
566
+ const eq = record.indexOf("=");
567
+ if (eq === -1)
568
+ throw new SnapshotError("malformed PAX header", "invalid_snapshot");
569
+ out.set(record.slice(0, eq), record.slice(eq + 1));
570
+ pos += len;
571
+ }
572
+ return out;
573
+ }
574
+ const HEX64 = /^[0-9a-f]{64}$/;
575
+ function parseManifest(entry) {
576
+ if (!entry || (entry.type !== "0" && entry.type !== "\0") || entry.name !== SNAPSHOT_MANIFEST)
577
+ throw new SnapshotError(`the first entry must be ${SNAPSHOT_MANIFEST}`, "invalid_snapshot");
578
+ let raw;
579
+ try {
580
+ raw = JSON.parse(entry.data.toString("utf8"));
581
+ }
582
+ catch {
583
+ throw new SnapshotError(`${SNAPSHOT_MANIFEST} is not valid JSON`, "invalid_snapshot");
584
+ }
585
+ if (!raw || typeof raw !== "object")
586
+ throw new SnapshotError(`${SNAPSHOT_MANIFEST} is not an object`, "invalid_snapshot");
587
+ const m = raw;
588
+ if (m.version !== 1)
589
+ throw new SnapshotError(`${SNAPSHOT_MANIFEST} has version ${JSON.stringify(m.version)}; this nerd reads version 1`, "invalid_snapshot");
590
+ if (!Array.isArray(m.files))
591
+ throw new SnapshotError(`${SNAPSHOT_MANIFEST} has no files list`, "invalid_snapshot");
592
+ if (m.files.length > MAX_SOURCE_FILES)
593
+ throw new SnapshotError(`${SNAPSHOT_MANIFEST} lists more than ${MAX_SOURCE_FILES} files`, "invalid_snapshot");
594
+ const seen = new Set();
595
+ const files = [];
596
+ for (const f of m.files) {
597
+ const r = (f && typeof f === "object" ? f : {});
598
+ if (typeof r.path !== "string" || typeof r.size !== "number" || typeof r.sha256 !== "string" || typeof r.mode !== "number")
599
+ throw new SnapshotError(`${SNAPSHOT_MANIFEST} has a malformed file entry`, "invalid_snapshot");
600
+ const reason = validateSnapshotEntry({ name: r.path, type: "0", size: r.size });
601
+ if (reason)
602
+ throw new SnapshotError(`${SNAPSHOT_MANIFEST}: ${reason}`, "invalid_snapshot");
603
+ if (r.path === SNAPSHOT_MANIFEST)
604
+ throw new SnapshotError(`${SNAPSHOT_MANIFEST} lists itself`, "invalid_snapshot");
605
+ if (!HEX64.test(r.sha256))
606
+ throw new SnapshotError(`${SNAPSHOT_MANIFEST}: "${r.path.slice(0, 120)}" has a malformed sha256`, "invalid_snapshot");
607
+ if (seen.has(r.path))
608
+ throw new SnapshotError(`${SNAPSHOT_MANIFEST} lists "${r.path.slice(0, 120)}" twice`, "invalid_snapshot");
609
+ seen.add(r.path);
610
+ files.push({ path: r.path, size: r.size, sha256: r.sha256, mode: r.mode & 0o111 ? MODE_EXEC : MODE_FILE });
611
+ }
612
+ const git = m.git && typeof m.git === "object" ? m.git : null;
613
+ return {
614
+ version: 1,
615
+ createdAt: typeof m.createdAt === "string" ? m.createdAt : "",
616
+ cli: typeof m.cli === "string" ? m.cli : "",
617
+ node: typeof m.node === "string" ? m.node : "",
618
+ git: git && typeof git.sha === "string" ? { sha: git.sha, branch: typeof git.branch === "string" ? git.branch : "" } : null,
619
+ files,
620
+ };
621
+ }
622
+ /** Uncompresses a snapshot with the unpacked cap enforced. */
623
+ export function gunzipSnapshot(gz) {
624
+ if (gz.length > MAX_SOURCE_BYTES)
625
+ throw new SnapshotError(`the snapshot is ${formatMb(gz.length)} compressed; the most this nerd accepts is ${MAX_SOURCE_BYTES / MB} MB`, "too_large");
626
+ try {
627
+ return gunzipSync(gz, { maxOutputLength: MAX_SOURCE_UNPACKED });
628
+ }
629
+ catch (err) {
630
+ const code = err.code;
631
+ if (code === "ERR_BUFFER_TOO_LARGE")
632
+ throw new SnapshotError(`the snapshot unpacks to more than ${MAX_SOURCE_UNPACKED / MB} MB and was refused`, "too_large");
633
+ throw new SnapshotError("the snapshot is not a gzip file nerd can read; the download may be damaged", "invalid_snapshot");
634
+ }
635
+ }
636
+ /** The manifest alone, for callers that only need the file list. */
637
+ export function readSnapshotManifest(gz) {
638
+ const tar = gunzipSnapshot(gz);
639
+ const first = parseTar(tar).next();
640
+ return parseManifest(first.done ? undefined : first.value);
641
+ }
642
+ function isInside(target, candidate) {
643
+ return candidate === target || candidate.startsWith(target + path.sep);
644
+ }
645
+ function shown(name) {
646
+ return name.slice(0, 120);
647
+ }
648
+ /**
649
+ * The archive-only pass: every header checksum, every type, PAX and long-name resolution, the
650
+ * manifest's rules, duplicates, sizes, the sha256 of every file, every manifest file present, and
651
+ * no path listed both as a file and as a folder. Reads nothing from and writes nothing to the disk,
652
+ * so a bad archive is refused before a single byte lands.
653
+ */
654
+ function planExtraction(gz) {
655
+ const tar = gunzipSnapshot(gz);
656
+ const entries = parseTar(tar);
657
+ const first = entries.next();
658
+ const manifest = parseManifest(first.done ? undefined : first.value);
659
+ const expected = new Map(manifest.files.map((f) => [f.path, f]));
660
+ const done = new Set();
661
+ const dirs = new Set();
662
+ const steps = [];
663
+ let pending = null;
664
+ let count = 0;
665
+ for (const raw of entries) {
666
+ const type = raw.type === "" ? "\0" : raw.type;
667
+ if (type === "x" || type === "L") {
668
+ // The raw ustar name of a PAX or long-name header is filler readers ignore (the packer writes
669
+ // "PaxHeader/<short name>" cut at 100 bytes, which can end on a slash); only its type and size count.
670
+ // The path it carries is held to the full rules when the entry it names comes up.
671
+ const headerReason = validateSnapshotEntry({ name: type === "x" ? "PaxHeader" : "LongLink", type, size: raw.size });
672
+ if (headerReason)
673
+ throw new SnapshotError(headerReason, "invalid_snapshot");
674
+ if (pending)
675
+ throw new SnapshotError(type === "x" ? "two PAX headers in a row" : "two long-name headers in a row", "invalid_snapshot");
676
+ if (type === "x") {
677
+ const records = parsePax(raw.data);
678
+ if (records.has("linkpath"))
679
+ throw new SnapshotError("a PAX header carries a link path; links are not allowed", "invalid_snapshot");
680
+ if (records.has("size") && Number(records.get("size")) > MAX_SNAPSHOT_FILE_BYTES)
681
+ throw new SnapshotError("a PAX header declares a file over the size cap", "invalid_snapshot");
682
+ pending = { path: records.get("path") ?? null };
683
+ }
684
+ else {
685
+ pending = { path: raw.data.toString("utf8").replace(/\0.*$/s, "") };
686
+ }
687
+ continue;
688
+ }
689
+ // Only data entries count toward the cap, the way the packer and the API count them.
690
+ count++;
691
+ if (count > MAX_SOURCE_FILES)
692
+ throw new SnapshotError(`the snapshot has more than ${MAX_SOURCE_FILES} entries`, "invalid_snapshot");
693
+ const name = pending?.path ?? raw.name;
694
+ pending = null;
695
+ const reason = validateSnapshotEntry({ name, type, size: raw.size });
696
+ if (reason)
697
+ throw new SnapshotError(reason, "invalid_snapshot");
698
+ const segments = name.replace(/\/+$/, "").split("/");
699
+ const clean = segments.join("/");
700
+ if (type === "5") {
701
+ if (expected.has(clean))
702
+ throw new SnapshotError(`"${shown(clean)}" is listed both as a file and as a folder`, "invalid_snapshot");
703
+ dirs.add(clean);
704
+ steps.push({ kind: "dir", name: clean, segments });
705
+ continue;
706
+ }
707
+ if (name === SNAPSHOT_MANIFEST)
708
+ throw new SnapshotError(`${SNAPSHOT_MANIFEST} appears twice`, "invalid_snapshot");
709
+ const listed = expected.get(name);
710
+ if (!listed)
711
+ throw new SnapshotError(`"${shown(name)}" is in the archive but not in its manifest`, "invalid_snapshot");
712
+ if (done.has(name))
713
+ throw new SnapshotError(`"${shown(name)}" appears twice`, "invalid_snapshot");
714
+ if (raw.data.length !== listed.size)
715
+ throw new SnapshotError(`"${shown(name)}" is ${raw.data.length} bytes but the manifest says ${listed.size}`, "invalid_snapshot");
716
+ if (sha256(raw.data) !== listed.sha256)
717
+ throw new SnapshotError(`"${shown(name)}" does not match its manifest hash`, "invalid_snapshot");
718
+ if (dirs.has(name))
719
+ throw new SnapshotError(`"${shown(name)}" is listed both as a file and as a folder`, "invalid_snapshot");
720
+ for (let i = 1; i < segments.length; i++) {
721
+ const parent = segments.slice(0, i).join("/");
722
+ if (expected.has(parent))
723
+ throw new SnapshotError(`"${shown(parent)}" is listed both as a file and as a folder`, "invalid_snapshot");
724
+ dirs.add(parent);
725
+ }
726
+ done.add(name);
727
+ steps.push({ kind: "file", name, segments, data: raw.data, mode: listed.mode });
728
+ }
729
+ if (pending)
730
+ throw new SnapshotError("the archive ends after a PAX or long-name header with no entry following it", "invalid_snapshot");
731
+ for (const f of manifest.files)
732
+ if (!done.has(f.path))
733
+ throw new SnapshotError(`"${shown(f.path)}" is in the manifest but missing from the archive`, "invalid_snapshot");
734
+ return { manifest, steps, files: done.size };
735
+ }
736
+ /**
737
+ * Checks the directories between the target and `dir` from the top down, each one that exists by
738
+ * lstat: a symbolic link is refused (wherever it points), a file in a folder's place is refused, and
739
+ * every real directory must resolve inside the real target. With `create`, the missing ones are then
740
+ * made one level at a time, so no directory is created anywhere before the check passes. `okDirs`
741
+ * remembers the directories already verified or created, so each one is looked at once.
742
+ */
743
+ function ensureInside(target, realTarget, dir, name, okDirs, create) {
744
+ const rel = path.relative(target, dir);
745
+ const segments = rel === "" ? [] : rel.split(path.sep);
746
+ let current = target;
747
+ let missing = false;
748
+ for (const segment of segments) {
749
+ current = path.join(current, segment);
750
+ if (okDirs.has(current))
751
+ continue;
752
+ let st = null;
753
+ if (!missing) {
754
+ try {
755
+ st = lstatSync(current);
756
+ }
757
+ catch {
758
+ st = null;
759
+ }
760
+ }
761
+ if (!st) {
762
+ missing = true;
763
+ if (create) {
764
+ mkdirSync(current);
765
+ okDirs.add(current);
766
+ }
767
+ continue;
768
+ }
769
+ const where = path.relative(target, current).split(path.sep).join("/");
770
+ if (st.isSymbolicLink()) {
771
+ let real;
772
+ try {
773
+ real = realpathSync(current);
774
+ }
775
+ catch {
776
+ real = null;
777
+ }
778
+ if (!real || !isInside(realTarget, real))
779
+ throw new SnapshotError(`"${shown(name)}" would be written outside the target folder through a link`, "invalid_snapshot");
780
+ throw new SnapshotError(`"${shown(name)}" would be written through a link at "${where}"; replace the link with a folder first`, "invalid_snapshot");
781
+ }
782
+ if (!st.isDirectory())
783
+ throw new SnapshotError(`"${shown(name)}" needs the folder "${where}", but a file is in its place; move it away first`, "invalid_snapshot");
784
+ if (!isInside(realTarget, realpathSync(current)))
785
+ throw new SnapshotError(`"${shown(name)}" would be written outside the target folder`, "invalid_snapshot");
786
+ okDirs.add(current);
787
+ }
788
+ }
789
+ /** What is on the disk where a step wants to write, or null when nothing is. */
790
+ function existingAt(dest) {
791
+ try {
792
+ return lstatSync(dest);
793
+ }
794
+ catch {
795
+ return null;
796
+ }
797
+ }
798
+ /**
799
+ * The disk pass, writing nothing: every step's destination is inside the target by path, the
800
+ * directories above it are real directories inside the target (no link anywhere on the way), and
801
+ * what sits at the destination can be replaced (a file or a link for a file; nothing or a real
802
+ * directory for a folder).
803
+ */
804
+ function checkDestinations(plan, target, realTarget, okDirs) {
805
+ for (const step of plan.steps) {
806
+ const dest = path.resolve(target, ...step.segments);
807
+ if (!isInside(target, dest) || dest === target)
808
+ throw new SnapshotError(`entry "${shown(step.name)}" would land outside the target folder`, "invalid_snapshot");
809
+ ensureInside(target, realTarget, path.dirname(dest), step.name, okDirs, false);
810
+ const existing = existingAt(dest);
811
+ if (!existing)
812
+ continue;
813
+ if (step.kind === "dir") {
814
+ if (existing.isSymbolicLink())
815
+ throw new SnapshotError(`"${shown(step.name)}" is a link on this computer; replace it with a folder first`, "invalid_snapshot");
816
+ if (!existing.isDirectory())
817
+ throw new SnapshotError(`"${shown(step.name)}" exists as a file; move it away first`, "invalid_snapshot");
818
+ }
819
+ else if (!existing.isSymbolicLink() && !existing.isFile()) {
820
+ throw new SnapshotError(`"${shown(step.name)}" exists as a folder; move it away first`, "invalid_snapshot");
821
+ }
822
+ }
823
+ }
824
+ /**
825
+ * Extracts a snapshot into `targetDir` with every hardening rule from the spec: only regular files,
826
+ * directories, PAX and long-name headers; relative names with no `..`, backslash, or `.git`; nothing
827
+ * outside the target after resolution (symlinked parents included); every file listed in the
828
+ * manifest with the listed size and sha256, and every listed file present. The whole archive and the
829
+ * whole target are checked before the first write, so a refused archive changes nothing on the disk,
830
+ * the files already in a `force` target included. A non-empty target needs `force`.
831
+ */
832
+ export function extractSnapshot(gz, targetDir, opts = {}) {
833
+ const target = path.resolve(targetDir);
834
+ const targetExists = existsSync(target);
835
+ if (targetExists) {
836
+ if (!lstatSync(target).isDirectory())
837
+ throw new SnapshotError(`${target} exists and is not a directory`, "not_empty");
838
+ if (readdirSync(target).length > 0 && !opts.force)
839
+ throw new SnapshotError(`${target} is not empty. Use --force to write into it anyway; files with the same names are replaced.`, "not_empty");
840
+ }
841
+ // Pass 1: the archive alone. Nothing exists on the disk yet for a bad archive, not even the target.
842
+ const plan = planExtraction(gz);
843
+ let createdTarget = false;
844
+ if (!targetExists) {
845
+ mkdirSync(target, { recursive: true });
846
+ createdTarget = true;
847
+ }
848
+ const realTarget = realpathSync(target);
849
+ const okDirs = new Set();
850
+ const written = [];
851
+ // Only a disk error during the write pass can reach this: pass 1 and the disk check refuse before any write.
852
+ const fail = (err) => {
853
+ try {
854
+ if (createdTarget)
855
+ rmSync(target, { recursive: true, force: true });
856
+ else
857
+ for (const f of written)
858
+ rmSync(f, { force: true });
859
+ }
860
+ catch {
861
+ // best effort
862
+ }
863
+ throw err;
864
+ };
865
+ try {
866
+ // Pass 1, on the disk: still writes nothing.
867
+ checkDestinations(plan, target, realTarget, okDirs);
868
+ // Pass 2: write.
869
+ for (const step of plan.steps) {
870
+ const dest = path.resolve(target, ...step.segments);
871
+ if (step.kind === "dir") {
872
+ ensureInside(target, realTarget, dest, step.name, okDirs, true);
873
+ continue;
874
+ }
875
+ ensureInside(target, realTarget, path.dirname(dest), step.name, okDirs, true);
876
+ const existing = existingAt(dest);
877
+ // A link in a file's place is replaced, never written through.
878
+ if (existing?.isSymbolicLink())
879
+ unlinkSync(dest);
880
+ else if (existing && !existing.isFile())
881
+ throw new SnapshotError(`"${shown(step.name)}" exists as a folder; move it away first`, "invalid_snapshot");
882
+ writeFileSync(dest, step.data, { mode: step.mode });
883
+ if (process.platform !== "win32")
884
+ chmodSync(dest, step.mode);
885
+ written.push(dest);
886
+ }
887
+ return { files: plan.files, manifest: plan.manifest, dir: target };
888
+ }
889
+ catch (err) {
890
+ return fail(err);
891
+ }
892
+ }
893
+ /** The size on disk of a folder's selected files, for messages. */
894
+ export function selectionBytes(selection) {
895
+ return selection.files.reduce((n, f) => n + f.size, 0);
896
+ }