@intx/tool-packaging 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cache.js ADDED
@@ -0,0 +1,750 @@
1
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference -- npm-team packages ship no types; declarations.d.ts must be visible to downstream typecheckers that import from this package's source.
2
+ /// <reference path="./declarations.d.ts" />
3
+ // Content-addressable tarball cache, shared across agent instances on
4
+ // the sidecar.
5
+ //
6
+ // Tarballs are immutable bytes addressed by their SRI integrity. The
7
+ // cache lives at `rootDir/sha512/<2-char>/<rest>/tarball.tgz`. Get
8
+ // returns cached bytes or null; put verifies bytes against the
9
+ // integrity, writes them atomically, and evicts least-recently-used
10
+ // entries until the total cache size is under maxBytes. Evict removes
11
+ // one entry (used after extraction discovers a corrupted file on disk).
12
+ //
13
+ // `maxBytes` covers both the tarball bytes and the size of the
14
+ // extracted directory tree the loader hardlinks from. The extraction
15
+ // tree dominates disk usage in practice (tarballs are gzip-compressed;
16
+ // the unpacked tree is multiples larger), so the cap reflects the
17
+ // caller-visible cost of holding an entry.
18
+ //
19
+ // Integrity is verified on store and re-verified inside
20
+ // `extractTarball` before unpacking. `get` returns bytes without
21
+ // re-hashing — callers that route through `extractTarball` get the
22
+ // extra check for free; callers that consume the bytes directly are
23
+ // trusted to validate as appropriate.
24
+ //
25
+ // `extractTarball` is the second face of the same on-disk store: it
26
+ // unpacks the tarball into a sibling `extracted/` directory keyed by
27
+ // the same integrity, so the per-instance loader can symlink into a
28
+ // stable, deduplicated extraction without re-doing the tar work on
29
+ // every apply. The unpack is gated by a per-integrity tmp-and-rename
30
+ // dance with the same crash-safety properties as `put`.
31
+ import { promises as fs } from "node:fs";
32
+ import path from "node:path";
33
+ import ssri from "ssri";
34
+ import * as tar from "tar";
35
+ import { getLogger } from "@intx/log";
36
+ import { hexEncode } from "@intx/types";
37
+ const logger = getLogger(["sidecar", "tool-packaging", "cache"]);
38
+ // Defense-in-depth bound for dirSize recursion. Real npm extractions
39
+ // nest a handful of levels deep at most; symlink loops or pathological
40
+ // trees would otherwise spin until the process is killed.
41
+ const DIR_SIZE_MAX_DEPTH = 20;
42
+ // The on-disk layout shards by the first two characters of the
43
+ // integrity's base64 payload; entries with shorter payloads cannot
44
+ // produce a deterministic shard path. SRI integrities for sha512
45
+ // payloads are 88 characters base64, so this bound is purely
46
+ // defensive — but it keeps the shard layout's invariant explicit
47
+ // rather than implicit in the slice indices.
48
+ const MIN_INTEGRITY_PAYLOAD = 2;
49
+ /**
50
+ * Thrown by `put` when supplied bytes do not match the supplied
51
+ * integrity. The cache never stores bytes that fail this check.
52
+ */
53
+ export class TarballIntegrityMismatchError extends Error {
54
+ integrity;
55
+ constructor(integrity) {
56
+ super(`tarball bytes do not match integrity ${integrity}`);
57
+ this.name = "TarballIntegrityMismatchError";
58
+ this.integrity = integrity;
59
+ }
60
+ }
61
+ /**
62
+ * Construct a TarballCache rooted at `config.rootDir`.
63
+ *
64
+ * **Single-process contract.** Pointing two sidecar processes at the
65
+ * same cache root is unsupported. The pid-prefixed staging path is
66
+ * decorative for intra-process races — it keeps two concurrent puts
67
+ * in the same process from clobbering each other's tmp files — but
68
+ * cross-process races on the same integrity can still collide during
69
+ * the final rename (one process moves the staged file into place, the
70
+ * other's rename overwrites or fails depending on the platform's
71
+ * rename-over-existing semantics). The atomicity guarantees in this
72
+ * module assume a single owning process per `rootDir`.
73
+ */
74
+ export function createTarballCache(config) {
75
+ if (config.maxBytes <= 0) {
76
+ throw new Error("createTarballCache: maxBytes must be positive");
77
+ }
78
+ const rootDir = config.rootDir;
79
+ // In-process refcount over (integrity, extraction directory) pairs.
80
+ // `extractTarball` increments on success; the returned `release`
81
+ // decrements. `evict` deletes the tarball blob immediately but
82
+ // defers physical removal of the extraction tree until the count
83
+ // reaches zero. This decouples mark-as-bad (atomic, prompt) from
84
+ // physical reclaim (deferred, safe) so an integrity-mismatch evict
85
+ // that races a concurrent `hardlinkTree` walk against the same
86
+ // extraction does not pull the tree out from under the walk and
87
+ // surface as ENOENT mid-readdir.
88
+ //
89
+ // The map keys on integrity, not extraction path, because both the
90
+ // path and the integrity are 1:1 for a content-addressable cache.
91
+ // Cross-process evicts are out of scope: the cache documents a
92
+ // single-process contract, and this refcount only protects against
93
+ // intra-process races between two agents on the same sidecar.
94
+ const extractionRefcounts = new Map();
95
+ const pendingEvictions = new Set();
96
+ function acquireExtraction(integrity) {
97
+ const next = (extractionRefcounts.get(integrity) ?? 0) + 1;
98
+ extractionRefcounts.set(integrity, next);
99
+ }
100
+ async function releaseExtraction(integrity) {
101
+ const current = extractionRefcounts.get(integrity);
102
+ if (current === undefined || current <= 0) {
103
+ // The release is a caller-driven contract; an extra release
104
+ // without a matching acquire is a programmer error. Log loudly
105
+ // — the call site for this function is wrapped in `void` to
106
+ // keep `release` synchronous for callers, so a throw here would
107
+ // become an unhandled rejection on the microtask queue rather
108
+ // than the immediate, observable failure the contract promises.
109
+ // Logging at error level keeps the failure surfaced through the
110
+ // operator's log pipeline without booby-trapping the process.
111
+ logger.error `cache.release: extraction for ${integrity} released more times than acquired`;
112
+ return;
113
+ }
114
+ if (current === 1) {
115
+ extractionRefcounts.delete(integrity);
116
+ if (pendingEvictions.has(integrity)) {
117
+ pendingEvictions.delete(integrity);
118
+ const entryDirPath = entryDir(integrity);
119
+ try {
120
+ await fs.rm(extractedDir(integrity), {
121
+ recursive: true,
122
+ force: true,
123
+ });
124
+ // The deferred-reclaim path is symmetric with the inline
125
+ // sweep at `evictUntilUnderCap`: empty entry/shard/algorithm
126
+ // parents must be swept too, otherwise every eviction that
127
+ // raced an in-flight reader leaves an orphan empty directory
128
+ // triple on disk that accumulates over the cache's lifetime.
129
+ // `rmdirIfEmpty` is best-effort (ENOTEMPTY when siblings
130
+ // remain) and silently no-ops if a different evict already
131
+ // pruned the parent.
132
+ await rmdirIfEmpty(entryDirPath);
133
+ await rmdirIfEmpty(path.dirname(entryDirPath));
134
+ await rmdirIfEmpty(path.dirname(path.dirname(entryDirPath)));
135
+ }
136
+ catch (err) {
137
+ logger.warn `deferred eviction of ${extractedDir(integrity)} failed: ${err instanceof Error ? err.message : String(err)}`;
138
+ }
139
+ }
140
+ return;
141
+ }
142
+ extractionRefcounts.set(integrity, current - 1);
143
+ }
144
+ function entryDir(integrity) {
145
+ const { algorithm, encoded } = parseIntegrity(integrity);
146
+ if (encoded.length < MIN_INTEGRITY_PAYLOAD) {
147
+ throw new Error(`integrity payload too short to shard: ${integrity}`);
148
+ }
149
+ return path.join(rootDir, algorithm, encoded.slice(0, MIN_INTEGRITY_PAYLOAD), encoded.slice(MIN_INTEGRITY_PAYLOAD));
150
+ }
151
+ function entryPath(integrity) {
152
+ return path.join(entryDir(integrity), "tarball.tgz");
153
+ }
154
+ function extractedDir(integrity) {
155
+ return path.join(entryDir(integrity), "extracted");
156
+ }
157
+ /**
158
+ * Advance the entry's atime so the cap-driven LRU sweep treats the
159
+ * access as recent. Mirrors the explicit `utimes` in `cache.get`:
160
+ * read-only mounts, `noatime`/`relatime` mounts, and FUSE
161
+ * filesystems that refuse `utimes` must not fail an otherwise-
162
+ * successful access. Log at debug and move on.
163
+ */
164
+ async function touchEntryAtime(integrity) {
165
+ const file = entryPath(integrity);
166
+ try {
167
+ const now = new Date();
168
+ const stat = await fs.stat(file);
169
+ await fs.utimes(file, now, stat.mtime);
170
+ }
171
+ catch (err) {
172
+ logger.debug `extractTarball atime update failed for ${file}; LRU ordering will be stale: ${err instanceof Error ? err.message : String(err)}`;
173
+ }
174
+ }
175
+ async function listEntries() {
176
+ const out = [];
177
+ let rootExists = false;
178
+ try {
179
+ await fs.access(rootDir);
180
+ rootExists = true;
181
+ }
182
+ catch {
183
+ // rootDir does not exist yet; nothing to list.
184
+ }
185
+ if (!rootExists)
186
+ return out;
187
+ const algorithms = await fs.readdir(rootDir);
188
+ for (const alg of algorithms) {
189
+ const algDir = path.join(rootDir, alg);
190
+ const shardEntries = await fs.readdir(algDir).catch(() => []);
191
+ for (const shard of shardEntries) {
192
+ const shardDir = path.join(algDir, shard);
193
+ const leafEntries = await fs.readdir(shardDir).catch(() => []);
194
+ for (const leaf of leafEntries) {
195
+ const entryDir = path.join(shardDir, leaf);
196
+ const file = path.join(entryDir, "tarball.tgz");
197
+ try {
198
+ const stat = await fs.stat(file);
199
+ const extractedPath = path.join(entryDir, "extracted");
200
+ // Contain dirSize failures here so a single corrupt
201
+ // extraction tree (symlink-depth overflow, permission
202
+ // refusal mid-walk, etc.) does not break cache accounting
203
+ // for every subsequent put. The entry is still surfaced —
204
+ // with `extractedSize: 0` — so the eviction sweep can
205
+ // still reach it; the warning names the entry so the
206
+ // operator can clear the broken tree by hand.
207
+ let extractedSize = 0;
208
+ try {
209
+ extractedSize = await dirSize(extractedPath);
210
+ }
211
+ catch (err) {
212
+ logger.warn `dirSize failed for ${extractedPath}; accounting that entry as 0 extracted bytes: ${err instanceof Error ? err.message : String(err)}`;
213
+ }
214
+ // Recover the integrity from the on-disk path. The layout
215
+ // writes `<algorithm>/<sharded payload>` with `/` → `-`
216
+ // substitution at write time (parseIntegrity sanitization);
217
+ // reverse the substitution to land back on the SRI form
218
+ // the caller passed. Standard base64 never produces `-`
219
+ // organically, so reversing `-` → `/` is unambiguous.
220
+ const integrity = `${alg}-${(shard + leaf).replace(/-/g, "/")}`;
221
+ out.push({
222
+ integrity,
223
+ entryDir,
224
+ tarballPath: file,
225
+ extractedPath,
226
+ tarballSize: stat.size,
227
+ extractedSize,
228
+ atimeMs: stat.atimeMs,
229
+ });
230
+ }
231
+ catch {
232
+ // File missing or unreadable; skip.
233
+ }
234
+ }
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+ /**
240
+ * Sum the on-disk size of every regular file under `dir` recursively.
241
+ * Returns 0 when `dir` does not exist. Hardlinks are counted once
242
+ * per inode would be ideal, but `node:fs` does not expose inode-
243
+ * dedup walking without a manual ino map; the loader hardlinks
244
+ * extraction trees into per-instance store dirs, so the extraction
245
+ * tree itself holds one link per file and `stat.size` per entry is
246
+ * the right number to charge to this cache entry.
247
+ *
248
+ * ACCOUNTING vs. DISK USAGE: `maxBytes` bounds the sum reported by
249
+ * this walker, not the actual disk consumption of the cache plus
250
+ * its downstream hardlink consumers. The loader's per-instance
251
+ * store dirs share inodes with `cache/extracted/`; evicting an
252
+ * entry here drops the cache's reference but the underlying file
253
+ * survives as long as any per-instance dir still points at it.
254
+ * The cap is a steady-state ceiling on the cache tree's own
255
+ * accounting, not a disk-usage limit. Concurrent vanishes during
256
+ * the walk are silently dropped via the inner `lstat` try/catch
257
+ * below; under the single-process contract this is rare, but the
258
+ * returned `total` reports the cap-relevant sum within one sweep's
259
+ * resolution rather than a strictly-consistent snapshot.
260
+ *
261
+ * Symlinks (both file- and directory-targeted) are NOT traversed:
262
+ * `lstat` here returns the link itself rather than its target, and
263
+ * `dirent.isSymbolicLink()` is the entry-walk equivalent. An npm
264
+ * tarball that ships symlinks is preserved verbatim by the loader's
265
+ * hardlink-tree pass; charging the link's own size (zero in our
266
+ * accounting) avoids both symlink-loop divergence and double-counting
267
+ * the target through whatever path also names it directly.
268
+ *
269
+ * Recursion is capped at `DIR_SIZE_MAX_DEPTH` as a defense-in-depth
270
+ * against a pathological tarball whose real-directory nesting exceeds
271
+ * what the cache layout (flat npm trees) ever expects.
272
+ */
273
+ async function dirSize(dir, depth = 0) {
274
+ if (depth > DIR_SIZE_MAX_DEPTH) {
275
+ throw new Error(`dirSize depth exceeded ${String(DIR_SIZE_MAX_DEPTH)} at ${dir}; likely a symlink loop in the cache extraction tree`);
276
+ }
277
+ let total = 0;
278
+ let entries;
279
+ try {
280
+ entries = await fs.readdir(dir, { withFileTypes: true });
281
+ }
282
+ catch (err) {
283
+ if (isENOENT(err))
284
+ return 0;
285
+ throw err;
286
+ }
287
+ for (const entry of entries) {
288
+ const abs = path.join(dir, entry.name);
289
+ if (entry.isSymbolicLink()) {
290
+ continue;
291
+ }
292
+ if (entry.isDirectory()) {
293
+ total += await dirSize(abs, depth + 1);
294
+ }
295
+ else if (entry.isFile()) {
296
+ try {
297
+ const stat = await fs.lstat(abs);
298
+ total += stat.size;
299
+ }
300
+ catch {
301
+ // Concurrent removal; ignore.
302
+ }
303
+ }
304
+ }
305
+ return total;
306
+ }
307
+ async function evictUntilUnderCap(justWritten) {
308
+ const entries = await listEntries();
309
+ const total = entries.reduce((sum, e) => sum + e.tarballSize + e.extractedSize, 0);
310
+ if (total <= config.maxBytes)
311
+ return;
312
+ // The just-written entry is the caller's reason for sweeping; if
313
+ // it is a single tarball larger than `maxBytes`, evicting it now
314
+ // would force a refetch on the next apply and the new fetch would
315
+ // be evicted again — perpetual churn. Treat it as ineligible for
316
+ // this sweep so the immediate apply succeeds; subsequent puts can
317
+ // evict it normally once it is no longer the LRU-newest.
318
+ const justWrittenPath = justWritten !== undefined ? entryPath(justWritten) : undefined;
319
+ const evictable = entries.filter((e) => justWrittenPath === undefined || e.tarballPath !== justWrittenPath);
320
+ evictable.sort((a, b) => a.atimeMs - b.atimeMs);
321
+ let remaining = total;
322
+ for (const e of evictable) {
323
+ if (remaining <= config.maxBytes)
324
+ break;
325
+ const reclaimable = e.tarballSize + e.extractedSize;
326
+ try {
327
+ // Drop the tarball blob immediately so a fresh `extractTarball`
328
+ // call cannot reuse the on-disk extraction tree from this
329
+ // entry. The extraction tree's physical reclaim is gated on
330
+ // the in-flight refcount — concurrent readers from another
331
+ // agent holding a `release` handle would otherwise see ENOENT
332
+ // mid-readdir if we rm-ed it out from under them. Defer to the
333
+ // last `release` to do the actual rm; if there are no readers
334
+ // (the common case), reclaim is immediate.
335
+ await fs.unlink(e.tarballPath);
336
+ if ((extractionRefcounts.get(e.integrity) ?? 0) > 0) {
337
+ pendingEvictions.add(e.integrity);
338
+ }
339
+ else {
340
+ await fs.rm(e.extractedPath, { recursive: true, force: true });
341
+ // Sweep the now-empty entry/shard/algorithm directories so
342
+ // listEntries does not accumulate O(historical-evictions)
343
+ // cost over the cache's lifetime. ENOTEMPTY means a sibling
344
+ // entry still occupies the parent; that is the expected case
345
+ // for any cache holding more than one entry per shard, so
346
+ // swallow it silently and move on.
347
+ await rmdirIfEmpty(e.entryDir);
348
+ await rmdirIfEmpty(path.dirname(e.entryDir));
349
+ await rmdirIfEmpty(path.dirname(path.dirname(e.entryDir)));
350
+ }
351
+ remaining -= reclaimable;
352
+ logger.debug `evicted ${e.tarballPath} (tarball ${String(e.tarballSize)} + extracted ${String(e.extractedSize)} bytes); cache now ${String(remaining)} bytes`;
353
+ }
354
+ catch (err) {
355
+ logger.warn `failed to evict ${e.tarballPath}: ${err instanceof Error ? err.message : String(err)}`;
356
+ }
357
+ }
358
+ if (remaining > config.maxBytes) {
359
+ // This warning may fire twice for the same integrity in one
360
+ // loader pass — once after `put` writes the bytes and once after
361
+ // `extractTarball` unpacks them, since both invoke the sweep
362
+ // with the same `justWritten` integrity. Operator log
363
+ // aggregation should dedup on `integrity` if the noise becomes
364
+ // a problem at scale.
365
+ logger.warn `cache exceeds maxBytes by ${String(remaining - config.maxBytes)} bytes after sweep; the just-written entry is exempt from its own sweep`;
366
+ }
367
+ }
368
+ async function rmdirIfEmpty(dir) {
369
+ try {
370
+ await fs.rmdir(dir);
371
+ }
372
+ catch (err) {
373
+ // ENOTEMPTY: a sibling entry still occupies the parent. ENOENT:
374
+ // the directory was already removed (e.g. by a concurrent
375
+ // evict). Both are expected; anything else propagates so a
376
+ // misconfigured cache root surfaces instead of corrupting the
377
+ // shard layout silently.
378
+ if (isENOTEMPTY(err) || isENOENT(err))
379
+ return;
380
+ throw err;
381
+ }
382
+ }
383
+ return {
384
+ async get(integrity) {
385
+ const file = entryPath(integrity);
386
+ let bytes;
387
+ try {
388
+ bytes = await fs.readFile(file);
389
+ }
390
+ catch (err) {
391
+ if (isENOENT(err))
392
+ return null;
393
+ throw err;
394
+ }
395
+ // Touch atime so LRU ordering reflects this access. utimes
396
+ // requires both atime and mtime; mtime is preserved. The atime
397
+ // update is best-effort LRU bookkeeping: a read-only mount, a
398
+ // noatime/relatime mount option, or a FUSE filesystem that
399
+ // refuses utimes must not fail an otherwise-successful read.
400
+ // Log at debug and return the bytes the caller already paid for.
401
+ try {
402
+ const now = new Date();
403
+ const stat = await fs.stat(file);
404
+ await fs.utimes(file, now, stat.mtime);
405
+ }
406
+ catch (err) {
407
+ logger.debug `cache.get atime update failed for ${file}; LRU ordering will be stale: ${err instanceof Error ? err.message : String(err)}`;
408
+ }
409
+ return bytes;
410
+ },
411
+ async has(integrity) {
412
+ const file = entryPath(integrity);
413
+ try {
414
+ await fs.access(file);
415
+ return true;
416
+ }
417
+ catch (err) {
418
+ if (isENOENT(err))
419
+ return false;
420
+ throw err;
421
+ }
422
+ },
423
+ async put(integrity, bytes) {
424
+ const matched = ssri.checkData(bytes, integrity);
425
+ if (matched === false) {
426
+ throw new TarballIntegrityMismatchError(integrity);
427
+ }
428
+ const dir = entryDir(integrity);
429
+ await fs.mkdir(dir, { recursive: true });
430
+ const file = entryPath(integrity);
431
+ // Add per-call randomness so two concurrent put()s for the same
432
+ // integrity (e.g. two agents on the same sidecar racing into the
433
+ // first apply) do not collide on the temp path.
434
+ const tmp = `${file}.tmp.${String(process.pid)}.${hexEncode(crypto.getRandomValues(new Uint8Array(8)))}`;
435
+ await fs.writeFile(tmp, bytes);
436
+ // No fsync before rename: the cache is content-addressable and
437
+ // rebuildable. A crash between write and rename leaves an orphaned
438
+ // .tmp file that `sweepOrphans` clears on the next boot; a crash
439
+ // after rename but before the data hits disk forces a re-fetch on
440
+ // the next miss, validated by SRI. The persistence requirement
441
+ // that earns an fsync is the apply pipeline's active-deploy-id,
442
+ // not cache entries.
443
+ await fs.rename(tmp, file);
444
+ // No cap sweep here. `put` only stages the tarball bytes; the
445
+ // entry's full on-disk footprint (tarball + extracted tree) is
446
+ // not knowable until `extractTarball` lands. Sweeping now would
447
+ // make the LRU decision against a half-sized entry — both
448
+ // double-counting noise (the warn fires twice for one miss when
449
+ // the sweep runs in both `put` and `extractTarball`) and a
450
+ // semantically wrong choice (the entry will grow, possibly past
451
+ // `maxBytes`, after the sweep already decided which neighbors
452
+ // to evict). The sweep belongs in `extractTarball` once the
453
+ // entry's bytes-on-disk are fully realized.
454
+ },
455
+ async evict(integrity) {
456
+ const file = entryPath(integrity);
457
+ const extracted = extractedDir(integrity);
458
+ try {
459
+ await fs.unlink(file);
460
+ logger.debug `evicted cache entry for ${integrity}`;
461
+ }
462
+ catch (err) {
463
+ if (!isENOENT(err))
464
+ throw err;
465
+ }
466
+ // The extraction is derived from the tarball bytes and is
467
+ // useless once the tarball is gone. If a hardlinkTree walk is
468
+ // in-flight for the same integrity, removing the tree now would
469
+ // surface as ENOENT mid-walk; defer the physical reclaim until
470
+ // every outstanding `release` from `extractTarball` has fired.
471
+ // With no outstanding readers the reclaim runs inline.
472
+ const inFlight = extractionRefcounts.get(integrity) ?? 0;
473
+ if (inFlight > 0) {
474
+ pendingEvictions.add(integrity);
475
+ logger.debug `deferring extraction reclaim for ${integrity}: ${String(inFlight)} reader(s) in flight`;
476
+ return;
477
+ }
478
+ await fs.rm(extracted, { recursive: true, force: true });
479
+ // Symmetric with the inline cap-driven sweep: prune the now-empty
480
+ // entry/shard/algorithm parents so `listEntries` does not
481
+ // accumulate O(historical-evictions) cost. `rmdirIfEmpty`
482
+ // tolerates ENOTEMPTY (siblings remain) silently.
483
+ const entryDirPath = entryDir(integrity);
484
+ await rmdirIfEmpty(entryDirPath);
485
+ await rmdirIfEmpty(path.dirname(entryDirPath));
486
+ await rmdirIfEmpty(path.dirname(path.dirname(entryDirPath)));
487
+ },
488
+ async extractTarball(integrity) {
489
+ const finalDir = extractedDir(integrity);
490
+ // Handle factory. The acquire MUST have already happened by the
491
+ // time we call this — the cache-hit path acquires before the
492
+ // stat (so a concurrent evict cannot race in between stat
493
+ // resolution and acquire), the unpack path acquires after a
494
+ // successful rename. Either way, `handOut` only constructs the
495
+ // release-pair, it does not increment the refcount itself.
496
+ const handOut = () => {
497
+ let released = false;
498
+ return {
499
+ dir: finalDir,
500
+ release: () => {
501
+ if (released)
502
+ return;
503
+ released = true;
504
+ // Fire-and-forget: deferred reclaim runs asynchronously,
505
+ // but release() returns synchronously so the loader's
506
+ // walk-complete site does not need to await. Reclaim
507
+ // failures are logged inside releaseExtraction.
508
+ void releaseExtraction(integrity);
509
+ },
510
+ };
511
+ };
512
+ // Acquire BEFORE probing the on-disk extraction. A concurrent
513
+ // `evict()` running between a stat-then-acquire would see
514
+ // refcount 0, take the inline-reclaim path, and remove the tree
515
+ // before the reader's acquire fires; the reader would then
516
+ // receive a `dir` pointing at a path that has been (or is
517
+ // being) unlinked. Acquiring first pins the refcount so any
518
+ // concurrent evict routes through `pendingEvictions` instead,
519
+ // and a stat miss (no extraction yet) releases the speculative
520
+ // refcount before falling through to the unpack path.
521
+ acquireExtraction(integrity);
522
+ try {
523
+ const stat = await fs.stat(finalDir);
524
+ if (stat.isDirectory()) {
525
+ // Touch the tarball's atime so the cap-driven LRU sweep
526
+ // sees this access. The loader's hot path is `cache.has` +
527
+ // `cache.extractTarball`; `cache.get` is the only other
528
+ // entry point that calls `utimes`, and the loader does not
529
+ // use it. Without this, hot integrities accessed only
530
+ // through this path stay LRU-stale at their put time and
531
+ // become preferential eviction targets on noatime/relatime
532
+ // mounts. Best-effort, same as `get`.
533
+ await touchEntryAtime(integrity);
534
+ return handOut();
535
+ }
536
+ }
537
+ catch (err) {
538
+ if (!isENOENT(err)) {
539
+ void releaseExtraction(integrity);
540
+ throw err;
541
+ }
542
+ }
543
+ // Stat missed; keep the speculative refcount HELD through the
544
+ // entire unpack path so a concurrent evict cannot observe
545
+ // refcount 0 between any await and reclaim either the tarball
546
+ // bytes, the extraction tree, or the entry/shard/algorithm
547
+ // parent directories (rmdirIfEmpty cascades up to those). With
548
+ // the refcount pinned, evict routes through pendingEvictions
549
+ // and the reader's unpack work observes a stable filesystem.
550
+ try {
551
+ // Whole tarball is read into memory for SRI verification and
552
+ // tar extraction. The bytes only land in the cache after
553
+ // passing the upload-time cap (`HUB_MAX_TARBALL_BYTES` on the
554
+ // hub edge) for asset-sourced tarballs, or the fetch-time cap
555
+ // (`maxRegistryTarballBytes` in the loader's HTTP fetcher)
556
+ // for registry-sourced tarballs — both default to 10 MiB. At
557
+ // those caps and the small concurrent-extraction count the
558
+ // memory footprint is bounded; a streaming path (pipe
559
+ // `fs.createReadStream` through `ssri.integrityStream` and
560
+ // then into `tar.extract`) is the obvious optimization if
561
+ // either cap grows materially.
562
+ const bytes = await fs.readFile(entryPath(integrity)).catch((err) => {
563
+ if (isENOENT(err)) {
564
+ throw new Error(`extractTarball: tarball bytes for ${integrity} are not in the cache`);
565
+ }
566
+ throw err;
567
+ });
568
+ // Re-verify the on-disk bytes against the integrity before
569
+ // unpacking. `put` validates on store, but bitrot between
570
+ // store and read can corrupt the payload in ways tar's
571
+ // structural checks miss (a flipped bit inside a compressed
572
+ // block can unpack to syntactically-valid but semantically-
573
+ // wrong content that then surfaces as a far-removed dynamic-
574
+ // import failure). Re-hashing here turns that failure into a
575
+ // structured TarballIntegrityMismatchError the loader can
576
+ // react to.
577
+ const matched = ssri.checkData(bytes, integrity);
578
+ if (matched === false) {
579
+ throw new TarballIntegrityMismatchError(integrity);
580
+ }
581
+ // Stage the unpack into a per-call tmp directory and rename
582
+ // it into place so a concurrent extractor either observes no
583
+ // extraction (and stages its own) or a complete one. A crash
584
+ // mid-unpack leaves an orphaned `.tmp.*` directory that
585
+ // `sweepOrphans` clears on the next boot; a crash after the
586
+ // rename leaves a valid extraction for the next caller.
587
+ const stagingDir = `${finalDir}.tmp.${String(process.pid)}.${hexEncode(crypto.getRandomValues(new Uint8Array(8)))}`;
588
+ await fs.mkdir(stagingDir, { recursive: true });
589
+ try {
590
+ await new Promise((resolve, reject) => {
591
+ const stream = tar.extract({ cwd: stagingDir, strip: 1 });
592
+ stream.on("error", reject);
593
+ // `close` is tar's documented post-flush event — it fires
594
+ // after every entry has been written and the parser has
595
+ // released its descriptors. `finish` fires earlier (when
596
+ // the writable side closes) and can race the FS writes
597
+ // the staged rename relies on.
598
+ stream.on("close", resolve);
599
+ stream.end(bytes);
600
+ });
601
+ }
602
+ catch (err) {
603
+ await fs.rm(stagingDir, { recursive: true, force: true });
604
+ throw err;
605
+ }
606
+ try {
607
+ await fs.rename(stagingDir, finalDir);
608
+ }
609
+ catch (err) {
610
+ // POSIX rename returns ENOTEMPTY when the target is a
611
+ // non-empty directory; EEXIST is a fallback for
612
+ // filesystem-dependent cases (notably macOS HFS+ and some
613
+ // FUSE mounts that surface EEXIST in lieu of ENOTEMPTY).
614
+ // Either way means a concurrent extractor won the race: the
615
+ // winner's directory is the canonical one, so sweep our
616
+ // staging and return the existing path.
617
+ await fs.rm(stagingDir, { recursive: true, force: true });
618
+ if (!isEEXIST(err) && !isENOTEMPTY(err))
619
+ throw err;
620
+ }
621
+ // Extraction is a write that the eviction sweep needs to
622
+ // charge against `maxBytes`. The integrity is treated as
623
+ // just-written so the cap covers the entry's tarball +
624
+ // extracted total while protecting this entry from its own
625
+ // sweep.
626
+ await evictUntilUnderCap(integrity);
627
+ await touchEntryAtime(integrity);
628
+ return handOut();
629
+ }
630
+ catch (err) {
631
+ // Release the refcount on failure; the caller will not call
632
+ // the handle's `release` because no handle was returned.
633
+ void releaseExtraction(integrity);
634
+ throw err;
635
+ }
636
+ },
637
+ async sweepOrphans() {
638
+ // The shard layout is `<rootDir>/<algorithm>/<2-char>/<rest>/`.
639
+ // Orphan tmp paths live at the leaf entry directory level — both
640
+ // `put` and `extractTarball` stage siblings of the canonical
641
+ // `tarball.tgz` / `extracted` paths. Walk down to the entry-dir
642
+ // depth and remove anything matching the `.tmp.<pid>.<rand>`
643
+ // sibling pattern.
644
+ let algorithms;
645
+ try {
646
+ algorithms = await fs.readdir(rootDir);
647
+ }
648
+ catch (err) {
649
+ if (isENOENT(err))
650
+ return;
651
+ throw err;
652
+ }
653
+ for (const alg of algorithms) {
654
+ const algDir = path.join(rootDir, alg);
655
+ const shardEntries = await fs.readdir(algDir).catch(() => []);
656
+ for (const shard of shardEntries) {
657
+ const shardDir = path.join(algDir, shard);
658
+ const leafEntries = await fs.readdir(shardDir).catch(() => []);
659
+ for (const leaf of leafEntries) {
660
+ const entryDir = path.join(shardDir, leaf);
661
+ let children;
662
+ try {
663
+ children = await fs.readdir(entryDir);
664
+ }
665
+ catch {
666
+ continue;
667
+ }
668
+ for (const child of children) {
669
+ if (!isOrphanTmpName(child))
670
+ continue;
671
+ const abs = path.join(entryDir, child);
672
+ try {
673
+ await fs.rm(abs, { recursive: true, force: true });
674
+ logger.debug `swept orphan cache tmp ${abs}`;
675
+ }
676
+ catch (err) {
677
+ logger.warn `failed to sweep orphan cache tmp ${abs}: ${err instanceof Error ? err.message : String(err)}`;
678
+ }
679
+ }
680
+ }
681
+ }
682
+ }
683
+ },
684
+ async size() {
685
+ const entries = await listEntries();
686
+ return entries.reduce((sum, e) => sum + e.tarballSize + e.extractedSize, 0);
687
+ },
688
+ };
689
+ }
690
+ // Orphan tmp names take the form `<base>.tmp.<pid>.<hex>` where
691
+ // `<base>` is `tarball.tgz` (from `put`) or `extracted` (from
692
+ // `extractTarball`). Match the `.tmp.` infix on a known prefix so a
693
+ // future on-disk addition the cache layout does not accidentally fall
694
+ // under the sweep.
695
+ function isOrphanTmpName(name) {
696
+ return (name.startsWith("tarball.tgz.tmp.") || name.startsWith("extracted.tmp."));
697
+ }
698
+ function isENOENT(err) {
699
+ return errCode(err) === "ENOENT";
700
+ }
701
+ function isEEXIST(err) {
702
+ return errCode(err) === "EEXIST";
703
+ }
704
+ function isENOTEMPTY(err) {
705
+ return errCode(err) === "ENOTEMPTY";
706
+ }
707
+ function errCode(err) {
708
+ if (err === null || typeof err !== "object")
709
+ return null;
710
+ if (!("code" in err))
711
+ return null;
712
+ const c = err.code;
713
+ return typeof c === "string" ? c : null;
714
+ }
715
+ // Standard base64 alphabet (`A-Z`, `a-z`, `0-9`, `+`, `/`, `=`). The
716
+ // shard layout assumes this alphabet; see parseIntegrity below for
717
+ // why.
718
+ const STANDARD_BASE64_PATTERN = /^[A-Za-z0-9+/=]+$/;
719
+ function parseIntegrity(integrity) {
720
+ // SRI form: "<algorithm>-<base64>[?<options>]" (per W3C SRI). We
721
+ // accept the basic form; ssri.parse normalizes more elaborate input
722
+ // but we want a stable on-disk layout independent of options.
723
+ const dash = integrity.indexOf("-");
724
+ if (dash === -1) {
725
+ throw new Error(`integrity is not in SRI form: ${integrity}`);
726
+ }
727
+ const algorithm = integrity.slice(0, dash);
728
+ const rest = integrity.slice(dash + 1);
729
+ const queryAt = rest.indexOf("?");
730
+ const encoded = queryAt === -1 ? rest : rest.slice(0, queryAt);
731
+ // `/` is the only base64 alphabet character that would create
732
+ // accidental nested directories on disk; escape it with `-`, which
733
+ // is not in standard base64. `+` and `=` are filesystem-safe and
734
+ // kept intact so the shard's first two characters carry their
735
+ // original meaning and `entryPath` is injective on integrity input.
736
+ //
737
+ // Assumes standard base64 (`A-Z`, `a-z`, `0-9`, `+`, `/`, `=`) on
738
+ // input. Base64url-encoded integrities (which use `-` and `_` in
739
+ // place of `+` and `/`) would break injectivity because a literal
740
+ // `-` already appears in the input. The npm registry uses standard
741
+ // base64 for `dist.integrity`; if a registry ever serves
742
+ // base64url, the cache layout has to change before this parser
743
+ // will round-trip — fail loudly here rather than producing
744
+ // colliding shard paths on disk.
745
+ if (!STANDARD_BASE64_PATTERN.test(encoded)) {
746
+ throw new Error(`integrity payload ${JSON.stringify(encoded)} is not standard base64; non-standard-base64 (e.g. base64url) integrities are not supported. If migrating to base64url, update the cache layout first.`);
747
+ }
748
+ const sanitized = encoded.replace(/\//g, "-");
749
+ return { algorithm, encoded: sanitized };
750
+ }