@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.
@@ -0,0 +1,19 @@
1
+ // stasis's `--mock` run-time loader entry (used by `stasis run --mock`). The
2
+ // import order is load-bearing:
3
+ // 1. the @exodus/stasis-core hooks lib -- evaluating it snapshots stasis's own
4
+ // node:fs/path/etc. bindings off the *real* builtins (state.js destructures
5
+ // them at module-eval time), so stasis keeps reading/writing through real fs;
6
+ // 2. ./mock.js -- which neutralizes the network/timer builtins and fs writers in
7
+ // JS and calls module.syncBuiltinESMExports() so user code's later ESM imports
8
+ // resolve to the mocked builtins.
9
+ // Only THEN install() the hooks. Running mock.js before registration keeps it from
10
+ // going through the hooks (so it never becomes the bundle entry), and running it
11
+ // after the lib's snapshots leaves stasis's own bindings untouched. This static
12
+ // composition is what lets core stay mock-agnostic with no env-var coordination.
13
+ import { install } from '@exodus/stasis-core/hooks'
14
+ // `export *` (mock.js has no exports) rather than a bare side-effect import only
15
+ // to satisfy the no-unassigned-import lint rule; ESM still evaluates it here, in
16
+ // source order, after the hooks lib above and before install() below.
17
+ export * from './mock.js'
18
+
19
+ install()
package/src/loader.js CHANGED
@@ -1,132 +1,6 @@
1
- import { registerHooks, findPackageJSON, isBuiltin } from 'node:module'
2
- import { basename, dirname, extname, resolve as resolvePath } from 'node:path'
3
- import { readFileSync } from 'node:fs'
4
- import { fileURLToPath, pathToFileURL } from 'node:url'
5
- import assert from 'node:assert/strict'
6
-
7
- import { State } from './state.js'
8
-
9
- // Warning: only covers code imports, not file reads
10
-
11
- let state
12
- let saved = false
13
-
14
- function initState(root) {
15
- state = new State(root, { preload: true })
16
-
17
- const save = () => {
18
- if (saved) return
19
- saved = true
20
- state.write()
21
- }
22
-
23
- process.on('beforeExit', save)
24
- process.on('exit', save)
25
- }
26
-
27
- function load(url, context, nextLoad) {
28
- assert.equal(typeof url, 'string')
29
-
30
- if (url.startsWith('node:')) {
31
- const result = nextLoad(url, context)
32
- assert.equal(result.format, 'builtin')
33
- return result
34
- }
35
-
36
- if (state && state.config.loadBundle) {
37
- // node_modules scope only bundles node_modules sources; non-tracked files are read
38
- // from disk through Node's default loader (state.getFile is never called for them).
39
- if (!state.config.full && !url.includes('/node_modules/')) {
40
- return nextLoad(url, context)
41
- }
42
- const { source, format } = state.getFile(url)
43
- // context.format may be null when the resolve chain didn't set it (e.g. node_modules
44
- // scope, where non-nm parents go through nextResolve and Node's default doesn't
45
- // populate format for the load hook). Only cross-check when the chain provided one.
46
- // TODO: format here is sourced from the bundle's `formats` map, which the lockfile
47
- // does not currently cover; a tampered bundle can flip module<->commonjs for a
48
- // hash-valid file. Derive format from extension + module.type (recorded in the
49
- // lockfile) and reject the bundle's claim when it disagrees.
50
- if (context.format != null) assert.equal(format, context.format)
51
- assert.ok(['module', 'commonjs', 'json'].includes(format))
52
- return { source, format, shortCircuit: true }
53
- }
54
-
55
- const result = nextLoad(url, context)
56
- let { source } = result
57
- const { format } = result
58
- assert.notEqual(format, 'builtin')
59
-
60
- // Node's CJS loader may return source=null and read from disk itself; capture it for the bundle
61
- if (source == null && format === 'commonjs') source = readFileSync(fileURLToPath(url))
62
-
63
- const isEntry = !state
64
- if (!state) {
65
- const pkg = findPackageJSON(url)
66
- assert.equal(basename(pkg), 'package.json')
67
- initState(dirname(pkg))
68
- }
69
-
70
- assert.equal(saved, false)
71
- state.addFile(url, { source, format, isEntry })
72
-
73
- return result
74
- }
75
-
76
- function resolve(specifier, context, nextResolve) {
77
- if (isBuiltin(specifier)) {
78
- const res = nextResolve(specifier)
79
- assert.equal(res.url, specifier.startsWith('node:') ? specifier : `node:${specifier}`)
80
- return res
81
- }
82
-
83
- if (context.importAttributes !== undefined) {
84
- const expectedAttrs = Object.create(null) // Saving is not supported yet, so we ensure expected ones
85
- if (extname(specifier) === '.json' && context.importAttributes?.type) expectedAttrs.type = 'json'
86
- assert.deepStrictEqual(context.importAttributes, expectedAttrs)
87
- }
88
-
89
- const { parentURL, conditions, importAttributes } = context
90
-
91
- // In full-scope load mode, the entry point is served from the bundle without touching
92
- // disk, so state must be initialised before nextResolve runs. node_modules scope reads
93
- // non-nm sources (including the entry) from disk, so state is initialised later in load().
94
- if (!state && !parentURL && process.env.EXODUS_STASIS_BUNDLE === 'load'
95
- && process.env.EXODUS_STASIS_SCOPE !== 'node_modules') {
96
- initState(process.cwd())
97
- }
98
-
99
- if (state && state.config.loadBundle) {
100
- // node_modules scope defers resolution of non-nm parents to Node. Relative imports
101
- // between non-nm sources then resolve to local files, and bare specifiers resolve via
102
- // the on-disk node_modules layout -- but the load() hook still serves the resulting
103
- // node_modules target from the bundle, so package contents need not be on disk.
104
- if (!state.config.full && !parentURL?.includes('/node_modules/')) {
105
- return nextResolve(specifier)
106
- }
107
- // Bare specifier with a parent is the regular ESM import: look up the recorded
108
- // import map. Otherwise we have either a pre-resolved file URL (CLI entries and
109
- // ESM-to-CJS translator require() targets both arrive that way) or a bare entry
110
- // specifier we anchor to cwd -- either way, skip nextResolve so the entry file
111
- // doesn't need to exist on disk.
112
- if (parentURL && !specifier.startsWith('file:')) {
113
- const { url, format } = state.getImport(parentURL, specifier, { conditions, importAttributes })
114
- return { url, format, importAttributes: undefined, shortCircuit: true }
115
- }
116
- const url = specifier.startsWith('file:')
117
- ? specifier
118
- : pathToFileURL(resolvePath(process.cwd(), specifier)).toString()
119
- if (!parentURL && state.config.full) state.assertEntry(url)
120
- return { url, format: state.getFormat(url), importAttributes: undefined, shortCircuit: true }
121
- }
122
-
123
- const res = nextResolve(specifier)
124
- const { url, format } = res
125
-
126
- assert.equal(res.importAttributes, undefined) // unsupported yet
127
- if (parentURL) assert.ok(state)
128
- if (state) state.addImport(parentURL, specifier, url, { conditions, format, importAttributes })
129
- return res
130
- }
131
-
132
- registerHooks({ load, resolve })
1
+ // Public-export adapter: backs `@exodus/stasis/loader` (and `node --import
2
+ // @exodus/stasis/loader`). The loader lives in @exodus/stasis-core; importing
3
+ // this evaluates it (registering Node's module hooks) for its side effect.
4
+ // Internal code uses `@exodus/stasis-core/loader` directly, so this file exists
5
+ // only to back the package's `exports` map.
6
+ export * from '@exodus/stasis-core/loader'
@@ -0,0 +1,257 @@
1
+ // Bash loader: produces a `{ sources, resolutions, missing }` triple from a
2
+ // set of shell scripts, mirroring the Solidity loader's interface (see
3
+ // ./solidity.js). A reference to a .sh/.bash file by a path that lands INSIDE
4
+ // the bundle root (`./lib.sh`, `dir/lib.sh`) must resolve to an in-set script;
5
+ // when it doesn't it lands in `missing` so the bundle builder can reject the
6
+ // dangling dependency. Everything else is best-effort and dropped: PATH
7
+ // commands (`grep`), `$VAR` paths, extensionless `source ./env`, absolute
8
+ // `source /etc/x.sh`, and `../` paths that escape the root — none is bundle-able.
9
+ // A `source "${VAR}/x.sh"` whose path can't be evaluated statically is picked
10
+ // up from its `# shellcheck source=...` directive when one is present.
11
+
12
+ import { realpathSync, statSync } from 'node:fs'
13
+ import { readFile } from 'node:fs/promises'
14
+ import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
15
+
16
+ import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
17
+
18
+ // The capture class excludes `` ` ``, `(`, `)` (on top of whitespace, quotes,
19
+ // `;`, `&`, `|`, `#`) so a reference inside a command substitution —
20
+ // `` `source x.sh` `` or `$(bash x.sh)` — doesn't swallow the closing delimiter
21
+ // into the path (which would silently drop the edge from the bundle).
22
+
23
+ // `source script.sh`, `. script.sh`, `. ./script.sh`
24
+ const SOURCE_RE = /(?:^|\n|;|&&|\|\||`|\$\()\s*(?:source|\.)\s+["']?([^\s"';&|#`()]+)["']?/gu
25
+
26
+ // `bash script.sh`, `sh script.sh`, `/usr/bin/env bash script.sh`,
27
+ // `/bin/sh script.sh`, `/usr/bin/bash script.sh`, etc.
28
+ const EXEC_RE = /(?:^|\n|;|&&|\|\||`|\$\()\s*(?:\/usr\/bin\/env\s+|\/bin\/|\/usr\/bin\/)?(?:ba)?sh\s+["']?([^\s"';&|#`()]+)["']?/gu
29
+
30
+ // Direct invocation: `./script.sh`, `../lib/helper.bash`, `/path/to/script.sh`
31
+ const DIRECT_RE = /(?:^|\n|;|&&|\|\||`|\$\()\s*["']?(\.\.?\/[^\s"';&|#`()]+\.(?:sh|bash))["']?/gu
32
+
33
+ // Dependency hint comment: `# Depends on: example.sh`
34
+ const COMMENT_RE = /^# Depends on: ([^ ]+\.(?:sh|bash))( |$)/gmu
35
+
36
+ // shellcheck `source=` directive: `# shellcheck source=../lib/config.sh`
37
+ // (optionally alongside other directives, e.g. `# shellcheck disable=SC1091 source=lib/x.sh`).
38
+ // This is the standard, explicit way to tell tooling where a dynamically
39
+ // sourced file lives — e.g. `source "${LIB_DIR}/config.sh"`, whose path is a
40
+ // `$VAR` expansion that can't be evaluated statically. The path is resolved
41
+ // relative to the script (matching shellcheck's default). `source=/dev/null`
42
+ // is shellcheck's "don't follow this source" sentinel and is ignored below.
43
+ const SHELLCHECK_SOURCE_RE = /#\s*shellcheck\s[^\n]*?\bsource=["']?([^\s"'#]+)["']?/gu
44
+
45
+ // Extract every file-like reference from a script's text. Variable
46
+ // expansions (`$VAR`, `${VAR}`, `$HOME/x.sh`) and flags (`-c`) are dropped
47
+ // here — they are never bundle-able file paths; a `source "${VAR}/x.sh"` whose
48
+ // real location can't be computed statically should carry a `# shellcheck
49
+ // source=` directive instead. The remaining strings are candidate references;
50
+ // resolveBashCall decides which name a real file.
51
+ export function extractBashCalls(content) {
52
+ const calls = new Set()
53
+ for (const re of [SOURCE_RE, EXEC_RE, DIRECT_RE, COMMENT_RE, SHELLCHECK_SOURCE_RE]) {
54
+ for (const m of content.matchAll(re)) {
55
+ const ref = m[1]
56
+ if (ref.includes('$') || ref.startsWith('-') || ref === '/dev/null') continue
57
+ calls.add(ref)
58
+ }
59
+ }
60
+ return [...calls]
61
+ }
62
+
63
+ // Resolve a bash reference to a baseDir-relative POSIX path. Existence is
64
+ // checked against `knownFiles` (tree mode, used once every script is in
65
+ // hand) or the filesystem under `baseDir` (walk mode, while discovering the
66
+ // graph); with neither, only path computation happens and unvalidated names
67
+ // return null.
68
+ //
69
+ // Reference kinds are treated differently:
70
+ // - absolute (`/x`): mapped to project-relative by stripping the leading
71
+ // slash, accepted only if that file actually exists in the set/on disk
72
+ // (so system paths like /etc/profile.d/x.sh are dropped, not bundled).
73
+ // - relative / project-relative WITH a slash (`./x`, `../x`, `lib/x.sh`):
74
+ // resolved against the caller's directory and returned as a computed
75
+ // path WITHOUT an existence check — the walk's readFile surfaces a
76
+ // missing target and the tree flags or drops it, mirroring the Solidity
77
+ // loader. Traversal above baseDir returns null rather than clamping, so
78
+ // an escaping `../x.sh` is treated as external (never flagged missing).
79
+ // - bare name (`helper.sh`, but also `grep`, `node`): could be a sibling
80
+ // script or a PATH command, so it is accepted only when it names a real
81
+ // file — caller's dir, then project root, then (knownFiles only) any
82
+ // in-set file by basename.
83
+ export function resolveBashCall(ref, fromFile, { knownFiles, baseDir } = {}) {
84
+ const present = (p) => {
85
+ if (!p) return false
86
+ if (knownFiles) return knownFiles.has(p)
87
+ if (baseDir) {
88
+ try {
89
+ return statSync(join(baseDir, p)).isFile()
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+ return false
95
+ }
96
+
97
+ const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
98
+ const resolveRel = (r) => {
99
+ const parts = [...(fromDir ? fromDir.split('/') : []), ...r.split('/')]
100
+ const out = []
101
+ for (const part of parts) {
102
+ if (part === '.' || part === '') continue
103
+ if (part === '..') {
104
+ if (out.length === 0) return null // escapes baseDir
105
+ out.pop()
106
+ } else {
107
+ out.push(part)
108
+ }
109
+ }
110
+ return out.join('/') || null
111
+ }
112
+
113
+ // Absolute → project-relative, validated.
114
+ if (ref.startsWith('/')) {
115
+ const stripped = ref.replace(/^\/+/u, '')
116
+ return present(stripped) ? stripped : null
117
+ }
118
+
119
+ // Path with a slash → computed, validation deferred to the caller.
120
+ if (ref.includes('/')) return resolveRel(ref)
121
+
122
+ // Bare name → only accept a real file.
123
+ const sibling = resolveRel(ref)
124
+ if (present(sibling)) return sibling
125
+ if (sibling !== ref && present(ref)) return ref
126
+ if (knownFiles) {
127
+ for (const known of knownFiles) {
128
+ const base = known.includes('/') ? known.slice(known.lastIndexOf('/') + 1) : known
129
+ if (base === ref) return known
130
+ }
131
+ }
132
+ return null
133
+ }
134
+
135
+ // A `.sh`/`.bash` extension marks a reference as a shell script (as opposed to
136
+ // an extensionless `source ./env`, which we don't treat as a missing script
137
+ // when absent).
138
+ const hasShellScriptExt = (ref) => ref.endsWith('.sh') || ref.endsWith('.bash')
139
+
140
+ // Build the { sources, resolutions, missing } triple from a Map of
141
+ // already-loaded scripts. A reference is recorded in `missing` (which the
142
+ // bundle builder treats as fatal) only when it resolves to a path INSIDE the
143
+ // bundle root that wasn't collected — i.e. a dangling `source ./lib.sh`.
144
+ // References that resolve to null are external and tolerated: bare names
145
+ // (PATH commands / runtime-cwd scripts), absolute `/etc/...` paths, `$VAR`
146
+ // paths, and `../` paths that escape the root — none can live in the bundle.
147
+ // Self-references and non-script extensions are dropped without flagging.
148
+ export function buildBashTree(sources) {
149
+ const knownFiles = new Set(sources.keys())
150
+ const resolutions = new Map()
151
+ const missing = []
152
+ for (const [path, content] of sources) {
153
+ const specMap = new Map()
154
+ for (const ref of extractBashCalls(content)) {
155
+ // In tree mode the slash branch returns a computed in-bundle path
156
+ // without checking existence, so a non-null target that isn't a known
157
+ // file is a genuine dangling local dependency; a null target is external.
158
+ const target = resolveBashCall(ref, path, { knownFiles })
159
+ if (target && knownFiles.has(target)) {
160
+ if (target !== path) specMap.set(ref, target) // drop self-references
161
+ } else if (target && hasShellScriptExt(ref)) {
162
+ console.warn(`[loader.bash] Missing file: ${ref} from ${path}`)
163
+ missing.push({ spec: ref, from: path })
164
+ }
165
+ }
166
+ resolutions.set(path, specMap)
167
+ }
168
+ return { sources, resolutions, missing }
169
+ }
170
+
171
+ // Walk the filesystem from `entries`, following resolved source/exec/direct
172
+ // references and reading each reachable script exactly once. Files in the
173
+ // same wave are read in parallel; the next wave depends on references found
174
+ // in the previous one. A reference that resolves to a path missing on disk
175
+ // warns and is skipped (the graph stays partial but the walk continues);
176
+ // unresolved references (commands, dynamic paths) are silently ignored.
177
+ export async function collectBashFilesFromDisk(baseDir, entries) {
178
+ const sources = new Map()
179
+ const realBase = realpathSync(baseDir)
180
+
181
+ const processWave = async (wave) => {
182
+ const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
183
+ if (toLoad.length === 0) return
184
+ const reads = await Promise.all(
185
+ toLoad.map(async (relPath) => {
186
+ try {
187
+ assertRealPathWithinBase(realBase, baseDir, relPath)
188
+ return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
189
+ } catch (err) {
190
+ if (err.code === 'ENOENT') {
191
+ console.warn(`[loader.bash] Missing file: ${relPath}`)
192
+ return null
193
+ }
194
+ // A slash-bearing ref can resolve to a directory (e.g. `source ./lib`);
195
+ // readFile then throws EISDIR. Skip it rather than aborting the walk.
196
+ if (err.code === 'EISDIR') {
197
+ console.warn(`[loader.bash] Skipping directory reference: ${relPath}`)
198
+ return null
199
+ }
200
+ throw err
201
+ }
202
+ })
203
+ )
204
+ const next = []
205
+ for (const entry of reads) {
206
+ if (!entry) continue
207
+ const [relPath, content] = entry
208
+ sources.set(relPath, content)
209
+ for (const ref of extractBashCalls(content)) {
210
+ const resolved = resolveBashCall(ref, relPath, { baseDir })
211
+ if (resolved && !sources.has(resolved)) next.push(resolved)
212
+ }
213
+ }
214
+ await processWave(next)
215
+ }
216
+
217
+ await processWave(entries)
218
+ return sources
219
+ }
220
+
221
+ // Reject absolute paths and `..`-escaping paths in a `.sh.txt` listing so a
222
+ // malicious or sloppy listing can't trick the loader into reading arbitrary
223
+ // files outside the listing's own directory.
224
+ function assertWithinBase(baseDir, candidate, label) {
225
+ if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
226
+ const rel = relative(baseDir, resolve(baseDir, candidate)).split(/[\\/]/u).join('/')
227
+ if (rel.startsWith('..') || isAbsolute(rel)) {
228
+ throw new Error(`${label} escapes baseDir: ${candidate}`)
229
+ }
230
+ }
231
+
232
+ // High-level: takes a `.sh.txt` listing file whose lines are the scripts to
233
+ // load (`.sh`, `.bash`, or extensionless), resolved relative to the listing.
234
+ // Unlike loadSolidity, the listing is the authoritative manifest — bash
235
+ // sourcing is too dynamic to walk reliably — so exactly the listed files are
236
+ // read and resolutions are computed among them. Returns { sources, resolutions, missing }.
237
+ export async function loadBash(shTxtFile) {
238
+ const baseDir = dirname(resolve(shTxtFile))
239
+ const listing = await readFile(shTxtFile, 'utf8')
240
+ const lines = listing.split('\n').map((l) => l.trim()).filter(Boolean)
241
+ .map((l) => l.replace(/^\.\//u, ''))
242
+ if (lines.length === 0) throw new Error(`Empty bash listing: ${shTxtFile}`)
243
+
244
+ if (!lines.every((line) => ['', '.sh', '.bash'].includes(extname(line)))) {
245
+ throw new Error(`Bash listing must only contain .sh/.bash files: ${shTxtFile}`)
246
+ }
247
+ for (const line of lines) assertWithinBase(baseDir, line, 'Entry path')
248
+
249
+ const realBase = realpathSync(baseDir)
250
+ const reads = await Promise.all(
251
+ lines.map(async (relPath) => {
252
+ assertRealPathWithinBase(realBase, baseDir, relPath)
253
+ return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
254
+ }),
255
+ )
256
+ return buildBashTree(new Map(reads))
257
+ }