@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,336 @@
|
|
|
1
|
+
// Rust loader: produces a `{ sources, resolutions, missing }` triple from a
|
|
2
|
+
// crate's source files, mirroring the Solidity/Bash loaders' interface. The
|
|
3
|
+
// reachable set is discovered by following `mod foo;` declarations (the only
|
|
4
|
+
// thing that pulls a new file into a crate); `use crate::path` statements
|
|
5
|
+
// don't add files, they just reference already-declared modules, so they're
|
|
6
|
+
// resolved against the module tree for the import graph but never widen the walk.
|
|
7
|
+
//
|
|
8
|
+
// `use crate::` resolution is best-effort (external crates, macro paths, and
|
|
9
|
+
// item-vs-module ambiguity are simply dropped). An unconditional `mod foo;`
|
|
10
|
+
// with no backing file is a genuine hole (a compile error in a real crate) and
|
|
11
|
+
// is reported in `missing` so the bundle builder can reject it; a cfg-gated
|
|
12
|
+
// `mod` (e.g. `#[cfg(test)] mod tests;`) may be compiled out and is tolerated.
|
|
13
|
+
// Comments are stripped before extraction so a commented-out `mod`/`use` is
|
|
14
|
+
// neither followed nor flagged.
|
|
15
|
+
|
|
16
|
+
import { realpathSync, statSync } from 'node:fs'
|
|
17
|
+
import { readFile } from 'node:fs/promises'
|
|
18
|
+
import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
19
|
+
|
|
20
|
+
import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
|
|
21
|
+
|
|
22
|
+
// `mod foo;` / `pub mod foo;` / `pub(crate) mod foo;` — external module
|
|
23
|
+
// declarations only. Inline `mod foo { ... }` is intentionally NOT matched.
|
|
24
|
+
// Applied per (trimmed) line, so it stays anchored at the line start.
|
|
25
|
+
const MOD_DECL_RE = /^(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*;/u
|
|
26
|
+
|
|
27
|
+
// `crate::path::to::item` references (in `use` or expression position).
|
|
28
|
+
const USE_CRATE_RE = /\bcrate(?:::\w+)+/gu
|
|
29
|
+
|
|
30
|
+
// `cargo vendor` copies external crates in-tree under this directory; their
|
|
31
|
+
// sources are pulled into the bundle and tagged `cargo` (see cmd/bundle.js).
|
|
32
|
+
const VENDOR_DIR = 'vendor'
|
|
33
|
+
|
|
34
|
+
// `extern crate foo;` and the leading segment of any `foo::path` use/expr path.
|
|
35
|
+
// The leading segment of a path that ISN'T crate/self/super or a std-family
|
|
36
|
+
// root is a candidate external crate name; resolveVendoredCrate keeps only the
|
|
37
|
+
// ones that actually exist under vendor/. The lead must start lowercase to skip
|
|
38
|
+
// `Type::assoc` associated-item paths (crate names are snake_case by convention).
|
|
39
|
+
const EXTERN_CRATE_RE = /^(?:pub\s+)?extern\s+crate\s+(\w+)\s*;/u
|
|
40
|
+
const PATH_LEAD_RE = /\b([a-z_]\w*)(?:::\w+)+/gu
|
|
41
|
+
const NON_CRATE_LEADS = new Set(['crate', 'self', 'super', 'std', 'core', 'alloc'])
|
|
42
|
+
|
|
43
|
+
// A `#[cfg(...)]` / `#[cfg_attr(...)]` attribute gates the next item on a build
|
|
44
|
+
// configuration that may be off (e.g. `#[cfg(test)]`).
|
|
45
|
+
const CFG_ATTR_RE = /^#\[\s*cfg(?:_attr)?\b/u
|
|
46
|
+
|
|
47
|
+
// Strip `//` line comments and `/* */` block comments so a commented-out
|
|
48
|
+
// `mod`/`use` isn't mistaken for a real one — a commented `mod foo;` with no
|
|
49
|
+
// backing file would otherwise be reported as a fatal "missing module".
|
|
50
|
+
// Newlines inside block comments are preserved so the per-line matching below
|
|
51
|
+
// stays aligned. Not string-literal aware (a `//` or `/* */` inside a string
|
|
52
|
+
// can't masquerade as a line-anchored `mod` decl, so it doesn't matter for
|
|
53
|
+
// extraction), and nested block comments aren't handled — both are acceptable
|
|
54
|
+
// for best-effort extraction and match the Solidity loader's fidelity.
|
|
55
|
+
function stripRustComments(content) {
|
|
56
|
+
const noBlock = content.replace(/\/\*[\s\S]*?\*\//gu, (m) => m.replace(/[^\n]/gu, ' '))
|
|
57
|
+
return noBlock.replace(/\/\/[^\n]*/gu, '')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Extract external `mod <name>;` declarations as { name, conditional } records.
|
|
61
|
+
// `conditional` is true when a `#[cfg(...)]` attribute gates the declaration:
|
|
62
|
+
// such a module may be compiled out (e.g. `#[cfg(test)] mod tests;`), so the
|
|
63
|
+
// bundle builder treats its absence as tolerable rather than fatal.
|
|
64
|
+
export function extractModDecls(content) {
|
|
65
|
+
const decls = []
|
|
66
|
+
let cfgPending = false
|
|
67
|
+
for (const raw of stripRustComments(content).split('\n')) {
|
|
68
|
+
const line = raw.trim()
|
|
69
|
+
if (line === '') continue // blank lines don't break a contiguous attribute group
|
|
70
|
+
const m = MOD_DECL_RE.exec(line)
|
|
71
|
+
if (m) {
|
|
72
|
+
decls.push({ name: m[1], conditional: cfgPending })
|
|
73
|
+
cfgPending = false
|
|
74
|
+
} else if (CFG_ATTR_RE.test(line)) {
|
|
75
|
+
cfgPending = true
|
|
76
|
+
} else if (!line.startsWith('#[')) {
|
|
77
|
+
cfgPending = false // a non-attribute line ends the attribute group
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return decls
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function extractCrateUses(content) {
|
|
84
|
+
return [...stripRustComments(content).matchAll(USE_CRATE_RE)].map((m) => m[0])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Candidate external-crate names referenced by a file: `extern crate <name>;`
|
|
88
|
+
// plus the leading segment of every `<name>::...` path (minus crate/self/super
|
|
89
|
+
// and the std family). Best-effort and deliberately over-inclusive — a lead
|
|
90
|
+
// that doesn't name a real vendored crate is dropped by resolveVendoredCrate.
|
|
91
|
+
export function extractExternalCrates(content) {
|
|
92
|
+
const stripped = stripRustComments(content)
|
|
93
|
+
const names = new Set()
|
|
94
|
+
for (const raw of stripped.split('\n')) {
|
|
95
|
+
const m = EXTERN_CRATE_RE.exec(raw.trim())
|
|
96
|
+
if (m) names.add(m[1])
|
|
97
|
+
}
|
|
98
|
+
for (const m of stripped.matchAll(PATH_LEAD_RE)) {
|
|
99
|
+
if (!NON_CRATE_LEADS.has(m[1])) names.add(m[1])
|
|
100
|
+
}
|
|
101
|
+
return [...names]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Resolve an external crate name to its vendored crate-root file
|
|
105
|
+
// (`vendor/<dir>/src/lib.rs`), or null when no such crate is vendored. A Rust
|
|
106
|
+
// `use` name is the crate's lib name (underscores); the on-disk vendor dir uses
|
|
107
|
+
// the package name, which may hyphenate — so try the name as-is and with
|
|
108
|
+
// underscores swapped for hyphens. Existence is checked against `knownSources`
|
|
109
|
+
// (tree mode) or the filesystem under `baseDir` (walk mode).
|
|
110
|
+
export function resolveVendoredCrate(crateName, { knownSources, baseDir, vendorDir = VENDOR_DIR } = {}) {
|
|
111
|
+
const dirs = crateName.includes('_') ? [crateName, crateName.replaceAll('_', '-')] : [crateName]
|
|
112
|
+
for (const dir of dirs) {
|
|
113
|
+
const lib = `${vendorDir}/${dir}/src/lib.rs`
|
|
114
|
+
if (knownSources) {
|
|
115
|
+
if (knownSources.has(lib)) return lib
|
|
116
|
+
} else if (baseDir) {
|
|
117
|
+
try {
|
|
118
|
+
if (statSync(join(baseDir, lib)).isFile()) return lib
|
|
119
|
+
} catch { /* not this spelling — try the next */ }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The directory where this file's submodules live, per Rust's path rules:
|
|
126
|
+
// - crate roots (main.rs/lib.rs) and mod.rs hold submodules as siblings
|
|
127
|
+
// - any other foo.rs holds them under a foo/ subdirectory
|
|
128
|
+
export function getModuleDir(filePath) {
|
|
129
|
+
const lastSlash = filePath.lastIndexOf('/')
|
|
130
|
+
const dir = lastSlash === -1 ? '' : filePath.slice(0, lastSlash)
|
|
131
|
+
const name = lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1)
|
|
132
|
+
if (name === 'main.rs' || name === 'lib.rs' || name === 'mod.rs') return dir
|
|
133
|
+
const stem = name.replace(/\.rs$/u, '')
|
|
134
|
+
return dir ? `${dir}/${stem}` : stem
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Resolve a `mod <name>;` declaration to the baseDir-relative file that
|
|
138
|
+
// defines it, trying `<dir>/<name>.rs` then `<dir>/<name>/mod.rs`. Existence
|
|
139
|
+
// is checked against `knownSources` (tree mode) or the filesystem under
|
|
140
|
+
// `baseDir` (walk mode); returns null when neither finds a file.
|
|
141
|
+
export function resolveModPath(modName, fromFile, { knownSources, baseDir } = {}) {
|
|
142
|
+
const modDir = getModuleDir(fromFile)
|
|
143
|
+
const base = modDir ? `${modDir}/${modName}` : modName
|
|
144
|
+
const candidates = [`${base}.rs`, `${base}/mod.rs`]
|
|
145
|
+
|
|
146
|
+
if (knownSources) return candidates.find((c) => knownSources.has(c)) ?? null
|
|
147
|
+
if (baseDir) {
|
|
148
|
+
for (const c of candidates) {
|
|
149
|
+
try {
|
|
150
|
+
if (statSync(join(baseDir, c)).isFile()) return c
|
|
151
|
+
} catch { /* missing — try the next candidate */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Build a `module path -> file` map (e.g. "crate::foo::bar" -> "src/foo/bar.rs")
|
|
158
|
+
// by walking `mod` edges out from every crate root (main.rs / lib.rs). The walk
|
|
159
|
+
// is iterative (a deep `mod` chain would overflow a recursive descent) and
|
|
160
|
+
// depth-bounded: a `seen` set stops mod cycles, and MAX_MODULE_DEPTH caps a
|
|
161
|
+
// pathologically deep chain — both the descent and the O(depth²) growth of the
|
|
162
|
+
// joined module-path strings. Real crates nest a handful deep; the cap only
|
|
163
|
+
// ever trims best-effort `use crate::` resolution for absurd input (file
|
|
164
|
+
// collection happens in collectRustFilesFromDisk, which is unaffected).
|
|
165
|
+
const MAX_MODULE_DEPTH = 1000
|
|
166
|
+
|
|
167
|
+
export function buildModuleTree(sources, resolutions, { vendorDir = null } = {}) {
|
|
168
|
+
const tree = new Map()
|
|
169
|
+
for (const filePath of sources.keys()) {
|
|
170
|
+
// A vendored crate is its own crate, not part of the project crate rooted
|
|
171
|
+
// at `crate::` — skip its root so its tree doesn't collide on the `crate`
|
|
172
|
+
// key (its internal `crate::` edges are left best-effort/unresolved).
|
|
173
|
+
if (vendorDir && filePath.startsWith(`${vendorDir}/`)) continue
|
|
174
|
+
const name = filePath.includes('/') ? filePath.slice(filePath.lastIndexOf('/') + 1) : filePath
|
|
175
|
+
if (name !== 'main.rs' && name !== 'lib.rs') continue
|
|
176
|
+
const seen = new Set()
|
|
177
|
+
const stack = [[filePath, 'crate', 0]]
|
|
178
|
+
while (stack.length > 0) {
|
|
179
|
+
const [path, modulePath, depth] = stack.pop()
|
|
180
|
+
tree.set(modulePath, path)
|
|
181
|
+
if (seen.has(path) || depth >= MAX_MODULE_DEPTH) continue
|
|
182
|
+
seen.add(path)
|
|
183
|
+
const specMap = resolutions.get(path)
|
|
184
|
+
if (!specMap) continue
|
|
185
|
+
for (const [spec, resolved] of specMap) {
|
|
186
|
+
if (!spec.startsWith('mod ')) continue
|
|
187
|
+
stack.push([resolved, `${modulePath}::${spec.slice(4)}`, depth + 1])
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return tree
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Resolve a `crate::a::b::Item` path to the file of the deepest module along
|
|
195
|
+
// it (the trailing item names aren't modules), e.g. `crate::foo::Bar` ->
|
|
196
|
+
// the file for `crate::foo`. Returns null when no prefix names a module.
|
|
197
|
+
export function resolveUsePath(usePath, moduleTree) {
|
|
198
|
+
const parts = usePath.split('::')
|
|
199
|
+
for (let i = parts.length; i >= 1; i--) {
|
|
200
|
+
const modulePath = parts.slice(0, i).join('::')
|
|
201
|
+
if (moduleTree.has(modulePath)) return moduleTree.get(modulePath)
|
|
202
|
+
}
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Build the { sources, resolutions, missing } triple from a Map of
|
|
207
|
+
// already-loaded Rust sources. Two-phase: resolve `mod` edges (which also
|
|
208
|
+
// defines the module tree), then resolve `crate::` references against it.
|
|
209
|
+
// An unconditional `mod foo;` with no file is recorded in `missing` (the
|
|
210
|
+
// bundle builder treats it as fatal); a cfg-gated `mod`, unresolved `crate::`
|
|
211
|
+
// references, and self/duplicate references are omitted.
|
|
212
|
+
export function buildRustTree(sources) {
|
|
213
|
+
const resolutions = new Map()
|
|
214
|
+
const missing = []
|
|
215
|
+
for (const [path, content] of sources) {
|
|
216
|
+
const specMap = new Map()
|
|
217
|
+
for (const { name, conditional } of extractModDecls(content)) {
|
|
218
|
+
const resolved = resolveModPath(name, path, { knownSources: sources })
|
|
219
|
+
if (resolved) {
|
|
220
|
+
specMap.set(`mod ${name}`, resolved)
|
|
221
|
+
} else if (!conditional) {
|
|
222
|
+
console.warn(`[loader.rust] Missing module: ${name} from ${path}`)
|
|
223
|
+
missing.push({ spec: `mod ${name}`, from: path })
|
|
224
|
+
}
|
|
225
|
+
// conditional && unresolved: a cfg-gated module that may be compiled out
|
|
226
|
+
// (e.g. #[cfg(test)] mod tests;) — tolerated, not a fatal hole.
|
|
227
|
+
}
|
|
228
|
+
resolutions.set(path, specMap)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const moduleTree = buildModuleTree(sources, resolutions, { vendorDir: VENDOR_DIR })
|
|
232
|
+
for (const [path, content] of sources) {
|
|
233
|
+
// Vendored crates keep their (file-relative) `mod` edges from above, but
|
|
234
|
+
// their `crate::`/external `use` edges are dropped rather than mis-resolved
|
|
235
|
+
// against the project crate's module tree.
|
|
236
|
+
if (path.startsWith(`${VENDOR_DIR}/`)) continue
|
|
237
|
+
const specMap = resolutions.get(path)
|
|
238
|
+
for (const usePath of extractCrateUses(content)) {
|
|
239
|
+
const resolved = resolveUsePath(usePath, moduleTree)
|
|
240
|
+
if (resolved && resolved !== path && !specMap.has(usePath)) {
|
|
241
|
+
specMap.set(usePath, resolved)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// A `use <crate>::…` of a vendored crate becomes a `use <crate>` edge to
|
|
245
|
+
// that crate's root, recording the cross-crate dependency in the graph.
|
|
246
|
+
for (const crate of extractExternalCrates(content)) {
|
|
247
|
+
const lib = resolveVendoredCrate(crate, { knownSources: sources })
|
|
248
|
+
const spec = `use ${crate}`
|
|
249
|
+
if (lib && lib !== path && !specMap.has(spec)) specMap.set(spec, lib)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { sources, resolutions, missing }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Walk the filesystem from `entries`, following `mod` declarations and reading
|
|
256
|
+
// each reachable file once. A `mod` whose file is missing on disk warns and is
|
|
257
|
+
// skipped; the walk continues. Files in a wave are read in parallel.
|
|
258
|
+
export async function collectRustFilesFromDisk(baseDir, entries) {
|
|
259
|
+
const sources = new Map()
|
|
260
|
+
const realBase = realpathSync(baseDir)
|
|
261
|
+
|
|
262
|
+
const processWave = async (wave) => {
|
|
263
|
+
const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
|
|
264
|
+
if (toLoad.length === 0) return
|
|
265
|
+
const reads = await Promise.all(
|
|
266
|
+
toLoad.map(async (relPath) => {
|
|
267
|
+
try {
|
|
268
|
+
assertRealPathWithinBase(realBase, baseDir, relPath)
|
|
269
|
+
return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (err.code === 'ENOENT') {
|
|
272
|
+
console.warn(`[loader.rust] Missing file: ${relPath}`)
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
// resolveModPath gates candidates with isFile so a directory is
|
|
276
|
+
// normally never queued; guard EISDIR anyway (symmetry with bash).
|
|
277
|
+
if (err.code === 'EISDIR') {
|
|
278
|
+
console.warn(`[loader.rust] Skipping directory reference: ${relPath}`)
|
|
279
|
+
return null
|
|
280
|
+
}
|
|
281
|
+
throw err
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
)
|
|
285
|
+
const next = []
|
|
286
|
+
for (const entry of reads) {
|
|
287
|
+
if (!entry) continue
|
|
288
|
+
const [relPath, content] = entry
|
|
289
|
+
sources.set(relPath, content)
|
|
290
|
+
for (const { name } of extractModDecls(content)) {
|
|
291
|
+
const resolved = resolveModPath(name, relPath, { baseDir })
|
|
292
|
+
if (resolved && !sources.has(resolved)) next.push(resolved)
|
|
293
|
+
}
|
|
294
|
+
// Follow `use <crate>::…` into a vendored crate's root; its own `mod`
|
|
295
|
+
// edges (read on the next wave) then pull in the rest of the crate.
|
|
296
|
+
for (const crate of extractExternalCrates(content)) {
|
|
297
|
+
const lib = resolveVendoredCrate(crate, { baseDir })
|
|
298
|
+
if (lib && !sources.has(lib)) next.push(lib)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
await processWave(next)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await processWave(entries)
|
|
305
|
+
return sources
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Reject absolute paths and `..`-escaping paths in a `.rs.txt` listing so a
|
|
309
|
+
// malicious or sloppy listing can't read files outside the listing's own dir.
|
|
310
|
+
function assertWithinBase(baseDir, candidate, label) {
|
|
311
|
+
if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
|
|
312
|
+
const rel = relative(baseDir, resolve(baseDir, candidate)).split(/[\\/]/u).join('/')
|
|
313
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
314
|
+
throw new Error(`${label} escapes baseDir: ${candidate}`)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// High-level: takes a `.rs.txt` listing whose lines are crate entry points
|
|
319
|
+
// (typically src/main.rs or src/lib.rs), resolved relative to the listing.
|
|
320
|
+
// Walks `mod` declarations from there, then resolves `crate::` references.
|
|
321
|
+
// Returns { sources, resolutions, missing }.
|
|
322
|
+
export async function loadRust(rsTxtFile) {
|
|
323
|
+
const baseDir = dirname(resolve(rsTxtFile))
|
|
324
|
+
const listing = await readFile(rsTxtFile, 'utf8')
|
|
325
|
+
const lines = listing.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
326
|
+
.map((l) => l.replace(/^\.\//u, ''))
|
|
327
|
+
if (lines.length === 0) throw new Error(`Empty Rust listing: ${rsTxtFile}`)
|
|
328
|
+
|
|
329
|
+
if (!lines.every((line) => extname(line) === '.rs')) {
|
|
330
|
+
throw new Error(`Rust listing must only contain .rs files: ${rsTxtFile}`)
|
|
331
|
+
}
|
|
332
|
+
for (const line of lines) assertWithinBase(baseDir, line, 'Entry path')
|
|
333
|
+
|
|
334
|
+
const sources = await collectRustFilesFromDisk(baseDir, lines)
|
|
335
|
+
return buildRustTree(sources)
|
|
336
|
+
}
|
package/src/loaders/solidity.js
CHANGED
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
// file (foundry.toml or remappings.txt) is read to extract `remappings`,
|
|
6
6
|
// but is itself not included in `sources`.
|
|
7
7
|
|
|
8
|
+
import { realpathSync, statSync } from 'node:fs'
|
|
8
9
|
import { readFile } from 'node:fs/promises'
|
|
10
|
+
import { createRequire } from 'node:module'
|
|
9
11
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
10
12
|
|
|
13
|
+
import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
|
|
14
|
+
|
|
11
15
|
const SOL_IMPORT_RE = /import\s[^"']*["']([^"']+)["']/gu
|
|
12
16
|
|
|
13
17
|
export function extractSolImports(content) {
|
|
@@ -28,16 +32,21 @@ export function parseRemappings(content) {
|
|
|
28
32
|
}).filter(Boolean)
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
// Resolve a Solidity import specifier to a baseDir-relative POSIX path,
|
|
36
|
+
// trying four strategies in order:
|
|
37
|
+
// 1. user remappings (longest prefix wins)
|
|
38
|
+
// 2. relative path (`./...` or `../...`); traversal above the project
|
|
39
|
+
// root returns null instead of clamping
|
|
40
|
+
// 3. Node's CJS resolver via createRequire, anchored at the importing
|
|
41
|
+
// source — only when `baseDir` is provided and the specifier looks
|
|
42
|
+
// like a scoped npm package (`@scope/pkg/sub/path`)
|
|
43
|
+
// 4. project-relative fallback: when `baseDir` is provided and a file
|
|
44
|
+
// exists at `<baseDir>/<specifier>`, accept the specifier as-is.
|
|
45
|
+
// Equivalent to an implicit `<top>/=<top>/` remapping for any
|
|
46
|
+
// top-level directory in the project. Lets Foundry-style
|
|
47
|
+
// `import "src/Foo.sol"` work without an explicit remapping.
|
|
48
|
+
// Returns null when no strategy resolves the import.
|
|
49
|
+
export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir } = {}) {
|
|
41
50
|
let best = null
|
|
42
51
|
for (const { prefix, target } of remappings) {
|
|
43
52
|
if (specifier.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) {
|
|
@@ -47,13 +56,9 @@ export function resolveSolImport(specifier, fromFile, remappings) {
|
|
|
47
56
|
if (best) {
|
|
48
57
|
const target = best.target.replace(/\/$/u, '')
|
|
49
58
|
const remaining = specifier.slice(best.prefix.length).replace(/^\//u, '')
|
|
50
|
-
|
|
51
|
-
return result.replace(/^\.\//u, '')
|
|
59
|
+
return (remaining ? `${target}/${remaining}` : target).replace(/^\.\//u, '')
|
|
52
60
|
}
|
|
53
61
|
|
|
54
|
-
// Relative import. `..` segments that would traverse above the project
|
|
55
|
-
// root return null instead of silently clamping to root — clamping would
|
|
56
|
-
// change the import target into something the source never asked for.
|
|
57
62
|
if (specifier.startsWith('.')) {
|
|
58
63
|
const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
|
|
59
64
|
const parts = [...(fromDir ? fromDir.split('/') : []), ...specifier.split('/')]
|
|
@@ -70,30 +75,56 @@ export function resolveSolImport(specifier, fromFile, remappings) {
|
|
|
70
75
|
return resolved.join('/')
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
// Node-style `@scope/pkg/sub/path`. createRequire is anchored at the
|
|
79
|
+
// importing source so nested node_modules resolve like Node would
|
|
80
|
+
// (a file under `node_modules/foo/` finds its deps in
|
|
81
|
+
// `foo/node_modules/` before walking up). `..` segments in the
|
|
82
|
+
// subpath are rejected so a crafted specifier can't escape the
|
|
83
|
+
// resolved package via require.resolve's own path normalization.
|
|
84
|
+
if (baseDir && specifier.startsWith('@')) {
|
|
85
|
+
const parts = specifier.split('/')
|
|
86
|
+
if (parts.length < 3) return null
|
|
87
|
+
if (parts.slice(2).includes('..')) return null
|
|
88
|
+
try {
|
|
89
|
+
const abs = createRequire(resolve(baseDir, fromFile)).resolve(specifier)
|
|
90
|
+
const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
|
|
91
|
+
if (!rel.startsWith('..') && !isAbsolute(rel)) return rel
|
|
92
|
+
} catch { /* package missing, exports map blocks the subpath, … */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Project-relative fallback: when nothing else resolved, accept the
|
|
96
|
+
// spec as-is if a real file sits at `<baseDir>/<spec>`. statSync's
|
|
97
|
+
// isFile() guard keeps directories and other non-files from being
|
|
98
|
+
// queued for readFile (which would surface as EISDIR mid-walk). `./`
|
|
99
|
+
// and `../` specs are already routed through the relative branch
|
|
100
|
+
// above, so we only see bare specs here.
|
|
101
|
+
if (baseDir && !isAbsolute(specifier)) {
|
|
102
|
+
const rel = relative(baseDir, resolve(baseDir, specifier)).split(/[\\/]/u).join('/')
|
|
103
|
+
if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) {
|
|
104
|
+
try {
|
|
105
|
+
if (statSync(join(baseDir, rel)).isFile()) return rel
|
|
106
|
+
} catch { /* missing or not statable */ }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
73
110
|
return null
|
|
74
111
|
}
|
|
75
112
|
|
|
76
113
|
// Build the { sources, resolutions, missing } triple from a Map of
|
|
77
|
-
// already-loaded Solidity sources
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
// resolved or that resolved to a file that wasn't loaded into `sources`.
|
|
85
|
-
export function buildSolidityTree(sources, { remappings = [] } = {}) {
|
|
114
|
+
// already-loaded Solidity sources plus optional remappings. Imports that
|
|
115
|
+
// resolve via remappings/relative/Node win; as a final fallback an
|
|
116
|
+
// import specifier that matches a stored key verbatim (etherscan
|
|
117
|
+
// bundles, hardhat flat layouts) is accepted. `missing` lists every
|
|
118
|
+
// (spec, from) pair that could not be resolved or that resolved to a
|
|
119
|
+
// file not loaded into `sources`.
|
|
120
|
+
export function buildSolidityTree(sources, { remappings = [], baseDir } = {}) {
|
|
86
121
|
const resolutions = new Map()
|
|
87
122
|
const missing = []
|
|
88
123
|
for (const [path, content] of sources) {
|
|
89
124
|
const specMap = new Map()
|
|
90
125
|
for (const spec of extractSolImports(content)) {
|
|
91
|
-
let resolved = resolveSolImport(spec, path, remappings)
|
|
126
|
+
let resolved = resolveSolImport(spec, path, { remappings, baseDir })
|
|
92
127
|
if (resolved && !sources.has(resolved)) resolved = null
|
|
93
|
-
// Verbatim fallback: many Solidity ecosystems (etherscan bundles,
|
|
94
|
-
// hardhat flat layouts) ship `@openzeppelin/...` imports as literal
|
|
95
|
-
// keys in the source map. If the remap/relative resolution didn't
|
|
96
|
-
// hit but the bundle has the specifier itself as a key, use it.
|
|
97
128
|
if (!resolved && sources.has(spec)) resolved = spec
|
|
98
129
|
if (resolved) {
|
|
99
130
|
specMap.set(spec, resolved)
|
|
@@ -107,34 +138,24 @@ export function buildSolidityTree(sources, { remappings = [] } = {}) {
|
|
|
107
138
|
return { sources, resolutions, missing }
|
|
108
139
|
}
|
|
109
140
|
|
|
110
|
-
// Walk the filesystem starting from `entries`, following
|
|
111
|
-
// imports and reading each file exactly once.
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// Specifiers that exactly match one of the explicit entries (the file
|
|
117
|
-
// list passed by the caller) are accepted as-is, even when they don't
|
|
118
|
-
// match any remapping. Solidity projects routinely write
|
|
119
|
-
// `import "src/A.sol"` and rely on solc's include-path / Foundry's
|
|
120
|
-
// remap-from-root behavior; here we don't have a true include path,
|
|
121
|
-
// but if the caller listed `src/A.sol` themselves we know the path is
|
|
122
|
-
// intentional and resolves to that file. Mirrors the `sources.has(spec)`
|
|
123
|
-
// fallback in `buildSolidityTree`, but applied during the walk so the
|
|
124
|
-
// file actually gets loaded into `sources`.
|
|
141
|
+
// Walk the filesystem starting from `entries`, following resolved
|
|
142
|
+
// imports and reading each file exactly once. Files in the same wave
|
|
143
|
+
// are read in parallel; the next wave depends on the imports
|
|
144
|
+
// discovered in the previous one. Caller-listed entries are also
|
|
145
|
+
// accepted as verbatim non-relative import targets (Foundry-style
|
|
146
|
+
// `import "src/A.sol"`).
|
|
125
147
|
export async function collectSolidityFilesFromDisk(baseDir, entries, remappings) {
|
|
126
148
|
const sources = new Map()
|
|
127
149
|
const knownEntries = new Set(entries)
|
|
150
|
+
const realBase = realpathSync(baseDir)
|
|
128
151
|
|
|
129
152
|
const processWave = async (wave) => {
|
|
130
153
|
const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
|
|
131
154
|
if (toLoad.length === 0) return
|
|
132
|
-
// A resolved path that doesn't exist on disk is treated like an
|
|
133
|
-
// unresolved import: warn and skip rather than aborting the entire
|
|
134
|
-
// walk. Other read errors (EACCES, EIO, …) still propagate.
|
|
135
155
|
const reads = await Promise.all(
|
|
136
156
|
toLoad.map(async (relPath) => {
|
|
137
157
|
try {
|
|
158
|
+
assertRealPathWithinBase(realBase, baseDir, relPath)
|
|
138
159
|
return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
|
|
139
160
|
} catch (err) {
|
|
140
161
|
if (err.code === 'ENOENT') {
|
|
@@ -151,7 +172,7 @@ export async function collectSolidityFilesFromDisk(baseDir, entries, remappings)
|
|
|
151
172
|
const [relPath, content] = entry
|
|
152
173
|
sources.set(relPath, content)
|
|
153
174
|
for (const spec of extractSolImports(content)) {
|
|
154
|
-
let resolved = resolveSolImport(spec, relPath, remappings)
|
|
175
|
+
let resolved = resolveSolImport(spec, relPath, { remappings, baseDir })
|
|
155
176
|
if (!resolved && knownEntries.has(spec)) resolved = spec
|
|
156
177
|
if (resolved) {
|
|
157
178
|
if (!sources.has(resolved)) next.push(resolved)
|
|
@@ -210,5 +231,5 @@ export async function loadSolidity(solTxtFile) {
|
|
|
210
231
|
const entries = lines.map((l) => l.replace(/^\.\//u, ''))
|
|
211
232
|
for (const e of entries) assertWithinBase(baseDir, e, 'Entry path')
|
|
212
233
|
const sources = await collectSolidityFilesFromDisk(baseDir, entries, remappings)
|
|
213
|
-
return buildSolidityTree(sources, { remappings })
|
|
234
|
+
return buildSolidityTree(sources, { remappings, baseDir })
|
|
214
235
|
}
|
package/src/lockfile.js
CHANGED
|
@@ -1,80 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const normalize = ({ name, version, files }) =>
|
|
6
|
-
({ name, version, files: fromEntries(Object.entries(files)) })
|
|
7
|
-
|
|
8
|
-
export class Lockfile {
|
|
9
|
-
static VERSION = VERSION
|
|
10
|
-
|
|
11
|
-
version = VERSION
|
|
12
|
-
config
|
|
13
|
-
entries
|
|
14
|
-
modules
|
|
15
|
-
|
|
16
|
-
constructor({ config = { scope: 'full' }, entries, modules } = {}) {
|
|
17
|
-
assert(['node_modules', 'full'].includes(config.scope))
|
|
18
|
-
this.config = config
|
|
19
|
-
this.entries = entries ?? new Set()
|
|
20
|
-
this.modules = modules ?? new Map()
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
static parse(text) {
|
|
24
|
-
const json = JSON.parse(text)
|
|
25
|
-
assert(json.version === VERSION)
|
|
26
|
-
assert(['node_modules', 'full'].includes(json.config?.scope))
|
|
27
|
-
|
|
28
|
-
const full = json.config.scope === 'full'
|
|
29
|
-
assert(!!json.entries === full)
|
|
30
|
-
assert(!!json.sources === full)
|
|
31
|
-
assert(json.modules)
|
|
32
|
-
|
|
33
|
-
const modules = new Map()
|
|
34
|
-
for (const [dir, info] of Object.entries(json.modules)) {
|
|
35
|
-
assert(dir.includes('node_modules'))
|
|
36
|
-
assert(info?.name && info.version && info.files)
|
|
37
|
-
modules.set(dir, normalize(info))
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
let entries = new Set()
|
|
41
|
-
if (full) {
|
|
42
|
-
assert(Array.isArray(json.entries))
|
|
43
|
-
entries = new Set(json.entries)
|
|
44
|
-
for (const [dir, info] of Object.entries(json.sources)) {
|
|
45
|
-
assert(!dir.includes('node_modules'))
|
|
46
|
-
modules.set(dir, normalize(info))
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
for (const [dir, { files }] of modules) {
|
|
51
|
-
assert(!dir.startsWith('..'))
|
|
52
|
-
assert(files)
|
|
53
|
-
for (const name of Object.keys(files)) {
|
|
54
|
-
assert(!name.startsWith('..'))
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return new Lockfile({ config: json.config, entries, modules })
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
serialize() {
|
|
62
|
-
const entries = fileSetToObject(this.entries)
|
|
63
|
-
const modules = []
|
|
64
|
-
const sources = []
|
|
65
|
-
for (const [dir, { name, version, files }] of this.modules) {
|
|
66
|
-
const inNodeModules = dir.includes('node_modules')
|
|
67
|
-
if (inNodeModules) assert(name && version && files)
|
|
68
|
-
const type = inNodeModules ? modules : sources
|
|
69
|
-
const sorted = fromEntries(Object.entries(files).toSorted((a, b) => sortPaths(a[0], b[0])))
|
|
70
|
-
type.push([dir, { name, version, files: sorted }])
|
|
71
|
-
}
|
|
72
|
-
modules.sort((a, b) => sortPaths(a[0], b[0]))
|
|
73
|
-
sources.sort((a, b) => sortPaths(a[0], b[0]))
|
|
74
|
-
|
|
75
|
-
const store = { version: this.version, config: this.config }
|
|
76
|
-
if (this.config.scope === 'full') Object.assign(store, { entries, sources: fromEntries(sources) })
|
|
77
|
-
Object.assign(store, { modules: fromEntries(modules) })
|
|
78
|
-
return JSON.stringify(store, undefined, 2) + '\n'
|
|
79
|
-
}
|
|
80
|
-
}
|
|
1
|
+
// Public-export adapter: backs `@exodus/stasis/lockfile`. The implementation lives in
|
|
2
|
+
// @exodus/stasis-core; internal tooling and tests import `@exodus/stasis-core/lockfile`
|
|
3
|
+
// directly, so this file exists only to back the package's `exports` map.
|
|
4
|
+
export * from '@exodus/stasis-core/lockfile'
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Public-export adapter: backs `@exodus/stasis/metro-transformer`. The transformer
|
|
2
|
+
// lives in @exodus/stasis-plugins (which depends on @exodus/stasis-core for State; it
|
|
3
|
+
// drives load-mode getFile directly). Wire it as Metro's `transformer.transformerPath`
|
|
4
|
+
// in load mode; see the source for the load asymmetry that lets this run per-worker
|
|
5
|
+
// without cross-worker state sync.
|
|
6
|
+
export * from '@exodus/stasis-plugins/metro-transformer'
|
package/src/metro.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Public-export adapter: backs `@exodus/stasis/metro`. The plugin lives in
|
|
2
|
+
// @exodus/stasis-plugins (which depends on @exodus/stasis-core for State /
|
|
3
|
+
// resolvePluginState). Internal code uses `@exodus/stasis-plugins/metro` directly;
|
|
4
|
+
// this file exists only to back the package's `exports` map.
|
|
5
|
+
export * from '@exodus/stasis-plugins/metro'
|