@exodus/stasis 1.0.0-alpha.2 → 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -3
- package/bin/stasis.js +322 -17
- package/logo.svg +12 -3
- package/package.json +39 -18
- package/src/audit.js +112 -0
- package/src/bundle-from-lockfile.js +65 -0
- package/src/bundle.js +4 -217
- package/src/cmd/build.js +321 -0
- package/src/cmd/bundle.js +950 -59
- package/src/cmd/diff.js +36 -0
- package/src/cmd/extract.js +193 -0
- package/src/cmd/sbom.js +35 -0
- package/src/diff.js +333 -0
- package/src/esbuild.js +5 -56
- package/src/loader-mock.js +19 -0
- package/src/loader.js +6 -132
- package/src/loaders/bash.js +257 -0
- package/src/loaders/php.js +1058 -0
- package/src/loaders/rust.js +336 -0
- package/src/loaders/solidity.js +70 -49
- package/src/lockfile.js +4 -80
- package/src/metro-transformer.js +6 -0
- package/src/metro.js +5 -0
- package/src/mock.js +310 -0
- package/src/parse.js +53 -0
- package/src/prune.js +4 -162
- package/src/sbom.js +266 -0
- package/src/scan.js +373 -0
- package/src/webpack.js +5 -48
- package/src/config.js +0 -205
- package/src/state.js +0 -440
- package/src/state.util.js +0 -25
- package/src/util.js +0 -60
|
@@ -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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|