@exodus/stasis 1.0.0-alpha.3 → 1.0.0-beta.1
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 +326 -19
- package/logo.svg +12 -3
- package/package.json +38 -18
- package/src/audit.js +1 -38
- 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 +949 -58
- 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 +26 -1
- 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,10 +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'
|
|
9
10
|
import { createRequire } from 'node:module'
|
|
10
11
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
11
12
|
|
|
13
|
+
import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
|
|
14
|
+
|
|
12
15
|
const SOL_IMPORT_RE = /import\s[^"']*["']([^"']+)["']/gu
|
|
13
16
|
|
|
14
17
|
export function extractSolImports(content) {
|
|
@@ -30,13 +33,18 @@ export function parseRemappings(content) {
|
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
// Resolve a Solidity import specifier to a baseDir-relative POSIX path,
|
|
33
|
-
// trying
|
|
36
|
+
// trying four strategies in order:
|
|
34
37
|
// 1. user remappings (longest prefix wins)
|
|
35
38
|
// 2. relative path (`./...` or `../...`); traversal above the project
|
|
36
39
|
// root returns null instead of clamping
|
|
37
40
|
// 3. Node's CJS resolver via createRequire, anchored at the importing
|
|
38
41
|
// source — only when `baseDir` is provided and the specifier looks
|
|
39
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.
|
|
40
48
|
// Returns null when no strategy resolves the import.
|
|
41
49
|
export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir } = {}) {
|
|
42
50
|
let best = null
|
|
@@ -84,6 +92,21 @@ export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir
|
|
|
84
92
|
} catch { /* package missing, exports map blocks the subpath, … */ }
|
|
85
93
|
}
|
|
86
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
|
+
|
|
87
110
|
return null
|
|
88
111
|
}
|
|
89
112
|
|
|
@@ -124,6 +147,7 @@ export function buildSolidityTree(sources, { remappings = [], baseDir } = {}) {
|
|
|
124
147
|
export async function collectSolidityFilesFromDisk(baseDir, entries, remappings) {
|
|
125
148
|
const sources = new Map()
|
|
126
149
|
const knownEntries = new Set(entries)
|
|
150
|
+
const realBase = realpathSync(baseDir)
|
|
127
151
|
|
|
128
152
|
const processWave = async (wave) => {
|
|
129
153
|
const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
|
|
@@ -131,6 +155,7 @@ export async function collectSolidityFilesFromDisk(baseDir, entries, remappings)
|
|
|
131
155
|
const reads = await Promise.all(
|
|
132
156
|
toLoad.map(async (relPath) => {
|
|
133
157
|
try {
|
|
158
|
+
assertRealPathWithinBase(realBase, baseDir, relPath)
|
|
134
159
|
return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
|
|
135
160
|
} catch (err) {
|
|
136
161
|
if (err.code === 'ENOENT') {
|
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'
|