@exodus/stasis 1.0.0-alpha.3 → 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mock.js ADDED
@@ -0,0 +1,310 @@
1
+ // Imported by src/loader-mock.js (the `stasis run --mock` entry) after the
2
+ // @exodus/stasis-core hooks lib is evaluated but before its hooks are installed.
3
+ // The goal is to capture the import graph for a bundle by actually running user
4
+ // code, but with outbound side-effects denied. Pair with Node's `--permission`
5
+ // flag (see bin/stasis.js when run with `--mock`), which fail-closed-denies fs
6
+ // writes, child processes, worker threads, native addons, and the inspector at
7
+ // the runtime boundary. This file covers what `--permission` cannot: there is no
8
+ // `--allow-net`, so the network builtins are neutralized here in JS.
9
+ //
10
+ // Design: FAIL CLOSED, WHOLE MODULE.
11
+ // - We replace *every* callable on each network builtin with a thrower, not a
12
+ // curated method list. There is no "I forgot net.Socket#connect" gap: if
13
+ // it's a function on a denied module (factory, constructor, or helper),
14
+ // invoking it is denied. This also closes higher-level HTTP clients
15
+ // (axios/got/node-fetch/undici/ws) for free -- they all bottom out at
16
+ // node:net / node:tls / node:dns.
17
+ // - Anything we don't explicitly model still fails closed, because the call
18
+ // throws rather than silently succeeding.
19
+ //
20
+ // Mechanism: node:test's MockTracker can only redefine *configurable*
21
+ // properties. ESM namespace objects (`import * as net from 'node:net'`) freeze
22
+ // their bindings, so mocking the ESM namespace throws "Cannot redefine
23
+ // property". The CommonJS exports object for the same builtin is mutable, and
24
+ // ESM live-bindings track CJS mutation for builtins -- so we go through
25
+ // `createRequire` to grab the mutable object and let live bindings propagate to
26
+ // any importer that loads *after* us. Modules linked before this file runs keep
27
+ // their original bindings -- which is exactly why the loader's own state.js
28
+ // (node:fs/path/url/module/zlib/buffer, none of them network) keeps working.
29
+
30
+ import { mock } from 'node:test'
31
+ import { createRequire, syncBuiltinESMExports } from 'node:module'
32
+
33
+ const require = createRequire(import.meta.url)
34
+
35
+ // Force lazy initialization of process.stdin/stdout/stderr *before* we mock
36
+ // the net/tls constructors. Node's process.std{in,out,err} are lazy getters
37
+ // that construct an underlying net.Socket (or tty.WriteStream) on first read;
38
+ // syncBuiltinESMExports() below would otherwise trigger those getters as it
39
+ // materializes node:process's ESM wrapper, and the constructor would hit our
40
+ // mock and throw mid-setup. Reading the property once here caches the real
41
+ // Socket/TTY in Node's closure, so the later sync just reads the cached value.
42
+ void process.stdin
43
+ void process.stdout
44
+ void process.stderr
45
+
46
+ const MARKER = '[stasis --mock]'
47
+
48
+ // Tagged so the unhandledRejection trap below can tell our denials apart from
49
+ // genuine application bugs.
50
+ const blocked = (label) => {
51
+ const err = new Error(`${MARKER} ${label} is blocked while capturing imports`)
52
+ err.code = 'ERR_STASIS_MOCK_BLOCKED'
53
+ err.__stasisMock = true
54
+ return err
55
+ }
56
+
57
+ // A *regular* (constructable) function, so that both `mod.factory()` and
58
+ // `new mod.Constructor()` throw our attributable error -- an arrow function
59
+ // would make `new` fail with a confusing "is not a constructor" TypeError.
60
+ const denyFn = (label) => function () { throw blocked(label) }
61
+
62
+ // Promise-shaped version: returns a rejected promise instead of sync-throwing.
63
+ // Used for node:fs/promises, node:dns/promises, and similar. Fire-and-forget
64
+ // `void fsPromises.writeFile(...)` then surfaces as an unhandledRejection
65
+ // (caught by the trap below) rather than crashing the script synchronously.
66
+ const denyFnAsync = (label) => function () { return Promise.reject(blocked(label)) }
67
+
68
+ // Replace every own function on `mod` (and, one level down, on `mod.promises`)
69
+ // with a thrower. The factory picks denyFnAsync for promise-shaped surfaces
70
+ // (/promises submodules) so fire-and-forget callers reject quietly through
71
+ // the unhandledRejection trap below rather than crashing the script with a
72
+ // synchronous throw -- *except* for class-shaped exports (PascalCase),
73
+ // because `new Class()` on a function that returns a Promise yields the
74
+ // Promise as the constructed value (per the `new` semantics), and the caller
75
+ // then sees a confused `r.resolve is not a function` instead of our
76
+ // attributable error. Sync-throw classes give the clean error in both
77
+ // `new dnsp.Resolver()` and `dns.lookup()` cases. Functions are configurable
78
+ // on the builtins we target; fall back to direct assignment on the off
79
+ // chance one isn't.
80
+ const isClassName = (key) => /^[A-Z]/.test(key)
81
+ const denyModuleSurface = (specifier, mod, opts = {}) => {
82
+ const { prefix = specifier, async: isAsync = specifier.endsWith('/promises') } = opts
83
+ for (const key of Object.getOwnPropertyNames(mod)) {
84
+ const value = mod[key]
85
+ if (typeof value === 'function') {
86
+ const label = `${prefix}.${key}()`
87
+ const impl = (isAsync && !isClassName(key)) ? denyFnAsync(label) : denyFn(label)
88
+ try {
89
+ mock.method(mod, key, impl)
90
+ } catch {
91
+ try { mod[key] = impl } catch { /* frozen: leave as-is */ }
92
+ }
93
+ } else if (key === 'promises' && value && typeof value === 'object') {
94
+ denyModuleSurface(specifier, value, { prefix: `${prefix}.promises`, async: true })
95
+ }
96
+ }
97
+ }
98
+
99
+ // Every callable on these builtins is replaced with a thrower. The list
100
+ // covers two categories:
101
+ //
102
+ // 1) Network surface (http/https/http2/net/dgram/tls/dns) -- the permission
103
+ // system has no --allow-net counterpart, so JS-mock is the *only* layer.
104
+ //
105
+ // 2) Process / worker surface (child_process/worker_threads/cluster/
106
+ // inspector) -- already denied at the kernel level by --permission (no
107
+ // --allow-child-process / --allow-worker / --allow-inspector and no
108
+ // --allow-addons). We duplicate the deny here on purpose: a JS-level
109
+ // thrower surfaces a clean, attributable ERR_STASIS_MOCK_BLOCKED instead
110
+ // of the kernel's ERR_ACCESS_DENIED, and it's defense in depth against
111
+ // anything that might quietly slip the permission rule (a future
112
+ // bypass, a misconfigured --allow flag, code that grabs primitives via
113
+ // process.binding before --permission's check fires, ...).
114
+ const DENY_ALL = [
115
+ // Network surface (no --allow-net counterpart -- JS-mock is the only layer).
116
+ 'node:http', 'node:https', 'node:http2',
117
+ 'node:net', 'node:dgram', 'node:tls',
118
+ 'node:dns', 'node:dns/promises',
119
+ // Process / worker / debug. Already kernel-denied by --permission; we
120
+ // duplicate the deny here for attributable errors and defense in depth
121
+ // against a future --allow-* slip-up or a process.binding bypass.
122
+ 'node:child_process',
123
+ 'node:worker_threads',
124
+ 'node:cluster',
125
+ 'node:inspector',
126
+ 'node:inspector/promises',
127
+ // Other side-effect surfaces nothing else covers:
128
+ 'node:repl', // repl.start() opens an interactive stdin loop
129
+ 'node:sqlite', // DatabaseSync can open/create SQLite files (Node 22+)
130
+ 'node:trace_events', // createTracing() can write trace files
131
+ 'node:v8', // writeHeapSnapshot bypasses node:fs (goes through V8);
132
+ // setFlagsFromString mutates global V8 state. Serialize/
133
+ // deserialize are blocked too -- if a package needs them
134
+ // it can't run under --mock, which is consistent with
135
+ // the "deny whole module" stance.
136
+ // node:wasi is *not* in this list: just require('node:wasi') emits an
137
+ // ExperimentalWarning to stderr on every --mock run, and --permission
138
+ // without --allow-wasi already denies WASI construction at the kernel
139
+ // level. The duplicate-deny isn't worth the warning noise.
140
+ ]
141
+
142
+ for (const specifier of DENY_ALL) {
143
+ let mod
144
+ try {
145
+ mod = require(specifier)
146
+ } catch {
147
+ continue // not present on this build
148
+ }
149
+ denyModuleSurface(specifier, mod)
150
+ }
151
+
152
+ // process method denials. process.exit / process.abort are deliberately left
153
+ // alone -- if user code wants to terminate early, let it; that's not a side
154
+ // effect we should hide. The rest mutate global process state, do real I/O,
155
+ // or talk to a parent over IPC:
156
+ // - chdir affects fs resolution for everything that runs after
157
+ // - kill signals other processes
158
+ // - umask affects every future file create
159
+ // - dlopen loads a native addon (--permission also denies)
160
+ // - binding returns Node's internal C++ bindings (--permission also denies);
161
+ // we duplicate to match the "process.binding bypass" claim in the header
162
+ // - setUid/setGid/setEuid/setEgid/setgroups/initgroups change privileges
163
+ // (--permission also denies these). Duplicated for attributable errors
164
+ // - send / disconnect talk to the parent over IPC (when the process was
165
+ // forked with stdio:'ipc'); not blocked by --permission
166
+ // - setSourceMapsEnabled / loadEnvFile mutate global Node state and (in
167
+ // loadEnvFile's case) read+mutate process.env from a file
168
+ // - setUncaughtExceptionCaptureCallback replaces Node's exception
169
+ // handling, which would also disable our exitCode=13 cleanup
170
+ const PROCESS_DENY = [
171
+ 'chdir', 'kill', 'umask', 'dlopen', 'binding',
172
+ 'setUid', 'setGid', 'setEuid', 'setEgid', 'setgroups', 'initgroups',
173
+ 'send', 'disconnect',
174
+ 'setSourceMapsEnabled', 'loadEnvFile',
175
+ 'setUncaughtExceptionCaptureCallback',
176
+ ]
177
+ for (const name of PROCESS_DENY) {
178
+ if (typeof process[name] === 'function') {
179
+ mock.method(process, name, denyFn(`process.${name}()`))
180
+ }
181
+ }
182
+
183
+ // process.report.writeReport(filename) writes a diagnostic JSON file to disk
184
+ // (under --report-dir or cwd by default). Deny it so user code can't drop
185
+ // reports into the project tree. process.report is an object, present on all
186
+ // Node 12+ builds; the methods inside it are configurable.
187
+ if (process.report && typeof process.report.writeReport === 'function') {
188
+ mock.method(process.report, 'writeReport', denyFn('process.report.writeReport()'))
189
+ }
190
+
191
+ // fs writers: --permission blocks them at the kernel level too, but we mock
192
+ // at the JS layer for cleaner attributable errors and so that user code can't
193
+ // even *try* to mutate files inside the project's cwd (which is on the
194
+ // --allow-fs-write list so stasis can emit its bundle/lockfile). Reads stay
195
+ // real so node_modules resolution and config discovery keep working. This
196
+ // works because loader-mock.js evaluates the core hooks lib *before* importing
197
+ // us, so stasis's own destructured `import { writeFileSync } from 'node:fs'`
198
+ // snapshots are already taken and it keeps real fs; the syncBuiltinESMExports()
199
+ // call below then refreshes the node:fs ESM wrapper so the user's subsequent
200
+ // ESM imports see the mocked names.
201
+ const fs = require('node:fs')
202
+ const fsPromises = require('node:fs/promises')
203
+ const FS_WRITERS = [
204
+ 'writeFile', 'writeFileSync', 'appendFile', 'appendFileSync',
205
+ 'unlink', 'unlinkSync', 'rm', 'rmSync', 'rmdir', 'rmdirSync',
206
+ 'mkdir', 'mkdirSync', 'rename', 'renameSync',
207
+ 'link', 'linkSync', 'symlink', 'symlinkSync',
208
+ 'truncate', 'truncateSync', 'ftruncate', 'ftruncateSync',
209
+ 'utimes', 'utimesSync', 'lutimes', 'lutimesSync', 'futimes', 'futimesSync',
210
+ 'chmod', 'chmodSync', 'lchmod', 'lchmodSync', 'fchmod', 'fchmodSync',
211
+ 'chown', 'chownSync', 'lchown', 'lchownSync', 'fchown', 'fchownSync',
212
+ 'copyFile', 'copyFileSync', 'cp', 'cpSync',
213
+ 'createWriteStream', 'mkdtemp', 'mkdtempSync',
214
+ ]
215
+ for (const name of FS_WRITERS) {
216
+ if (typeof fs[name] === 'function') {
217
+ mock.method(fs, name, denyFn(`node:fs.${name}()`))
218
+ }
219
+ if (typeof fsPromises[name] === 'function') {
220
+ mock.method(fsPromises, name, denyFnAsync(`node:fs/promises.${name}()`))
221
+ }
222
+ }
223
+
224
+ // Refresh the node:fs / node:fs/promises ESM wrappers so destructured/
225
+ // namespace ESM imports made *after* this point see the mocked names.
226
+ // Bindings already captured by stasis's own modules (linked before this file
227
+ // ran) keep their snapshot of the real functions.
228
+ syncBuiltinESMExports()
229
+
230
+ // Global fetch / WebSocket / EventSource come from undici, not node:http, so
231
+ // they need separate handling. (Their transport bottoms out at node:net, which
232
+ // is already blocked above -- but intercepting here yields a clean, attributable
233
+ // error instead of a confusing low-level socket failure.) These globals are
234
+ // writable + configurable, so assign directly.
235
+ //
236
+ // fetch returns a *forever-pending* promise rather than rejecting -- same
237
+ // "silently ignored" semantics as the promise-style timers. An awaiter hangs,
238
+ // fire-and-forget `void fetch(url)` quietly sits as a pending promise, and we
239
+ // don't need an unhandledRejection trap (no rejection is produced). The event
240
+ // loop is unaffected by a pending promise on its own, so the capture still
241
+ // drains and the bundle gets written on beforeExit. WebSocket/EventSource
242
+ // throw from their constructors so the synchronous shape is preserved.
243
+ if (typeof globalThis.fetch === 'function') {
244
+ globalThis.fetch = () => new Promise(() => {})
245
+ }
246
+ for (const name of ['WebSocket', 'EventSource']) {
247
+ if (typeof globalThis[name] === 'function') {
248
+ globalThis[name] = class {
249
+ constructor() { throw blocked(name) }
250
+ }
251
+ }
252
+ }
253
+
254
+ // Our promise-shaped mocks (fs/promises, dns/promises, ...) sync-throw, which
255
+ // `await` converts into a rejection. A fire-and-forget caller (no .catch / no
256
+ // try-catch) would otherwise surface as unhandledRejection and tear the
257
+ // process down before the loader writes the bundle on beforeExit. Swallow
258
+ // *only* our own denials; rethrow everything else so real bugs still crash.
259
+ process.on('unhandledRejection', (reason) => {
260
+ if (reason?.__stasisMock) return
261
+ throw reason
262
+ })
263
+
264
+ // User code that awaits a denied promise (a forever-pending timer, an
265
+ // undelivered fetch) leaves the top-level await unsettled, which Node 24+
266
+ // flags by setting exitCode=13 at exit time. The bundle has already been
267
+ // written by then (state.write() ran at beforeExit, before this), so the
268
+ // capture itself succeeded -- override the 13 back to 0 so the CLI doesn't
269
+ // report failure. Anything other than 13 (real assertion failure, syntax
270
+ // error, ...) is preserved.
271
+ process.on('exit', () => {
272
+ if (process.exitCode === 13) process.exitCode = 0
273
+ })
274
+
275
+ // Timers: silently ignored. Callback-style timers (setTimeout/setInterval/
276
+ // setImmediate) become no-ops -- the callback never runs and the scheduling
277
+ // call returns normally so user code keeps going. Promise-style timers
278
+ // (node:timers/promises) return *forever-pending* promises: `await
279
+ // setTimeout(...)` suspends forever and any code past the await never runs.
280
+ // That's the "silently ignored" semantics for promise timers -- the awaiter
281
+ // is dropped, not given a synthetic value. The process still exits cleanly
282
+ // because a pending promise on its own doesn't keep the event loop alive, so
283
+ // once everything else drains, Node tears down (logging an unsettled-
284
+ // top-level-await warning) and the loader's beforeExit hook writes the
285
+ // bundle.
286
+ //
287
+ // Stasis itself does not import node:timers anywhere, so unlike node:fs the
288
+ // ESM wrapper for node:timers/timers-promises is not pre-materialized; the
289
+ // CJS exports mutation below propagates to the user's first ESM import.
290
+ const noop = () => undefined
291
+ const pending = () => new Promise(() => {})
292
+ for (const name of ['setTimeout', 'setInterval', 'setImmediate']) globalThis[name] = noop
293
+ for (const name of ['clearTimeout', 'clearInterval', 'clearImmediate']) globalThis[name] = noop
294
+
295
+ const timers = require('node:timers')
296
+ for (const name of ['setTimeout', 'setInterval', 'setImmediate', 'clearTimeout', 'clearInterval', 'clearImmediate']) {
297
+ mock.method(timers, name, noop)
298
+ }
299
+
300
+ const timersPromises = require('node:timers/promises')
301
+ mock.method(timersPromises, 'setTimeout', pending)
302
+ mock.method(timersPromises, 'setImmediate', pending)
303
+ // setInterval returns an async iterable; each `await iter.next()` is also a
304
+ // forever-pending promise, so `for await (const _ of setInterval(...))` hangs
305
+ // at the first iteration -- the loop body never runs.
306
+ mock.method(timersPromises, 'setInterval', () => ({
307
+ [Symbol.asyncIterator]() {
308
+ return { next: pending }
309
+ },
310
+ }))
package/src/parse.js ADDED
@@ -0,0 +1,53 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { brotliDecompressSync } from 'node:zlib'
3
+
4
+ import { Bundle } from '@exodus/stasis-core/bundle'
5
+ import { Lockfile } from '@exodus/stasis-core/lockfile'
6
+
7
+ // Read a stasis artifact from disk and parse it into a Lockfile or Bundle,
8
+ // reporting which kind it turned out to be. `kind` is one of:
9
+ // 'lockfile' -- a `stasis.lock.json` (files recorded as SRI digests)
10
+ // 'bundle' -- a `stasis.code.br` (one unified format holding both code and
11
+ // resources; per-file encoding is read from its `formats` map)
12
+ // The bundle is one format: code files hash as UTF-8 text and 'resource:base64'
13
+ // files hash as their decoded bytes, decided per-file by `stasis diff` from the
14
+ // bundle's formats rather than from a whole-bundle kind.
15
+ //
16
+ // Lockfiles are JSON text starting with `{`; bundles are brotli-compressed JSON.
17
+ // Sniff the leading byte to route to the right parser so its diagnostic surfaces
18
+ // on failure.
19
+ export function parseFileWithKind(file) {
20
+ let buf
21
+ try {
22
+ buf = readFileSync(file)
23
+ } catch (cause) {
24
+ if (cause.code === 'ENOENT') throw new Error(`File not found: ${file}`, { cause })
25
+ throw new Error(`Failed to read ${file}: ${cause.message}`, { cause })
26
+ }
27
+ if (buf[0] === 0x7b /* '{' */) {
28
+ try {
29
+ return { kind: 'lockfile', artifact: Lockfile.parse(buf.toString('utf8')) }
30
+ } catch (cause) {
31
+ throw new Error(`Failed to parse stasis lockfile: ${file}`, { cause })
32
+ }
33
+ }
34
+ let text
35
+ try {
36
+ text = brotliDecompressSync(buf).toString('utf8')
37
+ } catch (cause) {
38
+ throw new Error(`Failed to read ${file} as a stasis bundle (not brotli)`, { cause })
39
+ }
40
+ try {
41
+ return { kind: 'bundle', artifact: Bundle.parse(text) }
42
+ } catch (cause) {
43
+ throw new Error(`Failed to parse stasis bundle: ${file}`, { cause })
44
+ }
45
+ }
46
+
47
+ // Read a stasis artifact from disk and parse it into a Lockfile or Bundle.
48
+ // Shared by `stasis audit` and `stasis sbom`, which both inventory the packages
49
+ // a lockfile/bundle records and don't care which kind it is. Thin wrapper over
50
+ // `parseFileWithKind` that drops the `kind`.
51
+ export function parseFile(file) {
52
+ return parseFileWithKind(file).artifact
53
+ }
package/src/prune.js CHANGED
@@ -1,162 +1,4 @@
1
- import assert from 'node:assert/strict'
2
- import {
3
- existsSync,
4
- readFileSync,
5
- readdirSync,
6
- rmdirSync,
7
- unlinkSync,
8
- } from 'node:fs'
9
- import { basename, dirname, join, relative, resolve, sep } from 'node:path'
10
-
11
- import { Lockfile } from './lockfile.js'
12
- import { sha512integrity } from './state.util.js' // also runs the posix-sep assertion
13
-
14
- const LOCKFILE = 'stasis.lock.json'
15
-
16
- function assertGlobalVirtualStoreDisabled() {
17
- // pnpm exports its settings as `npm_config_*` env vars to hooks and
18
- // lifecycle scripts. When the global virtual store is enabled, node_modules
19
- // is dominated by symlinks into a shared store; pruning would walk past
20
- // most files (we skip symlinks) and the lockfile-vs-disk comparison stops
21
- // being meaningful. Reject before touching anything.
22
- const env = process.env.npm_config_enable_global_virtual_store
23
- if (env !== undefined && env !== 'false' && env !== '') {
24
- throw new Error(
25
- `stasis prune: enableGlobalVirtualStore must be false, got ${JSON.stringify(env)}`,
26
- )
27
- }
28
- }
29
-
30
- function loadLockfile(root) {
31
- const path = join(root, LOCKFILE)
32
- assert.ok(existsSync(path), `stasis prune: ${LOCKFILE} not found at ${path}`)
33
- return Lockfile.parse(readFileSync(path, 'utf8'))
34
- }
35
-
36
- function buildExpected(lockfile) {
37
- const expected = new Map()
38
- const knownDirs = new Set()
39
- for (const [dir, { files }] of lockfile.modules) {
40
- if (!dir.includes('node_modules')) continue // workspace sources, not pnpm-managed
41
- knownDirs.add(dir)
42
- for (const [rel, hash] of Object.entries(files)) {
43
- expected.set(`${dir}/${rel}`, hash)
44
- }
45
- }
46
- return { expected, knownDirs }
47
- }
48
-
49
- function* walkFiles(nodeModules) {
50
- if (!existsSync(nodeModules)) return
51
- const stack = [nodeModules]
52
- while (stack.length > 0) {
53
- const dir = stack.pop()
54
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
55
- const full = join(dir, entry.name)
56
- // Skip symlinks: pnpm's flat node_modules symlinks into .pnpm; the file
57
- // bytes are reachable via the symlink targets which we also walk
58
- // directly when we descend into .pnpm.
59
- if (entry.isSymbolicLink()) continue
60
- if (entry.isDirectory()) stack.push(full)
61
- else if (entry.isFile()) yield full
62
- }
63
- }
64
- }
65
-
66
- function pruneEmptyDirs(dir, stopAt) {
67
- while (dir.startsWith(stopAt) && dir !== stopAt && existsSync(dir)) {
68
- let entries
69
- try {
70
- entries = readdirSync(dir)
71
- } catch {
72
- return
73
- }
74
- if (entries.length > 0) return
75
- try {
76
- rmdirSync(dir)
77
- } catch {
78
- return
79
- }
80
- dir = dirname(dir)
81
- }
82
- }
83
-
84
- export function prune({ root = process.cwd() } = {}) {
85
- assertGlobalVirtualStoreDisabled()
86
- root = resolve(root)
87
- const nodeModules = join(root, 'node_modules')
88
- const lockfile = loadLockfile(root)
89
- const { expected, knownDirs } = buildExpected(lockfile)
90
-
91
- // Plan first, mutate later: walk the tree, validate every tracked file,
92
- // collect the deletion list, and verify nothing in the lockfile is
93
- // missing. Bail with a thrown error before touching disk on any failure.
94
- const toRemove = []
95
- const validated = []
96
- const kept = []
97
- const seen = new Set()
98
- const mismatches = []
99
-
100
- for (const full of walkFiles(nodeModules)) {
101
- const rel = relative(root, full)
102
- const expectedHash = expected.get(rel)
103
- if (expectedHash !== undefined) {
104
- seen.add(rel)
105
- const actual = sha512integrity(readFileSync(full))
106
- if (actual === expectedHash) {
107
- validated.push(rel)
108
- kept.push(rel)
109
- } else {
110
- mismatches.push({ rel, expected: expectedHash, actual })
111
- }
112
- continue
113
- }
114
-
115
- // Package.json files are kept (without hash validation, since they may
116
- // not be enumerated in the lockfile) only when their containing dir is
117
- // a module recorded in the lockfile. Stray package.jsons under
118
- // node_modules dirs that the lockfile doesn't know about get pruned
119
- // along with the rest of those modules.
120
- if (basename(rel) === 'package.json' && knownDirs.has(dirname(rel))) {
121
- kept.push(rel)
122
- continue
123
- }
124
-
125
- // Defense-in-depth: the path must resolve inside node_modules (no
126
- // symlink escape, no `..` traversal) before we queue it for deletion.
127
- assert.ok(
128
- full === nodeModules || full.startsWith(`${nodeModules}${sep}`),
129
- `refusing to remove path outside node_modules: ${full}`,
130
- )
131
- toRemove.push(full)
132
- }
133
-
134
- const missing = []
135
- for (const [rel] of expected) {
136
- if (!seen.has(rel)) missing.push(rel)
137
- }
138
-
139
- if (mismatches.length > 0) {
140
- const lines = mismatches.map(({ rel, expected: exp, actual }) =>
141
- ` ${rel}: expected ${exp}, got ${actual}`).join('\n')
142
- throw new Error(`stasis prune: hash mismatch for ${mismatches.length} file(s):\n${lines}`)
143
- }
144
- if (missing.length > 0) {
145
- const sample = missing.slice(0, 5).join(', ')
146
- const suffix = missing.length > 5 ? `, ... (${missing.length} total)` : ''
147
- throw new Error(`stasis prune: files listed in lockfile are missing on disk: ${sample}${suffix}`)
148
- }
149
-
150
- const removed = []
151
- const touchedDirs = new Set()
152
- for (const full of toRemove) {
153
- unlinkSync(full)
154
- removed.push(relative(root, full))
155
- touchedDirs.add(dirname(full))
156
- }
157
-
158
- const sortedDirs = [...touchedDirs].toSorted((a, b) => b.length - a.length)
159
- for (const d of sortedDirs) pruneEmptyDirs(d, nodeModules)
160
-
161
- return { removed, validated, kept }
162
- }
1
+ // Public-export adapter: backs `@exodus/stasis/prune`. The implementation lives in
2
+ // @exodus/stasis-core; internal tooling and tests import `@exodus/stasis-core/prune`
3
+ // directly, so this file exists only to back the package's `exports` map.
4
+ export * from '@exodus/stasis-core/prune'