@exodus/stasis 1.0.0-beta.2 → 1.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +23 -3
  2. package/bin/stasis.js +375 -170
  3. package/package.json +44 -11
  4. package/src/audit-corrections.js +68 -0
  5. package/src/audit.js +243 -59
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +12 -18
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +95 -128
  11. package/src/cmd/bundle.js +877 -641
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +4 -18
  16. package/src/diff.js +67 -124
  17. package/src/json-stream.js +283 -0
  18. package/src/loader-mock.js +8 -15
  19. package/src/loaders/bash.js +49 -116
  20. package/src/loaders/cargo.js +1699 -0
  21. package/src/loaders/foundry.js +923 -0
  22. package/src/loaders/paths.js +33 -0
  23. package/src/loaders/php.js +339 -394
  24. package/src/loaders/rust.js +3254 -242
  25. package/src/loaders/solidity-ownership.js +531 -0
  26. package/src/loaders/solidity.js +412 -162
  27. package/src/loaders/toml.js +68 -0
  28. package/src/metro-resolver.js +150 -0
  29. package/src/mock.js +57 -165
  30. package/src/parse.js +4 -16
  31. package/src/resolve-fields.js +331 -0
  32. package/src/resolve-node.js +416 -0
  33. package/src/resolve-typescript.js +427 -0
  34. package/src/rollup.js +5 -0
  35. package/src/sbom.js +64 -92
  36. package/src/scan.js +234 -156
  37. package/src/vfs-bundle/entries.js +140 -0
  38. package/src/vfs-bundle/github.js +193 -0
  39. package/src/vfs-bundle/soldeer.js +125 -0
  40. package/src/vfs-bundle/tree.js +606 -0
  41. package/src/vfs-bundle.js +74 -0
  42. package/src/why.js +434 -0
  43. package/src/apis/npm/index.js +0 -36
  44. package/src/apis/npm/semver.cjs +0 -44
  45. package/src/cmd/extract.js +0 -193
@@ -0,0 +1,68 @@
1
+ // TOML for the build descriptions the loaders take -- Cargo.toml, Cargo.lock, foundry.toml -- read
2
+ // by @preventive/lockfile's strict TOML 1.0 parser into a tree of tables, and refused where it
3
+ // isn't TOML, or isn't the TOML those files are written in: a bad escape, an unterminated string, a
4
+ // key or table defined twice, a local date, U+FFFD where a lenient decoder replaced bytes ... each a
5
+ // TomlError naming the file and line, never a guessed value.
6
+
7
+ import { TomlError, parseToml } from '@preventive/lockfile/toml.js'
8
+
9
+ export { TomlError }
10
+
11
+ // --- Bracket helpers (for the Rust scanner) ----------------------------------------------
12
+
13
+ // Index of the bracket closing the one opened at `open`, or the last index when unbalanced.
14
+ export function matchClose(text, open) {
15
+ const close = { '[': ']', '(': ')', '{': '}' }[text[open]]
16
+ let depth = 0
17
+ for (let i = open; i < text.length; i++) {
18
+ if (text[i] === text[open]) depth++
19
+ else if (text[i] === close && --depth === 0) return i
20
+ }
21
+ return text.length - 1
22
+ }
23
+
24
+ // Split on commas outside brackets/braces/parentheses/strings.
25
+ export function splitTopLevel(text) {
26
+ const parts = []
27
+ let depth = 0
28
+ let start = 0
29
+ let inStr = false
30
+ for (let i = 0; i < text.length; i++) {
31
+ const ch = text[i]
32
+ if (inStr) {
33
+ if (ch === '\\') i++
34
+ else if (ch === '"') inStr = false
35
+ continue
36
+ }
37
+ if (ch === '"') inStr = true
38
+ else if (ch === '(' || ch === '[' || ch === '{') depth++
39
+ else if (ch === ')' || ch === ']' || ch === '}') depth--
40
+ else if (ch === ',' && depth === 0) {
41
+ parts.push(text.slice(start, i))
42
+ start = i + 1
43
+ }
44
+ }
45
+ parts.push(text.slice(start))
46
+ return parts
47
+ }
48
+
49
+ // --- The reader -----------------------------------------------------------------------------
50
+
51
+ // The table tree of TOML `text` (parseToml): tables are null-prototype objects, arrays arrays, and
52
+ // values strings, booleans, numbers (an integer past 2^53 a BigInt), TomlFloat and TomlDateTime.
53
+ // Throws a TomlError on anything else, naming `file` when given.
54
+ export const readToml = (text, file = null) => nameErrors(file, () => parseToml(text))
55
+
56
+ // `read()`, with an error of one of `classes` it throws (@preventive/lockfile's, whose message
57
+ // leads with where it is) leading with `file` too, its fields kept; null `file` names none.
58
+ export function nameErrors(file, read, classes = [TomlError]) {
59
+ try {
60
+ return read()
61
+ } catch (err) {
62
+ if (file === null || !classes.some((c) => err instanceof c)) throw err
63
+ throw Object.assign(new err.constructor(`${file}: ${err.message}`), err)
64
+ }
65
+ }
66
+
67
+ // Whether a TOML value is a table (a TomlFloat or TomlDateTime is an object too).
68
+ export const isTomlTable = (value) => value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === null
@@ -0,0 +1,150 @@
1
+ import { createRequire, isBuiltin } from 'node:module'
2
+ import { dirname, isAbsolute, join } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ import { diskHost } from '@exodus/stasis-core/host'
6
+ import { isTypeDeclaration } from '@exodus/stasis-core/util'
7
+ import { readJson } from './resolve-typescript.js'
8
+
9
+ // Adapter that drives the PROJECT's own `metro-resolver` from stasis's static scanner, so
10
+ // `stasis bundle --metro --metro-resolver` resolves modules EXACTLY the way that project's
11
+ // installed Metro does -- rather than the hand-rolled Metro/RN approximation in
12
+ // resolve-fields.js. `metro-resolver` is a leaf of the Metro toolchain (it takes a context of
13
+ // filesystem callbacks and does no I/O of its own), so we load whichever copy the project
14
+ // already ships and feed it a synchronous, disk-backed ResolutionContext.
15
+ //
16
+ // Returns, for each specifier, the same shape scan.js expects from a custom resolver:
17
+ // { url } resolved to a real file (file: URL string)
18
+ // { empty } a browser/react-native field mapped it to `false` (empty module)
19
+ // { builtin } a Node builtin (Metro itself has none; we classify a bare-name miss)
20
+ // null unresolved
21
+ //
22
+ // It is deliberately version-agnostic: it never imports metro-resolver internals (the
23
+ // `./private/*` subpath), only the public `resolve`. `resolve` applies the browser-field
24
+ // redirect via its own bundled helper, so the `redirectModulePath` we place on the context is
25
+ // an inert placeholder that satisfies the type but is never consulted for base resolution.
26
+
27
+ // 'f' | 'd' | null for a path, without throwing.
28
+ function pathType(p) {
29
+ const s = diskHost.stat(p)
30
+ return s?.isFile() ? 'f' : s?.isDirectory() ? 'd' : null
31
+ }
32
+
33
+ // A `.d.ts` is types-only, erased at runtime, so the resolver must never land on one: report it as
34
+ // non-existent to Metro's filesystem hooks and a package whose entry points at a declaration falls
35
+ // through to its real `.js`. Only the RESOLUTION hooks use this -- getPackageForModule's own
36
+ // dir-vs-file probe stays on raw pathType (it locates package scopes, never a module target).
37
+ const resolvableType = (p) => (isTypeDeclaration(p) ? null : pathType(p))
38
+
39
+ // Metro's `getPackageForModule`: the closest package scope for an absolute candidate path
40
+ // (which need not exist). The candidate can be a package DIRECTORY (a bare import lands on
41
+ // `<node_modules>/<pkg>`), so probing starts at the path itself, then walks up. `packageRelativePath`
42
+ // is '' when the candidate IS the package root -- Metro's exports resolver maps '' to the '.' subpath,
43
+ // and a stray '.' there would mismatch the exports key.
44
+ function getPackageForModule(absModulePath) {
45
+ let dir = pathType(absModulePath) === 'd' ? absModulePath : dirname(absModulePath)
46
+ while (true) {
47
+ const pkg = readJson(join(dir, 'package.json'))
48
+ if (pkg) {
49
+ const rel = dir === absModulePath ? '' : absModulePath.slice(dir.length + 1)
50
+ return { rootPath: dir, packageJson: pkg, packageRelativePath: rel }
51
+ }
52
+ const parent = dirname(dir)
53
+ if (parent === dir) return null
54
+ dir = parent
55
+ }
56
+ }
57
+
58
+ // Build a resolver bound to a platform. `metro-resolver` is required from `projectDir` so we
59
+ // honour the version the project's Metro uses; a missing package throws with actionable guidance.
60
+ // `sourceExts` are the extensions Metro probes (kept to what a source bundle can carry, so the
61
+ // resolver never lands on a file the bundle would reject). `mainFields` drives the browser-field
62
+ // spec; `conditionNames` are the extra `exports`/`imports` conditions on top of the ones Metro
63
+ // derives itself (default + require|import + platform).
64
+ export function createMetroResolver({
65
+ projectDir,
66
+ platform,
67
+ sourceExts = ['js', 'json', 'ts'],
68
+ mainFields = ['react-native', 'browser', 'main'],
69
+ conditionNames = ['react-native'],
70
+ conditionsByPlatform = { web: ['browser'] },
71
+ enablePackageExports = true,
72
+ host = diskHost,
73
+ } = {}) {
74
+ // It loads the project's metro-resolver, which reads the disk: never off it.
75
+ if (host !== diskHost) throw new Error('createMetroResolver: metro-resolver reads the disk, so it is not supported off disk')
76
+ const require = createRequire(join(projectDir, 'noop.js'))
77
+ let resolve
78
+ try {
79
+ ;({ resolve } = require('metro-resolver'))
80
+ } catch (cause) {
81
+ throw new Error(
82
+ `StasisMetroResolver: couldn't load 'metro-resolver' from ${projectDir}. --metro-resolver ` +
83
+ 'resolves through the project\'s own Metro; install it (it ships with react-native/metro) ' +
84
+ 'or drop --metro-resolver to use the built-in resolver.',
85
+ { cause }
86
+ )
87
+ }
88
+
89
+ // A React Native app has no Node builtins by default; Metro only prefers native suffixes off web.
90
+ const base = {
91
+ allowHaste: false,
92
+ assetExts: new Set(),
93
+ customResolverOptions: {},
94
+ disableHierarchicalLookup: false,
95
+ doesFileExist: (p) => resolvableType(p) === 'f',
96
+ extraNodeModules: null,
97
+ dev: false,
98
+ getPackage: (packageJsonPath) => readJson(packageJsonPath),
99
+ getPackageForModule,
100
+ fileSystemLookup: (p) => {
101
+ const abs = isAbsolute(p) ? p : join(projectDir, p)
102
+ const type = resolvableType(abs)
103
+ if (!type) return { exists: false }
104
+ let realPath = abs
105
+ try {
106
+ realPath = diskHost.realpath(abs)
107
+ } catch { /* keep the lexical path when realpath fails (e.g. a broken symlink) */ }
108
+ return { exists: true, type, realPath }
109
+ },
110
+ mainFields,
111
+ nodeModulesPaths: [],
112
+ preferNativePlatform: platform !== 'web',
113
+ resolveAsset: (dirPath, assetName, ext) => {
114
+ const f = join(dirPath, `${assetName}${ext}`)
115
+ return pathType(f) === 'f' ? [f] : null
116
+ },
117
+ resolveHasteModule: () => null,
118
+ resolveHastePackage: () => null,
119
+ // Inert placeholder: `resolve` redirects via its own bundled helper, never this (see header).
120
+ redirectModulePath: (modulePath) => modulePath,
121
+ sourceExts,
122
+ unstable_conditionNames: conditionNames,
123
+ unstable_conditionsByPlatform: conditionsByPlatform,
124
+ unstable_enablePackageExports: enablePackageExports,
125
+ unstable_incrementalResolution: false,
126
+ unstable_logWarning: () => {}, // a static build must not spam on exports fallbacks
127
+ }
128
+
129
+ return function resolveOne(parentFile, specifier, callConditions) {
130
+ // Metro splits `exports` on whether the edge was an ESM import vs a CJS require. scan.js hands
131
+ // us the parent's format-derived condition set, so 'import' membership is that signal.
132
+ const isESMImport = callConditions instanceof Set ? callConditions.has('import') : false
133
+ const context = { ...base, originModulePath: parentFile, isESMImport }
134
+ let res
135
+ try {
136
+ res = resolve(context, specifier, platform)
137
+ } catch {
138
+ // Metro carries no Node builtins: an unmapped builtin fails to resolve here. The browser-field
139
+ // remap of a builtin (`{"crypto": false}` / a shim) is handled inside resolve() before this.
140
+ if (isBuiltin(specifier)) return { builtin: true }
141
+ return null
142
+ }
143
+ if (!res) return null
144
+ if (res.type === 'empty') return { empty: true }
145
+ if (res.type === 'sourceFile') return { url: pathToFileURL(res.filePath).toString() }
146
+ // Assets are off (assetExts empty) for a code bundle, but map defensively to the first variant.
147
+ if (res.type === 'assetFiles' && res.filePaths?.length) return { url: pathToFileURL(res.filePaths[0]).toString() }
148
+ return null
149
+ }
150
+ }
package/src/mock.js CHANGED
@@ -1,52 +1,33 @@
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.
1
+ // Imported by src/loader-mock.js (the `stasis run --mock` entry) after the core hooks lib is
2
+ // evaluated but before its hooks are installed. Captures the import graph by running user code
3
+ // with side-effects denied. Pairs with Node's `--permission` (see bin/stasis.js), which denies fs
4
+ // writes/child procs/workers/addons/inspector at the runtime boundary; this file covers what
5
+ // `--permission` can't -- there is no `--allow-net`, so the network builtins are neutralized in JS.
9
6
  //
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.
7
+ // FAIL CLOSED, WHOLE MODULE: replace *every* callable on a denied builtin with a thrower, not a
8
+ // curated method list -- higher-level clients (axios/undici/ws) fall closed too since they bottom
9
+ // out at node:net/tls/dns, and anything unmodelled still throws rather than silently succeeding.
19
10
  //
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.
11
+ // Mechanism: go through `createRequire` to grab the *mutable* CJS exports object -- ESM namespace
12
+ // objects freeze their bindings, so mocking them throws "Cannot redefine property". ESM live-bindings
13
+ // track CJS mutation for builtins, so importers loading *after* us see the mocks; modules linked
14
+ // before (the loader's own state.js) keep their real bindings.
29
15
 
30
16
  import { mock } from 'node:test'
31
17
  import { createRequire, syncBuiltinESMExports } from 'node:module'
32
18
 
33
19
  const require = createRequire(import.meta.url)
34
20
 
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.
21
+ // Force lazy init of process.std{in,out,err} before mocking net/tls: they're lazy getters that
22
+ // build a net.Socket/tty.WriteStream on first read, which syncBuiltinESMExports() below would
23
+ // otherwise trigger through our mock mid-setup. Reading once here caches the real Socket/TTY.
42
24
  void process.stdin
43
25
  void process.stdout
44
26
  void process.stderr
45
27
 
46
28
  const MARKER = '[stasis --mock]'
47
29
 
48
- // Tagged so the unhandledRejection trap below can tell our denials apart from
49
- // genuine application bugs.
30
+ // Tagged (__stasisMock) so the unhandledRejection trap below can tell our denials from real bugs.
50
31
  const blocked = (label) => {
51
32
  const err = new Error(`${MARKER} ${label} is blocked while capturing imports`)
52
33
  err.code = 'ERR_STASIS_MOCK_BLOCKED'
@@ -54,29 +35,18 @@ const blocked = (label) => {
54
35
  return err
55
36
  }
56
37
 
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.
38
+ // A *regular* (constructable) function so both `mod.factory()` and `new mod.Constructor()` throw
39
+ // our error; an arrow would make `new` fail with a confusing "is not a constructor".
60
40
  const denyFn = (label) => function () { throw blocked(label) }
61
41
 
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.
42
+ // Promise-shaped version: rejects instead of sync-throwing, so fire-and-forget callers surface via
43
+ // the unhandledRejection trap below rather than crashing the script synchronously.
66
44
  const denyFnAsync = (label) => function () { return Promise.reject(blocked(label)) }
67
45
 
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.
46
+ // Replace every own function on `mod` (and one level down on `mod.promises`) with a thrower.
47
+ // Promise-shaped surfaces use denyFnAsync so fire-and-forget callers reject quietly -- except
48
+ // class-shaped exports (PascalCase): `new Class()` returning a Promise yields the Promise as the
49
+ // constructed value, so classes sync-throw to give a clean error in both `new` and call form.
80
50
  const isClassName = (key) => /^[A-Z]/.test(key)
81
51
  const denyModuleSurface = (specifier, mod, opts = {}) => {
82
52
  const { prefix = specifier, async: isAsync = specifier.endsWith('/promises') } = opts
@@ -96,29 +66,14 @@ const denyModuleSurface = (specifier, mod, opts = {}) => {
96
66
  }
97
67
  }
98
68
 
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, ...).
69
+ // Every callable on these builtins is replaced with a thrower.
114
70
  const DENY_ALL = [
115
71
  // Network surface (no --allow-net counterpart -- JS-mock is the only layer).
116
72
  'node:http', 'node:https', 'node:http2',
117
73
  'node:net', 'node:dgram', 'node:tls',
118
74
  '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.
75
+ // Process/worker/debug: already --permission-denied; duplicated for attributable errors and
76
+ // defense in depth against a future --allow-* slip-up or a process.binding bypass.
122
77
  'node:child_process',
123
78
  'node:worker_threads',
124
79
  'node:cluster',
@@ -128,15 +83,9 @@ const DENY_ALL = [
128
83
  'node:repl', // repl.start() opens an interactive stdin loop
129
84
  'node:sqlite', // DatabaseSync can open/create SQLite files (Node 22+)
130
85
  '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.
86
+ 'node:v8', // writeHeapSnapshot bypasses node:fs; setFlagsFromString mutates global V8 state
87
+ // node:wasi is deliberately omitted: require('node:wasi') emits an ExperimentalWarning, and
88
+ // --permission already denies WASI construction at the kernel level.
140
89
  ]
141
90
 
142
91
  for (const specifier of DENY_ALL) {
@@ -149,24 +98,10 @@ for (const specifier of DENY_ALL) {
149
98
  denyModuleSurface(specifier, mod)
150
99
  }
151
100
 
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
101
+ // process method denials. process.exit / process.abort are deliberately left alone -- early
102
+ // termination isn't a side effect we should hide. The rest mutate global process state, do real
103
+ // I/O, or talk to a parent over IPC; note setUncaughtExceptionCaptureCallback would also disable
104
+ // our exitCode=13 cleanup below.
170
105
  const PROCESS_DENY = [
171
106
  'chdir', 'kill', 'umask', 'dlopen', 'binding',
172
107
  'setUid', 'setGid', 'setEuid', 'setEgid', 'setgroups', 'initgroups',
@@ -180,24 +115,14 @@ for (const name of PROCESS_DENY) {
180
115
  }
181
116
  }
182
117
 
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.
118
+ // process.report.writeReport writes a diagnostic JSON file to disk; deny it too.
187
119
  if (process.report && typeof process.report.writeReport === 'function') {
188
120
  mock.method(process.report, 'writeReport', denyFn('process.report.writeReport()'))
189
121
  }
190
122
 
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.
123
+ // fs writers: --permission blocks these at the kernel level too, but JS-mocking gives cleaner
124
+ // errors and stops user code mutating even cwd files (cwd is on --allow-fs-write for the bundle).
125
+ // Reads stay real so node_modules resolution keeps working.
201
126
  const fs = require('node:fs')
202
127
  const fsPromises = require('node:fs/promises')
203
128
  const FS_WRITERS = [
@@ -221,25 +146,14 @@ for (const name of FS_WRITERS) {
221
146
  }
222
147
  }
223
148
 
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.
149
+ // Refresh the node:fs ESM wrappers so ESM imports after this point see the mocked names;
150
+ // bindings stasis already captured (linked before this file ran) keep their real snapshot.
228
151
  syncBuiltinESMExports()
229
152
 
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.
153
+ // Global fetch / WebSocket / EventSource come from undici, not node:http, so handle separately.
154
+ // fetch returns a *forever-pending* promise (not a rejection) so an awaiter hangs and the capture
155
+ // still drains to write the bundle on beforeExit; WebSocket/EventSource throw from their
156
+ // constructors to preserve the synchronous shape.
243
157
  if (typeof globalThis.fetch === 'function') {
244
158
  globalThis.fetch = () => new Promise(() => {})
245
159
  }
@@ -251,58 +165,36 @@ for (const name of ['WebSocket', 'EventSource']) {
251
165
  }
252
166
  }
253
167
 
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.
168
+ // Our denials become unhandledRejections for fire-and-forget callers, which would tear the process
169
+ // down before the loader writes the bundle. Swallow *only* our own denials; rethrow everything else.
259
170
  process.on('unhandledRejection', (reason) => {
260
171
  if (reason?.__stasisMock) return
261
172
  throw reason
262
173
  })
263
174
 
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.
175
+ // Node 24+ sets exitCode=13 when a top-level await stays unsettled (e.g. an awaited denied
176
+ // promise). The bundle already wrote at beforeExit, so reset 13 -> 0; any other code is preserved.
271
177
  process.on('exit', () => {
272
178
  if (process.exitCode === 13) process.exitCode = 0
273
179
  })
274
180
 
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.
181
+ // Timers: silently ignored. Callback timers (setTimeout/setInterval/setImmediate) become no-ops;
182
+ // promise timers (node:timers/promises) return *forever-pending* promises so `await setTimeout()`
183
+ // suspends forever. A pending promise doesn't keep the event loop alive, so the process still
184
+ // exits cleanly and the loader's beforeExit hook writes the bundle.
290
185
  const noop = () => undefined
291
186
  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
187
+ const TIMERS = ['setTimeout', 'setInterval', 'setImmediate', 'clearTimeout', 'clearInterval', 'clearImmediate']
188
+ for (const name of TIMERS) globalThis[name] = noop
294
189
 
295
190
  const timers = require('node:timers')
296
- for (const name of ['setTimeout', 'setInterval', 'setImmediate', 'clearTimeout', 'clearInterval', 'clearImmediate']) {
297
- mock.method(timers, name, noop)
298
- }
191
+ for (const name of TIMERS) mock.method(timers, name, noop)
299
192
 
300
193
  const timersPromises = require('node:timers/promises')
301
194
  mock.method(timersPromises, 'setTimeout', pending)
302
195
  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.
196
+ // setInterval returns an async iterable; each `await iter.next()` is forever-pending, so
197
+ // `for await (const _ of setInterval(...))` hangs at the first iteration.
306
198
  mock.method(timersPromises, 'setInterval', () => ({
307
199
  [Symbol.asyncIterator]() {
308
200
  return { next: pending }
package/src/parse.js CHANGED
@@ -4,18 +4,9 @@ import { brotliDecompressSync } from 'node:zlib'
4
4
  import { Bundle } from '@exodus/stasis-core/bundle'
5
5
  import { Lockfile } from '@exodus/stasis-core/lockfile'
6
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.
7
+ // Read a stasis artifact and parse it into a Lockfile or Bundle, reporting its `kind`
8
+ // ('lockfile' | 'bundle'). Lockfiles are JSON starting with `{`; bundles are brotli-compressed
9
+ // JSON — sniff the leading byte to route to the right parser so its diagnostic surfaces on failure.
19
10
  export function parseFileWithKind(file) {
20
11
  let buf
21
12
  try {
@@ -44,10 +35,7 @@ export function parseFileWithKind(file) {
44
35
  }
45
36
  }
46
37
 
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`.
38
+ // Thin wrapper over parseFileWithKind that drops the `kind` (for callers like audit/sbom).
51
39
  export function parseFile(file) {
52
40
  return parseFileWithKind(file).artifact
53
41
  }