@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.
@@ -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
+ }
@@ -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
- // Returns a candidate resolved path, or null if the specifier doesn't match
32
- // any remapping and isn't a relative import. The caller is responsible for
33
- // checking whether the resolved path actually exists in `sources` (the
34
- // etherscan bundle's cnames are exact-match keys — `@openzeppelin/…` lives
35
- // alongside `contracts/Foo.sol` — so a non-relative, non-remapped specifier
36
- // may still be a hit when matched verbatim against the bundle). Logging
37
- // "Missing import" also lives at the callsite because only the callsite
38
- // knows whether that verbatim fallback matched.
39
- export function resolveSolImport(specifier, fromFile, remappings) {
40
- // Try remappings (longest prefix match)
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
- const result = remaining ? `${target}/${remaining}` : target
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 (any key format — relative paths for
78
- // disk-loaded projects, `${address}/${cname}` for etherscan, etc.) plus
79
- // optional remappings. Imports that resolve via remappings/relative paths
80
- // win; as a fallback we also accept an import specifier that matches a
81
- // stored key verbatim (etherscan bundles imports like
82
- // `@openzeppelin/contracts/.../IERC20.sol` which is itself a stored
83
- // cname). `missing` lists every (spec, from) pair that could not be
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 relative/remapped
111
- // imports and reading each file exactly once. Returns a Map<relPath, content>
112
- // suitable for passing into buildSolidityTree. Files in the same wave are
113
- // read in parallel; subsequent waves depend on the imports discovered in
114
- // the previous one.
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
- import { assert, fileSetToObject, fromEntries, sortPaths } from './util.js'
2
-
3
- const VERSION = 0
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'