@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/LICENSE +176 -0
- package/README.md +116 -0
- package/dist/atomic-apply.d.ts +65 -0
- package/dist/atomic-apply.js +223 -0
- package/dist/cache.d.ts +89 -0
- package/dist/cache.js +750 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +30 -0
- package/dist/loader.d.ts +189 -0
- package/dist/loader.js +1387 -0
- package/dist/package-json-extract.d.ts +56 -0
- package/dist/package-json-extract.js +125 -0
- package/dist/resolver.d.ts +241 -0
- package/dist/resolver.js +821 -0
- package/package.json +39 -0
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
|
+
}
|