@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.
- package/README.md +23 -3
- package/bin/stasis.js +375 -170
- package/package.json +44 -11
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +243 -59
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +95 -128
- package/src/cmd/bundle.js +877 -641
- package/src/cmd/diff.js +2 -19
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +4 -18
- package/src/diff.js +67 -124
- package/src/json-stream.js +283 -0
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +49 -116
- package/src/loaders/cargo.js +1699 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +339 -394
- package/src/loaders/rust.js +3254 -242
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -162
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +150 -0
- package/src/mock.js +57 -165
- package/src/parse.js +4 -16
- package/src/resolve-fields.js +331 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +427 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +64 -92
- package/src/scan.js +234 -156
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +193 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +606 -0
- package/src/vfs-bundle.js +74 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -44
- 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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
|
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
|
|
58
|
-
// `new
|
|
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:
|
|
63
|
-
//
|
|
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
|
|
69
|
-
//
|
|
70
|
-
// (
|
|
71
|
-
//
|
|
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.
|
|
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
|
|
120
|
-
//
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
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
|
|
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
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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
|
|
225
|
-
//
|
|
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
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
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
|
|
255
|
-
//
|
|
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
|
-
//
|
|
265
|
-
//
|
|
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
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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
|
-
|
|
293
|
-
for (const name of
|
|
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
|
|
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
|
|
304
|
-
//
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
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
|
}
|