From f6b6491fa688fd357e5497b7e23a24f27749e25c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:57:40 +0000 Subject: [PATCH 01/20] fix(bundle): Solidity loader -- decide file ownership by real path The leaks and the workspace-link regression share one cause: containment was decided from the lexical path. - solidityOwnership resolves each path once, component by component, and gives its owner from where it really is: a file is a dependency's when its real path lies in one (node_modules packages, forge's libs entries, Soldeer's dependencies/, git submodules; a symlinked lib/ entry is the dependency where it points), however the path got there. - A symlink planted inside a dependency that leads out of it to anything but another dependency is never followed: not for an import (even one the project makes, or one a dependency's remapping routes, e.g. forge-std/), an entry, a carried manifest, or the dependency's own foundry.toml, extends base or remappings.txt. - Dependency code reached through a project symlink (src/vendor -> ../lib/dep/src) is the dependency's, so it can't import the project's files. - A workspace package linked into node_modules and a symlinked lib/forge-std can import their own files again. Also: - --mapping tolerates a root foundry.toml whose settings forge would reject (default lib dirs, warned) and reports FOUNDRY_PROFILE when it picks the lib dirs. - A legacy [] table's `extends` is ignored, as forge ignores it (checked against forge v1.8.3); a remappings.txt taken as written accepts an empty target (`x/=`), as solc does. - A lone missing extensionless entry is reported as "no such file or directory" instead of being bundled as a Solidity directory. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 37 +++-- stasis/bin/stasis.js | 2 + stasis/src/cmd/bundle.js | 28 ++-- stasis/src/loaders/foundry.js | 73 ++++++---- stasis/src/loaders/solidity.js | 258 ++++++++++++++++++++++++--------- tests/bundle-cmd.test.js | 116 ++++++++++++++- tests/solidity-loader.test.js | 80 +++++++++- 7 files changed, 476 insertions(+), 118 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index b9e64dc1..11928606 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -340,7 +340,8 @@ follows them: a symlinked directory that is one already on the walk is a loop and skipped, anything else is walked, so two links to one directory give two copies. A directory entry that is missing or holds no `.sol` file is skipped with a warning (a project without `script/` bundles with `src test script`); -only when no entry yields a file is it an error. Imports are found by a scan +only when no entry yields a file is it an error (when none exists, a mistyped +path: `no such file or directory`). Imports are found by a scan that skips comments (a `//` comment ends at `\n` or `\r`) and string literals (read as bytes: `\xNN` is one byte, and the path is those bytes as UTF-8), and resolve the way solc does under the project's build tool: @@ -361,12 +362,14 @@ resolve the way solc does under the project's build tool: copy of a package; aliases of the project's own `src`/`test`/`script` dirs are dropped, and `auto_detect_remappings = false` turns detection off. Profiles are `[profile.]` tables and the legacy top-level `[]` - ones (the former wins key by key); names match case-insensitively. Not + ones (the former wins key by key; `extends` counts only in the former, as in + forge); names match case-insensitively. Not read: `~/.foundry/foundry.toml`, `FOUNDRY_CONFIG` and the other `FOUNDRY_*` overrides. When `FOUNDRY_PROFILE` or a remapping variable shapes the result, `stasis bundle` says so on stderr; the bundle doesn't record it. Without a `foundry.toml`, a root `remappings.txt` applies as written, as solc - and Hardhat apply it (`@oz/=lib/oz` makes `@oz/X.sol` `lib/ozX.sol`). + and Hardhat apply it (`@oz/=lib/oz` makes `@oz/X.sol` `lib/ozX.sol`; `x/=` + makes `x/A.sol` `A.sol`). `--mapping=` replaces the remappings with exactly the ones that file lists: a `foundry.toml`'s selected profile (with its `extends` base; a `remappings` key outside any table is taken too), or a `remappings.txt`. A @@ -380,17 +383,29 @@ resolve the way solc does under the project's build tool: base path: `import "src/A.sol"`), then as a package file in `node_modules`, from the importer's directory up (Hardhat and Node: `hardhat/console.sol`, `@scope/pkg/contracts/X.sol`; a package's `exports` map doesn't apply to - Solidity files). `--mapping` changes none of these lookups. + Solidity files). `--mapping` changes none of these lookups: a root + `foundry.toml` still gives the `libs` (the default ones, warned, when forge + would reject the file), and `FOUNDRY_PROFILE` picking them is reported. Dependencies are input the project didn't write, so whatever resolves an import, the result must be a `.sol` file inside the bundle root (an `import -".env";` or a remapping to `/opt/x/` is refused, stating why), and an import -from a dependency — a file under forge's `libs`, Soldeer's `dependencies/`, a -git submodule or any `node_modules` — must land, by real path, on a dependency's -file too: a dependency may import another (forge-std's `ds-test`), never the -project's own files, whether through a relative path, a base-path lookup, its -own remappings or a symlink. A dependency's `foundry.toml` whose `extends` -lies outside it is skipped with a warning. +".env";` or a remapping to `/opt/x/` is refused, stating why), and who owns a +file is decided by where it really is. The dependencies are the entries of +forge's `libs` (a symlinked `lib/forge-std` is the dependency where it points), +Soldeer's `dependencies/`, git submodules and every `node_modules` package; a +file is a dependency's when its real path lies in one, however the path got +there (`src/vendor -> ../lib/dep/src` holds the dependency's code). An import +from a dependency must land on a dependency's file too: it may import its own +files and another dependency's (forge-std's `ds-test`), never the project's, +whether through a relative path, a base-path lookup, its own remappings or a +symlink. A symlink planted inside a dependency that leads out of it to anything +but another dependency (`lib/evil/src/Evil.sol -> ../../../.env`) is never +followed, whoever's import, entry or manifest the path is: the import is +refused, the entry rejected, the manifest not carried, and the dependency's own +`foundry.toml`, its `extends` base or its `remappings.txt` skipped with a +warning. A link the project placed (a workspace package linked into +`node_modules`, a linked `lib/` entry, `src/vendor`) may lead anywhere in the +root; a workspace package is the project's own code. The config files are read, not bundled. `--manifests` bundles the build description too: the `*.toml`/`*.txt` config files the resolution read, the diff --git a/stasis/bin/stasis.js b/stasis/bin/stasis.js index dbd70abe..1e6dfb0f 100755 --- a/stasis/bin/stasis.js +++ b/stasis/bin/stasis.js @@ -253,6 +253,8 @@ if (command === '-v' || command === '--version') { const { isDir } = await import('../src/resolve-typescript.js') const dirEntries = argv.filter((f) => isDir(resolve(f)) || (extname(f) === '' && !existsSync(resolve(f)))) const allSol = argv.every((f) => f.endsWith('.sol') || dirEntries.includes(f)) + // Only missing extensionless paths: a mistyped file, not a project without these dirs. + if (allSol && argv.every((f) => !f.endsWith('.sol') && !existsSync(resolve(f)))) usage(`Error: no such file or directory: ${argv[0]}`) if (dirEntries.length > 0 && !allSol) { const missing = dirEntries.find((f) => !existsSync(resolve(f))) usage(missing === undefined diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index 7c63e489..b5b2455f 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -232,9 +232,11 @@ function assembleCodeBundle({ // The build-description files of a Solidity bundle (--manifests), as Map: `configFiles` // (what discoverSolidityConfig read, when named `*.toml`/`*.txt`) plus the SOLIDITY_*_MANIFESTS // that exist, for the root and for each package dir `classifyDep`/package.json places a bundled -// source in. Files inside the root only, carried as written: whatever they hold (an RPC URL with -// its API key, an Etherscan key, a URL's credentials) is in the bundle too, as with --package-json. -function solidityManifests(baseDir, sources, configFiles, classifyDep, host) { +// source in. Files inside the root only, and none reached through a link a dependency planted out +// of itself (`ownership`, see solidityOwnership). Carried as written: whatever they hold (an RPC +// URL with its API key, an Etherscan key, a URL's credentials) is in the bundle too, as with +// --package-json. +function solidityManifests(baseDir, sources, configFiles, classifyDep, ownership) { const wanted = new Set([...configFiles.filter((f) => f.endsWith('.toml') || f.endsWith('.txt')), ...SOLIDITY_ROOT_MANIFESTS]) const dirs = new Set() for (const path of sources.keys()) { @@ -250,6 +252,11 @@ function solidityManifests(baseDir, sources, configFiles, classifyDep, host) { const out = new Map() for (const rel of [...wanted].toSorted()) { if (sources.has(rel) || posix.isAbsolute(rel) || rel.startsWith('../')) continue + const { escape } = ownership.of(rel) + if (escape) { + console.warn(`[stasis] Not carrying ${rel}: ${escape.link} is a link out of the dependency ${escape.root}`) + continue + } let buf try { assertRealPathWithinBase(realBase, baseDir, rel, host) @@ -289,11 +296,11 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin } const expanded = expandSolidityEntries(baseDir, normalized, host) - const { remappings, libs, dependencyDirs, files: configFiles, envUsed } = discoverSolidityConfig(baseDir, { mappingFile, env, host }) + const { remappings, libs, ownership, files: configFiles, envUsed } = await discoverSolidityConfig(baseDir, { mappingFile, env }) // The bundle doesn't record the environment, so say when it shaped the resolution. if (envUsed.length > 0) console.warn(`[stasis] Solidity imports resolved with ${envUsed.join(', ')} from the environment`) - const sources = collectSolidityFilesFromDisk(baseDir, expanded, remappings, { libs, dependencyDirs, host }) - const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir, libs, dependencyDirs, host }) + const sources = await collectSolidityFilesFromDisk(baseDir, expanded, remappings, { libs, ownership }) + const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir, libs, ownership }) // Bundles must be self-contained: fail on a missing entry or unresolved import. const issues = [] @@ -311,7 +318,7 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin const bundled = new Map(sources) const formats = new Map() if (manifests) { - for (const [path, text] of solidityManifests(baseDir, sources, configFiles, classifyDep, host)) { + for (const [path, text] of solidityManifests(baseDir, sources, configFiles, classifyDep, ownership)) { bundled.set(path, text) formats.set(path, path.endsWith('.json') ? 'json' : 'resource') } @@ -1001,8 +1008,11 @@ function classifyEntries(name, { cwd = process.cwd(), entries, mappingFile, mani const dirs = entries.filter((e) => isDirEntry(resolve(cwd, e), host)) const files = entries.filter((e) => !dirs.includes(e)) let kind - if (files.every((e) => e.endsWith('.sol'))) kind = 'sol' - else if (dirs.length > 0) { + if (files.every((e) => e.endsWith('.sol'))) { + // Only missing extensionless paths: a mistyped file, not a project without these dirs. + if (files.length === 0 && !dirs.some((e) => host.stat(resolve(cwd, e)) !== null)) throw new Error(`${name}: no such file or directory: ${dirs[0]}`) + kind = 'sol' + } else if (dirs.length > 0) { const missing = dirs.find((e) => host.stat(resolve(cwd, e)) === null) if (missing !== undefined) throw new Error(`${name}: no such file or directory: ${missing}`) throw new Error(`${name}: a directory entry is only supported for Solidity bundles (it stands for the .sol files under it): ${dirs[0]}`) diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index 83d12e1c..a497090c 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -109,8 +109,9 @@ const readDir = (dir, host) => listDir(dir, host).filter((e) => !e.name.startsWi // --- Remapping values ---------------------------------------------------------------------- // `[context:]name=path`, as forge (`Remapping::from_str`) and solc split it: at the first `=`, then -// the first `:` before it. An empty context is global; an empty name or path is invalid (null). -export function parseRemapping(entry) { +// the first `:` before it. An empty context is global; an empty name or path is invalid (null), +// but for solc (`emptyPath`) only an empty name is: `x/=` maps `x/A.sol` to `A.sol`. +export function parseRemapping(entry, { emptyPath = false } = {}) { const eq = entry.indexOf('=') if (eq === -1) return null let name = entry.slice(0, eq) @@ -121,17 +122,18 @@ export function parseRemapping(entry) { context = name.slice(0, colon) name = name.slice(colon + 1) } - if (name.trim() === '' || path.trim() === '') return null + if (name.trim() === '' || (!emptyPath && path.trim() === '')) return null if (context !== null && context.trim() === '') context = null return { context, name, path } } // A remappings.txt / env var body: one remapping per non-blank (trimmed) line; invalid lines // (forge rejects the whole file on one) are skipped, and reported when a `label` names the source. -export function parseRemappingLines(text, label) { +// `options`: see parseRemapping. +export function parseRemappingLines(text, label, options) { const out = [] for (const line of text.split('\n').map((l) => l.trim()).filter(Boolean)) { - const r = parseRemapping(line) + const r = parseRemapping(line, options) if (r) out.push(r) else if (label !== undefined) console.warn(`[loader.solidity] Invalid remapping in ${label}: ${line}`) } @@ -398,11 +400,11 @@ const STANDALONE_SECTIONS = new Set([ // foundry.toml -> `{ profiles, topLevel }`. `profiles` is Map> (profile // names lowercased, keys snake_cased as forge does) from the `[profile.]` tables and the -// legacy top-level `[]` ones forge still reads, the former winning key by key; a profile's -// sub-tables are its values like any other (`extends`, `fuzz`: forge compares them all for a -// `no-collision` extends). `topLevel` holds the values set outside any table (forge rejects those; -// a `--mapping` file may list its `remappings` there). Throws a TomlError naming `file` on text -// that isn't TOML, as forge refuses the file. +// legacy top-level `[]` ones forge still reads (not for `extends`), the former winning key +// by key; a profile's sub-tables are its values like any other (`extends`, `fuzz`: forge compares +// them all for a `no-collision` extends). `topLevel` holds the values set outside any table (forge +// rejects those; a `--mapping` file may list its `remappings` there). Throws a TomlError naming +// `file` on text that isn't TOML, as forge refuses the file. function parseFoundryToml(text, file = null) { const current = new Map() const legacy = new Map() @@ -410,7 +412,10 @@ function parseFoundryToml(text, file = null) { const read = (map, name, table) => { const profile = name.toLowerCase() const dict = map.get(profile) ?? map.set(profile, new Map()).get(profile) - for (const [key, value] of Object.entries(table)) dict.set(snakeCase(key), value) + for (const [key, value] of Object.entries(table)) { + const k = snakeCase(key) + if (k !== 'extends' || map === current) dict.set(k, value) // forge reads `extends` from `[profile.]` only + } } for (const [key, value] of Object.entries(readToml(text, file))) { if (!isTomlTable(value)) topLevel.set(snakeCase(key), value) @@ -438,13 +443,18 @@ function mergeExtended(base, local, strategy) { return out } +// Whether `file`'s real path lies in the real dir `root` (always, with no `root`). +const confinedTo = (file, root) => root === undefined || pathStartsWith(canonicalize(file) ?? file, root) + // A foundry.toml's profiles, with the selected profile's `extends` base merged in (forge's // `TomlFileProvider`). `files` lists what was read; `topLevel` is the file's own (see // parseFoundryToml). Throws where forge refuses the config, and where `confineTo` (a dependency's -// real root) doesn't hold the base: a dependency's config may not read the project's files. -function readFoundryProfiles(file, profile, { confineTo, host }) { - const text = readText(host, file) +// real root) doesn't hold the file or its base (a link out of it): a dependency's config may not +// read the project's files. +function readFoundryProfiles(file, profile, { confineTo } = {}) { + const text = readFileOrNull(file) if (text === null) return { profiles: new Map(), topLevel: new Map(), files: [] } + if (!confinedTo(file, confineTo)) throw new Error(`${file}: refusing to read it, a link out of the dependency`) let { profiles, topLevel } = parseFoundryToml(text, file) const files = [file] const ext = profiles.get(profile)?.get('extends') @@ -452,10 +462,8 @@ function readFoundryProfiles(file, profile, { confineTo, host }) { if (typeof extPath === 'string') { const strategy = (typeof ext === 'object' && typeof ext.strategy === 'string') ? ext.strategy : 'extend-arrays' const baseFile = toPosix(resolve(posix.dirname(file), extPath)) - if (confineTo !== undefined && !pathStartsWith(canonicalize(baseFile, host) ?? baseFile, confineTo)) { - throw new Error(`${file}: refusing to extend ${extPath}, which lies outside the dependency`) - } - const baseText = readText(host, baseFile) + if (!confinedTo(baseFile, confineTo)) throw new Error(`${file}: refusing to extend ${extPath}, which lies outside the dependency`) + const baseText = readFileOrNull(baseFile) if (baseText === null) throw new Error(`${file}: the inherited config file does not exist: ${extPath}`) const base = parseFoundryToml(baseText, baseFile).profiles if (base.get(profile)?.has('extends')) { @@ -574,8 +582,9 @@ function rebaseNested(r, canonical, lexical) { // A dependency's config as forge's `load_nested_config` reads it: remappings rebased onto its // canonical root, its remappings.txt, its src and libs. Null when forge would reject the config, -// or when its `extends` reaches outside the dependency (warned). -function loadNestedConfig(canonical, profile, host) { +// or when it or its `extends` base lies outside the dependency (warned); a remappings.txt that does +// is skipped (warned). +function loadNestedConfig(canonical, profile) { let config try { config = loadFoundryConfig(canonical, profile, { confineTo: canonical, host }) @@ -584,14 +593,19 @@ function loadNestedConfig(canonical, profile, host) { return null } if (config.remappings === null) return null - const text = readText(host, rustJoin(canonical, REMAPPINGS_TXT)) + const txt = rustJoin(canonical, REMAPPINGS_TXT) + let text = readFileOrNull(txt) + if (text !== null && !confinedTo(txt, canonical)) { + console.warn(`[loader.solidity] Skipping a dependency's ${txt}: it is a link out of the dependency`) + text = null + } return { src: config.src, libs: config.libs, - files: [...config.files, ...(text === null ? [] : [rustJoin(canonical, REMAPPINGS_TXT)])], + files: [...config.files, ...(text === null ? [] : [txt])], // `sanitized()` roots them, then `Remapping::from` makes the path absolute and slash-terminated. remappings: config.remappings.map((r) => fromRelative(relativePreservingBoundary(fromRelative({ ...r, path: { parent: null, path: r.path } }), canonical))), - fileRemappings: text === null ? [] : parseRemappingLines(text, rustJoin(canonical, REMAPPINGS_TXT)), + fileRemappings: text === null ? [] : parseRemappingLines(text, txt), } } @@ -751,9 +765,16 @@ function providerRemappings(root, { userRemappings, libs, autoDetect, profile, f } // The lib dirs `forge build` uses for the Foundry project at `baseDir` (its selected profile's -// `libs`, else the detected ones). -export function foundryLibs(baseDir, { env = process.env, host = diskHost } = {}) { - return loadFoundryConfig(toPosix(resolve(baseDir)), foundryProfile(env), { host }).libs +// `libs`, else the detected ones; also those, warned, when forge would reject the foundry.toml: +// with a pinned mapping file, nothing else is read from it). +export function foundryLibs(baseDir, { env = process.env } = {}) { + const root = toPosix(resolve(baseDir)) + try { + return loadFoundryConfig(root, foundryProfile(env)).libs + } catch (err) { + console.warn(`[loader.solidity] Using the default lib dirs: ${err.message}`) + return detectLibs(root) + } } // The Foundry project at `baseDir`: what `forge build` would use. `remappings` are diff --git a/stasis/src/loaders/solidity.js b/stasis/src/loaders/solidity.js index 3647fd45..0b6d8246 100644 --- a/stasis/src/loaders/solidity.js +++ b/stasis/src/loaders/solidity.js @@ -5,9 +5,10 @@ // foundry.js), then a Foundry library's include path, solc's base path (the project root), and // Hardhat's/Node's node_modules lookup. The mapping/config files are read, not added to `sources`. // Dependencies are untrusted input: an import only ever reaches a `.sol` file inside the project, -// and a dependency's imports only other dependencies' files. The project is read through a `host` -// (@exodus/stasis-core/host), the disk's by default. +// a dependency's imports only its own and other dependencies' files (by real path), and nothing +// is read through a link a dependency planted out of itself (solidityOwnership). +import { existsSync, lstatSync, readdirSync, readlinkSync, realpathSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path' @@ -137,13 +138,21 @@ const realpathOrNull = (p, host) => { } } +const readdirOrEmpty = (dir) => { + try { + return readdirSync(dir, { withFileTypes: true }) + } catch { + return [] + } +} + // Loader-side shape: `{ context, prefix, target }` (context null = global). const toLoaderRemapping = ({ context, name, path }) => ({ context, prefix: name, target: path }) // remappings.txt text -> remappings as written, one `[context:]prefix=target` per line (lines -// trimmed; blank and invalid lines skipped). +// trimmed; blank and invalid lines skipped; an empty target is solc's, valid). export function parseRemappings(content) { - return parseRemappingLines(content).map(toLoaderRemapping) + return parseRemappingLines(content, undefined, { emptyPath: true }).map(toLoaderRemapping) } // foundry.toml text -> the `remappings` of `[profile.default]`, overlaid by the selected profile's @@ -161,7 +170,7 @@ function readMapping(mappingFile, { env, forge, host }) { const { remappings, files } = readFoundryTomlRemappings(mappingFile, foundryProfile(env), host) return { remappings: remappings.map(toSolcRemapping), files } } - const listed = parseRemappingLines(host.readFile(mappingFile).toString('utf8'), mappingFile) + const listed = parseRemappingLines(await readFile(mappingFile, 'utf8'), mappingFile, { emptyPath: !forge }) return { remappings: listed.map(forge ? toSolcRemapping : toLoaderRemapping), files: [mappingFile] } } @@ -177,51 +186,152 @@ function gitSubmodulePaths(baseDir, host) { return [...text.matchAll(/^\s*path\s*=\s*(.+?)\s*$/gmu)].map((m) => m[1]) } -// Project-relative, clean, inside the root; each also by its real path (relative to the real root), -// so a symlink can't pass a project file off as a dependency's. -function dependencyDirsOf(baseDir, dirs, host) { - const realBase = host.realpath(baseDir) - const out = new Set() - for (const d of dirs) { - const rel = posix.normalize(toPosix(d)).replace(/\/+$/u, '') - if (rel === '.' || rel === '' || rel === '..' || rel.startsWith('../') || posix.isAbsolute(rel)) continue - out.add(rel) - const real = realpathOrNull(join(baseDir, rel), host) - if (real !== null) out.add(toPosix(relative(realBase, real))) +// --- Ownership ---------------------------------------------------------------------------------- + +// Who owns each project-relative path, decided from how it resolves on disk. The dependencies are +// every `node_modules/` (`@scope/`), each entry of the `dirs` (forge's libs, Soldeer's +// `dependencies/`; a linked entry is the dependency where it points, as a symlinked +// `lib/forge-std`), and the `packages` (git submodules). `of(path)` gives `{ real, outside, +// dependency, escape }`: +// - `real`: the real path (project-relative; null when nothing is there), `outside` when it's out +// of the root; +// - `dependency`: the real path lies in a dependency, however the path got there (a project's +// `src/vendor -> ../lib/dep/src` holds the dependency's code); +// - `escape`: `{ link, root }` when the path crosses a symlink planted inside the dependency `root` +// that leads out of it to anything but another dependency (`lib/evil/src/Evil.sol -> +// ../../../.env`): such a path is never read. A link the project placed (a workspace package in +// node_modules, a linked `lib/` entry) may lead anywhere in the root. +export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { + const realBase = realpathSync.native(baseDir) + const toRel = (abs) => toPosix(relative(realBase, abs)) || '.' + const inRoot = (rel) => rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel) + const inside = (rel) => rel !== '.' && inRoot(rel) + const under = (rel, dir) => rel === dir || rel.startsWith(`${dir}/`) + const realRel = (rel) => { + const real = realpathOrNull(join(baseDir, rel)) + return real === null ? null : toRel(real) } - return [...out] + const clean = (d) => posix.normalize(toPosix(d)).replace(/\/+$/u, '') + + // Dirs whose entries are dependencies, and dependency dirs themselves; each by its real path too. + const holders = new Set() + const roots = new Set() + const addReal = (set, rel) => { + const real = realRel(rel) + if (real !== null && inside(real)) set.add(real) + } + for (const d of dirs.map(clean).filter(inside)) { + if (posix.basename(d) === 'node_modules') continue // a package's own rule, below + holders.add(d) + addReal(holders, d) + for (const e of readdirOrEmpty(join(baseDir, d))) if (e.isSymbolicLink() && isDir(join(baseDir, d, e.name))) addReal(roots, `${d}/${e.name}`) + } + for (const p of packages.map(clean).filter(inside)) { + roots.add(p) + addReal(roots, p) + } + const inDependency = (rel) => inside(rel) && (rel.split('/').includes('node_modules') || [...holders, ...roots].some((d) => under(rel, d))) + // The innermost dependency holding `rel`, a real path. + const rootOf = (rel) => { + if (!inside(rel)) return null + const parts = rel.split('/') + let best = null + const take = (r) => { + if (best === null || r.length > best.length) best = r + } + for (let i = 0; i < parts.length; i++) { + const end = i + (parts[i + 1]?.startsWith('@') ? 3 : 2) + if (parts[i] === 'node_modules' && end <= parts.length) take(parts.slice(0, end).join('/')) + } + for (const d of holders) if (rel.startsWith(`${d}/`)) take(`${d}/${rel.slice(d.length + 1).split('/')[0]}`) + for (const r of roots) if (under(rel, r)) take(r) + return best + } + + // Resolve `parts` from the real dir `start` as realpath does, checking each symlink crossed + // (and those its target crosses): `{ abs, escape }`, `abs` null when nothing is there. + const walk = (start, parts, depth) => { + let cur = start + for (const part of parts) { + if (part === '' || part === '.') continue + if (part === '..') { + cur = dirname(cur) + continue + } + const next = join(cur, part) + let target + try { + if (!lstatSync(next).isSymbolicLink()) { + cur = next + continue + } + target = readlinkSync(next) + } catch { + return { abs: null, escape: null } + } + if (depth >= 40) return { abs: null, escape: null } // ELOOP + const r = walk(isAbsolute(target) ? '/' : cur, toPosix(target).split('/'), depth + 1) + if (r.abs === null || r.escape !== null) return r + const root = rootOf(toRel(cur)) + const to = toRel(r.abs) + if (root !== null && !under(to, root) && !inDependency(to)) return { abs: r.abs, escape: { link: toRel(next), root } } + cur = r.abs + } + return { abs: cur, escape: null } + } + + const owners = new Map() + const of = (rel) => { + let owner = owners.get(rel) + if (owner === undefined) { + const { abs, escape } = walk(realBase, rel.split('/'), 0) + const real = abs === null ? null : toRel(abs) + owner = { real, outside: real !== null && !inRoot(real), dependency: real !== null && inDependency(real), escape } + owners.set(rel, owner) + } + return owner + } + return { of } } +// Why a path crossing a dependency's link out of itself is refused (see solidityOwnership). +const escapeReason = (path, { link, root }) => + link === path ? `${path} is a link out of the dependency ${root}` : `it resolves to ${path} through ${link}, a link out of the dependency ${root}` + +// --- Resolution --------------------------------------------------------------------------------- + // What resolves the imports of the project at `baseDir`: -// `{ remappings, libs, dependencyDirs, files, envUsed }`. +// `{ remappings, libs, ownership, files, envUsed }`. // - `mappingFile` (foundry.toml / remappings.txt): exactly the remappings it lists (see readMapping). // - else, with a foundry.toml at the root: what `forge build` uses (foundry.js) -- remappings.txt, // the profile's remappings, dependencies' own configs, auto-detected `lib/` remappings and their // contexts. // - else a remappings.txt at the root (solc / Hardhat 3), taken as written. // `libs` are forge's lib dirs whenever the root has a foundry.toml (an absolute import inside a -// library resolves against it); `dependencyDirs` the dirs holding dependencies (forge's libs, -// Soldeer's `dependencies/`, git submodules; a `node_modules` dir always is one); `files` the -// project-relative config files read; `envUsed` the environment variables that shaped the result. -export function discoverSolidityConfig(baseDir, { mappingFile, env = process.env, host = diskHost } = {}) { - const forge = isFile(join(baseDir, FOUNDRY_TOML), host) - const project = forge && !mappingFile ? foundryProject(baseDir, { env, host }) : null - const libs = project?.libs ?? (forge ? foundryLibs(baseDir, { env, host }) : []) - const dependencyDirs = dependencyDirsOf(baseDir, [...libs, ...(forge ? ['dependencies'] : []), ...gitSubmodulePaths(baseDir, host)], host) - if (project) return { remappings: project.remappings, libs, dependencyDirs, files: project.files, envUsed: project.envUsed } +// library resolves against it); `ownership` tells the dependencies' files from the project's +// (solidityOwnership: forge's libs, Soldeer's `dependencies/`, git submodules, node_modules); +// `files` the project-relative config files read; `envUsed` the environment variables that +// shaped the result. +export async function discoverSolidityConfig(baseDir, { mappingFile, env = process.env } = {}) { + const forge = isFile(join(baseDir, FOUNDRY_TOML)) + const project = forge && !mappingFile ? foundryProject(baseDir, { env }) : null + const libs = project?.libs ?? (forge ? foundryLibs(baseDir, { env }) : []) + const ownership = solidityOwnership(baseDir, { dirs: [...libs, ...(forge ? ['dependencies'] : [])], packages: gitSubmodulePaths(baseDir) }) + if (project) return { remappings: project.remappings, libs, ownership, files: project.files, envUsed: project.envUsed } const within = (abs) => { const rel = toPosix(relative(baseDir, abs)) return rel.startsWith('..') || isAbsolute(rel) ? [] : [rel] } if (mappingFile) { const abs = resolve(baseDir, mappingFile) - const { remappings, files } = readMapping(abs, { env, forge, host }) - const envUsed = abs.endsWith('.toml') && env.FOUNDRY_PROFILE ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : [] - return { remappings, libs, dependencyDirs, files: files.flatMap(within), envUsed } + const { remappings, files } = await readMapping(abs, { env, forge }) + // The profile picks the mapping file's remappings (a .toml) or the root foundry.toml's libs. + const envUsed = (forge || abs.endsWith('.toml')) && env.FOUNDRY_PROFILE ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : [] + return { remappings, libs, ownership, files: files.flatMap(within), envUsed } } const txt = join(baseDir, REMAPPINGS_TXT) - const remappings = isFile(txt, host) ? readMapping(txt, { env, forge, host }).remappings : [] - return { remappings, libs, dependencyDirs, files: isFile(txt, host) ? [REMAPPINGS_TXT] : [], envUsed: [] } + const remappings = isFile(txt) ? (await readMapping(txt, { env, forge })).remappings : [] + return { remappings, libs, ownership, files: isFile(txt) ? [REMAPPINGS_TXT] : [], envUsed: [] } } // Solc's remapping choice for the source unit `name` imported from `fromFile`: among the @@ -302,13 +412,9 @@ function nodeModulesFile(baseDir, spec, fromFile, host) { } } -// Whether a project-relative path lies in a dependency: in a node_modules dir or one of `dirs`. -const inDependency = (rel, dirs) => rel.split('/').includes('node_modules') || dirs.some((d) => rel === d || rel.startsWith(`${d}/`)) - // Where an import resolves, as `{ path }`, or `{ reason }` when it may not be read (`reason: null`: -// it names no file). See resolveSolImport; `dependencyDirs` (discoverSolidityConfig's) turns on -// the dependency rule, which takes `realBase`, the real path of `baseDir`, where given. -function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [], dependencyDirs, host = diskHost, realBase } = {}) { +// it names no file). See resolveSolImport. +function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [], ownership } = {}) { const relativeImport = isRelativeImport(specifier) const name = relativeImport ? resolveRelativeImport(specifier, fromFile) : specifier if (name === null) return { reason: 'it climbs above the project root' } @@ -321,12 +427,12 @@ function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [ if (path === null) return { reason: null } if (isAbsolute(path) || posix.isAbsolute(path) || path === '..' || path.startsWith('../')) return { reason: `it resolves to ${path}, outside the project root` } if (!path.endsWith('.sol')) return { reason: `it resolves to ${path}, which is not a .sol file` } - if (baseDir && dependencyDirs && inDependency(fromFile, dependencyDirs)) { - const real = realpathOrNull(join(baseDir, path), host) - const rel = real === null ? path : toPosix(relative(realBase ?? host.realpath(baseDir), real)) - if (rel.startsWith('..') || isAbsolute(rel)) return { reason: `it resolves to ${path}, outside the project root` } - if (!inDependency(rel, dependencyDirs)) return { reason: `a dependency may not import the project's own ${path}` } - } + if (!baseDir) return { path } + const own = ownership ?? solidityOwnership(baseDir) + const target = own.of(path) + if (target.escape) return { reason: escapeReason(path, target.escape) } + // (A link out of the root is refused when the file is read.) + if (target.real !== null && !target.outside && !target.dependency && own.of(fromFile).dependency) return { reason: `a dependency may not import the project's own ${path}` } return { path } } @@ -336,8 +442,9 @@ function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [ // is then looked up, when `baseDir` is given, inside the importer's library (forge's include path; // `libs` are forge's lib dirs), as a project file (solc's base path), and through node_modules by // file path (Hardhat / Node). Returns null when nothing resolves, or when the result isn't a `.sol` -// file inside the root, or, with `dependencyDirs`, when a dependency's import lands (by real -// path) on a file that isn't a dependency's. +// file inside the root, crosses a link a dependency planted out of itself, or is the project's own +// file imported by a dependency's -- by real path, with `ownership` (solidityOwnership's; by default +// only node_modules holds dependencies). export function resolveSolImport(specifier, fromFile, options = {}) { return resolveImport(specifier, fromFile, options).path ?? null } @@ -351,18 +458,18 @@ export const SOLIDITY_PACKAGE_MANIFESTS = ['package.json', FOUNDRY_TOML, REMAPPI // --- The walk ----------------------------------------------------------------------------------- // Build `{ sources, resolutions, missing }` from already-loaded Solidity sources plus remappings. -// Imports resolve as resolveSolImport does (`libs`, `dependencyDirs`: see there); as a final +// Imports resolve as resolveSolImport does (`libs`, `ownership`: see there); as a final // fallback a specifier naming no file but matching a stored key verbatim is accepted. `missing` // lists every `{ spec, from }` that didn't resolve or resolved outside `sources`, with the // `reason` when it was refused. -export function buildSolidityTree(sources, { remappings = [], baseDir, libs = [], dependencyDirs, host = diskHost } = {}) { +export function buildSolidityTree(sources, { remappings = [], baseDir, libs = [], ownership = baseDir && solidityOwnership(baseDir) } = {}) { const resolutions = new Map() const missing = [] const options = { remappings, baseDir, libs, dependencyDirs, host, realBase: baseDir && dependencyDirs ? host.realpath(baseDir) : undefined } for (const [path, content] of sources) { const specMap = new Map() for (const spec of extractSolImports(content)) { - const r = resolveImport(spec, path, options) + const r = resolveImport(spec, path, { remappings, baseDir, libs, ownership }) let resolved = r.path && sources.has(r.path) ? r.path : null if (!resolved && !r.reason && sources.has(spec)) resolved = spec if (resolved) { @@ -377,23 +484,33 @@ export function buildSolidityTree(sources, { remappings = [], baseDir, libs = [] return { sources, resolutions, missing } } -// Walk the filesystem from `entries`, following resolved imports and reading each file once, a wave -// at a time: the files of one, then the imports they name. Caller-listed entries are also accepted -// as verbatim non-relative import targets naming no file (Foundry-style `import "src/A.sol"`). -export function collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs = [], dependencyDirs, host = diskHost } = {}) { +// Walk the filesystem from `entries`, following resolved imports and reading each file once +// (same-wave reads run in parallel). Caller-listed entries are also accepted as verbatim +// non-relative import targets naming no file (Foundry-style `import "src/A.sol"`). An entry that +// crosses a dependency's link out of itself (see solidityOwnership) is refused. +export async function collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs = [], ownership = solidityOwnership(baseDir) } = {}) { const sources = new Map() const knownEntries = new Set(entries) - const realBase = host.realpath(baseDir) - const options = { remappings, baseDir, libs, dependencyDirs, host, realBase } - for (let wave = entries; wave.length > 0;) { - const reads = [...new Set(wave)].filter((p) => !sources.has(p)).map((relPath) => { - try { - assertRealPathWithinBase(realBase, baseDir, relPath, host) - return [relPath, host.readFile(join(baseDir, relPath)).toString('utf8')] - } catch (err) { - if (err.code === 'ENOENT') { - console.warn(`[loader.solidity] Missing import: ${relPath}`) - return null + const realBase = realpathSync(baseDir) + for (const entry of entries) { + const { escape } = ownership.of(entry) + if (escape) throw new Error(`Refusing entry ${entry}: ${escapeReason(entry, escape)}`) + } + + const processWave = async (wave) => { + const toLoad = [...new Set(wave)].filter((p) => !sources.has(p)) + if (toLoad.length === 0) return + const reads = await Promise.all( + toLoad.map(async (relPath) => { + try { + assertRealPathWithinBase(realBase, baseDir, relPath) + return [relPath, await readFile(join(baseDir, relPath), 'utf8')] + } catch (err) { + if (err.code === 'ENOENT') { + console.warn(`[loader.solidity] Missing import: ${relPath}`) + return null + } + throw err } throw err } @@ -404,7 +521,7 @@ export function collectSolidityFilesFromDisk(baseDir, entries, remappings, { lib const [relPath, content] = entry sources.set(relPath, content) for (const spec of extractSolImports(content)) { - const r = resolveImport(spec, relPath, options) + const r = resolveImport(spec, relPath, { remappings, baseDir, libs, ownership }) const resolved = r.path ?? (!r.reason && knownEntries.has(spec) ? spec : null) if (resolved) { if (!sources.has(resolved)) next.push(resolved) @@ -453,8 +570,8 @@ function solidityFilesUnder(baseDir, dir, host) { // Project-relative entries with each directory replaced by the `.sol` files under it (deduped, in // order). A `.sol` entry is kept as is (a missing one is reported by the walk); a directory that // is missing or holds no `.sol` file is skipped with a warning, as forge skips an absent `script/`, -// and it's an error only when no entry yields a file. -export function expandSolidityEntries(baseDir, entries, host = diskHost) { +// and it's an error only when no entry yields a file (when none exists, a mistyped path). +export function expandSolidityEntries(baseDir, entries) { const out = new Set() const shown = (entry) => (entry === '.' ? './' : `${entry}/`) for (const e of entries) { @@ -469,6 +586,7 @@ export function expandSolidityEntries(baseDir, entries, host = diskHost) { for (const f of files) out.add(f) } if (out.size === 0) { + if (entries.every((e) => !existsSync(join(baseDir, e)))) throw new Error(`No such file or directory: ${entries[0]}`) throw new Error(`No .sol files under ${entries.map((e) => shown(e === '' ? '.' : e)).join(', ')} (a directory entry stands for the Solidity sources under it)`) } return [...out] @@ -505,7 +623,7 @@ export async function loadSolidity(solTxtFile, { env = process.env } = {}) { const entries = lines.map((l) => l.replace(/^\.\//u, '')) for (const e of entries) assertWithinBase(baseDir, e, 'Entry path') - const { remappings, libs, dependencyDirs } = discoverSolidityConfig(baseDir, { mappingFile, env }) - const sources = collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs, dependencyDirs }) - return buildSolidityTree(sources, { remappings, baseDir, libs, dependencyDirs }) + const { remappings, libs, ownership } = await discoverSolidityConfig(baseDir, { mappingFile, env }) + const sources = await collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs, ownership }) + return buildSolidityTree(sources, { remappings, baseDir, libs, ownership }) } diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 47213e98..a8725920 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -468,7 +468,8 @@ test('buildSolidityBundle keeps a dependency\'s imports inside the dependencies' symlinkSync(join(tmp, 'secrets'), join(tmp, 'lib/evil/src/linked')) await captureStderr(() => t.assert.rejects( () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), - (err) => ['steal/Keys.sol', 'script/Secrets.sol', './linked/Keys.sol'].every((spec) => err.message.includes(`Unresolved import: ${spec} from lib/evil/src/E.sol (refused: a dependency may not import the project's own`)), + (err) => ['steal/Keys.sol', 'script/Secrets.sol'].every((spec) => err.message.includes(`Unresolved import: ${spec} from lib/evil/src/E.sol (refused: a dependency may not import the project's own`)) + && err.message.includes('Unresolved import: ./linked/Keys.sol from lib/evil/src/E.sol (refused: it resolves to lib/evil/src/linked/Keys.sol through lib/evil/src/linked, a link out of the dependency lib/evil)'), )) // The project's own code may import its own files, and a dependency another dependency's. writeFileSync(join(tmp, 'lib/evil/src/E.sol'), 'import "ok/B.sol";\n') @@ -477,6 +478,119 @@ test('buildSolidityBundle keeps a dependency\'s imports inside the dependencies' t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/evil/src/E.sol', 'lib/ok/src/B.sol', 'script/Secrets.sol', 'src/A.sol']) })) +test('buildSolidityBundle never reads through a link a dependency planted out of itself', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + '.env': 'PRIVATE_KEY=0xabc\n', + 'src/A.sol': 'import "evil/Evil.sol";\n', + // A dependency's remapping may route the project's own `forge-std/` imports into it. + 'src/B.sol': 'import "forge-std/Test.sol";\n', + 'lib/evil/foundry.toml': '[profile.default]\n', + 'lib/evil/remappings.txt': 'forge-std/=src/\n', + }) + mkdirSync(join(tmp, 'lib/evil/src')) + symlinkSync('../../../.env', join(tmp, 'lib/evil/src/Evil.sol')) + symlinkSync('../../../.env', join(tmp, 'lib/evil/src/Test.sol')) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + (err) => err.message.includes('Unresolved import: evil/Evil.sol from src/A.sol (refused: lib/evil/src/Evil.sol is a link out of the dependency lib/evil)') + && err.message.includes('Unresolved import: forge-std/Test.sol from src/B.sol (refused: lib/evil/src/Test.sol is a link out of the dependency lib/evil)'), + )) + // Nor as an entry, nor when the project reaches the dependency through a link of its own. + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['lib/evil/src'], env: {} }), /Refusing entry lib\/evil\/src\/Evil\.sol: lib\/evil\/src\/Evil\.sol is a link out of the dependency lib\/evil/u) + writeProject(tmp, { 'src/A.sol': 'import "./vendor/Evil.sol";\n', 'src/B.sol': 'contract B {}\n' }) + symlinkSync('../lib/evil/src', join(tmp, 'src/vendor')) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src/A.sol'], env: {} }), + /refused: it resolves to src\/vendor\/Evil\.sol through lib\/evil\/src\/Evil\.sol, a link out of the dependency lib\/evil/u, + )) +})) + +test('buildSolidityBundle treats a dependency reached through a project link as the dependency', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'secrets/Keys.sol': 'contract Keys {}\n', + 'src/A.sol': 'import "./vendor/E.sol";\n', + 'lib/evil/src/E.sol': 'import "./F.sol";\nimport "../../secrets/Keys.sol";\n', + 'lib/evil/src/F.sol': 'contract F {}\n', + }) + symlinkSync('../lib/evil/src', join(tmp, 'src/vendor')) + const { lines } = await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src/A.sol'], env: {} }), + (err) => err.message.includes("Unresolved import: ../../secrets/Keys.sol from src/vendor/E.sol (refused: a dependency may not import the project's own secrets/Keys.sol)"), + )) + // Its own files are still its own. + t.assert.ok(!lines.some((l) => l.includes('./F.sol'))) +})) + +test('buildSolidityBundle lets a linked dependency (workspace package, symlinked lib/) import its own files', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'contracts/A.sol': 'import "@org/lib/A.sol";\n', + 'packages/lib/package.json': '{"name":"@org/lib","version":"1.0.0"}', + 'packages/lib/A.sol': 'import "./B.sol";\n', + 'packages/lib/B.sol': 'contract B {}\n', + }) + mkdirSync(join(tmp, 'node_modules/@org'), { recursive: true }) + symlinkSync('../../packages/lib', join(tmp, 'node_modules/@org/lib')) + let bundle = await buildSolidityBundle({ cwd: tmp, entries: ['contracts'], env: {} }) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['contracts/A.sol', 'node_modules/@org/lib/A.sol', 'node_modules/@org/lib/B.sol']) + + const forge = join(tmp, 'forge') + writeProject(forge, { + 'foundry.toml': '[profile.default]\n', + 'src/A.sol': 'import "forge-std/Test.sol";\nimport "solmate/S.sol";\n', + 'vendor/forge-std/src/Test.sol': 'import "./Vm.sol";\n', + 'vendor/forge-std/src/Vm.sol': 'contract Vm {}\n', + // Another dependency imports the linked one. + 'lib/solmate/src/S.sol': 'import "forge-std/Test.sol";\n', + }) + symlinkSync('../vendor/forge-std', join(forge, 'lib/forge-std')) + bundle = await buildSolidityBundle({ cwd: forge, entries: ['src'], env: {} }) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/forge-std/src/Test.sol', 'lib/forge-std/src/Vm.sol', 'lib/solmate/src/S.sol', 'src/A.sol']) +})) + +test('buildSolidityBundle with manifests carries no dependency config reached through its link out', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + '.env': 'PRIVATE_KEY=0xabc\n', + '.gitmodules': '[submodule "lib/evil"]\n\tpath = lib/evil\n\turl = https://github.com/e/evil\n', + 'src/A.sol': 'import "evil/E.sol";\nimport "evil2/E.sol";\n', + 'lib/evil/src/E.sol': 'contract E {}\n', + 'lib/evil/foundry.toml': '[profile.default]\n', + 'lib/evil2/src/E.sol': 'contract E {}\n', + }) + symlinkSync('../../.env', join(tmp, 'lib/evil/remappings.txt')) + symlinkSync('../../.env', join(tmp, 'lib/evil/package.json')) + symlinkSync('../../.env', join(tmp, 'lib/evil2/foundry.toml')) + const { result: bundle, lines } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} })) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['.gitmodules', 'foundry.toml', 'lib/evil/foundry.toml', 'lib/evil/src/E.sol', 'lib/evil2/src/E.sol', 'src/A.sol']) + // Nor are they read as its config. + t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's") && l.includes('lib/evil/remappings.txt: it is a link out of the dependency'))) + t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's config") && l.includes('lib/evil2/foundry.toml: refusing to read it'))) + t.assert.ok(lines.some((l) => l === '[stasis] Not carrying lib/evil/package.json: lib/evil/package.json is a link out of the dependency lib/evil')) +})) + +test('buildSolidityBundle with --mapping bundles when forge would reject the root foundry.toml', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\nextends = "missing.toml"\n', + 'remappings.txt': 'x/=lib/x/\n', + 'lib/x/X.sol': 'contract X {}\n', + 'src/A.sol': 'import "x/X.sol";\n', + }) + const { result: bundle, lines } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], mappingFile: 'remappings.txt', env: {} })) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/x/X.sol', 'src/A.sol']) + t.assert.ok(lines.some((l) => l.includes('Using the default lib dirs'))) +})) + +test('a missing extensionless entry alone is a mistyped path, not a Solidity directory', withTmp(async (t, tmp) => { + writeProject(tmp, { 'index.js': '' }) + await t.assert.rejects(() => buildBundle({ cwd: tmp, entries: ['indx'] }), /buildBundle: no such file or directory: indx/u) + await captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['indx'] }), /No such file or directory: indx/u)) + const r = runCli(['bundle', 'indx'], { cwd: tmp }) + t.assert.equal(r.status, 1) + t.assert.match(r.stderr, /Error: no such file or directory: indx/u) +})) + test('buildSolidityBundle refuses a remapping target outside the project root', withTmp(async (t, tmp) => { writeProject(tmp, { 'foundry.toml': '[profile.default]\nremappings = ["x/=/opt/evil/", "up/=../elsewhere/"]\n', diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index 7980ade0..801100b4 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -16,6 +16,7 @@ import { parseRemappingsFromToml, readRemappingsFile, resolveSolImport, + solidityOwnership, } from '../stasis/src/loaders/solidity.js' import { findRemappingsWithContext, foundryProject, foundryTomlRemappings } from '../stasis/src/loaders/foundry.js' @@ -629,13 +630,90 @@ test('resolveSolImport refuses a non-.sol target, one outside the root, and a de }, (t, dir) => { t.assert.equal(resolveSolImport('../../.env', 'lib/dep/src/A.sol', { baseDir: dir }), null) t.assert.equal(resolveSolImport('x/Y.sol', 'src/A.sol', { baseDir: dir, remappings: [{ context: null, prefix: 'x/', target: '/abs/' }] }), null) - const opts = { baseDir: dir, libs: ['lib'], dependencyDirs: ['lib'] } + const opts = { baseDir: dir, libs: ['lib'], ownership: solidityOwnership(dir, { dirs: ['lib'] }) } t.assert.equal(resolveSolImport('secret.sol', 'lib/dep/src/A.sol', opts), null) t.assert.equal(resolveSolImport('secret.sol', 'src/Main.sol', opts), 'secret.sol') t.assert.equal(resolveSolImport('../../other/src/B.sol', 'lib/dep/src/A.sol', opts), 'lib/other/src/B.sol') t.assert.equal(resolveSolImport('../../../node_modules/pkg/C.sol', 'lib/dep/src/A.sol', opts), 'node_modules/pkg/C.sol') })) +test('solidityOwnership decides a path\'s owner from where it really is, and catches a dependency\'s link out of itself', withProject({ + '.env': 'K=1\n', + 'secrets/Keys.sol': '', + 'lib/dep/src/A.sol': '', + 'lib/forge-std/src/Test.sol': '', + 'vendor/linked/src/L.sol': '', + 'packages/ws/W.sol': '', + 'node_modules/.pnpm/foo@1/node_modules/foo/F.sol': '', + 'node_modules/.pnpm/bar@1/node_modules/bar/B.sol': '', +}, (t, dir) => { + const link = (target, at) => { + mkdirSync(dirname(join(dir, at)), { recursive: true }) + symlinkSync(target, join(dir, at)) + } + link('../../../.env', 'lib/dep/src/Evil.sol') // planted by the dependency: out of it + link('../../forge-std/src', 'lib/dep/src/fs') // into another dependency: fine + link('../../../secrets', 'lib/dep/node_modules/x') // a package slot inside the dependency is still its own + link('../lib/dep/src', 'src/vendor') // the project's link into the dependency + link('../vendor/linked', 'lib/linked') // a linked lib entry: the dependency is where it points + link('../../packages/ws', 'node_modules/@org/ws') // a workspace package: the project's own + link('.pnpm/foo@1/node_modules/foo', 'node_modules/foo') + link('../../bar@1/node_modules/bar', 'node_modules/.pnpm/foo@1/node_modules/bar') + const { of } = solidityOwnership(dir, { dirs: ['lib'] }) + const owner = (p) => { + const o = of(p) + return o.escape ? `escape ${o.escape.link} (${o.escape.root})` : o.dependency ? 'dependency' : 'project' + } + t.assert.equal(owner('lib/dep/src/A.sol'), 'dependency') + t.assert.equal(owner('lib/dep/src/Evil.sol'), 'escape lib/dep/src/Evil.sol (lib/dep)') + t.assert.equal(owner('lib/dep/src/fs/Test.sol'), 'dependency') + t.assert.equal(owner('lib/dep/node_modules/x/Keys.sol'), 'escape lib/dep/node_modules/x (lib/dep)') + t.assert.equal(owner('src/vendor/A.sol'), 'dependency') + // Reached through the project's own link, the dependency's link out is still caught. + t.assert.equal(owner('src/vendor/Evil.sol'), 'escape lib/dep/src/Evil.sol (lib/dep)') + t.assert.equal(owner('lib/linked/src/L.sol'), 'dependency') + t.assert.equal(owner('vendor/linked/src/L.sol'), 'dependency') + t.assert.equal(owner('node_modules/@org/ws/W.sol'), 'project') + t.assert.equal(owner('node_modules/foo/F.sol'), 'dependency') + t.assert.equal(owner('node_modules/.pnpm/foo@1/node_modules/bar/B.sol'), 'dependency') + t.assert.equal(owner('secrets/Keys.sol'), 'project') + t.assert.deepEqual(of('lib/dep/src/Nope.sol'), { real: null, outside: false, dependency: false, escape: null }) +})) + +test('a remappings.txt taken as written (solc) may map a prefix to nothing', (t) => { + const remappings = parseRemappings('x/=\nctx:y/=\n=z\n') + t.assert.deepEqual(remappings, [{ context: null, prefix: 'x/', target: '' }, { context: 'ctx', prefix: 'y/', target: '' }]) + t.assert.equal(resolveSolImport('x/A.sol', 'src/B.sol', { remappings }), 'A.sol') +}) + +test('a legacy [default] table\'s `extends` is ignored, as forge ignores it', withProject({ + 'foundry.toml': '[default]\nextends = "base.toml"\n', + 'base.toml': '[profile.default]\nremappings = ["x/=lib/elsewhere/"]\n', +}, (t, dir) => { + const { remappings, files } = foundryProject(dir, { env: {} }) + t.assert.deepEqual(files, ['foundry.toml']) + t.assert.deepEqual(remappings, []) +})) + +test('discoverSolidityConfig with a mapping file: a foundry.toml forge rejects still gives lib dirs, and FOUNDRY_PROFILE is reported', withProject({ + 'foundry.toml': '[profile.default]\nextends = "missing.toml"\n', + 'remappings.txt': 'x/=lib/x/\n', +}, async (t, dir) => { + const warn = console.warn + const lines = [] + console.warn = (...a) => lines.push(a.join(' ')) + try { + const config = await discoverSolidityConfig(dir, { mappingFile: 'remappings.txt', env: {} }) + t.assert.deepEqual(config.libs, ['lib']) + t.assert.deepEqual(config.envUsed, []) + // The profile picks the lib dirs, so it's reported. + t.assert.deepEqual((await discoverSolidityConfig(dir, { mappingFile: 'remappings.txt', env: { FOUNDRY_PROFILE: 'ci' } })).envUsed, ['FOUNDRY_PROFILE=ci']) + } finally { + console.warn = warn + } + t.assert.ok(lines.some((l) => l.includes('Using the default lib dirs') && l.includes('missing.toml'))) +})) + test('resolveSolImport starts a library lookup at the importer directory\'s parent, as foundry-compilers does', withProject({ 'lib/dep/src/utils/C.sol': '', 'lib/dep/src/utils/src/B.sol': '', From 33239f4416b8d6de103f77ef93b08d0a3dcf4b01 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:58:43 +0000 Subject: [PATCH 02/20] fix(bundle): close the remaining Solidity leaks Ownership (moved to loaders/solidity-ownership.js, shared with foundry.js): - A link outside the project root that leads back into it is untrusted (a dependency linked from elsewhere, lib/evil -> ../../shared/evil, holding a link to the project's .env), unless it lies on the path the root was named by (a symlinked checkout). - Each path component takes the filesystem's own spelling, so on a case-insensitive filesystem LIB/evil is checked as lib/evil. - .gitmodules is read as git reads it (quotes, escapes, comments, key case, continuations, merged sections); bundle.js's classifier uses the same parser. - A dependency's foundry.toml, extends base and remappings.txt may be another dependency's file (by real path), as for sources: a remappings.txt linked into another dependency is read again. Also: - FOUNDRY_PROFILE is reported only when it names a profile of the root foundry.toml, and warned about otherwise, with or without --mapping. - The directory-entry rules live in one place (directoryEntryError) for the CLI and buildBundle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 55 +++--- stasis/bin/stasis.js | 17 +- stasis/package.json | 1 + stasis/src/cmd/bundle.js | 64 +++---- stasis/src/loaders/foundry.js | 101 ++++++----- stasis/src/loaders/solidity-ownership.js | 217 +++++++++++++++++++++++ stasis/src/loaders/solidity.js | 150 ++-------------- tests/bundle-cmd.test.js | 40 +++++ tests/solidity-loader.test.js | 96 +++++++++- 9 files changed, 483 insertions(+), 258 deletions(-) create mode 100644 stasis/src/loaders/solidity-ownership.js diff --git a/doc/file-formats.md b/doc/file-formats.md index 11928606..5a6e48f5 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -365,8 +365,9 @@ resolve the way solc does under the project's build tool: ones (the former wins key by key; `extends` counts only in the former, as in forge); names match case-insensitively. Not read: `~/.foundry/foundry.toml`, `FOUNDRY_CONFIG` and the other `FOUNDRY_*` - overrides. When `FOUNDRY_PROFILE` or a remapping variable shapes the - result, `stasis bundle` says so on stderr; the bundle doesn't record it. + overrides. When `FOUNDRY_PROFILE` (a profile the `foundry.toml` has; one it + hasn't is warned about) or a remapping variable shapes the result, `stasis + bundle` says so on stderr; the bundle doesn't record it. Without a `foundry.toml`, a root `remappings.txt` applies as written, as solc and Hardhat apply it (`@oz/=lib/oz` makes `@oz/X.sol` `lib/ozX.sol`; `x/=` makes `x/A.sol` `A.sol`). @@ -385,27 +386,35 @@ resolve the way solc does under the project's build tool: `@scope/pkg/contracts/X.sol`; a package's `exports` map doesn't apply to Solidity files). `--mapping` changes none of these lookups: a root `foundry.toml` still gives the `libs` (the default ones, warned, when forge - would reject the file), and `FOUNDRY_PROFILE` picking them is reported. - -Dependencies are input the project didn't write, so whatever resolves an -import, the result must be a `.sol` file inside the bundle root (an `import -".env";` or a remapping to `/opt/x/` is refused, stating why), and who owns a -file is decided by where it really is. The dependencies are the entries of -forge's `libs` (a symlinked `lib/forge-std` is the dependency where it points), -Soldeer's `dependencies/`, git submodules and every `node_modules` package; a -file is a dependency's when its real path lies in one, however the path got -there (`src/vendor -> ../lib/dep/src` holds the dependency's code). An import -from a dependency must land on a dependency's file too: it may import its own -files and another dependency's (forge-std's `ds-test`), never the project's, -whether through a relative path, a base-path lookup, its own remappings or a -symlink. A symlink planted inside a dependency that leads out of it to anything -but another dependency (`lib/evil/src/Evil.sol -> ../../../.env`) is never -followed, whoever's import, entry or manifest the path is: the import is -refused, the entry rejected, the manifest not carried, and the dependency's own -`foundry.toml`, its `extends` base or its `remappings.txt` skipped with a -warning. A link the project placed (a workspace package linked into -`node_modules`, a linked `lib/` entry, `src/vendor`) may lead anywhere in the -root; a workspace package is the project's own code. + would reject the file), and `FOUNDRY_PROFILE` picking them is reported (one + that isn't a profile of the `foundry.toml` is warned about instead, as without + `--mapping`). + +Dependencies are input the project didn't write, so whatever resolves an import, +the result must be a `.sol` file inside the bundle root (an `import ".env";` or +a remapping to `/opt/x/` is refused, stating why), and who owns a file is +decided by where it really is, spelled as the filesystem spells it (on a +case-insensitive one, `LIB/evil` is `lib/evil`). The dependencies are the +entries of forge's `libs` (a symlinked `lib/forge-std` is the dependency where +it points), Soldeer's `dependencies/`, git submodules (`.gitmodules` read as git +reads it: quoted and escaped paths too) and every `node_modules` package; a file +is a dependency's when its real path lies in one, however the path got there +(`src/vendor -> ../lib/dep/src` holds the dependency's code). An import from a +dependency must land on a dependency's file too: it may import its own files and +another dependency's (forge-std's `ds-test`), never the project's, whether +through a relative path, a base-path lookup, its own remappings or a symlink. A +symlink no one trusted placed is never followed: one planted inside a dependency +that leads out of it to anything but another dependency (`lib/evil/src/Evil.sol +-> ../../../.env`), and one outside the project that leads back into it (a +dependency linked from elsewhere, `lib/evil -> ../../shared/evil`, holding a +link to the project's `.env`). Whoever's import, entry or manifest the path is, +the import is refused, the entry rejected, the manifest not carried, and a +dependency's own `foundry.toml`, `extends` base or `remappings.txt` skipped with +a warning (one that is another dependency's file is read). A link the project +placed (a workspace package linked into `node_modules`, a linked `lib/` entry, +`src/vendor`) may lead anywhere in the root, and so may one on the path the +project was named by (a symlinked checkout); a workspace package is the +project's own code. The config files are read, not bundled. `--manifests` bundles the build description too: the `*.toml`/`*.txt` config files the resolution read, the diff --git a/stasis/bin/stasis.js b/stasis/bin/stasis.js index 1e6dfb0f..b74fdb3b 100755 --- a/stasis/bin/stasis.js +++ b/stasis/bin/stasis.js @@ -3,7 +3,7 @@ import { spawn } from 'node:child_process' import { once } from 'node:events' import { fileURLToPath } from 'node:url' -import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path' +import { basename, dirname, isAbsolute, join, resolve } from 'node:path' import { existsSync, realpathSync } from 'node:fs' import { homedir, constants as osConstants } from 'node:os' import assert from 'node:assert/strict' @@ -250,17 +250,10 @@ if (command === '-v' || command === '--version') { if (argv.length === 0) usage('Nothing to bundle: no entry file given') // A directory entry stands for the .sol files under it (Solidity only); an extensionless path // that doesn't exist is a missing one (skipped with a warning). - const { isDir } = await import('../src/resolve-typescript.js') - const dirEntries = argv.filter((f) => isDir(resolve(f)) || (extname(f) === '' && !existsSync(resolve(f)))) - const allSol = argv.every((f) => f.endsWith('.sol') || dirEntries.includes(f)) - // Only missing extensionless paths: a mistyped file, not a project without these dirs. - if (allSol && argv.every((f) => !f.endsWith('.sol') && !existsSync(resolve(f)))) usage(`Error: no such file or directory: ${argv[0]}`) - if (dirEntries.length > 0 && !allSol) { - const missing = dirEntries.find((f) => !existsSync(resolve(f))) - usage(missing === undefined - ? `Error: a directory entry is only supported for Solidity bundles (it stands for the .sol files under it): ${dirEntries[0]}` - : `Error: no such file or directory: ${missing}`) - } + const { directoryEntryError, isDirEntry } = await import('../src/cmd/bundle.js') + const dirError = directoryEntryError(argv) + if (dirError !== null) usage(`Error: ${dirError}`) + const allSol = argv.every((f) => f.endsWith('.sol') || isDirEntry(resolve(f))) const allPhp = argv.every((f) => f.endsWith('.php')) const allJs = argv.every((f) => /\.(?:js|cjs|mjs|ts|cts|mts)$/u.test(f)) const allBash = argv.every((f) => /\.(?:sh|bash)$/u.test(f)) diff --git a/stasis/package.json b/stasis/package.json index 1698a00d..1e11fe95 100644 --- a/stasis/package.json +++ b/stasis/package.json @@ -43,6 +43,7 @@ "src/loaders/foundry.js", "src/loaders/rust.js", "src/loaders/solidity.js", + "src/loaders/solidity-ownership.js", "src/loaders/toml.js", "src/loaders/php.js", "src/lockfile.js", diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index b5b2455f..a3cd334d 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -24,6 +24,7 @@ import { discoverSolidityConfig, expandSolidityEntries, } from '../loaders/solidity.js' +import { parseGitmodules } from '../loaders/solidity-ownership.js' import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js' import { buildRustTree, collectRustFilesFromDisk } from '../loaders/rust.js' import { VENDOR_DIR as CARGO_VENDOR_DIR, createCargoContext } from '../loaders/cargo.js' @@ -86,34 +87,14 @@ function githubSlug(url) { return m ? `${m[1]}/${m[2]}` : null } -// Parse `.gitmodules` (git-config INI) into Map, -// github.com submodules only. -function parseGithubSubmodules(baseDir, host) { +// `.gitmodules` (as git reads it) -> Map, github.com submodules +// only. +function parseGithubSubmodules(baseDir) { const byPath = new Map() - const text = readText(host, join(baseDir, '.gitmodules')) - if (!text) return byPath - let cur = null - const flush = () => { - if (cur?.path && cur?.url) { - const name = githubSlug(cur.url) - if (name) byPath.set(cur.path.replace(/\/+$/u, ''), { name, branch: cur.branch }) - } - cur = null - } - for (const raw of text.split('\n')) { - const line = raw.trim() - if (line.startsWith('[')) { - flush() - cur = line.startsWith('[submodule') ? {} : null - continue - } - if (!cur) continue - const eq = line.indexOf('=') - if (eq === -1) continue - const key = line.slice(0, eq).trim() - if (key === 'path' || key === 'url' || key === 'branch') cur[key] = line.slice(eq + 1).trim() + for (const { path, url, branch } of parseGitmodules(readFileSyncOrNull(join(baseDir, '.gitmodules')) ?? '')) { + const name = path && url ? githubSlug(url) : null + if (name) byPath.set(path.replace(/\/+$/u, ''), { name, branch }) } - flush() return byPath } @@ -273,7 +254,21 @@ function solidityManifests(baseDir, sources, configFiles, classifyDep, ownership // An entry `stasis bundle` takes for a directory: one that is, or an extensionless path that // doesn't exist (a project without `script/` still bundles with `src test script`). -const isDirEntry = (abs, host = diskHost) => isDir(abs, host) || (extname(abs) === '' && host.stat(abs) === null) +export const isDirEntry = (abs, host = diskHost) => isDir(abs, host) || (extname(abs) === '' && host.stat(abs) === null) + +// What's wrong with `entries`' directory entries (resolved against `cwd`), or null: a directory +// entry stands for the .sol files under it, so it goes with Solidity entries only; and entries +// that are all missing extensionless paths are a mistyped file, not a project without those dirs. +export function directoryEntryError(entries, cwd = process.cwd(), host = diskHost) { + const exists = (e) => host.stat(resolve(cwd, e)) !== null + const dirs = entries.filter((e) => isDirEntry(resolve(cwd, e), host)) + const missing = dirs.find((e) => !exists(e)) + if (dirs.length === entries.length && !dirs.some(exists)) return `no such file or directory: ${dirs[0]}` + if (dirs.length === 0 || entries.every((e) => e.endsWith('.sol') || dirs.includes(e))) return null + return missing === undefined + ? `a directory entry is only supported for Solidity bundles (it stands for the .sol files under it): ${dirs[0]}` + : `no such file or directory: ${missing}` +} // Build an in-memory Bundle from entry .sol files and directories (a directory stands for the .sol // files under it, as forge's src/test/script dirs and Hardhat's contracts dir do; a missing or @@ -1005,18 +1000,11 @@ function classifyEntries(name, { cwd = process.cwd(), entries, mappingFile, mani if (!Array.isArray(entries) || entries.length === 0) { throw new Error(`${name}: at least one entry file is required`) } - const dirs = entries.filter((e) => isDirEntry(resolve(cwd, e), host)) - const files = entries.filter((e) => !dirs.includes(e)) + const dirError = directoryEntryError(entries, cwd, host) + if (dirError !== null) throw new Error(`${name}: ${dirError}`) let kind - if (files.every((e) => e.endsWith('.sol'))) { - // Only missing extensionless paths: a mistyped file, not a project without these dirs. - if (files.length === 0 && !dirs.some((e) => host.stat(resolve(cwd, e)) !== null)) throw new Error(`${name}: no such file or directory: ${dirs[0]}`) - kind = 'sol' - } else if (dirs.length > 0) { - const missing = dirs.find((e) => host.stat(resolve(cwd, e)) === null) - if (missing !== undefined) throw new Error(`${name}: no such file or directory: ${missing}`) - throw new Error(`${name}: a directory entry is only supported for Solidity bundles (it stands for the .sol files under it): ${dirs[0]}`) - } else if (entries.every((e) => e.endsWith('.php'))) kind = 'php' + if (entries.every((e) => e.endsWith('.sol') || isDirEntry(resolve(cwd, e), host))) kind = 'sol' + else if (entries.every((e) => e.endsWith('.php'))) kind = 'php' else if (entries.every((e) => JS_EXTS.has(extname(e)))) kind = 'js' else if (entries.every((e) => BASH_EXTS.has(extname(e)))) kind = 'bash' else if (entries.every((e) => RUST_EXTS.has(extname(e)))) kind = 'rust' diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index a497090c..2c9532cd 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -19,6 +19,8 @@ import { readText } from '@exodus/stasis-core/bundle-util' import { diskHost } from '@exodus/stasis-core/host' import { toPosix } from '@exodus/stasis-core/util' import { isDir } from '../resolve-typescript.js' +import { readFileOrNull } from './cargo.js' +import { projectOwnership } from './solidity-ownership.js' import { isTomlTable, readToml } from './toml.js' export const FOUNDRY_TOML = 'foundry.toml' @@ -443,18 +445,15 @@ function mergeExtended(base, local, strategy) { return out } -// Whether `file`'s real path lies in the real dir `root` (always, with no `root`). -const confinedTo = (file, root) => root === undefined || pathStartsWith(canonicalize(file) ?? file, root) - // A foundry.toml's profiles, with the selected profile's `extends` base merged in (forge's // `TomlFileProvider`). `files` lists what was read; `topLevel` is the file's own (see -// parseFoundryToml). Throws where forge refuses the config, and where `confineTo` (a dependency's -// real root) doesn't hold the file or its base (a link out of it): a dependency's config may not -// read the project's files. -function readFoundryProfiles(file, profile, { confineTo } = {}) { +// parseFoundryToml). Throws where forge refuses the config, and where `readable` (a dependency's +// config: see findNestedFoundryRemappings) refuses the file or its base: a dependency's config may +// not read the project's files. +function readFoundryProfiles(file, profile, { readable } = {}) { const text = readFileOrNull(file) if (text === null) return { profiles: new Map(), topLevel: new Map(), files: [] } - if (!confinedTo(file, confineTo)) throw new Error(`${file}: refusing to read it, a link out of the dependency`) + if (readable && !readable(file)) throw new Error(`${file}: refusing to read it, a link out of the dependency`) let { profiles, topLevel } = parseFoundryToml(text, file) const files = [file] const ext = profiles.get(profile)?.get('extends') @@ -462,7 +461,7 @@ function readFoundryProfiles(file, profile, { confineTo } = {}) { if (typeof extPath === 'string') { const strategy = (typeof ext === 'object' && typeof ext.strategy === 'string') ? ext.strategy : 'extend-arrays' const baseFile = toPosix(resolve(posix.dirname(file), extPath)) - if (!confinedTo(baseFile, confineTo)) throw new Error(`${file}: refusing to extend ${extPath}, which lies outside the dependency`) + if (readable && !readable(baseFile)) throw new Error(`${file}: refusing to extend ${extPath}, which lies outside the dependency`) const baseText = readFileOrNull(baseFile) if (baseText === null) throw new Error(`${file}: the inherited config file does not exist: ${extPath}`) const base = parseFoundryToml(baseText, baseFile).profiles @@ -498,10 +497,19 @@ export function foundryTomlRemappings(text, profile = 'default') { } // The same for a foundry.toml file, with its `extends` base: what `--mapping=foundry.toml` takes. -// `files` lists what was read. -export function readFoundryTomlRemappings(file, profile = 'default', host = diskHost) { - const read = readFoundryProfiles(toPosix(resolve(file)), profile, { host }) - return { remappings: profileRemappings(read, profile), files: read.files } +// `files` lists what was read; `profiled` whether the selected `profile` is one of the file's. +export function readFoundryTomlRemappings(file, profile = 'default') { + const read = readFoundryProfiles(toPosix(resolve(file)), profile) + return { remappings: profileRemappings(read, profile), files: read.files, profiled: profile !== 'default' && read.profiles.has(profile) } +} + +// Whether the selected `profile` is one of the root foundry.toml's `profiles` (forge uses +// `[profile.default]` for one that isn't: warned). +function profileApplies(profiles, profile) { + if (profile === 'default') return false + if (profiles.has(profile)) return true + console.warn(`[loader.solidity] FOUNDRY_PROFILE=${profile} is not a profile in foundry.toml; using [profile.default]`) + return false } // `ProjectPathsConfig::find_source_dir`: `src` unless only `contracts` exists. @@ -519,10 +527,10 @@ const stringList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'stri // The selected profile's settings for a Foundry project at `root` (absolute POSIX), defaults // filled in the way forge fills them. `remappings` are the profile's own, unnormalized. Null -// `remappings` means one didn't parse (forge rejects such a config). `confineTo`: see +// `remappings` means one didn't parse (forge rejects such a config). `readable`: see // readFoundryProfiles. -function loadFoundryConfig(root, profile, { confineTo, host }) { - const { profiles, files } = readFoundryProfiles(rustJoin(root, FOUNDRY_TOML), profile, { confineTo, host }) +function loadFoundryConfig(root, profile, { readable } = {}) { + const { profiles, files } = readFoundryProfiles(rustJoin(root, FOUNDRY_TOML), profile, { readable }) const dict = selectProfile(profiles, profile) const str = (k) => (typeof dict.get(k) === 'string' ? dict.get(k) : null) const remappings = (stringList(dict.get('remappings')) ?? []).map(parseRemapping) @@ -582,12 +590,12 @@ function rebaseNested(r, canonical, lexical) { // A dependency's config as forge's `load_nested_config` reads it: remappings rebased onto its // canonical root, its remappings.txt, its src and libs. Null when forge would reject the config, -// or when it or its `extends` base lies outside the dependency (warned); a remappings.txt that does -// is skipped (warned). -function loadNestedConfig(canonical, profile) { +// or when `readable` refuses it or its `extends` base (warned); a remappings.txt it refuses is +// skipped (warned). +function loadNestedConfig(canonical, profile, readable) { let config try { - config = loadFoundryConfig(canonical, profile, { confineTo: canonical, host }) + config = loadFoundryConfig(canonical, profile, { readable }) } catch (err) { console.warn(`[loader.solidity] Skipping a dependency's config: ${err.message}`) return null @@ -595,7 +603,7 @@ function loadNestedConfig(canonical, profile) { if (config.remappings === null) return null const txt = rustJoin(canonical, REMAPPINGS_TXT) let text = readFileOrNull(txt) - if (text !== null && !confinedTo(txt, canonical)) { + if (text !== null && !readable(txt)) { console.warn(`[loader.solidity] Skipping a dependency's ${txt}: it is a link out of the dependency`) text = null } @@ -610,9 +618,15 @@ function loadNestedConfig(canonical, profile) { } // `find_nested_foundry_remappings`: `[lexicalLibPath, remapping, isPackageEntry]` for every -// dependency (transitively, through each one's own libs) that is a Foundry project. -function findNestedFoundryRemappings(root, libPaths, profile, files, host) { - const canonicalRoot = canonicalize(root, host) ?? root +// dependency (transitively, through each one's own libs) that is a Foundry project. A dependency's +// config reads only its own files and other dependencies' (by real path: `ownership`, see +// solidityOwnership), as forge would find them from its lexical path. +function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) { + const canonicalRoot = canonicalize(root) ?? root + const readable = (entry) => (file) => { + const o = ownership.of(`${stripPrefix(entry.path, root)}/${posix.relative(entry.canonical, file)}`) + return o.real === null || (o.escape === null && (o.dependency || pathStartsWith(canonicalize(file) ?? file, entry.canonical))) + } // A BTreeSet popped in (canonical, path) order. const pending = new Map() const addPending = (e) => pending.set(`${e.canonical}\0${e.path}`, e) @@ -629,7 +643,7 @@ function findNestedFoundryRemappings(root, libPaths, profile, files, host) { pending.delete(key) if (entry.canonical === canonicalRoot) continue if (!configs.has(entry.canonical)) { - const config = loadNestedConfig(entry.canonical, profile, host) + const config = loadNestedConfig(entry.canonical, profile, readable(entry)) configs.set(entry.canonical, config) // Record what was read under the dependency's lexical path (where the bundle sees it). for (const f of config?.files ?? []) files.add(rustJoin(entry.path, stripPrefix(f, entry.canonical) ?? f)) @@ -711,12 +725,12 @@ const withOverlays = (authoritative, r) => { } // `RemappingsProvider::get_remappings`: the remappings in the order forge settles them. -function providerRemappings(root, { userRemappings, libs, autoDetect, profile, files, host }) { +function providerRemappings(root, { userRemappings, libs, autoDetect, profile, files, ownership }) { const authoritativeUser = userRemappings.map((r) => (r.context === null ? r : { ...r, context: rustJoin(root, r.context) })) const all = new Remappings([...userRemappings]) if (!autoDetect) return all.intoInner() - const nested = findNestedFoundryRemappings(root, libs, profile, files, host) + const nested = findNestedFoundryRemappings(root, libs, profile, files, ownership) const auto = { global: [], contextual: [] } for (const lib of libs) { const found = findRemappingsWithContext(rustJoin(root, lib), host) @@ -764,31 +778,34 @@ function providerRemappings(root, { userRemappings, libs, autoDetect, profile, f return all.intoInner() } -// The lib dirs `forge build` uses for the Foundry project at `baseDir` (its selected profile's -// `libs`, else the detected ones; also those, warned, when forge would reject the foundry.toml: -// with a pinned mapping file, nothing else is read from it). +// The lib dirs `forge build` uses for the Foundry project at `baseDir`, `{ libs, profiled }`: the +// selected profile's `libs` (`profiled` when that profile is the file's), else the detected ones; +// also those, warned, when forge would reject the foundry.toml (with a pinned mapping file, +// nothing else is read from it). export function foundryLibs(baseDir, { env = process.env } = {}) { const root = toPosix(resolve(baseDir)) + const profile = foundryProfile(env) try { - return loadFoundryConfig(root, foundryProfile(env)).libs + const config = loadFoundryConfig(root, profile) + return { libs: config.libs, profiled: profileApplies(config.profiles, profile) } } catch (err) { console.warn(`[loader.solidity] Using the default lib dirs: ${err.message}`) - return detectLibs(root) + return { libs: detectLibs(root), profiled: false } } } // The Foundry project at `baseDir`: what `forge build` would use. `remappings` are // `{ context, prefix, target }` relative to the root, in forge's order; `libs` the lib dirs; // `files` the config files read (project-relative, when inside the project); `envUsed` the -// environment variables that shaped them. `env` supplies FOUNDRY_PROFILE and FOUNDRY_REMAPPINGS / -// DAPP_REMAPPINGS. -export function foundryProject(baseDir, { env = process.env, host = diskHost } = {}) { +// environment variables that shaped them; `ownership` its files' owners (see solidityOwnership), +// which also confines what a dependency's config reads. `env` supplies FOUNDRY_PROFILE and +// FOUNDRY_REMAPPINGS / DAPP_REMAPPINGS. +export function foundryProject(baseDir, { env = process.env } = {}) { const root = toPosix(resolve(baseDir)) const profile = foundryProfile(env) - const config = loadFoundryConfig(root, profile, { host }) - if (profile !== 'default' && !config.profiles.has(profile)) { - console.warn(`[loader.solidity] FOUNDRY_PROFILE=${profile} is not a profile in foundry.toml; using [profile.default]`) - } + const config = loadFoundryConfig(root, profile) + const profiled = profileApplies(config.profiles, profile) + const ownership = projectOwnership(baseDir, config.libs, { soldeer: true }) if (config.remappings === null) throw new Error(`${rustJoin(root, FOUNDRY_TOML)}: invalid remapping in \`remappings\``) const files = new Set(config.files) @@ -798,7 +815,7 @@ export function foundryProject(baseDir, { env = process.env, host = diskHost } = if (txt !== null) files.add(rustJoin(root, REMAPPINGS_TXT)) const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, REMAPPINGS_TXT)), ...config.remappings] - const provided = providerRemappings(root, { userRemappings, libs: config.libs, autoDetect: config.autoDetect, profile, files, host }) + const provided = providerRemappings(root, { userRemappings, libs: config.libs, autoDetect: config.autoDetect, profile, files, ownership }) .map((r) => displayRelative(relativePreservingBoundary(r, root))) // `forge build` re-reads them as config remappings, dropping aliases of its own input dirs. @@ -814,6 +831,6 @@ export function foundryProject(baseDir, { env = process.env, host = diskHost } = .map(toSolcRemapping) const relFiles = [...files].map((f) => stripPrefix(f, root)).filter((f) => f !== null && f !== '') - const envUsed = [...(env.FOUNDRY_PROFILE ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : []), ...(envName === null ? [] : [envName])] - return { remappings, libs: config.libs, files: relFiles, envUsed } + const envUsed = [...(profiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : []), ...(envName === null ? [] : [envName])] + return { remappings, libs: config.libs, files: relFiles, envUsed, ownership } } diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js new file mode 100644 index 00000000..3b4abc08 --- /dev/null +++ b/stasis/src/loaders/solidity-ownership.js @@ -0,0 +1,217 @@ +// Who owns each file of a Solidity project -- the project or one of its dependencies -- decided by +// where the file really is, for the import resolution (solidity.js), forge's config discovery +// (foundry.js) and the bundler's --manifests. Dependencies are untrusted input: a link one plants +// out of itself is never followed. + +import { lstatSync, readdirSync, readlinkSync, realpathSync } from 'node:fs' +import { isAbsolute, join, parse, posix, relative, resolve, sep } from 'node:path' + +import { toPosix } from '@exodus/stasis-core/util' +import { isDir } from '../resolve-typescript.js' +import { readFileOrNull } from './cargo.js' + +// --- .gitmodules ------------------------------------------------------------------------------ + +const GIT_ESCAPES = { n: '\n', t: '\t', b: '\b' } + +// A git-config value as git reads it: `"` quotes (dropped), `\` escapes, a `#`/`;` comment outside +// quotes, and whitespace trimmed at both ends outside quotes. +function gitConfigValue(raw) { + let out = '' + let held = '' // unquoted whitespace, kept only if more value follows + let quoted = false + for (let i = 0; i < raw.length; i++) { + const ch = raw[i] + if (ch === '\\') { + const next = raw[++i] ?? '' + out += held + (GIT_ESCAPES[next] ?? next) + held = '' + } else if (ch === '"') { + quoted = !quoted + } else if (!quoted && (ch === '#' || ch === ';')) { + break + } else if (!quoted && (ch === ' ' || ch === '\t')) { + if (out !== '') held += ch + } else { + out += held + ch + held = '' + } + } + return out +} + +// `.gitmodules` text -> its submodules, `{ name, path, url, branch }` (those set), as git reads the +// file: keys case-insensitive, values unquoted and unescaped, a line ending in `\` continued, and a +// submodule's sections merged by name. +export function parseGitmodules(text) { + const byName = new Map() + let cur = null + const lines = text.split(/\r?\n/u) + for (let i = 0; i < lines.length; i++) { + let line = lines[i] + while (/(?:^|[^\\])(?:\\\\)*\\$/u.test(line) && i + 1 < lines.length) line = line.slice(0, -1) + lines[++i] + const header = /^\s*\[\s*([\w.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\]/u.exec(line) + if (header) { + const section = header[1].toLowerCase() + let name = null + if (section === 'submodule' && header[2] !== undefined) name = header[2].replaceAll(/\\(.)/gu, '$1') + else if (section.startsWith('submodule.')) name = header[1].slice('submodule.'.length) + cur = name === null ? null : (byName.get(name) ?? byName.set(name, { name }).get(name)) + continue + } + const pair = cur && /^\s*([a-z][\w-]*)\s*(?:=(.*))?$/iu.exec(line) + if (!pair) continue + const key = pair[1].toLowerCase() + if (key === 'path' || key === 'url' || key === 'branch') cur[key] = gitConfigValue(pair[2] ?? '') + } + return [...byName.values()] +} + +// The directories of `.gitmodules`' submodules: dependencies, whatever their host. +export function gitSubmodulePaths(baseDir) { + return parseGitmodules(readFileOrNull(join(baseDir, '.gitmodules')) ?? '').map((s) => s.path).filter(Boolean) +} + +// --- Ownership -------------------------------------------------------------------------------- + +const realpathOrNull = (p) => { + try { + return realpathSync.native(p) + } catch { + return null + } +} + +const readdirOrEmpty = (dir) => { + try { + return readdirSync(dir, { withFileTypes: true }) + } catch { + return [] + } +} + +const NOTHING = { abs: null, escape: null } + +// Who owns each project-relative path, decided from how it resolves on disk. The dependencies are +// every `node_modules/` (`@scope/`), each entry of the `dirs` (forge's libs, Soldeer's +// `dependencies/`; a linked entry is the dependency where it points, as a symlinked +// `lib/forge-std`), and the `packages` (git submodules). `of(path)` gives `{ real, outside, +// dependency, escape }`: +// - `real`: the real path, spelled as the filesystem spells it (project-relative; null when +// nothing is there), `outside` when it's out of the root; +// - `dependency`: the real path lies in a dependency, however the path got there (a project's +// `src/vendor -> ../lib/dep/src` holds the dependency's code); +// - `escape`: `{ link, root }` when the path crosses a symlink that no one trusted placed: one +// planted inside the dependency `root` that leads out of it to anything but another dependency +// (`lib/evil/src/Evil.sol -> ../../../.env`), or one outside the project (`root` null) that leads +// back into it (a dependency linked from elsewhere: `lib/evil -> ../../shared/evil` holding +// `Evil.sol -> ../../proj/.env`). Such a path is never read. A link the project placed (a +// workspace package in node_modules, a linked `lib/` entry) may lead anywhere in the root, and so +// may one on the path the project was named by (a symlinked checkout, macOS's `/tmp`). +export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { + const realBase = realpathSync.native(baseDir) + const named = resolve(baseDir) + const onNamedPath = (abs) => named === abs || named.startsWith(abs.endsWith(sep) ? abs : `${abs}${sep}`) + const toRel = (abs) => toPosix(relative(realBase, abs)) || '.' + const inRoot = (rel) => rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel) + const inside = (rel) => rel !== '.' && inRoot(rel) + const under = (rel, dir) => rel === dir || rel.startsWith(`${dir}/`) + const clean = (d) => posix.normalize(toPosix(d)).replace(/\/+$/u, '') + + // Dirs whose entries are dependencies, and dependency dirs themselves; each by its real path too. + const holders = new Set() + const roots = new Set() + const addReal = (set, rel) => { + const real = realpathOrNull(join(baseDir, rel)) + if (real !== null && inside(toRel(real))) set.add(toRel(real)) + } + for (const d of dirs.map(clean).filter(inside)) { + if (posix.basename(d) === 'node_modules') continue // a package's own rule, below + holders.add(d) + addReal(holders, d) + for (const e of readdirOrEmpty(join(baseDir, d))) if (e.isSymbolicLink() && isDir(join(baseDir, d, e.name))) addReal(roots, `${d}/${e.name}`) + } + for (const p of packages.map(clean).filter(inside)) { + roots.add(p) + addReal(roots, p) + } + const inDependency = (rel) => inside(rel) && (rel.split('/').includes('node_modules') || [...holders, ...roots].some((d) => under(rel, d))) + // The innermost dependency holding `rel`, a real path. + const rootOf = (rel) => { + if (!inside(rel)) return null + const parts = rel.split('/') + let best = null + const take = (r) => { + if (best === null || r.length > best.length) best = r + } + for (let i = 0; i < parts.length; i++) { + const end = i + (parts[i + 1]?.startsWith('@') ? 3 : 2) + if (parts[i] === 'node_modules' && end <= parts.length) take(parts.slice(0, end).join('/')) + } + for (const d of holders) if (rel.startsWith(`${d}/`)) take(`${d}/${rel.slice(d.length + 1).split('/')[0]}`) + for (const r of roots) if (under(rel, r)) take(r) + return best + } + + // Resolve `parts` from the real dir `start` as realpath does, checking each symlink crossed + // (and those its target crosses): `{ abs, escape }`, `abs` null when nothing is there. Each + // component takes the filesystem's spelling (a case-insensitive one finds `lib` for `LIB`). + const walk = (start, parts, depth) => { + let cur = start + for (const part of parts) { + if (part === '' || part === '.') continue + if (part === '..') { + cur = parse(cur).root === cur ? cur : join(cur, '..') + continue + } + const next = join(cur, part) + let target + try { + if (!lstatSync(next).isSymbolicLink()) { + cur = realpathOrNull(next) ?? next + continue + } + target = readlinkSync(next) + } catch { + return NOTHING + } + if (depth >= 40) return NOTHING // ELOOP + const r = walk(isAbsolute(target) ? parse(target).root : cur, target.split(/[\\/]/u), depth + 1) + if (r.abs === null || r.escape !== null) return r + const at = toRel(next) + const to = toRel(r.abs) + if (inside(at)) { + const root = rootOf(toRel(cur)) + if (root !== null && !under(to, root) && !inDependency(to)) return { abs: r.abs, escape: { link: at, root } } + } else if (inRoot(to) && !onNamedPath(next)) { + return { abs: r.abs, escape: { link: at, root: null } } + } + cur = r.abs + } + return { abs: cur, escape: null } + } + + const owners = new Map() + const of = (rel) => { + let owner = owners.get(rel) + if (owner === undefined) { + const { abs, escape } = walk(realBase, rel.split('/'), 0) + const real = abs === null ? null : toRel(abs) + owner = { real, outside: real !== null && !inRoot(real), dependency: real !== null && inDependency(real), escape } + owners.set(rel, owner) + } + return owner + } + return { of } +} + +// The ownership of the project at `baseDir` given its lib dirs (`soldeer`: forge's `dependencies/` +// holds dependencies too), with its git submodules. +export const projectOwnership = (baseDir, libs, { soldeer = false } = {}) => + solidityOwnership(baseDir, { dirs: [...libs, ...(soldeer ? ['dependencies'] : [])], packages: gitSubmodulePaths(baseDir) }) + +// Why a path crossing an untrusted link is refused (see solidityOwnership). +export function escapeReason(path, { link, root }) { + const what = root === null ? 'a link from outside the project root back into it' : `a link out of the dependency ${root}` + return link === path ? `${path} is ${what}` : `it resolves to ${path} through ${link}, ${what}` +} diff --git a/stasis/src/loaders/solidity.js b/stasis/src/loaders/solidity.js index 0b6d8246..584f3d2e 100644 --- a/stasis/src/loaders/solidity.js +++ b/stasis/src/loaders/solidity.js @@ -8,7 +8,7 @@ // a dependency's imports only its own and other dependencies' files (by real path), and nothing // is read through a link a dependency planted out of itself (solidityOwnership). -import { existsSync, lstatSync, readdirSync, readlinkSync, realpathSync, statSync } from 'node:fs' +import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path' @@ -27,6 +27,9 @@ import { readFoundryTomlRemappings, toSolcRemapping, } from './foundry.js' +import { escapeReason, projectOwnership, solidityOwnership } from './solidity-ownership.js' + +export { solidityOwnership } from './solidity-ownership.js' // --- Import scan ------------------------------------------------------------------------------ @@ -138,14 +141,6 @@ const realpathOrNull = (p, host) => { } } -const readdirOrEmpty = (dir) => { - try { - return readdirSync(dir, { withFileTypes: true }) - } catch { - return [] - } -} - // Loader-side shape: `{ context, prefix, target }` (context null = global). const toLoaderRemapping = ({ context, name, path }) => ({ context, prefix: name, target: path }) @@ -167,8 +162,8 @@ export function parseRemappingsFromToml(tomlContent, { env = process.env } = {}) // Otherwise (solc, Hardhat) a remappings.txt applies as written. function readMapping(mappingFile, { env, forge, host }) { if (mappingFile.endsWith('.toml')) { - const { remappings, files } = readFoundryTomlRemappings(mappingFile, foundryProfile(env), host) - return { remappings: remappings.map(toSolcRemapping), files } + const { remappings, files, profiled } = readFoundryTomlRemappings(mappingFile, foundryProfile(env)) + return { remappings: remappings.map(toSolcRemapping), files, profiled } } const listed = parseRemappingLines(await readFile(mappingFile, 'utf8'), mappingFile, { emptyPath: !forge }) return { remappings: listed.map(forge ? toSolcRemapping : toLoaderRemapping), files: [mappingFile] } @@ -180,124 +175,6 @@ export function readRemappingsFile(mappingFile, { env = process.env, forge = fal return readMapping(mappingFile, { env, forge, host }).remappings } -// The directories of `.gitmodules`' submodules: dependencies, whatever their host. -function gitSubmodulePaths(baseDir, host) { - const text = readText(host, join(baseDir, '.gitmodules')) ?? '' - return [...text.matchAll(/^\s*path\s*=\s*(.+?)\s*$/gmu)].map((m) => m[1]) -} - -// --- Ownership ---------------------------------------------------------------------------------- - -// Who owns each project-relative path, decided from how it resolves on disk. The dependencies are -// every `node_modules/` (`@scope/`), each entry of the `dirs` (forge's libs, Soldeer's -// `dependencies/`; a linked entry is the dependency where it points, as a symlinked -// `lib/forge-std`), and the `packages` (git submodules). `of(path)` gives `{ real, outside, -// dependency, escape }`: -// - `real`: the real path (project-relative; null when nothing is there), `outside` when it's out -// of the root; -// - `dependency`: the real path lies in a dependency, however the path got there (a project's -// `src/vendor -> ../lib/dep/src` holds the dependency's code); -// - `escape`: `{ link, root }` when the path crosses a symlink planted inside the dependency `root` -// that leads out of it to anything but another dependency (`lib/evil/src/Evil.sol -> -// ../../../.env`): such a path is never read. A link the project placed (a workspace package in -// node_modules, a linked `lib/` entry) may lead anywhere in the root. -export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { - const realBase = realpathSync.native(baseDir) - const toRel = (abs) => toPosix(relative(realBase, abs)) || '.' - const inRoot = (rel) => rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel) - const inside = (rel) => rel !== '.' && inRoot(rel) - const under = (rel, dir) => rel === dir || rel.startsWith(`${dir}/`) - const realRel = (rel) => { - const real = realpathOrNull(join(baseDir, rel)) - return real === null ? null : toRel(real) - } - const clean = (d) => posix.normalize(toPosix(d)).replace(/\/+$/u, '') - - // Dirs whose entries are dependencies, and dependency dirs themselves; each by its real path too. - const holders = new Set() - const roots = new Set() - const addReal = (set, rel) => { - const real = realRel(rel) - if (real !== null && inside(real)) set.add(real) - } - for (const d of dirs.map(clean).filter(inside)) { - if (posix.basename(d) === 'node_modules') continue // a package's own rule, below - holders.add(d) - addReal(holders, d) - for (const e of readdirOrEmpty(join(baseDir, d))) if (e.isSymbolicLink() && isDir(join(baseDir, d, e.name))) addReal(roots, `${d}/${e.name}`) - } - for (const p of packages.map(clean).filter(inside)) { - roots.add(p) - addReal(roots, p) - } - const inDependency = (rel) => inside(rel) && (rel.split('/').includes('node_modules') || [...holders, ...roots].some((d) => under(rel, d))) - // The innermost dependency holding `rel`, a real path. - const rootOf = (rel) => { - if (!inside(rel)) return null - const parts = rel.split('/') - let best = null - const take = (r) => { - if (best === null || r.length > best.length) best = r - } - for (let i = 0; i < parts.length; i++) { - const end = i + (parts[i + 1]?.startsWith('@') ? 3 : 2) - if (parts[i] === 'node_modules' && end <= parts.length) take(parts.slice(0, end).join('/')) - } - for (const d of holders) if (rel.startsWith(`${d}/`)) take(`${d}/${rel.slice(d.length + 1).split('/')[0]}`) - for (const r of roots) if (under(rel, r)) take(r) - return best - } - - // Resolve `parts` from the real dir `start` as realpath does, checking each symlink crossed - // (and those its target crosses): `{ abs, escape }`, `abs` null when nothing is there. - const walk = (start, parts, depth) => { - let cur = start - for (const part of parts) { - if (part === '' || part === '.') continue - if (part === '..') { - cur = dirname(cur) - continue - } - const next = join(cur, part) - let target - try { - if (!lstatSync(next).isSymbolicLink()) { - cur = next - continue - } - target = readlinkSync(next) - } catch { - return { abs: null, escape: null } - } - if (depth >= 40) return { abs: null, escape: null } // ELOOP - const r = walk(isAbsolute(target) ? '/' : cur, toPosix(target).split('/'), depth + 1) - if (r.abs === null || r.escape !== null) return r - const root = rootOf(toRel(cur)) - const to = toRel(r.abs) - if (root !== null && !under(to, root) && !inDependency(to)) return { abs: r.abs, escape: { link: toRel(next), root } } - cur = r.abs - } - return { abs: cur, escape: null } - } - - const owners = new Map() - const of = (rel) => { - let owner = owners.get(rel) - if (owner === undefined) { - const { abs, escape } = walk(realBase, rel.split('/'), 0) - const real = abs === null ? null : toRel(abs) - owner = { real, outside: real !== null && !inRoot(real), dependency: real !== null && inDependency(real), escape } - owners.set(rel, owner) - } - return owner - } - return { of } -} - -// Why a path crossing a dependency's link out of itself is refused (see solidityOwnership). -const escapeReason = (path, { link, root }) => - link === path ? `${path} is a link out of the dependency ${root}` : `it resolves to ${path} through ${link}, a link out of the dependency ${root}` - // --- Resolution --------------------------------------------------------------------------------- // What resolves the imports of the project at `baseDir`: @@ -314,19 +191,22 @@ const escapeReason = (path, { link, root }) => // shaped the result. export async function discoverSolidityConfig(baseDir, { mappingFile, env = process.env } = {}) { const forge = isFile(join(baseDir, FOUNDRY_TOML)) - const project = forge && !mappingFile ? foundryProject(baseDir, { env }) : null - const libs = project?.libs ?? (forge ? foundryLibs(baseDir, { env }) : []) - const ownership = solidityOwnership(baseDir, { dirs: [...libs, ...(forge ? ['dependencies'] : [])], packages: gitSubmodulePaths(baseDir) }) - if (project) return { remappings: project.remappings, libs, ownership, files: project.files, envUsed: project.envUsed } + if (forge && !mappingFile) { + const { remappings, libs, ownership, files, envUsed } = foundryProject(baseDir, { env }) + return { remappings, libs, ownership, files, envUsed } + } + const { libs, profiled } = forge ? foundryLibs(baseDir, { env }) : { libs: [], profiled: false } + const ownership = projectOwnership(baseDir, libs, { soldeer: forge }) const within = (abs) => { const rel = toPosix(relative(baseDir, abs)) return rel.startsWith('..') || isAbsolute(rel) ? [] : [rel] } if (mappingFile) { const abs = resolve(baseDir, mappingFile) - const { remappings, files } = await readMapping(abs, { env, forge }) + const read = await readMapping(abs, { env, forge }) + const { remappings, files } = read // The profile picks the mapping file's remappings (a .toml) or the root foundry.toml's libs. - const envUsed = (forge || abs.endsWith('.toml')) && env.FOUNDRY_PROFILE ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : [] + const envUsed = profiled || read.profiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : [] return { remappings, libs, ownership, files: files.flatMap(within), envUsed } } const txt = join(baseDir, REMAPPINGS_TXT) diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index a8725920..874230ee 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -570,6 +570,46 @@ test('buildSolidityBundle with manifests carries no dependency config reached th t.assert.ok(lines.some((l) => l === '[stasis] Not carrying lib/evil/package.json: lib/evil/package.json is a link out of the dependency lib/evil')) })) +test('buildSolidityBundle never follows a link from outside the root back into it', withTmp(async (t, tmp) => { + const proj = join(tmp, 'proj') + writeProject(proj, { 'foundry.toml': '[profile.default]\n', '.env': 'PRIVATE_KEY=0xabc\n', 'src/A.sol': 'import "evil/Evil.sol";\n' }) + mkdirSync(join(tmp, 'shared/evil/src'), { recursive: true }) + symlinkSync('../../../proj/.env', join(tmp, 'shared/evil/src/Evil.sol')) + mkdirSync(join(proj, 'lib')) + symlinkSync('../../shared/evil', join(proj, 'lib/evil')) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: proj, entries: ['src'], env: {} }), + /refused: it resolves to lib\/evil\/src\/Evil\.sol through \.\.\/shared\/evil\/src\/Evil\.sol, a link from outside the project root back into it/u, + )) +})) + +test('buildSolidityBundle reads .gitmodules paths as git does, so a quoted submodule is a dependency', withTmp(async (t, tmp) => { + writeProject(tmp, { + '.env': 'PRIVATE_KEY=0xabc\n', + '.gitmodules': '[submodule "evil"]\n\tpath = "vendor/evil"\n\turl = https://github.com/e/evil\n', + 'contracts/A.sol': 'import "../vendor/evil/E.sol";\n', + }) + mkdirSync(join(tmp, 'vendor/evil'), { recursive: true }) + symlinkSync('../../.env', join(tmp, 'vendor/evil/E.sol')) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['contracts'], env: {} }), + /refused: vendor\/evil\/E\.sol is a link out of the dependency vendor\/evil/u, + )) +})) + +test('buildSolidityBundle follows a dependency\'s config linked into another dependency', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'src/A.sol': 'import "x/X.sol";\n', + 'lib/a/foundry.toml': '[profile.default]\n', + 'lib/shared/remappings.txt': 'x/=../b/src/\n', + 'lib/b/src/X.sol': 'contract X {}\n', + }) + symlinkSync('../shared/remappings.txt', join(tmp, 'lib/a/remappings.txt')) + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/b/src/X.sol', 'src/A.sol']) +})) + test('buildSolidityBundle with --mapping bundles when forge would reject the root foundry.toml', withTmp(async (t, tmp) => { writeProject(tmp, { 'foundry.toml': '[profile.default]\nextends = "missing.toml"\n', diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index 801100b4..73d30563 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -1,5 +1,6 @@ import { test } from 'node:test' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import fs, { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { syncBuiltinESMExports } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -19,6 +20,7 @@ import { solidityOwnership, } from '../stasis/src/loaders/solidity.js' import { findRemappingsWithContext, foundryProject, foundryTomlRemappings } from '../stasis/src/loaders/foundry.js' +import { parseGitmodules } from '../stasis/src/loaders/solidity-ownership.js' const fixtures = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'solidity-bundle') @@ -680,6 +682,77 @@ test('solidityOwnership decides a path\'s owner from where it really is, and cat t.assert.deepEqual(of('lib/dep/src/Nope.sol'), { real: null, outside: false, dependency: false, escape: null }) })) +test('solidityOwnership: a link from outside the root back into it is untrusted, unless the root was named through it', async (t) => { + const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'stasis-sol-'))) + try { + const proj = join(tmp, 'proj') + mkdirSync(join(proj, 'lib'), { recursive: true }) + mkdirSync(join(tmp, 'shared/evil/src'), { recursive: true }) + writeFileSync(join(proj, '.env'), 'K=1\n') + writeFileSync(join(proj, 'Own.sol'), '') + symlinkSync('../../shared/evil', join(proj, 'lib/evil')) // the project's link to a dependency elsewhere + symlinkSync('../../../proj/.env', join(tmp, 'shared/evil/src/Evil.sol')) // ...which links back in + t.assert.deepEqual(solidityOwnership(proj, { dirs: ['lib'] }).of('lib/evil/src/Evil.sol').escape, { link: '../shared/evil/src/Evil.sol', root: null }) + // Named through a link (a symlinked checkout), an absolute link through that name is fine. + symlinkSync(proj, join(tmp, 'named')) + symlinkSync(join(tmp, 'named/Own.sol'), join(proj, 'Abs.sol')) + t.assert.deepEqual(solidityOwnership(join(tmp, 'named')).of('Abs.sol'), { real: 'Own.sol', outside: false, dependency: false, escape: null }) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('solidityOwnership judges the path as the filesystem spells it (a case-insensitive one)', async (t) => { + // Emulate a case-insensitive filesystem under `tmp`, whose names are lowercase on disk. + const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'stasis-sol-'))) + const lower = (p) => (typeof p === 'string' && p.startsWith(tmp) ? tmp + p.slice(tmp.length).toLowerCase() : p) + const saved = {} + for (const name of ['lstatSync', 'statSync', 'readlinkSync', 'readdirSync']) { + saved[name] = fs[name] + fs[name] = (p, ...rest) => saved[name](lower(p), ...rest) + } + saved.native = fs.realpathSync.native + fs.realpathSync.native = (p, ...rest) => saved.native(lower(p), ...rest) + syncBuiltinESMExports() + try { + mkdirSync(join(tmp, 'lib/evil/src'), { recursive: true }) + writeFileSync(join(tmp, '.env'), 'K=1\n') + symlinkSync('../../../.env', join(tmp, 'lib/evil/src/test.sol')) + const { of } = solidityOwnership(tmp, { dirs: ['lib'] }) + // A dependency's remapping to `../../LIB/evil/src/` names the same link. + for (const p of ['lib/evil/src/test.sol', 'LIB/evil/src/Test.sol', 'Lib/Evil/SRC/TEST.sol']) t.assert.equal(of(p).escape?.root, 'lib/evil', p) + } finally { + for (const name of ['lstatSync', 'statSync', 'readlinkSync', 'readdirSync']) fs[name] = saved[name] + fs.realpathSync.native = saved.native + syncBuiltinESMExports() + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('parseGitmodules reads .gitmodules as git does: quotes, escapes, comments, key case, continuations', (t) => { + const text = [ + '[submodule "a"]', + '\tpath = "vendor/a" ; a comment', + '\tURL = https://github.com/o/a', + '[submodule "b"]', + '\tpath = lib/b\\', + 'x', + '\turl = "https://github.com/o/b" # comment', + '[core]', + '\tpath = not/a/submodule', + '[submodule "a"]', + '\tbranch = "v1 \\"x\\""', + '[submodule.c]', + '\tpath = lib/c ', + '', + ].join('\n') + t.assert.deepEqual(parseGitmodules(text), [ + { name: 'a', path: 'vendor/a', url: 'https://github.com/o/a', branch: 'v1 "x"' }, + { name: 'b', path: 'lib/bx', url: 'https://github.com/o/b' }, + { name: 'c', path: 'lib/c' }, + ]) +}) + test('a remappings.txt taken as written (solc) may map a prefix to nothing', (t) => { const remappings = parseRemappings('x/=\nctx:y/=\n=z\n') t.assert.deepEqual(remappings, [{ context: null, prefix: 'x/', target: '' }, { context: 'ctx', prefix: 'y/', target: '' }]) @@ -695,23 +768,30 @@ test('a legacy [default] table\'s `extends` is ignored, as forge ignores it', wi t.assert.deepEqual(remappings, []) })) -test('discoverSolidityConfig with a mapping file: a foundry.toml forge rejects still gives lib dirs, and FOUNDRY_PROFILE is reported', withProject({ +test('discoverSolidityConfig with a mapping file: a foundry.toml forge rejects still gives lib dirs, and FOUNDRY_PROFILE is reported when it picks them', withProject({ 'foundry.toml': '[profile.default]\nextends = "missing.toml"\n', 'remappings.txt': 'x/=lib/x/\n', + 'ci/foundry.toml': '[profile.default]\n[profile.ci]\nlibs = ["deps"]\n', + 'ci/remappings.txt': 'x/=deps/x/\n', }, async (t, dir) => { const warn = console.warn const lines = [] console.warn = (...a) => lines.push(a.join(' ')) + const discover = (sub, env) => discoverSolidityConfig(join(dir, sub), { mappingFile: 'remappings.txt', env }) try { - const config = await discoverSolidityConfig(dir, { mappingFile: 'remappings.txt', env: {} }) - t.assert.deepEqual(config.libs, ['lib']) - t.assert.deepEqual(config.envUsed, []) - // The profile picks the lib dirs, so it's reported. - t.assert.deepEqual((await discoverSolidityConfig(dir, { mappingFile: 'remappings.txt', env: { FOUNDRY_PROFILE: 'ci' } })).envUsed, ['FOUNDRY_PROFILE=ci']) + const root = await discover('.', {}) + t.assert.deepEqual([root.libs, root.envUsed], [['lib'], []]) + t.assert.ok(lines.some((l) => l.includes('Using the default lib dirs') && l.includes('missing.toml'))) + const ci = await discover('ci', { FOUNDRY_PROFILE: 'ci' }) + t.assert.deepEqual([ci.libs, ci.envUsed], [['deps'], ['FOUNDRY_PROFILE=ci']]) + // A profile foundry.toml doesn't have picks nothing: said, and not reported as shaping the result. + lines.length = 0 + const nope = await discover('ci', { FOUNDRY_PROFILE: 'nope' }) + t.assert.deepEqual([nope.libs, nope.envUsed], [['lib'], []]) + t.assert.deepEqual(lines, ['[loader.solidity] FOUNDRY_PROFILE=nope is not a profile in foundry.toml; using [profile.default]']) } finally { console.warn = warn } - t.assert.ok(lines.some((l) => l.includes('Using the default lib dirs') && l.includes('missing.toml'))) })) test('resolveSolImport starts a library lookup at the importer directory\'s parent, as foundry-compilers does', withProject({ From 2c5f43d378d79deef3054e5aadbd63cfab716cb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:59:17 +0000 Subject: [PATCH 03/20] fix(bundle): a foundry.toml that isn't TOML is an error, not a skipped config loadNestedConfig (a dependency's foundry.toml and its `extends` base) and foundryLibs (the root foundry.toml under --mapping) caught every error and went on with a warning. A TomlError now propagates: the bundle fails naming the file and line, whosever the file is. forge quietly skips a dependency's config it can't read; what can't be read is not left out silently here. A config forge rejects for its settings (a missing `extends` base, nested inheritance, a link out of the dependency) is still skipped with a warning. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 5 +++++ stasis/src/loaders/foundry.js | 11 +++++++---- tests/bundle-cmd.test.js | 26 ++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index 5a6e48f5..f8bb4f89 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -361,6 +361,11 @@ resolve the way solc does under the project's build tool: including the contextual ones that scope a dependency's imports to its own copy of a package; aliases of the project's own `src`/`test`/`script` dirs are dropped, and `auto_detect_remappings = false` turns detection off. + A `foundry.toml` or `extends` base that isn't TOML is an error naming the + file and line, whosever it is and in every mode (forge quietly skips a + dependency's); a dependency's config forge rejects for its settings (a + missing `extends` base, nested inheritance) is skipped with a warning, as + forge skips it. Profiles are `[profile.]` tables and the legacy top-level `[]` ones (the former wins key by key; `extends` counts only in the former, as in forge); names match case-insensitively. Not diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index 2c9532cd..d335c3e4 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -21,7 +21,7 @@ import { toPosix } from '@exodus/stasis-core/util' import { isDir } from '../resolve-typescript.js' import { readFileOrNull } from './cargo.js' import { projectOwnership } from './solidity-ownership.js' -import { isTomlTable, readToml } from './toml.js' +import { TomlError, isTomlTable, readToml } from './toml.js' export const FOUNDRY_TOML = 'foundry.toml' export const REMAPPINGS_TXT = 'remappings.txt' @@ -591,12 +591,14 @@ function rebaseNested(r, canonical, lexical) { // A dependency's config as forge's `load_nested_config` reads it: remappings rebased onto its // canonical root, its remappings.txt, its src and libs. Null when forge would reject the config, // or when `readable` refuses it or its `extends` base (warned); a remappings.txt it refuses is -// skipped (warned). +// skipped (warned). One that isn't TOML throws: forge skips it, but what can't be read is an +// error here, not a config quietly left out. function loadNestedConfig(canonical, profile, readable) { let config try { config = loadFoundryConfig(canonical, profile, { readable }) } catch (err) { + if (err instanceof TomlError) throw err console.warn(`[loader.solidity] Skipping a dependency's config: ${err.message}`) return null } @@ -780,8 +782,8 @@ function providerRemappings(root, { userRemappings, libs, autoDetect, profile, f // The lib dirs `forge build` uses for the Foundry project at `baseDir`, `{ libs, profiled }`: the // selected profile's `libs` (`profiled` when that profile is the file's), else the detected ones; -// also those, warned, when forge would reject the foundry.toml (with a pinned mapping file, -// nothing else is read from it). +// also those, warned, when forge would reject the foundry.toml's settings (with a pinned mapping +// file, nothing else is read from it). A foundry.toml that isn't TOML throws. export function foundryLibs(baseDir, { env = process.env } = {}) { const root = toPosix(resolve(baseDir)) const profile = foundryProfile(env) @@ -789,6 +791,7 @@ export function foundryLibs(baseDir, { env = process.env } = {}) { const config = loadFoundryConfig(root, profile) return { libs: config.libs, profiled: profileApplies(config.profiles, profile) } } catch (err) { + if (err instanceof TomlError) throw err console.warn(`[loader.solidity] Using the default lib dirs: ${err.message}`) return { libs: detectLibs(root), profiled: false } } diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 874230ee..5f52cb9b 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -610,6 +610,32 @@ test('buildSolidityBundle follows a dependency\'s config linked into another dep t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/b/src/X.sol', 'src/A.sol']) })) +test('buildSolidityBundle fails on a foundry.toml that isn\'t TOML, naming the file and line', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'src/A.sol': 'import "dep/D.sol";\n', + 'lib/dep/src/D.sol': 'contract D {}\n', + // forge skips a dependency's config it can't read; here it's an error, not a config left out. + 'lib/dep/foundry.toml': '[profile.default]\nremappings = ["x/=y/"\n', + }) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + { name: 'TomlError', message: `${join(realpathSync(tmp), 'lib/dep/foundry.toml')}:2: unterminated array` }, + )) + // ...and so is its `extends` base. + writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\nextends = "base.toml"\n', 'lib/dep/base.toml': '[profile.default]\nsrc = "src" junk\n' }) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + { name: 'TomlError', message: `${join(realpathSync(tmp), 'lib/dep/base.toml')}:2: unexpected text after the value` }, + )) + // With a pinned mapping file, the root foundry.toml is still read for its lib dirs. + writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\n', 'foundry.toml': '[profile.default]\nlibs = ["lib"\n', 'remappings.txt': 'dep/=lib/dep/src/\n' }) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], mappingFile: 'remappings.txt', env: {} }), + { name: 'TomlError', message: `${join(tmp, 'foundry.toml')}:2: unterminated array` }, + )) +})) + test('buildSolidityBundle with --mapping bundles when forge would reject the root foundry.toml', withTmp(async (t, tmp) => { writeProject(tmp, { 'foundry.toml': '[profile.default]\nextends = "missing.toml"\n', From 3d2dd00aaed836ef1448a0baaedb3cd54636f71d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:04:59 +0000 Subject: [PATCH 04/20] fix(bundle): an invalid remapping is an error, the project's or a dependency's A remappings.txt line or FOUNDRY_REMAPPINGS/DAPP_REMAPPINGS entry that isn't `[context:]prefix=target` used to be skipped with a warning; it now throws, naming the file (or variable) and line, as forge and solc refuse the file. That holds for the root remappings.txt (Foundry or as written for solc), a --mapping file and a dependency's remappings.txt. A foundry.toml `remappings` value that isn't an array of such strings throws too, naming the file: the project's, a --mapping one and a dependency's (forge skips a dependency's config holding one; here it's an error, as for one that isn't TOML). loadNestedConfig and foundryLibs now catch only ConfigRefused -- the settings forge answers by skipping a dependency's config (a missing or nested `extends`, colliding keys) and a link out of the dependency -- and let every other error through. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 14 ++++-- stasis/src/loaders/foundry.js | 92 +++++++++++++++++++++------------- stasis/src/loaders/solidity.js | 2 +- tests/bundle-cmd.test.js | 23 +++++++++ tests/solidity-loader.test.js | 24 +++++++-- 5 files changed, 111 insertions(+), 44 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index f8bb4f89..b876c302 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -361,11 +361,15 @@ resolve the way solc does under the project's build tool: including the contextual ones that scope a dependency's imports to its own copy of a package; aliases of the project's own `src`/`test`/`script` dirs are dropped, and `auto_detect_remappings = false` turns detection off. - A `foundry.toml` or `extends` base that isn't TOML is an error naming the - file and line, whosever it is and in every mode (forge quietly skips a - dependency's); a dependency's config forge rejects for its settings (a - missing `extends` base, nested inheritance) is skipped with a warning, as - forge skips it. + A `foundry.toml` or `extends` base that isn't TOML, and an invalid remapping + (a `remappings.txt` line or `FOUNDRY_REMAPPINGS` entry that isn't + `[context:]prefix=target`, or a `remappings` value that isn't an array of + such strings), is an error naming the file and line, whosever it is and in + every mode (forge refuses an invalid `remappings.txt` line too, but quietly + skips a dependency's `foundry.toml` that isn't TOML or holds a bad + remapping); a dependency's config forge rejects for its settings (a missing + `extends` base, nested inheritance) is skipped with a warning, as forge skips + it. Profiles are `[profile.]` tables and the legacy top-level `[]` ones (the former wins key by key; `extends` counts only in the former, as in forge); names match case-insensitively. Not diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index d335c3e4..833feeea 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -21,7 +21,7 @@ import { toPosix } from '@exodus/stasis-core/util' import { isDir } from '../resolve-typescript.js' import { readFileOrNull } from './cargo.js' import { projectOwnership } from './solidity-ownership.js' -import { TomlError, isTomlTable, readToml } from './toml.js' +import { isTomlTable, readToml } from './toml.js' export const FOUNDRY_TOML = 'foundry.toml' export const REMAPPINGS_TXT = 'remappings.txt' @@ -129,19 +129,39 @@ export function parseRemapping(entry, { emptyPath = false } = {}) { return { context, name, path } } -// A remappings.txt / env var body: one remapping per non-blank (trimmed) line; invalid lines -// (forge rejects the whole file on one) are skipped, and reported when a `label` names the source. -// `options`: see parseRemapping. -export function parseRemappingLines(text, label, options) { +// A remappings.txt / env var body: one remapping per non-blank (trimmed) line. A line that isn't +// one throws, naming `label` (the file or variable) and the line, as forge and solc refuse the +// file. `options`: see parseRemapping. +export function parseRemappingLines(text, label = 'remappings', options = undefined) { const out = [] - for (const line of text.split('\n').map((l) => l.trim()).filter(Boolean)) { + text.split('\n').forEach((raw, i) => { + const line = raw.trim() + if (line === '') return const r = parseRemapping(line, options) - if (r) out.push(r) - else if (label !== undefined) console.warn(`[loader.solidity] Invalid remapping in ${label}: ${line}`) - } + if (r === null) throw new Error(`${label}:${i + 1}: invalid remapping ${JSON.stringify(line)}`) + out.push(r) + }) return out } +// A foundry.toml's `remappings` value, parsed. One forge rejects -- not an array of strings, or an +// entry that isn't `[context:]name=path` -- throws, naming `file` when given. +function configRemappings(value, file = null) { + const where = `${file === null ? '' : `${file}: `}\`remappings\`` + if (!Array.isArray(value)) throw new Error(`${where} is not an array of strings`) + return value.map((entry) => { + const r = typeof entry === 'string' ? parseRemapping(entry) : null + if (r === null) throw new Error(`${where}: invalid remapping ${typeof entry === 'string' ? JSON.stringify(entry) : String(entry)}`) + return r + }) +} + +// A config forge refuses for its settings (a missing or nested `extends`, colliding keys) or that a +// dependency may not read (a link out of it): forge skips such a dependency's config, and so does +// loadNestedConfig. Anything else wrong with a config -- text that isn't TOML, an invalid +// remapping -- is another error, and fatal. +class ConfigRefused extends Error {} + // Forge's trailing `/` on a remapping's name and path, unless they end in `/` or `.sol`. const withSlash = (s) => (s.endsWith('/') || s.endsWith('.sol') ? s : `${s}/`) @@ -453,7 +473,7 @@ function mergeExtended(base, local, strategy) { function readFoundryProfiles(file, profile, { readable } = {}) { const text = readFileOrNull(file) if (text === null) return { profiles: new Map(), topLevel: new Map(), files: [] } - if (readable && !readable(file)) throw new Error(`${file}: refusing to read it, a link out of the dependency`) + if (readable && !readable(file)) throw new ConfigRefused(`${file}: refusing to read it, a link out of the dependency`) let { profiles, topLevel } = parseFoundryToml(text, file) const files = [file] const ext = profiles.get(profile)?.get('extends') @@ -461,16 +481,16 @@ function readFoundryProfiles(file, profile, { readable } = {}) { if (typeof extPath === 'string') { const strategy = (typeof ext === 'object' && typeof ext.strategy === 'string') ? ext.strategy : 'extend-arrays' const baseFile = toPosix(resolve(posix.dirname(file), extPath)) - if (readable && !readable(baseFile)) throw new Error(`${file}: refusing to extend ${extPath}, which lies outside the dependency`) + if (readable && !readable(baseFile)) throw new ConfigRefused(`${file}: refusing to extend ${extPath}, which lies outside the dependency`) const baseText = readFileOrNull(baseFile) - if (baseText === null) throw new Error(`${file}: the inherited config file does not exist: ${extPath}`) + if (baseText === null) throw new ConfigRefused(`${file}: the inherited config file does not exist: ${extPath}`) const base = parseFoundryToml(baseText, baseFile).profiles if (base.get(profile)?.has('extends')) { - throw new Error(`${file}: nested inheritance is not allowed (${extPath} has an 'extends' field in profile '${profile}')`) + throw new ConfigRefused(`${file}: nested inheritance is not allowed (${extPath} has an 'extends' field in profile '${profile}')`) } if (strategy === 'no-collision') { const collisions = [...(profiles.get(profile)?.keys() ?? [])].filter((k) => k !== 'extends' && base.get(profile)?.has(k)) - if (collisions.length > 0) throw new Error(`${file}: key collision in profile '${profile}' when extending ${extPath}: ${collisions.join(', ')}`) + if (collisions.length > 0) throw new ConfigRefused(`${file}: key collision in profile '${profile}' when extending ${extPath}: ${collisions.join(', ')}`) } profiles = mergeExtended(base, profiles, strategy) files.push(baseFile) @@ -487,9 +507,12 @@ function selectProfile(profiles, profile) { } // The `remappings` a foundry.toml's profiles set for `profile` (`[profile.default]` overlaid by -// it), else the file's top-level `remappings` (a mapping file written for stasis), as written. -const profileRemappings = ({ profiles, topLevel }, profile) => - (stringList(selectProfile(profiles, profile).get('remappings')) ?? stringList(topLevel.get('remappings')) ?? []).map(parseRemapping).filter(Boolean) +// it), else the file's top-level `remappings` (a mapping file written for stasis), as written; an +// invalid one throws (configRemappings, naming `file`). +function profileRemappings({ profiles, topLevel }, profile, file = null) { + const value = selectProfile(profiles, profile).get('remappings') ?? topLevel.get('remappings') + return value === undefined ? [] : configRemappings(value, file) +} // A foundry.toml text's own remappings for `profile` (see profileRemappings). export function foundryTomlRemappings(text, profile = 'default') { @@ -500,7 +523,7 @@ export function foundryTomlRemappings(text, profile = 'default') { // `files` lists what was read; `profiled` whether the selected `profile` is one of the file's. export function readFoundryTomlRemappings(file, profile = 'default') { const read = readFoundryProfiles(toPosix(resolve(file)), profile) - return { remappings: profileRemappings(read, profile), files: read.files, profiled: profile !== 'default' && read.profiles.has(profile) } + return { remappings: profileRemappings(read, profile, file), files: read.files, profiled: profile !== 'default' && read.profiles.has(profile) } } // Whether the selected `profile` is one of the root foundry.toml's `profiles` (forge uses @@ -526,22 +549,21 @@ function detectLibs(root, host) { const stringList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : null) // The selected profile's settings for a Foundry project at `root` (absolute POSIX), defaults -// filled in the way forge fills them. `remappings` are the profile's own, unnormalized. Null -// `remappings` means one didn't parse (forge rejects such a config). `readable`: see -// readFoundryProfiles. +// filled in the way forge fills them. `remappings` are the profile's own, unnormalized; an invalid +// one throws (configRemappings). `readable`: see readFoundryProfiles. function loadFoundryConfig(root, profile, { readable } = {}) { - const { profiles, files } = readFoundryProfiles(rustJoin(root, FOUNDRY_TOML), profile, { readable }) + const file = rustJoin(root, FOUNDRY_TOML) + const { profiles, files } = readFoundryProfiles(file, profile, { readable }) const dict = selectProfile(profiles, profile) const str = (k) => (typeof dict.get(k) === 'string' ? dict.get(k) : null) - const remappings = (stringList(dict.get('remappings')) ?? []).map(parseRemapping) return { profiles, files, src: str('src') ?? findSourceDir(root, host), test: str('test') ?? 'test', script: str('script') ?? 'script', - libs: stringList(dict.get('libs')) ?? detectLibs(root, host), - remappings: remappings.includes(null) ? null : remappings, + libs: stringList(dict.get('libs')) ?? detectLibs(root), + remappings: dict.has('remappings') ? configRemappings(dict.get('remappings'), file) : [], autoDetect: dict.get('auto_detect_remappings') !== false, } } @@ -590,19 +612,19 @@ function rebaseNested(r, canonical, lexical) { // A dependency's config as forge's `load_nested_config` reads it: remappings rebased onto its // canonical root, its remappings.txt, its src and libs. Null when forge would reject the config, -// or when `readable` refuses it or its `extends` base (warned); a remappings.txt it refuses is -// skipped (warned). One that isn't TOML throws: forge skips it, but what can't be read is an -// error here, not a config quietly left out. +// or when `readable` refuses it or its `extends` base (warned: ConfigRefused); a remappings.txt it +// refuses is skipped (warned). One that isn't TOML or holds an invalid remapping throws: forge +// refuses a bad remappings.txt line too, and skips a foundry.toml it can't read, which here is an +// error rather than a config quietly left out. function loadNestedConfig(canonical, profile, readable) { let config try { config = loadFoundryConfig(canonical, profile, { readable }) } catch (err) { - if (err instanceof TomlError) throw err + if (!(err instanceof ConfigRefused)) throw err console.warn(`[loader.solidity] Skipping a dependency's config: ${err.message}`) return null } - if (config.remappings === null) return null const txt = rustJoin(canonical, REMAPPINGS_TXT) let text = readFileOrNull(txt) if (text !== null && !readable(txt)) { @@ -782,8 +804,9 @@ function providerRemappings(root, { userRemappings, libs, autoDetect, profile, f // The lib dirs `forge build` uses for the Foundry project at `baseDir`, `{ libs, profiled }`: the // selected profile's `libs` (`profiled` when that profile is the file's), else the detected ones; -// also those, warned, when forge would reject the foundry.toml's settings (with a pinned mapping -// file, nothing else is read from it). A foundry.toml that isn't TOML throws. +// also those, warned, when forge would reject the foundry.toml's settings (ConfigRefused; with a +// pinned mapping file, nothing else is read from it). A foundry.toml that isn't TOML or holds an +// invalid remapping throws. export function foundryLibs(baseDir, { env = process.env } = {}) { const root = toPosix(resolve(baseDir)) const profile = foundryProfile(env) @@ -791,7 +814,7 @@ export function foundryLibs(baseDir, { env = process.env } = {}) { const config = loadFoundryConfig(root, profile) return { libs: config.libs, profiled: profileApplies(config.profiles, profile) } } catch (err) { - if (err instanceof TomlError) throw err + if (!(err instanceof ConfigRefused)) throw err console.warn(`[loader.solidity] Using the default lib dirs: ${err.message}`) return { libs: detectLibs(root), profiled: false } } @@ -809,14 +832,13 @@ export function foundryProject(baseDir, { env = process.env } = {}) { const config = loadFoundryConfig(root, profile) const profiled = profileApplies(config.profiles, profile) const ownership = projectOwnership(baseDir, config.libs, { soldeer: true }) - if (config.remappings === null) throw new Error(`${rustJoin(root, FOUNDRY_TOML)}: invalid remapping in \`remappings\``) const files = new Set(config.files) const envName = env.DAPP_REMAPPINGS !== undefined ? 'DAPP_REMAPPINGS' : env.FOUNDRY_REMAPPINGS !== undefined ? 'FOUNDRY_REMAPPINGS' : null const envRemappings = envName === null ? [] : parseRemappingLines(env[envName], envName) const txt = readText(host, rustJoin(root, REMAPPINGS_TXT)) if (txt !== null) files.add(rustJoin(root, REMAPPINGS_TXT)) - const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, REMAPPINGS_TXT)), ...config.remappings] + const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, rustJoin(root, REMAPPINGS_TXT))), ...config.remappings] const provided = providerRemappings(root, { userRemappings, libs: config.libs, autoDetect: config.autoDetect, profile, files, ownership }) .map((r) => displayRelative(relativePreservingBoundary(r, root))) diff --git a/stasis/src/loaders/solidity.js b/stasis/src/loaders/solidity.js index 584f3d2e..c927e4ee 100644 --- a/stasis/src/loaders/solidity.js +++ b/stasis/src/loaders/solidity.js @@ -145,7 +145,7 @@ const realpathOrNull = (p, host) => { const toLoaderRemapping = ({ context, name, path }) => ({ context, prefix: name, target: path }) // remappings.txt text -> remappings as written, one `[context:]prefix=target` per line (lines -// trimmed; blank and invalid lines skipped; an empty target is solc's, valid). +// trimmed, blank ones skipped; an empty target is solc's, valid). A line that isn't one throws. export function parseRemappings(content) { return parseRemappingLines(content, undefined, { emptyPath: true }).map(toLoaderRemapping) } diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 5f52cb9b..efc3f1f1 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -636,6 +636,29 @@ test('buildSolidityBundle fails on a foundry.toml that isn\'t TOML, naming the f )) })) +test('buildSolidityBundle fails on an invalid remapping, the project\'s or a dependency\'s, naming the file and line', withTmp(async (t, tmp) => { + const root = realpathSync(tmp) + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'remappings.txt': 'dep/=lib/dep/src/\n# not a remapping\n', + 'src/A.sol': 'import "dep/D.sol";\n', + 'lib/dep/src/D.sol': 'contract D {}\n', + }) + const fails = (opts, message) => captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {}, ...opts }), { message })) + await fails({}, `${join(tmp, 'remappings.txt')}:2: invalid remapping "# not a remapping"`) + // As written for solc, and as a pinned mapping file, alike. + await fails({ mappingFile: 'remappings.txt' }, `${join(tmp, 'remappings.txt')}:2: invalid remapping "# not a remapping"`) + writeFileSync(join(tmp, 'remappings.txt'), 'dep/=lib/dep/src/\n') + // forge skips a dependency's config holding one; here it's an error, not a config left out. + writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\nremappings = ["x"]\n' }) + await fails({}, `${join(root, 'lib/dep/foundry.toml')}: \`remappings\`: invalid remapping "x"`) + writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\n', 'lib/dep/remappings.txt': 'y/=src/\n=z\n' }) + await fails({}, `${join(root, 'lib/dep/remappings.txt')}:2: invalid remapping "=z"`) + writeFileSync(join(tmp, 'lib/dep/remappings.txt'), 'y/=src/\n') + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/dep/src/D.sol', 'src/A.sol']) +})) + test('buildSolidityBundle with --mapping bundles when forge would reject the root foundry.toml', withTmp(async (t, tmp) => { writeProject(tmp, { 'foundry.toml': '[profile.default]\nextends = "missing.toml"\n', diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index 73d30563..6f91ebc4 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -5,6 +5,8 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { toPosix } from '@exodus/stasis-core/util' + import { applyRemappings, buildSolidityTree, @@ -77,12 +79,14 @@ test('extractSolImports finds remapped imports', (t) => { t.assert.deepEqual(extractSolImports(src), ['@openzeppelin/contracts/utils/Math.sol']) }) -test('parseRemappings handles one-per-line entries and ignores invalid lines', (t) => { - const out = parseRemappings('@a/=lib/a/\n @b/=lib/b/\r\ngarbage line\n=empty-prefix\n') +test('parseRemappings handles one-per-line entries and refuses an invalid line, naming it', (t) => { + const out = parseRemappings('@a/=lib/a/\n @b/=lib/b/\r\n\n') t.assert.deepEqual(out, [ { context: null, prefix: '@a/', target: 'lib/a/' }, { context: null, prefix: '@b/', target: 'lib/b/' }, ]) + t.assert.throws(() => parseRemappings('@a/=lib/a/\ngarbage line\n'), { message: 'remappings:2: invalid remapping "garbage line"' }) + t.assert.throws(() => parseRemappings('\n=empty-prefix\n'), { message: 'remappings:2: invalid remapping "=empty-prefix"' }) }) test('parseRemappings reads a `context:` before the prefix', (t) => { @@ -754,11 +758,25 @@ test('parseGitmodules reads .gitmodules as git does: quotes, escapes, comments, }) test('a remappings.txt taken as written (solc) may map a prefix to nothing', (t) => { - const remappings = parseRemappings('x/=\nctx:y/=\n=z\n') + const remappings = parseRemappings('x/=\nctx:y/=\n') t.assert.deepEqual(remappings, [{ context: null, prefix: 'x/', target: '' }, { context: 'ctx', prefix: 'y/', target: '' }]) t.assert.equal(resolveSolImport('x/A.sol', 'src/B.sol', { remappings }), 'A.sol') }) +test('an invalid remapping in a foundry.toml or remappings variable is an error, naming where it is', withProject({ + 'foundry.toml': '[profile.default]\nremappings = ["a/=b/", "nope"]\n', + 'list/foundry.toml': '[profile.default]\nremappings = "a/=b/"\n', + 'num/foundry.toml': '[profile.default]\nremappings = [1]\n', + 'ok/foundry.toml': '[profile.default]\n', +}, (t, dir) => { + const root = toPosix(dir) + t.assert.throws(() => foundryProject(dir, { env: {} }), { message: `${root}/foundry.toml: \`remappings\`: invalid remapping "nope"` }) + t.assert.throws(() => foundryProject(join(dir, 'list'), { env: {} }), { message: `${root}/list/foundry.toml: \`remappings\` is not an array of strings` }) + t.assert.throws(() => foundryProject(join(dir, 'num'), { env: {} }), { message: `${root}/num/foundry.toml: \`remappings\`: invalid remapping 1` }) + t.assert.throws(() => foundryProject(join(dir, 'ok'), { env: { FOUNDRY_REMAPPINGS: 'x/=y/\nbad' } }), { message: 'FOUNDRY_REMAPPINGS:2: invalid remapping "bad"' }) + t.assert.throws(() => foundryTomlRemappings('[profile.default]\nremappings = ["=x/"]\n'), { message: '`remappings`: invalid remapping "=x/"' }) +})) + test('a legacy [default] table\'s `extends` is ignored, as forge ignores it', withProject({ 'foundry.toml': '[default]\nextends = "base.toml"\n', 'base.toml': '[profile.default]\nremappings = ["x/=lib/elsewhere/"]\n', From e776729f1e8faeefbf4ad61f542b5bd52eeb468f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:55:58 +0000 Subject: [PATCH 05/20] fix(bundle): the ownership walk refuses what it can't vouch for; strict config reading Containment: - The link-by-link walk is checked against the OS's realpath: where it can't resolve a path the OS can, or lands elsewhere, the path is refused instead of trusted. Link targets split on the OS's separators only (a `\` is part of a name on POSIX), and one that isn't UTF-8 is unresolved, not missing. - A dependency's config is judged by its path from the root, lexical or else canonical (an absolute or /proc/self/cwd lib); one outside the root reads nothing, and a dir a dependency's `libs` names must be a dependency itself. Absolute libs count as dependency dirs by their real path. - .gitmodules takes a key on its section header's line, as git does. - A package.json that decides a file's package is refused when a dependency planted it as a link, and one that doesn't parse is an error (naming the file and position, never quoting it) instead of giving its files to the parent package. `stasis add` and --package-json keep walking past one. Configs: - foundry.toml, remappings.txt, --mapping files and .gitmodules that aren't UTF-8 are errors; a byte-order mark stays, and remappings.txt lines are trimmed as forge trims them (a BOM is part of the first remapping). - src/test/script/libs/auto_detect_remappings/extends of the wrong type are errors naming the file, instead of quietly falling back to defaults. - --manifests carries every config file the resolution read, whatever it's called (extends = "base.conf", --mapping=remaps); `.env` files and hardhat.config.* never. One realpath helper (realpathOrNull, realpath(3)) for solidity.js, foundry.js and the ownership walk. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 74 ++++++++------ stasis-core/src/bundle-util.js | 30 ++++-- stasis/src/cmd/bundle.js | 65 ++++++++---- stasis/src/loaders/foundry.js | 82 +++++++++------ stasis/src/loaders/solidity-ownership.js | 98 ++++++++++++------ stasis/src/loaders/solidity.js | 14 +-- tests/bundle-cmd.test.js | 121 ++++++++++++++++++++++- tests/solidity-loader.test.js | 5 + 8 files changed, 358 insertions(+), 131 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index b876c302..ee82972b 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -361,15 +361,18 @@ resolve the way solc does under the project's build tool: including the contextual ones that scope a dependency's imports to its own copy of a package; aliases of the project's own `src`/`test`/`script` dirs are dropped, and `auto_detect_remappings = false` turns detection off. - A `foundry.toml` or `extends` base that isn't TOML, and an invalid remapping - (a `remappings.txt` line or `FOUNDRY_REMAPPINGS` entry that isn't - `[context:]prefix=target`, or a `remappings` value that isn't an array of - such strings), is an error naming the file and line, whosever it is and in - every mode (forge refuses an invalid `remappings.txt` line too, but quietly - skips a dependency's `foundry.toml` that isn't TOML or holds a bad - remapping); a dependency's config forge rejects for its settings (a missing - `extends` base, nested inheritance) is skipped with a warning, as forge skips - it. +A `foundry.toml` or `extends` base that isn't TOML, a config that isn't UTF-8 + (`foundry.toml`, `remappings.txt`, `.gitmodules`), a setting of the wrong type + (`libs = "deps"`, a `src` that isn't a string, an `extends` that isn't a path + or `{ path, strategy }`), and an invalid remapping (a `remappings.txt` line or + `FOUNDRY_REMAPPINGS` entry that isn't `[context:]prefix=target`, or a + `remappings` value that isn't an array of such strings), is an error naming + the file, whosever it is and in every mode: nothing falls back to a default + (forge refuses these too, but quietly skips a dependency's `foundry.toml` it + can't read). A `remappings.txt` line is trimmed as forge trims it, so a + byte-order mark stays part of the first remapping. A dependency's config forge + rejects for its settings (a missing `extends` base, nested inheritance) is + skipped with a warning, as forge skips it. Profiles are `[profile.]` tables and the legacy top-level `[]` ones (the former wins key by key; `extends` counts only in the former, as in forge); names match case-insensitively. Not @@ -404,9 +407,10 @@ the result must be a `.sol` file inside the bundle root (an `import ".env";` or a remapping to `/opt/x/` is refused, stating why), and who owns a file is decided by where it really is, spelled as the filesystem spells it (on a case-insensitive one, `LIB/evil` is `lib/evil`). The dependencies are the -entries of forge's `libs` (a symlinked `lib/forge-std` is the dependency where -it points), Soldeer's `dependencies/`, git submodules (`.gitmodules` read as git -reads it: quoted and escaped paths too) and every `node_modules` package; a file +entries of forge's `libs` (an absolute one by its real path; a symlinked +`lib/forge-std` is the dependency where it points), Soldeer's `dependencies/`, +git submodules (`.gitmodules` read as git reads it: quoted and escaped paths, +and a key on its section header's line) and every `node_modules` package; a file is a dependency's when its real path lies in one, however the path got there (`src/vendor -> ../lib/dep/src` holds the dependency's code). An import from a dependency must land on a dependency's file too: it may import its own files and @@ -416,27 +420,37 @@ symlink no one trusted placed is never followed: one planted inside a dependency that leads out of it to anything but another dependency (`lib/evil/src/Evil.sol -> ../../../.env`), and one outside the project that leads back into it (a dependency linked from elsewhere, `lib/evil -> ../../shared/evil`, holding a -link to the project's `.env`). Whoever's import, entry or manifest the path is, -the import is refused, the entry rejected, the manifest not carried, and a +link to the project's `.env`). Links are followed one by one and the result +checked against the OS's own realpath: a path the two resolve differently (a +link target that isn't UTF-8, one whose `\` the OS reads as part of a name) is +refused, not trusted. Whoever's import, entry or manifest the path is, the +import is refused, the entry rejected, the manifest not carried, and a dependency's own `foundry.toml`, `extends` base or `remappings.txt` skipped with -a warning (one that is another dependency's file is read). A link the project -placed (a workspace package linked into `node_modules`, a linked `lib/` entry, -`src/vendor`) may lead anywhere in the root, and so may one on the path the -project was named by (a symlinked checkout); a workspace package is the -project's own code. +a warning (one that is another dependency's file is read). A dependency's config +reaches only what the path from the root does: one found through an absolute or +`/proc/self/cwd` lib is judged by its real path, a dependency outside the root +reads nothing, and a dir a dependency's `libs` names must be a dependency +itself. A `package.json` that decides a file's package is refused the same way +when a dependency planted it as a link, and one that doesn't parse is an error +naming it (not quoting it) rather than giving its files to the parent package. A +link the project placed (a workspace package linked into `node_modules`, a +linked `lib/` entry, `src/vendor`) may lead anywhere in the root, and so may one +on the path the project was named by (a symlinked checkout); a workspace package +is the project's own code. The config files are read, not bundled. `--manifests` bundles the build -description too: the `*.toml`/`*.txt` config files the resolution read, the -root's `foundry.lock`, `soldeer.lock`, `.gitmodules` and `package.json`, and the -`package.json`, `foundry.toml` and `remappings.txt` of every package the bundle -holds files of — `json` for a `package.json`, `resource` otherwise, so `stasis -extract` restores them. They are carried as written, as `--package-json` carries -`package.json`: stasis doesn't edit them, so whatever they hold — an -`eth_rpc_url` or `[rpc_endpoints]` URL with its API key, an `[etherscan]` key, -the credentials in a `.gitmodules` URL — is in the bundle too. Keep secrets in -the environment (`${VAR}` in `foundry.toml`) rather than in these files, or -don't pass `--manifests`. `hardhat.config.*`, being code, and `.env` files are -never carried. +description too: every config file the resolution read, whatever it's called (an +`extends = "base.conf"`, a `--mapping=remaps`), the root's `foundry.lock`, +`soldeer.lock`, `.gitmodules` and `package.json`, and the `package.json`, +`foundry.toml` and `remappings.txt` of every package the bundle holds files of — +`json` for a `package.json`, `resource` otherwise, so `stasis extract` restores +them. They are carried as written, as `--package-json` carries `package.json`: +stasis doesn't edit them, so whatever they hold — an `eth_rpc_url` or +`[rpc_endpoints]` URL with its API key, an `[etherscan]` key, the credentials in +a `.gitmodules` URL — is in the bundle too. Keep secrets in the environment +(`${VAR}` in `foundry.toml`) rather than in these files, or don't pass +`--manifests`. `hardhat.config.*`, being code, and `.env` files are never +carried. Rust entries are crate roots (`src/main.rs`, `src/lib.rs`, `src/bin/*.rs`, `tests/*.rs`, …): their `mod` declarations resolve as siblings, as rustc does, diff --git a/stasis-core/src/bundle-util.js b/stasis-core/src/bundle-util.js index 4a10d3dd..4c585322 100644 --- a/stasis-core/src/bundle-util.js +++ b/stasis-core/src/bundle-util.js @@ -24,19 +24,25 @@ export function packageType(file, host = diskHost) { // Nearest package.json (walking up) that identifies a bucket; pkgDir is relative to baseDir ("." // at the root). Inside node_modules both name and version are required; a workspace package // outside node_modules may omit version (the name alone claims the bucket, matching -// State#locateModule). Null if none. -export function findPackageMetadata(baseDir, fileRelPath, host = diskHost) { +// State#locateModule). Null if none. A malformed one is walked past, or with `strict` throws +// (its files would otherwise land in the parent package). `check(rel)`, when given, sees each +// package.json's path before it is read, and may throw to refuse it. Read through `host`. +export function findPackageMetadata(baseDir, fileRelPath, { strict = false, check, host = diskHost } = {}) { let dir = dirname(fileRelPath) while (true) { const pkgPath = join(baseDir, dir, 'package.json') if (host.stat(pkgPath)?.isFile()) { + check?.(toPosix(join(dir, 'package.json'))) + let pkg try { - const pkg = JSON.parse(packageJSONText(host.readFile(pkgPath))) - if (pkg.name && (pkg.version || !hasNodeModulesSegment(toPosix(dir)))) { - // `?? undefined` folds a literal `"version": null` into the one absent-version spelling. - return { pkgDir: dir, name: pkg.name, version: pkg.version ?? undefined } - } - } catch { /* malformed -- keep walking */ } + pkg = JSON.parse(host.readFile(pkgPath).toString('utf8')) + } catch (err) { + if (strict) throw jsonError(toPosix(join(dir, 'package.json')), err) + } + if (pkg?.name && (pkg.version || !hasNodeModulesSegment(toPosix(dir)))) { + // `?? undefined` folds a literal `"version": null` into the one absent-version spelling. + return { pkgDir: dir, name: pkg.name, version: pkg.version ?? undefined } + } } if (dir === '.' || dir === '/' || dir === '') return null const parent = dirname(dir) @@ -45,6 +51,14 @@ export function findPackageMetadata(baseDir, fileRelPath, host = diskHost) { } } +// `rel` isn't valid JSON: said with the parser's line and column, never its message, which quotes +// the text (a file that isn't JSON may be anything, a secret included). +export function jsonError(rel, err) { + const at = /\(line \d+ column \d+\)/u.exec(err.message)?.[0] + // eslint-disable-next-line preserve-caught-error -- the parser's error quotes the file + return new Error(`${rel} is not valid JSON${at ? ` ${at}` : ''}`) +} + export function normalizeEntries(entries, cwd) { const baseDir = resolve(cwd) return entries.map((e) => { diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index a3cd334d..5c54f8eb 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -13,7 +13,7 @@ import { createMetroResolver } from '../metro-resolver.js' import { State } from '@exodus/stasis-core/state' import { brotliOptions } from '@exodus/stasis-core/brotli' import { sha512integrity } from '@exodus/stasis-core/state-util' -import { detectRepo, findPackageMetadata, normalizeEntries, packageType, readJson, readModuleManifest, readText } from '@exodus/stasis-core/bundle-util' +import { detectRepo, findPackageMetadata, jsonError, normalizeEntries, packageType, readJson, readModuleManifest } from '@exodus/stasis-core/bundle-util' import { RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPodspec, isSkippedNativeWalkDir, moduleFileKey, parseResourcesOption, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util' import { diskHost } from '@exodus/stasis-core/host' import { @@ -24,7 +24,7 @@ import { discoverSolidityConfig, expandSolidityEntries, } from '../loaders/solidity.js' -import { parseGitmodules } from '../loaders/solidity-ownership.js' +import { escapeReason, parseGitmodules, readUtf8OrNull } from '../loaders/solidity-ownership.js' import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js' import { buildRustTree, collectRustFilesFromDisk } from '../loaders/rust.js' import { VENDOR_DIR as CARGO_VENDOR_DIR, createCargoContext } from '../loaders/cargo.js' @@ -91,7 +91,7 @@ function githubSlug(url) { // only. function parseGithubSubmodules(baseDir) { const byPath = new Map() - for (const { path, url, branch } of parseGitmodules(readFileSyncOrNull(join(baseDir, '.gitmodules')) ?? '')) { + for (const { path, url, branch } of parseGitmodules(readUtf8OrNull(join(baseDir, '.gitmodules')) ?? '')) { const name = path && url ? githubSlug(url) : null if (name) byPath.set(path.replace(/\/+$/u, ''), { name, branch }) } @@ -100,9 +100,9 @@ function parseGithubSubmodules(baseDir) { // Classify a Solidity file's dep bucket: Soldeer (`dependencies/-/`) or a // github submodule (`lib/`, via `.gitmodules`), else null to defer to the node_modules/ -// workspace logic. -function makeSolidityClassifier(baseDir, host) { - const submodules = parseGithubSubmodules(baseDir, host) +// workspace logic. `check` vets a package.json path before it is read (readableBy). +function makeSolidityClassifier(baseDir, check) { + const submodules = parseGithubSubmodules(baseDir) return (path) => { if (path.startsWith('dependencies/')) { const seg = path.slice('dependencies/'.length).split('/')[0] @@ -113,7 +113,7 @@ function makeSolidityClassifier(baseDir, host) { } for (const [sub, { name, branch }] of submodules) { if (path === sub || path.startsWith(`${sub}/`)) { - const pkg = readJson(join(baseDir, sub, 'package.json'), host) + const pkg = readPackageJsonOrNull(baseDir, moduleFileKey(sub, 'package.json'), check) return { bucketDir: sub, name, version: pkg?.version ?? branch ?? '0.0.0', ecosystem: 'github' } } } @@ -156,7 +156,7 @@ function executableSources(baseDir, sources, host) { // every file; `formats` (Map) overrides it per file. `resolutions` values are // a flat target string or a Map; both round-trip untouched. function assembleCodeBundle({ - baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep, host, + baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep, host, checkManifest, }) { const modules = new Map() const ensureBucket = (dir, name, version, bucketEcosystem) => { @@ -174,7 +174,7 @@ function assembleCodeBundle({ ensureBucket(dep.bucketDir, dep.name, dep.version, dep.ecosystem).files[fileInBucket(dep.bucketDir, path)] = content continue } - const meta = findPackageMetadata(baseDir, path, host) + const meta = findPackageMetadata(baseDir, path, { strict: true, check: checkManifest, host }) const inNodeModules = splitNodeModulesPath(path) !== null if (meta) { if (inNodeModules && !meta.pkgDir.includes('node_modules')) { @@ -210,20 +210,43 @@ function assembleCodeBundle({ }).withReason('bundle') } +// Files never carried, whatever reads them: `.env` files, and Hardhat's config, which is code. +const NEVER_CARRIED = (name) => name === '.env' || name.startsWith('.env.') || name.startsWith('hardhat.config.') + +// A check for findPackageMetadata: throws for a path `ownership` refuses (see solidityOwnership), +// as a package.json that decides a file's package may not be read through a planted link. +const readableBy = (ownership) => (rel) => { + const { escape } = ownership.of(rel) + if (escape) throw new Error(`Refusing ${rel}: ${escapeReason(rel, escape)}`) +} + +// A package.json's contents, or null when there's none; one that doesn't parse throws, and so does +// one `check` refuses. +function readPackageJsonOrNull(baseDir, rel, check) { + const text = readFileSyncOrNull(join(baseDir, rel)) + if (text === null) return null + check?.(rel) + try { + return JSON.parse(text) + } catch (err) { + throw jsonError(rel, err) + } +} + // The build-description files of a Solidity bundle (--manifests), as Map: `configFiles` -// (what discoverSolidityConfig read, when named `*.toml`/`*.txt`) plus the SOLIDITY_*_MANIFESTS -// that exist, for the root and for each package dir `classifyDep`/package.json places a bundled -// source in. Files inside the root only, and none reached through a link a dependency planted out -// of itself (`ownership`, see solidityOwnership). Carried as written: whatever they hold (an RPC -// URL with its API key, an Etherscan key, a URL's credentials) is in the bundle too, as with -// --package-json. +// (what discoverSolidityConfig read, whatever they're called; NEVER_CARRIED aside) plus the +// SOLIDITY_*_MANIFESTS that exist, for the root and for each package dir `classifyDep`/package.json +// places a bundled source in. Files inside the root only, and none whose path `ownership` refuses +// (see solidityOwnership). Carried as written: whatever they hold (an RPC URL with its API key, an +// Etherscan key, a URL's credentials) is in the bundle too, as with --package-json. function solidityManifests(baseDir, sources, configFiles, classifyDep, ownership) { - const wanted = new Set([...configFiles.filter((f) => f.endsWith('.toml') || f.endsWith('.txt')), ...SOLIDITY_ROOT_MANIFESTS]) + const check = readableBy(ownership) + const wanted = new Set([...configFiles.filter((f) => !NEVER_CARRIED(posix.basename(f))), ...SOLIDITY_ROOT_MANIFESTS]) const dirs = new Set() for (const path of sources.keys()) { const dep = classifyDep(path) if (dep) dirs.add(dep.bucketDir) - const meta = findPackageMetadata(baseDir, path, host) + const meta = findPackageMetadata(baseDir, path, { strict: true, check }) if (meta) dirs.add(meta.pkgDir) } for (const dir of dirs) { @@ -235,7 +258,7 @@ function solidityManifests(baseDir, sources, configFiles, classifyDep, ownership if (sources.has(rel) || posix.isAbsolute(rel) || rel.startsWith('../')) continue const { escape } = ownership.of(rel) if (escape) { - console.warn(`[stasis] Not carrying ${rel}: ${escape.link} is a link out of the dependency ${escape.root}`) + console.warn(`[stasis] Not carrying ${rel}: ${escapeReason(rel, escape)}`) continue } let buf @@ -309,7 +332,7 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin throw new Error(`Solidity bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`) } - const classifyDep = makeSolidityClassifier(baseDir, host) + const classifyDep = makeSolidityClassifier(baseDir, readableBy(ownership)) const bundled = new Map(sources) const formats = new Map() if (manifests) { @@ -330,7 +353,7 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin formats, conditionKey: 'solidity', classifyDep, - host, + checkManifest: readableBy(ownership), }) } @@ -930,7 +953,7 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields, for (const abs of reached) { const rel = toRel(abs) const dir = dirname(rel) - if (!metaByDir.has(dir)) metaByDir.set(dir, findPackageMetadata(baseDir, rel, host)) + if (!metaByDir.has(dir)) metaByDir.set(dir, findPackageMetadata(baseDir, rel, { host })) const meta = metaByDir.get(dir) if (meta) pkgDirs.add(meta.pkgDir) else if (!splitNodeModulesPath(rel)) pkgDirs.add('.') diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index 833feeea..af1f0d18 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -13,14 +13,14 @@ // other keys (`FOUNDRY_PROFILE` and the remapping env vars are). The project is read through a // `host` (@exodus/stasis-core/host), the disk's by default. +import { existsSync, lstatSync, opendirSync, statSync } from 'node:fs' import { posix, resolve } from 'node:path' import { readText } from '@exodus/stasis-core/bundle-util' import { diskHost } from '@exodus/stasis-core/host' import { toPosix } from '@exodus/stasis-core/util' import { isDir } from '../resolve-typescript.js' -import { readFileOrNull } from './cargo.js' -import { projectOwnership } from './solidity-ownership.js' +import { projectOwnership, readUtf8OrNull, realpathOrNull } from './solidity-ownership.js' import { isTomlTable, readToml } from './toml.js' export const FOUNDRY_TOML = 'foundry.toml' @@ -69,12 +69,9 @@ function cmpPath(a, b) { return x.length - y.length } -function canonicalize(p, host) { - try { - return toPosix(host.realpath(p)) - } catch { - return null - } +function canonicalize(p) { + const real = realpathOrNull(p) + return real === null ? null : toPosix(real) } const isSymlinkPath = (p, host) => { @@ -110,6 +107,10 @@ const readDir = (dir, host) => listDir(dir, host).filter((e) => !e.name.startsWi // --- Remapping values ---------------------------------------------------------------------- +// Rust's `str::trim`: Unicode White_Space only, so a byte-order mark (U+FEFF, which JS's `trim` +// takes) stays, as it does for forge. +const rustTrim = (s) => s.replaceAll(/^\p{White_Space}+|\p{White_Space}+$/gu, '') + // `[context:]name=path`, as forge (`Remapping::from_str`) and solc split it: at the first `=`, then // the first `:` before it. An empty context is global; an empty name or path is invalid (null), // but for solc (`emptyPath`) only an empty name is: `x/=` maps `x/A.sol` to `A.sol`. @@ -124,8 +125,8 @@ export function parseRemapping(entry, { emptyPath = false } = {}) { context = name.slice(0, colon) name = name.slice(colon + 1) } - if (name.trim() === '' || (!emptyPath && path.trim() === '')) return null - if (context !== null && context.trim() === '') context = null + if (rustTrim(name) === '' || (!emptyPath && rustTrim(path) === '')) return null + if (context !== null && rustTrim(context) === '') context = null return { context, name, path } } @@ -135,7 +136,7 @@ export function parseRemapping(entry, { emptyPath = false } = {}) { export function parseRemappingLines(text, label = 'remappings', options = undefined) { const out = [] text.split('\n').forEach((raw, i) => { - const line = raw.trim() + const line = rustTrim(raw) if (line === '') return const r = parseRemapping(line, options) if (r === null) throw new Error(`${label}:${i + 1}: invalid remapping ${JSON.stringify(line)}`) @@ -465,24 +466,32 @@ function mergeExtended(base, local, strategy) { return out } +// forge's `Extends`: a path, or `{ path, strategy? }`. +const EXTEND_STRATEGIES = new Set(['extend-arrays', 'replace-arrays', 'no-collision']) +const isExtends = (v) => typeof v === 'string' + || (v !== null && typeof v === 'object' && !Array.isArray(v) && typeof v.path === 'string' && (v.strategy === undefined || EXTEND_STRATEGIES.has(v.strategy))) + // A foundry.toml's profiles, with the selected profile's `extends` base merged in (forge's // `TomlFileProvider`). `files` lists what was read; `topLevel` is the file's own (see // parseFoundryToml). Throws where forge refuses the config, and where `readable` (a dependency's // config: see findNestedFoundryRemappings) refuses the file or its base: a dependency's config may // not read the project's files. function readFoundryProfiles(file, profile, { readable } = {}) { - const text = readFileOrNull(file) - if (text === null) return { profiles: new Map(), topLevel: new Map(), files: [] } if (readable && !readable(file)) throw new ConfigRefused(`${file}: refusing to read it, a link out of the dependency`) + const text = readUtf8OrNull(file) + if (text === null) return { profiles: new Map(), topLevel: new Map(), files: [] } let { profiles, topLevel } = parseFoundryToml(text, file) const files = [file] const ext = profiles.get(profile)?.get('extends') - const extPath = typeof ext === 'string' ? ext : ext?.path - if (typeof extPath === 'string') { - const strategy = (typeof ext === 'object' && typeof ext.strategy === 'string') ? ext.strategy : 'extend-arrays' + if (ext !== undefined && !isExtends(ext)) { + throw new Error(`${file}: \`extends\` must be a path, or a table with a \`path\` and an optional \`strategy\` (${[...EXTEND_STRATEGIES].join(', ')})`) + } + if (ext !== undefined) { + const extPath = typeof ext === 'string' ? ext : ext.path + const strategy = typeof ext === 'string' ? 'extend-arrays' : (ext.strategy ?? 'extend-arrays') const baseFile = toPosix(resolve(posix.dirname(file), extPath)) if (readable && !readable(baseFile)) throw new ConfigRefused(`${file}: refusing to extend ${extPath}, which lies outside the dependency`) - const baseText = readFileOrNull(baseFile) + const baseText = readUtf8OrNull(baseFile) if (baseText === null) throw new ConfigRefused(`${file}: the inherited config file does not exist: ${extPath}`) const base = parseFoundryToml(baseText, baseFile).profiles if (base.get(profile)?.has('extends')) { @@ -546,8 +555,6 @@ function detectLibs(root, host) { return lib ? ['lib', 'node_modules'] : ['node_modules'] } -const stringList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : null) - // The selected profile's settings for a Foundry project at `root` (absolute POSIX), defaults // filled in the way forge fills them. `remappings` are the profile's own, unnormalized; an invalid // one throws (configRemappings). `readable`: see readFoundryProfiles. @@ -555,16 +562,22 @@ function loadFoundryConfig(root, profile, { readable } = {}) { const file = rustJoin(root, FOUNDRY_TOML) const { profiles, files } = readFoundryProfiles(file, profile, { readable }) const dict = selectProfile(profiles, profile) - const str = (k) => (typeof dict.get(k) === 'string' ? dict.get(k) : null) + // A setting of the wrong type throws, as forge refuses the config: no quiet default. + const setting = (key, ok, what) => { + const value = dict.get(key) + if (value !== undefined && !ok(value)) throw new Error(`${file}: \`${key}\` must be ${what}`) + return value + } + const isString = (v) => typeof v === 'string' return { profiles, files, - src: str('src') ?? findSourceDir(root, host), - test: str('test') ?? 'test', - script: str('script') ?? 'script', - libs: stringList(dict.get('libs')) ?? detectLibs(root), + src: setting('src', isString, 'a string') ?? findSourceDir(root), + test: setting('test', isString, 'a string') ?? 'test', + script: setting('script', isString, 'a string') ?? 'script', + libs: setting('libs', (v) => Array.isArray(v) && v.every(isString), 'an array of strings') ?? detectLibs(root), remappings: dict.has('remappings') ? configRemappings(dict.get('remappings'), file) : [], - autoDetect: dict.get('auto_detect_remappings') !== false, + autoDetect: setting('auto_detect_remappings', (v) => typeof v === 'boolean', 'a boolean') !== false, } } @@ -626,7 +639,7 @@ function loadNestedConfig(canonical, profile, readable) { return null } const txt = rustJoin(canonical, REMAPPINGS_TXT) - let text = readFileOrNull(txt) + let text = readUtf8OrNull(txt) if (text !== null && !readable(txt)) { console.warn(`[loader.solidity] Skipping a dependency's ${txt}: it is a link out of the dependency`) text = null @@ -647,9 +660,18 @@ function loadNestedConfig(canonical, profile, readable) { // solidityOwnership), as forge would find them from its lexical path. function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) { const canonicalRoot = canonicalize(root) ?? root + // Whether the config of the dependency at `entry` may read `file` (a path from its canonical + // dir): judged by the path from the root, the lexical one or else the canonical one (an absolute + // lib, `/proc/self/cwd/...`). It may read its own files and other dependencies'; a dependency + // outside the root reads nothing, and one a dependency's `libs` named must be a dependency itself + // (not the project's own dir passed off as one). Nothing there (the OS agrees: + // solidityOwnership) is left for the read to find missing. const readable = (entry) => (file) => { - const o = ownership.of(`${stripPrefix(entry.path, root)}/${posix.relative(entry.canonical, file)}`) - return o.real === null || (o.escape === null && (o.dependency || pathStartsWith(canonicalize(file) ?? file, entry.canonical))) + const dir = stripPrefix(entry.path, root) ?? stripPrefix(entry.canonical, canonicalRoot) + if (dir === null || (entry.viaDependency && !ownership.of(dir).dependency)) return false + const o = ownership.of(`${dir}/${posix.relative(entry.canonical, file)}`) + if (o.escape !== null || o.outside) return false + return o.real === null || o.dependency || pathStartsWith(rustJoin(canonicalRoot, o.real), entry.canonical) } // A BTreeSet popped in (canonical, path) order. const pending = new Map() @@ -679,7 +701,7 @@ function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) if (!entry.isSymlink && !seen.has(entry.canonical)) { seen.add(entry.canonical) for (const lib of config.libs) { - for (const e of foundryTomlDirEntries(rustJoin(entry.path, lib), host)) if (!e.isSymlink) addPending(e) + for (const e of foundryTomlDirEntries(rustJoin(entry.path, lib))) if (!e.isSymlink) addPending({ ...e, viaDependency: true }) } } // A custom (or missing) source dir isn't auto-detected: forge synthesizes `/=//`. @@ -836,7 +858,7 @@ export function foundryProject(baseDir, { env = process.env } = {}) { const envName = env.DAPP_REMAPPINGS !== undefined ? 'DAPP_REMAPPINGS' : env.FOUNDRY_REMAPPINGS !== undefined ? 'FOUNDRY_REMAPPINGS' : null const envRemappings = envName === null ? [] : parseRemappingLines(env[envName], envName) - const txt = readText(host, rustJoin(root, REMAPPINGS_TXT)) + const txt = readUtf8OrNull(rustJoin(root, REMAPPINGS_TXT)) if (txt !== null) files.add(rustJoin(root, REMAPPINGS_TXT)) const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, rustJoin(root, REMAPPINGS_TXT))), ...config.remappings] diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index 3b4abc08..554bd221 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -3,12 +3,40 @@ // (foundry.js) and the bundler's --manifests. Dependencies are untrusted input: a link one plants // out of itself is never followed. -import { lstatSync, readdirSync, readlinkSync, realpathSync } from 'node:fs' +import { isUtf8 } from 'node:buffer' +import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync } from 'node:fs' import { isAbsolute, join, parse, posix, relative, resolve, sep } from 'node:path' -import { toPosix } from '@exodus/stasis-core/util' import { isDir } from '../resolve-typescript.js' -import { readFileOrNull } from './cargo.js' + +// `/`-separated, as the loader's paths are: only Windows' separator is converted (on POSIX a `\\` is +// part of a name, and must not read as a directory boundary). +const toSlashes = (p) => (sep === '\\' ? p.replaceAll('\\', '/') : p) + +// --- Reading -------------------------------------------------------------------------------- + +// `p`'s real path as the OS resolves it (realpath(3): the filesystem's own spelling), or null. +export function realpathOrNull(p) { + try { + return realpathSync.native(p) + } catch { + return null + } +} + +// A config file's text, or null when there's no file. One that isn't UTF-8 throws: forge and git +// refuse it, and a text read with U+FFFD in it isn't the one they read. A byte-order mark stays. +export function readUtf8OrNull(file) { + let buf + try { + buf = readFileSync(file) + } catch (err) { + if (err.code === 'ENOENT' || err.code === 'ENOTDIR' || err.code === 'EISDIR') return null + throw err + } + if (!isUtf8(buf)) throw new Error(`${file}: not valid UTF-8`) + return buf.toString('utf8') +} // --- .gitmodules ------------------------------------------------------------------------------ @@ -41,8 +69,9 @@ function gitConfigValue(raw) { } // `.gitmodules` text -> its submodules, `{ name, path, url, branch }` (those set), as git reads the -// file: keys case-insensitive, values unquoted and unescaped, a line ending in `\` continued, and a -// submodule's sections merged by name. +// file: keys case-insensitive, values unquoted and unescaped, a line ending in `\` continued, a +// key after a section header on its line (`[submodule "x"] path = lib/x`), and a submodule's +// sections merged by name. export function parseGitmodules(text) { const byName = new Map() let cur = null @@ -57,7 +86,7 @@ export function parseGitmodules(text) { if (section === 'submodule' && header[2] !== undefined) name = header[2].replaceAll(/\\(.)/gu, '$1') else if (section.startsWith('submodule.')) name = header[1].slice('submodule.'.length) cur = name === null ? null : (byName.get(name) ?? byName.set(name, { name }).get(name)) - continue + line = line.slice(header[0].length) } const pair = cur && /^\s*([a-z][\w-]*)\s*(?:=(.*))?$/iu.exec(line) if (!pair) continue @@ -69,19 +98,11 @@ export function parseGitmodules(text) { // The directories of `.gitmodules`' submodules: dependencies, whatever their host. export function gitSubmodulePaths(baseDir) { - return parseGitmodules(readFileOrNull(join(baseDir, '.gitmodules')) ?? '').map((s) => s.path).filter(Boolean) + return parseGitmodules(readUtf8OrNull(join(baseDir, '.gitmodules')) ?? '').map((s) => s.path).filter(Boolean) } // --- Ownership -------------------------------------------------------------------------------- -const realpathOrNull = (p) => { - try { - return realpathSync.native(p) - } catch { - return null - } -} - const readdirOrEmpty = (dir) => { try { return readdirSync(dir, { withFileTypes: true }) @@ -91,6 +112,8 @@ const readdirOrEmpty = (dir) => { } const NOTHING = { abs: null, escape: null } +// A link target's separators, as the OS reads them (a `\\` is part of a name on POSIX). +const TARGET_SEPARATORS = sep === '\\' ? /[\\/]/u : /\//u // Who owns each project-relative path, decided from how it resolves on disk. The dependencies are // every `node_modules/` (`@scope/`), each entry of the `dirs` (forge's libs, Soldeer's @@ -101,22 +124,25 @@ const NOTHING = { abs: null, escape: null } // nothing is there), `outside` when it's out of the root; // - `dependency`: the real path lies in a dependency, however the path got there (a project's // `src/vendor -> ../lib/dep/src` holds the dependency's code); -// - `escape`: `{ link, root }` when the path crosses a symlink that no one trusted placed: one -// planted inside the dependency `root` that leads out of it to anything but another dependency -// (`lib/evil/src/Evil.sol -> ../../../.env`), or one outside the project (`root` null) that leads -// back into it (a dependency linked from elsewhere: `lib/evil -> ../../shared/evil` holding -// `Evil.sol -> ../../proj/.env`). Such a path is never read. A link the project placed (a -// workspace package in node_modules, a linked `lib/` entry) may lead anywhere in the root, and so -// may one on the path the project was named by (a symlinked checkout, macOS's `/tmp`). +// - `escape`: `{ link, root, why }` when the path may not be read: it crosses a symlink that no one +// trusted placed -- one planted inside the dependency `root` that leads out of it to anything but +// another dependency (`lib/evil/src/Evil.sol -> ../../../.env`), or one outside the project +// (`root` null) that leads back into it (a dependency linked from elsewhere: `lib/evil -> +// ../../shared/evil` holding `Evil.sol -> ../../proj/.env`) -- or (`why: 'unresolved'`) the walk +// below can't vouch for it: it resolves the path link by link, and where that doesn't land where +// the OS's realpath does (a link target it can't read as the OS does, one that isn't UTF-8), the +// path is refused rather than trusted. A link the project placed (a workspace package in +// node_modules, a linked `lib/` entry) may lead anywhere in the root, and so may one on the path +// the project was named by (a symlinked checkout, macOS's `/tmp`). export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { const realBase = realpathSync.native(baseDir) const named = resolve(baseDir) const onNamedPath = (abs) => named === abs || named.startsWith(abs.endsWith(sep) ? abs : `${abs}${sep}`) - const toRel = (abs) => toPosix(relative(realBase, abs)) || '.' + const toRel = (abs) => toSlashes(relative(realBase, abs)) || '.' const inRoot = (rel) => rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel) const inside = (rel) => rel !== '.' && inRoot(rel) const under = (rel, dir) => rel === dir || rel.startsWith(`${dir}/`) - const clean = (d) => posix.normalize(toPosix(d)).replace(/\/+$/u, '') + const clean = (d) => posix.normalize(toSlashes(d)).replace(/\/+$/u, '') // Dirs whose entries are dependencies, and dependency dirs themselves; each by its real path too. const holders = new Set() @@ -125,7 +151,13 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { const real = realpathOrNull(join(baseDir, rel)) if (real !== null && inside(toRel(real))) set.add(toRel(real)) } - for (const d of dirs.map(clean).filter(inside)) { + // A dir as the project names it: relative to the root, or (an absolute lib) by its real path. + const projectDir = (d) => { + if (!isAbsolute(d)) return clean(d) + const real = realpathOrNull(d) + return real === null ? null : toRel(real) + } + for (const d of dirs.map(projectDir).filter((rel) => rel !== null && inside(rel))) { if (posix.basename(d) === 'node_modules') continue // a package's own rule, below holders.add(d) addReal(holders, d) @@ -171,12 +203,14 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { cur = realpathOrNull(next) ?? next continue } - target = readlinkSync(next) + const bytes = readlinkSync(next, { encoding: 'buffer' }) + if (!isUtf8(bytes)) return NOTHING // not a name a string path can spell: unresolved + target = bytes.toString('utf8') } catch { return NOTHING } if (depth >= 40) return NOTHING // ELOOP - const r = walk(isAbsolute(target) ? parse(target).root : cur, target.split(/[\\/]/u), depth + 1) + const r = walk(isAbsolute(target) ? parse(target).root : cur, target.split(TARGET_SEPARATORS), depth + 1) if (r.abs === null || r.escape !== null) return r const at = toRel(next) const to = toRel(r.abs) @@ -195,7 +229,10 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { const of = (rel) => { let owner = owners.get(rel) if (owner === undefined) { - const { abs, escape } = walk(realBase, rel.split('/'), 0) + let { abs, escape } = walk(realBase, rel.split('/'), 0) + // The OS's answer is the one a read gets: the walk must agree with it, or the path is refused. + const os = realpathOrNull(join(realBase, rel)) + if (escape === null && abs !== os) [abs, escape] = [os, { link: rel, root: null, why: 'unresolved' }] const real = abs === null ? null : toRel(abs) owner = { real, outside: real !== null && !inRoot(real), dependency: real !== null && inDependency(real), escape } owners.set(rel, owner) @@ -210,8 +247,9 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { export const projectOwnership = (baseDir, libs, { soldeer = false } = {}) => solidityOwnership(baseDir, { dirs: [...libs, ...(soldeer ? ['dependencies'] : [])], packages: gitSubmodulePaths(baseDir) }) -// Why a path crossing an untrusted link is refused (see solidityOwnership). -export function escapeReason(path, { link, root }) { +// Why a path is refused (see solidityOwnership). +export function escapeReason(path, { link, root, why }) { + if (why === 'unresolved') return `${path} crosses a link stasis can't follow the way the filesystem does` const what = root === null ? 'a link from outside the project root back into it' : `a link out of the dependency ${root}` return link === path ? `${path} is ${what}` : `it resolves to ${path} through ${link}, ${what}` } diff --git a/stasis/src/loaders/solidity.js b/stasis/src/loaders/solidity.js index c927e4ee..00d6305f 100644 --- a/stasis/src/loaders/solidity.js +++ b/stasis/src/loaders/solidity.js @@ -27,7 +27,7 @@ import { readFoundryTomlRemappings, toSolcRemapping, } from './foundry.js' -import { escapeReason, projectOwnership, solidityOwnership } from './solidity-ownership.js' +import { escapeReason, projectOwnership, readUtf8OrNull, realpathOrNull, solidityOwnership } from './solidity-ownership.js' export { solidityOwnership } from './solidity-ownership.js' @@ -133,14 +133,6 @@ export function extractSolImports(content) { // --- Remappings --------------------------------------------------------------------------------- -const realpathOrNull = (p, host) => { - try { - return host.realpath(p) - } catch { - return null - } -} - // Loader-side shape: `{ context, prefix, target }` (context null = global). const toLoaderRemapping = ({ context, name, path }) => ({ context, prefix: name, target: path }) @@ -165,7 +157,9 @@ function readMapping(mappingFile, { env, forge, host }) { const { remappings, files, profiled } = readFoundryTomlRemappings(mappingFile, foundryProfile(env)) return { remappings: remappings.map(toSolcRemapping), files, profiled } } - const listed = parseRemappingLines(await readFile(mappingFile, 'utf8'), mappingFile, { emptyPath: !forge }) + const text = readUtf8OrNull(mappingFile) + if (text === null) throw new Error(`${mappingFile}: no such file`) + const listed = parseRemappingLines(text, mappingFile, { emptyPath: !forge }) return { remappings: listed.map(forge ? toSolcRemapping : toLoaderRemapping), files: [mappingFile] } } diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index efc3f1f1..3b79845e 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -560,14 +560,131 @@ test('buildSolidityBundle with manifests carries no dependency config reached th 'lib/evil2/src/E.sol': 'contract E {}\n', }) symlinkSync('../../.env', join(tmp, 'lib/evil/remappings.txt')) - symlinkSync('../../.env', join(tmp, 'lib/evil/package.json')) symlinkSync('../../.env', join(tmp, 'lib/evil2/foundry.toml')) const { result: bundle, lines } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} })) t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['.gitmodules', 'foundry.toml', 'lib/evil/foundry.toml', 'lib/evil/src/E.sol', 'lib/evil2/src/E.sol', 'src/A.sol']) // Nor are they read as its config. t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's") && l.includes('lib/evil/remappings.txt: it is a link out of the dependency'))) t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's config") && l.includes('lib/evil2/foundry.toml: refusing to read it'))) - t.assert.ok(lines.some((l) => l === '[stasis] Not carrying lib/evil/package.json: lib/evil/package.json is a link out of the dependency lib/evil')) + t.assert.ok(lines.some((l) => l === '[stasis] Not carrying lib/evil/remappings.txt: lib/evil/remappings.txt is a link out of the dependency lib/evil')) +})) + +test('buildSolidityBundle refuses a package.json a dependency planted as a link, without quoting what it leads to', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + '.env': 'PRIVATE_KEY=0xabc\n', + '.gitmodules': '[submodule "lib/evil"]\n\tpath = lib/evil\n\turl = https://github.com/e/evil\n', + 'src/A.sol': 'import "evil/E.sol";\n', + 'lib/evil/src/E.sol': 'contract E {}\n', + }) + symlinkSync('../../.env', join(tmp, 'lib/evil/package.json')) + for (const manifests of [false, true]) { + await t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests, env: {} }), + (err) => err.message === 'Refusing lib/evil/package.json: lib/evil/package.json is a link out of the dependency lib/evil' && !String(err.cause ?? '').includes('0xabc'), + ) + } +})) + +test('buildSolidityBundle fails on a package.json that doesn\'t parse, rather than giving its files to the parent package', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'contracts/A.sol': 'import "pkg/sub/B.sol";\n', + 'node_modules/pkg/package.json': '{"name":"pkg","version":"1.0.0"}', + 'node_modules/pkg/sub/package.json': '{ "name": SECRET }', + 'node_modules/pkg/sub/B.sol': 'contract B {}\n', + }) + // The error says where, never what: the parser's own message quotes the text. + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['contracts'], env: {} }), { message: 'node_modules/pkg/sub/package.json is not valid JSON' }) + writeFileSync(join(tmp, 'node_modules/pkg/sub/package.json'), '{\n "name": "sub",\n}\n') + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['contracts'], env: {} }), { message: 'node_modules/pkg/sub/package.json is not valid JSON (line 3 column 1)' }) +})) + +test('buildSolidityBundle refuses a dependency config reached through an absolute or /proc lib, and reads it from the root', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'secrets.toml': '# SECRET\n[profile.default]\n', + 'src/A.sol': 'import "dep/D.sol";\n', + 'lib/dep/src/D.sol': 'contract D {}\n', + // A dependency naming the project's own dir as a lib, through /proc/self/cwd: its "nested" + // config is the project's file, and may not extend the project's secrets. + 'lib/dep/foundry.toml': '[profile.default]\nlibs = ["/proc/self/cwd/sub"]\n', + 'sub/x/foundry.toml': '[profile.default]\nextends = "../../secrets.toml"\n', + }) + const root = realpathSync(tmp) + // A dependency's lib: the "dependency" is the project's dir, and isn't read at all. The root's + // own absolute lib: its entry is taken as a dependency, which may not extend the project's file. + for (const [foundry, refused] of [ + ['[profile.default]\n', 'sub/x/foundry.toml: refusing to read it'], + [`[profile.default]\nlibs = ["lib", "${join(root, 'sub')}"]\n`, 'sub/x/foundry.toml: refusing to extend ../../secrets.toml'], + ]) { + writeFileSync(join(tmp, 'foundry.toml'), foundry) + const cwd = process.cwd() + process.chdir(tmp) + try { + const { result: bundle, lines } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} })) + t.assert.ok(!bundle.sources.has('secrets.toml')) + t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's config") && l.includes(refused)), lines.join('\n')) + } finally { + process.chdir(cwd) + } + } +})) + +test('buildSolidityBundle refuses a path the ownership walk reads differently from the OS', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + '.env': 'PRIVATE_KEY=0xabc\n', + 'src/A.sol': 'import "evil/E.sol";\n', + // A `\` is part of a name on POSIX: `a\b` is one entry (a link to .env), not the harmless a/b. + 'lib/evil/src/a/b': 'contract Harmless {}\n', + }) + symlinkSync('../../../.env', join(tmp, 'lib/evil/src/a\\b')) + symlinkSync('a\\b', join(tmp, 'lib/evil/src/E.sol')) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + /refused: it resolves to lib\/evil\/src\/E\.sol through lib\/evil\/src\/a\\b, a link out of the dependency lib\/evil/u, + )) + // A link target that isn't UTF-8 names a file no string path can: refused, not taken as missing. + rmSync(join(tmp, 'lib/evil/src/E.sol')) + symlinkSync(Buffer.from([0xff]), Buffer.from(join(tmp, 'lib/evil/src/E.sol'))) + symlinkSync('../../../.env', Buffer.concat([Buffer.from(`${join(tmp, 'lib/evil/src')}/`), Buffer.from([0xff])])) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + /refused: lib\/evil\/src\/E\.sol crosses a link stasis can't follow the way the filesystem does/u, + )) +})) + +test('buildSolidityBundle fails on a config that isn\'t UTF-8 or holds a mistyped setting, as forge does', withTmp(async (t, tmp) => { + writeProject(tmp, { 'foundry.toml': '[profile.default]\n', 'src/A.sol': 'import "x/X.sol";\n', 'lib/x/X.sol': 'contract X {}\n', 'deps/x/X.sol': 'contract Y {}\n' }) + writeFileSync(join(tmp, 'remappings.txt'), Buffer.concat([Buffer.from('x/=lib/x'), Buffer.from([0xff]), Buffer.from('/\n')])) + await captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: `${join(tmp, 'remappings.txt')}: not valid UTF-8` })) + rmSync(join(tmp, 'remappings.txt')) + for (const [setting, message] of [ + ['libs = "deps"', '`libs` must be an array of strings'], + ['src = 1', '`src` must be a string'], + ['auto_detect_remappings = "no"', '`auto_detect_remappings` must be a boolean'], + ['extends = { path = "b.toml", strategy = "merge" }', '`extends` must be a path, or a table with a `path` and an optional `strategy` (extend-arrays, replace-arrays, no-collision)'], + ]) { + writeFileSync(join(tmp, 'foundry.toml'), `[profile.default]\n${setting}\n`) + await captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: `${join(tmp, 'foundry.toml')}: ${message}` })) + } +})) + +test('buildSolidityBundle keeps a remappings.txt byte-order mark as forge does, and carries configs whatever they are called', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\nextends = "base.conf"\n', + 'base.conf': '[profile.default]\nsrc = "src"\n', + // forge's trim keeps U+FEFF, so this remapping's prefix is `x/`: `x/` stays lib/x's. + 'remappings.txt': 'x/=lib/other/\n', + 'remaps': 'x/=lib/x/\n', + 'src/A.sol': 'import "x/X.sol";\n', + 'lib/x/X.sol': 'contract X {}\n', + 'lib/other/X.sol': 'contract O {}\n', + }) + let bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} }) + t.assert.equal(bundle.imports.get('solidity').get('src/A.sol').get('x/X.sol'), 'lib/x/X.sol') + t.assert.equal(bundle.sources.get('base.conf'), '[profile.default]\nsrc = "src"\n') + bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, mappingFile: 'remaps', env: {} }) + t.assert.equal(bundle.sources.get('remaps'), 'x/=lib/x/\n') })) test('buildSolidityBundle never follows a link from outside the root back into it', withTmp(async (t, tmp) => { diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index 6f91ebc4..d75a2ca9 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -86,6 +86,8 @@ test('parseRemappings handles one-per-line entries and refuses an invalid line, { context: null, prefix: '@b/', target: 'lib/b/' }, ]) t.assert.throws(() => parseRemappings('@a/=lib/a/\ngarbage line\n'), { message: 'remappings:2: invalid remapping "garbage line"' }) + // Lines are trimmed as Rust trims them: a byte-order mark isn't whitespace, and stays. + t.assert.deepEqual(parseRemappings('\uFEFFx/=a/\n'), [{ context: null, prefix: '\uFEFFx/', target: 'a/' }]) t.assert.throws(() => parseRemappings('\n=empty-prefix\n'), { message: 'remappings:2: invalid remapping "=empty-prefix"' }) }) @@ -748,12 +750,15 @@ test('parseGitmodules reads .gitmodules as git does: quotes, escapes, comments, '\tbranch = "v1 \\"x\\""', '[submodule.c]', '\tpath = lib/c ', + // A key may follow its section header on the line. + '[submodule "d"] path = vendor/d', '', ].join('\n') t.assert.deepEqual(parseGitmodules(text), [ { name: 'a', path: 'vendor/a', url: 'https://github.com/o/a', branch: 'v1 "x"' }, { name: 'b', path: 'lib/bx', url: 'https://github.com/o/b' }, { name: 'c', path: 'lib/c' }, + { name: 'd', path: 'vendor/d' }, ]) }) From 71c1b1db34817620fbc072a9f3f77e786b6b7a2c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 12:14:15 +0000 Subject: [PATCH 06/20] refactor(bundle): simplify the Solidity loader's ownership, config and manifest code No behavior change; one `.env` rule, see below. - solidityOwnership's `of()` returns the refusal `reason`, so callers stop formatting escapes themselves (escapeReason is private). The walk realpaths only at links and once at the end, instead of every component; the dependency-dir list is built once, and the node_modules test is hasNodeModulesSegment. - One readGitmodules for the ownership roots and the bundle classifier. - One package.json reader (readPackageJson: check, read, parse or a content-free error) behind findPackageMetadata and the submodule classifier, which reads each submodule's once. buildSolidityBundle builds one ownership check and one per-directory package lookup, shared by bucketing (assembleCodeBundle's `packageOf`) and --manifests. - --manifests' `.env` rule is stasis-core's isDotEnvFile (so `*.env` and any case are never carried either), plus hardhat.config.*. - readMapping is synchronous; discoverSolidityConfig returns foundryProject's result as is; parseRemappingLines takes `{ label, emptyPath }`; one hasProfile rule; a dependency's remappings.txt is checked before it's read, as its foundry.toml is. - Test loops that must run sequentially say so to the linter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- stasis-core/src/bundle-util.js | 43 ++++++------ stasis/src/cmd/bundle.js | 84 ++++++++++++------------ stasis/src/loaders/foundry.js | 46 +++++++------ stasis/src/loaders/solidity-ownership.js | 57 ++++++++++------ stasis/src/loaders/solidity.js | 32 ++++----- tests/bundle-cmd.test.js | 12 ++-- tests/solidity-loader.test.js | 7 +- 7 files changed, 145 insertions(+), 136 deletions(-) diff --git a/stasis-core/src/bundle-util.js b/stasis-core/src/bundle-util.js index 4c585322..a35954fa 100644 --- a/stasis-core/src/bundle-util.js +++ b/stasis-core/src/bundle-util.js @@ -25,24 +25,14 @@ export function packageType(file, host = diskHost) { // at the root). Inside node_modules both name and version are required; a workspace package // outside node_modules may omit version (the name alone claims the bucket, matching // State#locateModule). Null if none. A malformed one is walked past, or with `strict` throws -// (its files would otherwise land in the parent package). `check(rel)`, when given, sees each -// package.json's path before it is read, and may throw to refuse it. Read through `host`. +// (its files would otherwise land in the parent package); `check`, `host`: see readPackageJson. export function findPackageMetadata(baseDir, fileRelPath, { strict = false, check, host = diskHost } = {}) { let dir = dirname(fileRelPath) while (true) { - const pkgPath = join(baseDir, dir, 'package.json') - if (host.stat(pkgPath)?.isFile()) { - check?.(toPosix(join(dir, 'package.json'))) - let pkg - try { - pkg = JSON.parse(host.readFile(pkgPath).toString('utf8')) - } catch (err) { - if (strict) throw jsonError(toPosix(join(dir, 'package.json')), err) - } - if (pkg?.name && (pkg.version || !hasNodeModulesSegment(toPosix(dir)))) { - // `?? undefined` folds a literal `"version": null` into the one absent-version spelling. - return { pkgDir: dir, name: pkg.name, version: pkg.version ?? undefined } - } + const pkg = readPackageJson(baseDir, toPosix(join(dir, 'package.json')), { strict, check, host }) + if (pkg?.name && (pkg.version || !hasNodeModulesSegment(toPosix(dir)))) { + // `?? undefined` folds a literal `"version": null` into the one absent-version spelling. + return { pkgDir: dir, name: pkg.name, version: pkg.version ?? undefined } } if (dir === '.' || dir === '/' || dir === '') return null const parent = dirname(dir) @@ -51,12 +41,23 @@ export function findPackageMetadata(baseDir, fileRelPath, { strict = false, chec } } -// `rel` isn't valid JSON: said with the parser's line and column, never its message, which quotes -// the text (a file that isn't JSON may be anything, a secret included). -export function jsonError(rel, err) { - const at = /\(line \d+ column \d+\)/u.exec(err.message)?.[0] - // eslint-disable-next-line preserve-caught-error -- the parser's error quotes the file - return new Error(`${rel} is not valid JSON${at ? ` ${at}` : ''}`) +// The package.json at `rel` (under `baseDir`), parsed; null when there's none, or when it doesn't +// parse -- unless `strict`, then that throws, saying where with the parser's line and column but +// never its message, which quotes the text (a file that isn't JSON may be anything, a secret +// included). `check(rel)`, when given, sees the path before it is read, and may throw to refuse it. +// Read through `host`. +export function readPackageJson(baseDir, rel, { strict = false, check, host = diskHost } = {}) { + const file = join(baseDir, rel) + if (!host.stat(file)?.isFile()) return null + check?.(rel) + try { + return JSON.parse(host.readFile(file).toString('utf8')) + } catch (err) { + if (!strict) return null + const at = /\(line \d+ column \d+\)/u.exec(err.message)?.[0] + // eslint-disable-next-line preserve-caught-error -- the parser's error quotes the file + throw new Error(`${rel} is not valid JSON${at ? ` ${at}` : ''}`) + } } export function normalizeEntries(entries, cwd) { diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index 5c54f8eb..a17b5005 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -13,8 +13,8 @@ import { createMetroResolver } from '../metro-resolver.js' import { State } from '@exodus/stasis-core/state' import { brotliOptions } from '@exodus/stasis-core/brotli' import { sha512integrity } from '@exodus/stasis-core/state-util' -import { detectRepo, findPackageMetadata, jsonError, normalizeEntries, packageType, readJson, readModuleManifest } from '@exodus/stasis-core/bundle-util' -import { RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPodspec, isSkippedNativeWalkDir, moduleFileKey, parseResourcesOption, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util' +import { detectRepo, findPackageMetadata, jsonError, normalizeEntries, packageType, readJson, readModuleManifest, readPackageJson } from '@exodus/stasis-core/bundle-util' +import { RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, isDotEnvFile, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPodspec, isSkippedNativeWalkDir, moduleFileKey, parseResourcesOption, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util' import { diskHost } from '@exodus/stasis-core/host' import { SOLIDITY_PACKAGE_MANIFESTS, @@ -24,7 +24,7 @@ import { discoverSolidityConfig, expandSolidityEntries, } from '../loaders/solidity.js' -import { escapeReason, parseGitmodules, readUtf8OrNull } from '../loaders/solidity-ownership.js' +import { readGitmodules } from '../loaders/solidity-ownership.js' import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js' import { buildRustTree, collectRustFilesFromDisk } from '../loaders/rust.js' import { VENDOR_DIR as CARGO_VENDOR_DIR, createCargoContext } from '../loaders/cargo.js' @@ -91,7 +91,7 @@ function githubSlug(url) { // only. function parseGithubSubmodules(baseDir) { const byPath = new Map() - for (const { path, url, branch } of parseGitmodules(readUtf8OrNull(join(baseDir, '.gitmodules')) ?? '')) { + for (const { path, url, branch } of readGitmodules(baseDir)) { const name = path && url ? githubSlug(url) : null if (name) byPath.set(path.replace(/\/+$/u, ''), { name, branch }) } @@ -103,6 +103,7 @@ function parseGithubSubmodules(baseDir) { // workspace logic. `check` vets a package.json path before it is read (readableBy). function makeSolidityClassifier(baseDir, check) { const submodules = parseGithubSubmodules(baseDir) + const versions = new Map() // a submodule's package.json version, read once return (path) => { if (path.startsWith('dependencies/')) { const seg = path.slice('dependencies/'.length).split('/')[0] @@ -113,8 +114,8 @@ function makeSolidityClassifier(baseDir, check) { } for (const [sub, { name, branch }] of submodules) { if (path === sub || path.startsWith(`${sub}/`)) { - const pkg = readPackageJsonOrNull(baseDir, moduleFileKey(sub, 'package.json'), check) - return { bucketDir: sub, name, version: pkg?.version ?? branch ?? '0.0.0', ecosystem: 'github' } + if (!versions.has(sub)) versions.set(sub, readPackageJson(baseDir, moduleFileKey(sub, 'package.json'), { strict: true, check })?.version) + return { bucketDir: sub, name, version: versions.get(sub) ?? branch ?? '0.0.0', ecosystem: 'github' } } } return null @@ -151,12 +152,14 @@ function executableSources(baseDir, sources, host) { // Assemble a full-scope code Bundle shared by the non-JS bundlers. Files are bucketed by // nearest package.json (node_modules -> `npm`-tagged bucket, workspace -> its dir, none -> // "." with the placeholder identity); a node_modules file whose nearest package.json is the -// workspace root is rejected, not mislabeled. `classifyDep(path)` optionally places a file +// workspace root is rejected, not mislabeled; `packageOf(path)` finds that package.json +// (findPackageMetadata, strict, by default). `classifyDep(path)` optionally places a file // directly (non-node_modules ecosystems like Soldeer/github); null defers. `format` tags // every file; `formats` (Map) overrides it per file. `resolutions` values are // a flat target string or a Map; both round-trip untouched. function assembleCodeBundle({ - baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep, host, checkManifest, + baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep, host, + packageOf = (path) => findPackageMetadata(baseDir, path, { strict: true, host }), }) { const modules = new Map() const ensureBucket = (dir, name, version, bucketEcosystem) => { @@ -174,7 +177,7 @@ function assembleCodeBundle({ ensureBucket(dep.bucketDir, dep.name, dep.version, dep.ecosystem).files[fileInBucket(dep.bucketDir, path)] = content continue } - const meta = findPackageMetadata(baseDir, path, { strict: true, check: checkManifest, host }) + const meta = packageOf(path) const inNodeModules = splitNodeModulesPath(path) !== null if (meta) { if (inNodeModules && !meta.pkgDir.includes('node_modules')) { @@ -211,42 +214,38 @@ function assembleCodeBundle({ } // Files never carried, whatever reads them: `.env` files, and Hardhat's config, which is code. -const NEVER_CARRIED = (name) => name === '.env' || name.startsWith('.env.') || name.startsWith('hardhat.config.') +const neverCarried = (rel) => isDotEnvFile(rel) || posix.basename(rel).startsWith('hardhat.config.') -// A check for findPackageMetadata: throws for a path `ownership` refuses (see solidityOwnership), -// as a package.json that decides a file's package may not be read through a planted link. +// A check for readPackageJson: throws for a path `ownership` refuses (see solidityOwnership), as a +// package.json that decides a file's package may not be read through a planted link. const readableBy = (ownership) => (rel) => { - const { escape } = ownership.of(rel) - if (escape) throw new Error(`Refusing ${rel}: ${escapeReason(rel, escape)}`) + const { reason } = ownership.of(rel) + if (reason) throw new Error(`Refusing ${rel}: ${reason}`) } -// A package.json's contents, or null when there's none; one that doesn't parse throws, and so does -// one `check` refuses. -function readPackageJsonOrNull(baseDir, rel, check) { - const text = readFileSyncOrNull(join(baseDir, rel)) - if (text === null) return null - check?.(rel) - try { - return JSON.parse(text) - } catch (err) { - throw jsonError(rel, err) +// findPackageMetadata (strict, `check`ed) once per directory, the only thing its answer depends on. +function packageLookup(baseDir, check) { + const byDir = new Map() + return (path) => { + const dir = posix.dirname(path) + if (!byDir.has(dir)) byDir.set(dir, findPackageMetadata(baseDir, path, { strict: true, check })) + return byDir.get(dir) } } // The build-description files of a Solidity bundle (--manifests), as Map: `configFiles` -// (what discoverSolidityConfig read, whatever they're called; NEVER_CARRIED aside) plus the -// SOLIDITY_*_MANIFESTS that exist, for the root and for each package dir `classifyDep`/package.json +// (what discoverSolidityConfig read, whatever they're called; neverCarried aside) plus the +// SOLIDITY_*_MANIFESTS that exist, for the root and for each package dir `classifyDep`/`packageOf` // places a bundled source in. Files inside the root only, and none whose path `ownership` refuses // (see solidityOwnership). Carried as written: whatever they hold (an RPC URL with its API key, an // Etherscan key, a URL's credentials) is in the bundle too, as with --package-json. -function solidityManifests(baseDir, sources, configFiles, classifyDep, ownership) { - const check = readableBy(ownership) - const wanted = new Set([...configFiles.filter((f) => !NEVER_CARRIED(posix.basename(f))), ...SOLIDITY_ROOT_MANIFESTS]) +function solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership }) { + const wanted = new Set([...configFiles.filter((f) => !neverCarried(f)), ...SOLIDITY_ROOT_MANIFESTS]) const dirs = new Set() for (const path of sources.keys()) { const dep = classifyDep(path) if (dep) dirs.add(dep.bucketDir) - const meta = findPackageMetadata(baseDir, path, { strict: true, check }) + const meta = packageOf(path) if (meta) dirs.add(meta.pkgDir) } for (const dir of dirs) { @@ -256,9 +255,9 @@ function solidityManifests(baseDir, sources, configFiles, classifyDep, ownership const out = new Map() for (const rel of [...wanted].toSorted()) { if (sources.has(rel) || posix.isAbsolute(rel) || rel.startsWith('../')) continue - const { escape } = ownership.of(rel) - if (escape) { - console.warn(`[stasis] Not carrying ${rel}: ${escapeReason(rel, escape)}`) + const { reason } = ownership.of(rel) + if (reason) { + console.warn(`[stasis] Not carrying ${rel}: ${reason}`) continue } let buf @@ -283,14 +282,13 @@ export const isDirEntry = (abs, host = diskHost) => isDir(abs, host) || (extname // entry stands for the .sol files under it, so it goes with Solidity entries only; and entries // that are all missing extensionless paths are a mistyped file, not a project without those dirs. export function directoryEntryError(entries, cwd = process.cwd(), host = diskHost) { - const exists = (e) => host.stat(resolve(cwd, e)) !== null const dirs = entries.filter((e) => isDirEntry(resolve(cwd, e), host)) - const missing = dirs.find((e) => !exists(e)) - if (dirs.length === entries.length && !dirs.some(exists)) return `no such file or directory: ${dirs[0]}` + const absent = dirs.filter((e) => host.stat(resolve(cwd, e)) === null) + if (absent.length === entries.length) return `no such file or directory: ${absent[0]}` if (dirs.length === 0 || entries.every((e) => e.endsWith('.sol') || dirs.includes(e))) return null - return missing === undefined - ? `a directory entry is only supported for Solidity bundles (it stands for the .sol files under it): ${dirs[0]}` - : `no such file or directory: ${missing}` + return absent.length > 0 + ? `no such file or directory: ${absent[0]}` + : `a directory entry is only supported for Solidity bundles (it stands for the .sol files under it): ${dirs[0]}` } // Build an in-memory Bundle from entry .sol files and directories (a directory stands for the .sol @@ -332,11 +330,13 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin throw new Error(`Solidity bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`) } - const classifyDep = makeSolidityClassifier(baseDir, readableBy(ownership)) + const check = readableBy(ownership) + const classifyDep = makeSolidityClassifier(baseDir, check) + const packageOf = packageLookup(baseDir, check) const bundled = new Map(sources) const formats = new Map() if (manifests) { - for (const [path, text] of solidityManifests(baseDir, sources, configFiles, classifyDep, ownership)) { + for (const [path, text] of solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership })) { bundled.set(path, text) formats.set(path, path.endsWith('.json') ? 'json' : 'resource') } @@ -353,7 +353,7 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin formats, conditionKey: 'solidity', classifyDep, - checkManifest: readableBy(ownership), + packageOf, }) } diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index af1f0d18..e4965e4f 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -132,13 +132,13 @@ export function parseRemapping(entry, { emptyPath = false } = {}) { // A remappings.txt / env var body: one remapping per non-blank (trimmed) line. A line that isn't // one throws, naming `label` (the file or variable) and the line, as forge and solc refuse the -// file. `options`: see parseRemapping. -export function parseRemappingLines(text, label = 'remappings', options = undefined) { +// file. `emptyPath`: see parseRemapping. +export function parseRemappingLines(text, { label = 'remappings', emptyPath = false } = {}) { const out = [] text.split('\n').forEach((raw, i) => { const line = rustTrim(raw) if (line === '') return - const r = parseRemapping(line, options) + const r = parseRemapping(line, { emptyPath }) if (r === null) throw new Error(`${label}:${i + 1}: invalid remapping ${JSON.stringify(line)}`) out.push(r) }) @@ -147,7 +147,7 @@ export function parseRemappingLines(text, label = 'remappings', options = undefi // A foundry.toml's `remappings` value, parsed. One forge rejects -- not an array of strings, or an // entry that isn't `[context:]name=path` -- throws, naming `file` when given. -function configRemappings(value, file = null) { +function configRemappings(value, file) { const where = `${file === null ? '' : `${file}: `}\`remappings\`` if (!Array.isArray(value)) throw new Error(`${where} is not an array of strings`) return value.map((entry) => { @@ -483,12 +483,9 @@ function readFoundryProfiles(file, profile, { readable } = {}) { let { profiles, topLevel } = parseFoundryToml(text, file) const files = [file] const ext = profiles.get(profile)?.get('extends') - if (ext !== undefined && !isExtends(ext)) { - throw new Error(`${file}: \`extends\` must be a path, or a table with a \`path\` and an optional \`strategy\` (${[...EXTEND_STRATEGIES].join(', ')})`) - } if (ext !== undefined) { - const extPath = typeof ext === 'string' ? ext : ext.path - const strategy = typeof ext === 'string' ? 'extend-arrays' : (ext.strategy ?? 'extend-arrays') + if (!isExtends(ext)) throw new Error(`${file}: \`extends\` must be a path, or a table with a \`path\` and an optional \`strategy\` (${[...EXTEND_STRATEGIES].join(', ')})`) + const { path: extPath, strategy = 'extend-arrays' } = typeof ext === 'string' ? { path: ext } : ext const baseFile = toPosix(resolve(posix.dirname(file), extPath)) if (readable && !readable(baseFile)) throw new ConfigRefused(`${file}: refusing to extend ${extPath}, which lies outside the dependency`) const baseText = readUtf8OrNull(baseFile) @@ -532,16 +529,19 @@ export function foundryTomlRemappings(text, profile = 'default') { // `files` lists what was read; `profiled` whether the selected `profile` is one of the file's. export function readFoundryTomlRemappings(file, profile = 'default') { const read = readFoundryProfiles(toPosix(resolve(file)), profile) - return { remappings: profileRemappings(read, profile, file), files: read.files, profiled: profile !== 'default' && read.profiles.has(profile) } + return { remappings: profileRemappings(read, profile, file), files: read.files, profiled: hasProfile(read.profiles, profile) } } -// Whether the selected `profile` is one of the root foundry.toml's `profiles` (forge uses -// `[profile.default]` for one that isn't: warned). +// Whether the selected `profile` is one of `profiles` (not the default, which always applies). +const hasProfile = (profiles, profile) => profile !== 'default' && profiles.has(profile) + +// hasProfile, for the root foundry.toml: one that isn't there is warned about (forge uses +// `[profile.default]` for it). function profileApplies(profiles, profile) { - if (profile === 'default') return false - if (profiles.has(profile)) return true - console.warn(`[loader.solidity] FOUNDRY_PROFILE=${profile} is not a profile in foundry.toml; using [profile.default]`) - return false + if (profile !== 'default' && !profiles.has(profile)) { + console.warn(`[loader.solidity] FOUNDRY_PROFILE=${profile} is not a profile in foundry.toml; using [profile.default]`) + } + return hasProfile(profiles, profile) } // `ProjectPathsConfig::find_source_dir`: `src` unless only `contracts` exists. @@ -639,18 +639,16 @@ function loadNestedConfig(canonical, profile, readable) { return null } const txt = rustJoin(canonical, REMAPPINGS_TXT) - let text = readUtf8OrNull(txt) - if (text !== null && !readable(txt)) { - console.warn(`[loader.solidity] Skipping a dependency's ${txt}: it is a link out of the dependency`) - text = null - } + const allowed = readable(txt) // (true when nothing is there) + if (!allowed) console.warn(`[loader.solidity] Skipping a dependency's ${txt}: it is a link out of the dependency`) + const text = allowed ? readUtf8OrNull(txt) : null return { src: config.src, libs: config.libs, files: [...config.files, ...(text === null ? [] : [txt])], // `sanitized()` roots them, then `Remapping::from` makes the path absolute and slash-terminated. remappings: config.remappings.map((r) => fromRelative(relativePreservingBoundary(fromRelative({ ...r, path: { parent: null, path: r.path } }), canonical))), - fileRemappings: text === null ? [] : parseRemappingLines(text, txt), + fileRemappings: text === null ? [] : parseRemappingLines(text, { label: txt }), } } @@ -857,10 +855,10 @@ export function foundryProject(baseDir, { env = process.env } = {}) { const files = new Set(config.files) const envName = env.DAPP_REMAPPINGS !== undefined ? 'DAPP_REMAPPINGS' : env.FOUNDRY_REMAPPINGS !== undefined ? 'FOUNDRY_REMAPPINGS' : null - const envRemappings = envName === null ? [] : parseRemappingLines(env[envName], envName) + const envRemappings = envName === null ? [] : parseRemappingLines(env[envName], { label: envName }) const txt = readUtf8OrNull(rustJoin(root, REMAPPINGS_TXT)) if (txt !== null) files.add(rustJoin(root, REMAPPINGS_TXT)) - const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, rustJoin(root, REMAPPINGS_TXT))), ...config.remappings] + const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, { label: rustJoin(root, REMAPPINGS_TXT) })), ...config.remappings] const provided = providerRemappings(root, { userRemappings, libs: config.libs, autoDetect: config.autoDetect, profile, files, ownership }) .map((r) => displayRelative(relativePreservingBoundary(r, root))) diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index 554bd221..0c482e75 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -7,6 +7,7 @@ import { isUtf8 } from 'node:buffer' import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync } from 'node:fs' import { isAbsolute, join, parse, posix, relative, resolve, sep } from 'node:path' +import { hasNodeModulesSegment } from '@exodus/stasis-core/util' import { isDir } from '../resolve-typescript.js' // `/`-separated, as the loader's paths are: only Windows' separator is converted (on POSIX a `\\` is @@ -96,10 +97,8 @@ export function parseGitmodules(text) { return [...byName.values()] } -// The directories of `.gitmodules`' submodules: dependencies, whatever their host. -export function gitSubmodulePaths(baseDir) { - return parseGitmodules(readUtf8OrNull(join(baseDir, '.gitmodules')) ?? '').map((s) => s.path).filter(Boolean) -} +// The submodules of the project at `baseDir` (its `.gitmodules`, see parseGitmodules). +export const readGitmodules = (baseDir) => parseGitmodules(readUtf8OrNull(join(baseDir, '.gitmodules')) ?? '') // --- Ownership -------------------------------------------------------------------------------- @@ -124,7 +123,8 @@ const TARGET_SEPARATORS = sep === '\\' ? /[\\/]/u : /\//u // nothing is there), `outside` when it's out of the root; // - `dependency`: the real path lies in a dependency, however the path got there (a project's // `src/vendor -> ../lib/dep/src` holds the dependency's code); -// - `escape`: `{ link, root, why }` when the path may not be read: it crosses a symlink that no one +// - `escape`: `{ link, root, why }` (and `reason`, saying so) when the path may not be read: it +// crosses a symlink that no one // trusted placed -- one planted inside the dependency `root` that leads out of it to anything but // another dependency (`lib/evil/src/Evil.sol -> ../../../.env`), or one outside the project // (`root` null) that leads back into it (a dependency linked from elsewhere: `lib/evil -> @@ -167,7 +167,8 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { roots.add(p) addReal(roots, p) } - const inDependency = (rel) => inside(rel) && (rel.split('/').includes('node_modules') || [...holders, ...roots].some((d) => under(rel, d))) + const dependencyDirs = [...holders, ...roots] + const inDependency = (rel) => inside(rel) && (hasNodeModulesSegment(rel) || dependencyDirs.some((d) => under(rel, d))) // The innermost dependency holding `rel`, a real path. const rootOf = (rel) => { if (!inside(rel)) return null @@ -186,8 +187,9 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { } // Resolve `parts` from the real dir `start` as realpath does, checking each symlink crossed - // (and those its target crosses): `{ abs, escape }`, `abs` null when nothing is there. Each - // component takes the filesystem's spelling (a case-insensitive one finds `lib` for `LIB`). + // (and those its target crosses): `{ abs, escape }`, `abs` null when nothing is there, and + // spelled as given past the last link. A link's dir and target take the filesystem's spelling (a + // case-insensitive one finds `lib` for `LIB`) before their owners are judged. const walk = (start, parts, depth) => { let cur = start for (const part of parts) { @@ -200,7 +202,7 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { let target try { if (!lstatSync(next).isSymbolicLink()) { - cur = realpathOrNull(next) ?? next + cur = next continue } const bytes = readlinkSync(next, { encoding: 'buffer' }) @@ -212,15 +214,18 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { if (depth >= 40) return NOTHING // ELOOP const r = walk(isAbsolute(target) ? parse(target).root : cur, target.split(TARGET_SEPARATORS), depth + 1) if (r.abs === null || r.escape !== null) return r - const at = toRel(next) - const to = toRel(r.abs) + const dir = realpathOrNull(cur) ?? cur + const abs = realpathOrNull(r.abs) + if (abs === null) return NOTHING + const at = toRel(join(dir, part)) + const to = toRel(abs) if (inside(at)) { - const root = rootOf(toRel(cur)) - if (root !== null && !under(to, root) && !inDependency(to)) return { abs: r.abs, escape: { link: at, root } } + const root = rootOf(toRel(dir)) + if (root !== null && !under(to, root) && !inDependency(to)) return { abs, escape: { link: at, root } } } else if (inRoot(to) && !onNamedPath(next)) { - return { abs: r.abs, escape: { link: at, root: null } } + return { abs, escape: { link: at, root: null } } } - cur = r.abs + cur = abs } return { abs: cur, escape: null } } @@ -229,12 +234,24 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { const of = (rel) => { let owner = owners.get(rel) if (owner === undefined) { + const path = join(realBase, rel) let { abs, escape } = walk(realBase, rel.split('/'), 0) // The OS's answer is the one a read gets: the walk must agree with it, or the path is refused. - const os = realpathOrNull(join(realBase, rel)) - if (escape === null && abs !== os) [abs, escape] = [os, { link: rel, root: null, why: 'unresolved' }] + // (Past its last link the walk's path is spelled as given; with none, it's `path` itself.) + const os = realpathOrNull(path) + if (escape === null) { + const walked = abs === null ? null : abs === path ? os : realpathOrNull(abs) + if (walked !== os) escape = { link: rel, root: null, why: 'unresolved' } + abs = os + } const real = abs === null ? null : toRel(abs) - owner = { real, outside: real !== null && !inRoot(real), dependency: real !== null && inDependency(real), escape } + owner = { + real, + outside: real !== null && !inRoot(real), + dependency: real !== null && inDependency(real), + escape, + reason: escape && escapeReason(rel, escape), + } owners.set(rel, owner) } return owner @@ -245,10 +262,10 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { // The ownership of the project at `baseDir` given its lib dirs (`soldeer`: forge's `dependencies/` // holds dependencies too), with its git submodules. export const projectOwnership = (baseDir, libs, { soldeer = false } = {}) => - solidityOwnership(baseDir, { dirs: [...libs, ...(soldeer ? ['dependencies'] : [])], packages: gitSubmodulePaths(baseDir) }) + solidityOwnership(baseDir, { dirs: [...libs, ...(soldeer ? ['dependencies'] : [])], packages: readGitmodules(baseDir).map((s) => s.path).filter(Boolean) }) // Why a path is refused (see solidityOwnership). -export function escapeReason(path, { link, root, why }) { +function escapeReason(path, { link, root, why }) { if (why === 'unresolved') return `${path} crosses a link stasis can't follow the way the filesystem does` const what = root === null ? 'a link from outside the project root back into it' : `a link out of the dependency ${root}` return link === path ? `${path} is ${what}` : `it resolves to ${path} through ${link}, ${what}` diff --git a/stasis/src/loaders/solidity.js b/stasis/src/loaders/solidity.js index 00d6305f..3f8102c0 100644 --- a/stasis/src/loaders/solidity.js +++ b/stasis/src/loaders/solidity.js @@ -27,9 +27,7 @@ import { readFoundryTomlRemappings, toSolcRemapping, } from './foundry.js' -import { escapeReason, projectOwnership, readUtf8OrNull, realpathOrNull, solidityOwnership } from './solidity-ownership.js' - -export { solidityOwnership } from './solidity-ownership.js' +import { projectOwnership, readUtf8OrNull, realpathOrNull, solidityOwnership } from './solidity-ownership.js' // --- Import scan ------------------------------------------------------------------------------ @@ -139,7 +137,7 @@ const toLoaderRemapping = ({ context, name, path }) => ({ context, prefix: name, // remappings.txt text -> remappings as written, one `[context:]prefix=target` per line (lines // trimmed, blank ones skipped; an empty target is solc's, valid). A line that isn't one throws. export function parseRemappings(content) { - return parseRemappingLines(content, undefined, { emptyPath: true }).map(toLoaderRemapping) + return parseRemappingLines(content, { emptyPath: true }).map(toLoaderRemapping) } // foundry.toml text -> the `remappings` of `[profile.default]`, overlaid by the selected profile's @@ -152,21 +150,21 @@ export function parseRemappingsFromToml(tomlContent, { env = process.env } = {}) // foundry.toml (its selected profile, with its `extends` base) is forge's, and so is a // remappings.txt when `forge` says forge reads it: slash-terminated the way forge reads them. // Otherwise (solc, Hardhat) a remappings.txt applies as written. -function readMapping(mappingFile, { env, forge, host }) { +function readMapping(mappingFile, { env, forge }) { if (mappingFile.endsWith('.toml')) { const { remappings, files, profiled } = readFoundryTomlRemappings(mappingFile, foundryProfile(env)) return { remappings: remappings.map(toSolcRemapping), files, profiled } } const text = readUtf8OrNull(mappingFile) if (text === null) throw new Error(`${mappingFile}: no such file`) - const listed = parseRemappingLines(text, mappingFile, { emptyPath: !forge }) + const listed = parseRemappingLines(text, { label: mappingFile, emptyPath: !forge }) return { remappings: listed.map(forge ? toSolcRemapping : toLoaderRemapping), files: [mappingFile] } } // Read a foundry.toml/remappings.txt mapping file -> its remappings (see readMapping; `forge` // defaults to a remappings.txt applying as written). The file itself is not added to sources. -export function readRemappingsFile(mappingFile, { env = process.env, forge = false, host = diskHost } = {}) { - return readMapping(mappingFile, { env, forge, host }).remappings +export async function readRemappingsFile(mappingFile, { env = process.env, forge = false } = {}) { + return readMapping(mappingFile, { env, forge }).remappings } // --- Resolution --------------------------------------------------------------------------------- @@ -185,10 +183,7 @@ export function readRemappingsFile(mappingFile, { env = process.env, forge = fal // shaped the result. export async function discoverSolidityConfig(baseDir, { mappingFile, env = process.env } = {}) { const forge = isFile(join(baseDir, FOUNDRY_TOML)) - if (forge && !mappingFile) { - const { remappings, libs, ownership, files, envUsed } = foundryProject(baseDir, { env }) - return { remappings, libs, ownership, files, envUsed } - } + if (forge && !mappingFile) return foundryProject(baseDir, { env }) const { libs, profiled } = forge ? foundryLibs(baseDir, { env }) : { libs: [], profiled: false } const ownership = projectOwnership(baseDir, libs, { soldeer: forge }) const within = (abs) => { @@ -197,14 +192,13 @@ export async function discoverSolidityConfig(baseDir, { mappingFile, env = proce } if (mappingFile) { const abs = resolve(baseDir, mappingFile) - const read = await readMapping(abs, { env, forge }) - const { remappings, files } = read + const { remappings, files, profiled: mappingProfiled } = readMapping(abs, { env, forge }) // The profile picks the mapping file's remappings (a .toml) or the root foundry.toml's libs. - const envUsed = profiled || read.profiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : [] + const envUsed = profiled || mappingProfiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : [] return { remappings, libs, ownership, files: files.flatMap(within), envUsed } } const txt = join(baseDir, REMAPPINGS_TXT) - const remappings = isFile(txt) ? (await readMapping(txt, { env, forge })).remappings : [] + const remappings = isFile(txt) ? readMapping(txt, { env, forge }).remappings : [] return { remappings, libs, ownership, files: isFile(txt) ? [REMAPPINGS_TXT] : [], envUsed: [] } } @@ -304,7 +298,7 @@ function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [ if (!baseDir) return { path } const own = ownership ?? solidityOwnership(baseDir) const target = own.of(path) - if (target.escape) return { reason: escapeReason(path, target.escape) } + if (target.reason) return { reason: target.reason } // (A link out of the root is refused when the file is read.) if (target.real !== null && !target.outside && !target.dependency && own.of(fromFile).dependency) return { reason: `a dependency may not import the project's own ${path}` } return { path } @@ -367,8 +361,8 @@ export async function collectSolidityFilesFromDisk(baseDir, entries, remappings, const knownEntries = new Set(entries) const realBase = realpathSync(baseDir) for (const entry of entries) { - const { escape } = ownership.of(entry) - if (escape) throw new Error(`Refusing entry ${entry}: ${escapeReason(entry, escape)}`) + const { reason } = ownership.of(entry) + if (reason) throw new Error(`Refusing entry ${entry}: ${reason}`) } const processWave = async (wave) => { diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 3b79845e..5e28e8a2 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -578,12 +578,10 @@ test('buildSolidityBundle refuses a package.json a dependency planted as a link, 'lib/evil/src/E.sol': 'contract E {}\n', }) symlinkSync('../../.env', join(tmp, 'lib/evil/package.json')) - for (const manifests of [false, true]) { - await t.assert.rejects( - () => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests, env: {} }), - (err) => err.message === 'Refusing lib/evil/package.json: lib/evil/package.json is a link out of the dependency lib/evil' && !String(err.cause ?? '').includes('0xabc'), - ) - } + await Promise.all([false, true].map((manifests) => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests, env: {} }), + (err) => err.message === 'Refusing lib/evil/package.json: lib/evil/package.json is a link out of the dependency lib/evil' && !String(err.cause ?? '').includes('0xabc'), + ))) })) test('buildSolidityBundle fails on a package.json that doesn\'t parse, rather than giving its files to the parent package', withTmp(async (t, tmp) => { @@ -620,6 +618,7 @@ test('buildSolidityBundle refuses a dependency config reached through an absolut const cwd = process.cwd() process.chdir(tmp) try { + // eslint-disable-next-line no-await-in-loop -- each run rewrites foundry.toml and needs the cwd const { result: bundle, lines } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} })) t.assert.ok(!bundle.sources.has('secrets.toml')) t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's config") && l.includes(refused)), lines.join('\n')) @@ -665,6 +664,7 @@ test('buildSolidityBundle fails on a config that isn\'t UTF-8 or holds a mistype ['extends = { path = "b.toml", strategy = "merge" }', '`extends` must be a path, or a table with a `path` and an optional `strategy` (extend-arrays, replace-arrays, no-collision)'], ]) { writeFileSync(join(tmp, 'foundry.toml'), `[profile.default]\n${setting}\n`) + // eslint-disable-next-line no-await-in-loop -- each run rewrites foundry.toml await captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: `${join(tmp, 'foundry.toml')}: ${message}` })) } })) diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index d75a2ca9..523221bd 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -19,10 +19,9 @@ import { parseRemappingsFromToml, readRemappingsFile, resolveSolImport, - solidityOwnership, } from '../stasis/src/loaders/solidity.js' import { findRemappingsWithContext, foundryProject, foundryTomlRemappings } from '../stasis/src/loaders/foundry.js' -import { parseGitmodules } from '../stasis/src/loaders/solidity-ownership.js' +import { parseGitmodules, solidityOwnership } from '../stasis/src/loaders/solidity-ownership.js' const fixtures = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'solidity-bundle') @@ -685,7 +684,7 @@ test('solidityOwnership decides a path\'s owner from where it really is, and cat t.assert.equal(owner('node_modules/foo/F.sol'), 'dependency') t.assert.equal(owner('node_modules/.pnpm/foo@1/node_modules/bar/B.sol'), 'dependency') t.assert.equal(owner('secrets/Keys.sol'), 'project') - t.assert.deepEqual(of('lib/dep/src/Nope.sol'), { real: null, outside: false, dependency: false, escape: null }) + t.assert.deepEqual(of('lib/dep/src/Nope.sol'), { real: null, outside: false, dependency: false, escape: null, reason: null }) })) test('solidityOwnership: a link from outside the root back into it is untrusted, unless the root was named through it', async (t) => { @@ -702,7 +701,7 @@ test('solidityOwnership: a link from outside the root back into it is untrusted, // Named through a link (a symlinked checkout), an absolute link through that name is fine. symlinkSync(proj, join(tmp, 'named')) symlinkSync(join(tmp, 'named/Own.sol'), join(proj, 'Abs.sol')) - t.assert.deepEqual(solidityOwnership(join(tmp, 'named')).of('Abs.sol'), { real: 'Own.sol', outside: false, dependency: false, escape: null }) + t.assert.deepEqual(solidityOwnership(join(tmp, 'named')).of('Abs.sol'), { real: 'Own.sol', outside: false, dependency: false, escape: null, reason: null }) } finally { rmSync(tmp, { recursive: true, force: true }) } From acea14f5f76d0b11b0bcfc6f7fe866b16df92b44 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 13:30:13 +0000 Subject: [PATCH 07/20] fix(bundle): refuse what has no real path; resolve extends as forge does; --manifests fails on a config it can't carry - The ownership walk refuses a path the OS can't resolve at all (a real path past PATH_MAX, ENAMETOOLONG) instead of reporting it as missing: a dependency's remappings.txt reached through such a chain is skipped with a warning rather than read, with or without --manifests. - An `extends` path is joined as forge joins it, not normalized, so a `..` after a symlink leads where forge's does; the base is recorded by the real path of the file read. - Every config file the resolution read must be carried by --manifests: one outside the bundle root (`../shared-base.toml`), a .env one (`base.env`, `.env.toml`, `Base.ENV`, `.env.local`), a refused one or one that is gone is an error naming it, not a silent skip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 46 ++++++++------- stasis/src/cmd/bundle.js | 27 ++++++--- stasis/src/loaders/foundry.js | 38 ++++++++---- stasis/src/loaders/solidity-ownership.js | 28 +++++---- stasis/src/loaders/solidity.js | 13 ++-- tests/bundle-cmd.test.js | 75 ++++++++++++++++++++++++ 6 files changed, 170 insertions(+), 57 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index ee82972b..c9f53381 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -422,15 +422,17 @@ that leads out of it to anything but another dependency (`lib/evil/src/Evil.sol dependency linked from elsewhere, `lib/evil -> ../../shared/evil`, holding a link to the project's `.env`). Links are followed one by one and the result checked against the OS's own realpath: a path the two resolve differently (a -link target that isn't UTF-8, one whose `\` the OS reads as part of a name) is -refused, not trusted. Whoever's import, entry or manifest the path is, the -import is refused, the entry rejected, the manifest not carried, and a -dependency's own `foundry.toml`, `extends` base or `remappings.txt` skipped with -a warning (one that is another dependency's file is read). A dependency's config -reaches only what the path from the root does: one found through an absolute or -`/proc/self/cwd` lib is judged by its real path, a dependency outside the root -reads nothing, and a dir a dependency's `libs` names must be a dependency -itself. A `package.json` that decides a file's package is refused the same way +link target that isn't UTF-8, one whose `\` the OS reads as part of a name), or +one the OS can't resolve at all (a real path past `PATH_MAX`), is refused, not +trusted. An `extends` path is joined as forge joins it and resolved by the OS, +so a `..` after a symlink leads where forge's does. Whoever's import, entry or +manifest the path is, the import is refused, the entry rejected, the manifest +not carried, and a dependency's own `foundry.toml`, `extends` base or +`remappings.txt` skipped with a warning (one that is another dependency's file +is read). A dependency's config reaches only what the path from the root does: +one found through an absolute or `/proc/self/cwd` lib is judged by its real +path, a dependency outside the root reads nothing, and a dir a dependency's +`libs` names must be a dependency itself. A `package.json` that decides a file's package is refused the same way when a dependency planted it as a link, and one that doesn't parse is an error naming it (not quoting it) rather than giving its files to the parent package. A link the project placed (a workspace package linked into `node_modules`, a @@ -440,17 +442,21 @@ is the project's own code. The config files are read, not bundled. `--manifests` bundles the build description too: every config file the resolution read, whatever it's called (an -`extends = "base.conf"`, a `--mapping=remaps`), the root's `foundry.lock`, -`soldeer.lock`, `.gitmodules` and `package.json`, and the `package.json`, -`foundry.toml` and `remappings.txt` of every package the bundle holds files of — -`json` for a `package.json`, `resource` otherwise, so `stasis extract` restores -them. They are carried as written, as `--package-json` carries `package.json`: -stasis doesn't edit them, so whatever they hold — an `eth_rpc_url` or -`[rpc_endpoints]` URL with its API key, an `[etherscan]` key, the credentials in -a `.gitmodules` URL — is in the bundle too. Keep secrets in the environment -(`${VAR}` in `foundry.toml`) rather than in these files, or don't pass -`--manifests`. `hardhat.config.*`, being code, and `.env` files are never -carried. +`extends = "base.conf"`, a `--mapping=remaps`; an `extends` base by the real +path of the file read), the root's `foundry.lock`, `soldeer.lock`, `.gitmodules` +and `package.json`, and the `package.json`, `foundry.toml` and `remappings.txt` +of every package the bundle holds files of — `json` for a `package.json`, +`resource` otherwise, so `stasis extract` restores them. They are carried as +written, as `--package-json` carries `package.json`: stasis doesn't edit them, +so whatever they hold — an `eth_rpc_url` or `[rpc_endpoints]` URL with its API +key, an `[etherscan]` key, the credentials in a `.gitmodules` URL — is in the +bundle too. Keep secrets in the environment (`${VAR}` in `foundry.toml`) rather +than in these files, or don't pass `--manifests`. `hardhat.config.*`, being +code, and `.env` files are never carried. A config the resolution read that +can't be carried — one outside the bundle root (`extends = +"../shared-base.toml"`), a `.env` one (`base.env`, `.env.toml`, `.env.local`), +or one the ownership rules refuse — fails `--manifests`, naming it: without it +the bundle couldn't reproduce the resolution. Rust entries are crate roots (`src/main.rs`, `src/lib.rs`, `src/bin/*.rs`, `tests/*.rs`, …): their `mod` declarations resolve as siblings, as rustc does, diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index a17b5005..9dc5d4f0 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -234,13 +234,22 @@ function packageLookup(baseDir, check) { } // The build-description files of a Solidity bundle (--manifests), as Map: `configFiles` -// (what discoverSolidityConfig read, whatever they're called; neverCarried aside) plus the +// (what discoverSolidityConfig read, whatever they're called: each must be carried) plus the // SOLIDITY_*_MANIFESTS that exist, for the root and for each package dir `classifyDep`/`packageOf` -// places a bundled source in. Files inside the root only, and none whose path `ownership` refuses -// (see solidityOwnership). Carried as written: whatever they hold (an RPC URL with its API key, an -// Etherscan key, a URL's credentials) is in the bundle too, as with --package-json. +// places a bundled source in. Files inside the root only, never a neverCarried one, and none whose +// path `ownership` refuses (see solidityOwnership). Carried as written: whatever they hold (an RPC +// URL with its API key, an Etherscan key, a URL's credentials) is in the bundle too, as with +// --package-json. function solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership }) { - const wanted = new Set([...configFiles.filter((f) => !neverCarried(f)), ...SOLIDITY_ROOT_MANIFESTS]) + // Every config the resolution read is carried, or the bundle couldn't be reproduced: one that + // can't be (outside the root, never carried, refused) is an error, not a skip. + const unreproducible = (rel, why) => new Error(`--manifests can't carry ${rel}, which the Solidity resolution read: ${why}`) + for (const rel of configFiles) { + if (rel.startsWith('../') || posix.isAbsolute(rel)) throw unreproducible(rel, 'it lies outside the bundle root') + if (neverCarried(rel)) throw unreproducible(rel, '.env files and hardhat.config.* are never carried') + } + const required = new Set(configFiles) + const wanted = new Set([...configFiles, ...SOLIDITY_ROOT_MANIFESTS]) const dirs = new Set() for (const path of sources.keys()) { const dep = classifyDep(path) @@ -254,9 +263,10 @@ function solidityManifests(baseDir, sources, configFiles, { classifyDep, package const realBase = host.realpath(baseDir) const out = new Map() for (const rel of [...wanted].toSorted()) { - if (sources.has(rel) || posix.isAbsolute(rel) || rel.startsWith('../')) continue + if (sources.has(rel)) continue const { reason } = ownership.of(rel) if (reason) { + if (required.has(rel)) throw unreproducible(rel, reason) console.warn(`[stasis] Not carrying ${rel}: ${reason}`) continue } @@ -265,8 +275,9 @@ function solidityManifests(baseDir, sources, configFiles, { classifyDep, package assertRealPathWithinBase(realBase, baseDir, rel, host) buf = host.readFile(join(baseDir, rel)) } catch (err) { - if (err.code === 'ENOENT' || err.code === 'EISDIR') continue - throw err + if (err.code !== 'ENOENT' && err.code !== 'EISDIR') throw err + if (required.has(rel)) throw unreproducible(rel, 'it is gone') + continue } if (!isUtf8(buf)) throw new Error(`Solidity manifest is not valid UTF-8: ${rel}`) out.set(rel, buf.toString('utf8')) diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index e4965e4f..7763973f 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -477,19 +477,25 @@ const isExtends = (v) => typeof v === 'string' // config: see findNestedFoundryRemappings) refuses the file or its base: a dependency's config may // not read the project's files. function readFoundryProfiles(file, profile, { readable } = {}) { + const none = { profiles: new Map(), topLevel: new Map(), files: [] } + if (!existsSync(file)) return none if (readable && !readable(file)) throw new ConfigRefused(`${file}: refusing to read it, a link out of the dependency`) const text = readUtf8OrNull(file) - if (text === null) return { profiles: new Map(), topLevel: new Map(), files: [] } + if (text === null) return none let { profiles, topLevel } = parseFoundryToml(text, file) const files = [file] const ext = profiles.get(profile)?.get('extends') if (ext !== undefined) { if (!isExtends(ext)) throw new Error(`${file}: \`extends\` must be a path, or a table with a \`path\` and an optional \`strategy\` (${[...EXTEND_STRATEGIES].join(', ')})`) const { path: extPath, strategy = 'extend-arrays' } = typeof ext === 'string' ? { path: ext } : ext - const baseFile = toPosix(resolve(posix.dirname(file), extPath)) + // Joined as forge joins it, not normalized: the read resolves a `..` after a symlink the way + // forge's does (from where the link leads), not textually. + const baseFile = rustJoin(posix.dirname(file), extPath) + const missing = () => new ConfigRefused(`${file}: the inherited config file does not exist: ${extPath}`) + if (!existsSync(baseFile)) throw missing() if (readable && !readable(baseFile)) throw new ConfigRefused(`${file}: refusing to extend ${extPath}, which lies outside the dependency`) const baseText = readUtf8OrNull(baseFile) - if (baseText === null) throw new ConfigRefused(`${file}: the inherited config file does not exist: ${extPath}`) + if (baseText === null) throw missing() const base = parseFoundryToml(baseText, baseFile).profiles if (base.get(profile)?.has('extends')) { throw new ConfigRefused(`${file}: nested inheritance is not allowed (${extPath} has an 'extends' field in profile '${profile}')`) @@ -499,7 +505,7 @@ function readFoundryProfiles(file, profile, { readable } = {}) { if (collisions.length > 0) throw new ConfigRefused(`${file}: key collision in profile '${profile}' when extending ${extPath}: ${collisions.join(', ')}`) } profiles = mergeExtended(base, profiles, strategy) - files.push(baseFile) + files.push(canonicalize(baseFile) ?? baseFile) // the file read, by its real path } return { profiles, topLevel, files } } @@ -639,8 +645,8 @@ function loadNestedConfig(canonical, profile, readable) { return null } const txt = rustJoin(canonical, REMAPPINGS_TXT) - const allowed = readable(txt) // (true when nothing is there) - if (!allowed) console.warn(`[loader.solidity] Skipping a dependency's ${txt}: it is a link out of the dependency`) + const allowed = existsSync(txt) && readable(txt) + if (existsSync(txt) && !allowed) console.warn(`[loader.solidity] Skipping a dependency's ${txt}: it is a link out of the dependency`) const text = allowed ? readUtf8OrNull(txt) : null return { src: config.src, @@ -662,14 +668,17 @@ function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) // dir): judged by the path from the root, the lexical one or else the canonical one (an absolute // lib, `/proc/self/cwd/...`). It may read its own files and other dependencies'; a dependency // outside the root reads nothing, and one a dependency's `libs` named must be a dependency itself - // (not the project's own dir passed off as one). Nothing there (the OS agrees: - // solidityOwnership) is left for the read to find missing. + // (not the project's own dir passed off as one). Callers ask only about a file that exists. const readable = (entry) => (file) => { const dir = stripPrefix(entry.path, root) ?? stripPrefix(entry.canonical, canonicalRoot) if (dir === null || (entry.viaDependency && !ownership.of(dir).dependency)) return false - const o = ownership.of(`${dir}/${posix.relative(entry.canonical, file)}`) - if (o.escape !== null || o.outside) return false - return o.real === null || o.dependency || pathStartsWith(rustJoin(canonicalRoot, o.real), entry.canonical) + // `file` as joined under the dependency's dir (an `extends` path unnormalized, for the walk to + // resolve as the read does); one not under it (an absolute path elsewhere) lies outside it. + if (!file.startsWith(`${entry.canonical}/`)) return false + const o = ownership.of(`${dir}/${file.slice(entry.canonical.length + 1)}`) + // No real path -- nothing there, or nothing the OS can resolve -- is refused too. + if (o.real === null || o.escape !== null || o.outside) return false + return o.dependency || pathStartsWith(rustJoin(canonicalRoot, o.real), entry.canonical) } // A BTreeSet popped in (canonical, path) order. const pending = new Map() @@ -842,7 +851,7 @@ export function foundryLibs(baseDir, { env = process.env } = {}) { // The Foundry project at `baseDir`: what `forge build` would use. `remappings` are // `{ context, prefix, target }` relative to the root, in forge's order; `libs` the lib dirs; -// `files` the config files read (project-relative, when inside the project); `envUsed` the +// `files` the config files read (project-relative, `../` when outside the project); `envUsed` the // environment variables that shaped them; `ownership` its files' owners (see solidityOwnership), // which also confines what a dependency's config reads. `env` supplies FOUNDRY_PROFILE and // FOUNDRY_REMAPPINGS / DAPP_REMAPPINGS. @@ -875,7 +884,10 @@ export function foundryProject(baseDir, { env = process.env } = {}) { .filter(Boolean) .map(toSolcRemapping) - const relFiles = [...files].map((f) => stripPrefix(f, root)).filter((f) => f !== null && f !== '') + // Project-relative; one read from outside the root (an `extends = "../base.toml"`) stays as its + // `../` path, for --manifests to refuse (it can't be carried). + const canonicalRoot = canonicalize(root) ?? root + const relFiles = [...files].map((f) => stripPrefix(f, root) ?? stripPrefix(f, canonicalRoot) ?? posix.relative(canonicalRoot, f)).filter((f) => f !== '') const envUsed = [...(profiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : []), ...(envName === null ? [] : [envName])] return { remappings, libs: config.libs, files: relFiles, envUsed, ownership } } diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index 0c482e75..311f954d 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -17,11 +17,15 @@ const toSlashes = (p) => (sep === '\\' ? p.replaceAll('\\', '/') : p) // --- Reading -------------------------------------------------------------------------------- // `p`'s real path as the OS resolves it (realpath(3): the filesystem's own spelling), or null. -export function realpathOrNull(p) { +export const realpathOrNull = (p) => osRealpath(p).real + +// realpath(3) of `p`: `{ real }`, or `{ real: null, missing }`, `missing` false when something is +// there that the OS can't resolve (a real path past PATH_MAX, a loop, a dir it may not search). +function osRealpath(p) { try { - return realpathSync.native(p) - } catch { - return null + return { real: realpathSync.native(p), missing: false } + } catch (err) { + return { real: null, missing: err.code === 'ENOENT' || err.code === 'ENOTDIR' } } } @@ -130,10 +134,11 @@ const TARGET_SEPARATORS = sep === '\\' ? /[\\/]/u : /\//u // (`root` null) that leads back into it (a dependency linked from elsewhere: `lib/evil -> // ../../shared/evil` holding `Evil.sol -> ../../proj/.env`) -- or (`why: 'unresolved'`) the walk // below can't vouch for it: it resolves the path link by link, and where that doesn't land where -// the OS's realpath does (a link target it can't read as the OS does, one that isn't UTF-8), the -// path is refused rather than trusted. A link the project placed (a workspace package in -// node_modules, a linked `lib/` entry) may lead anywhere in the root, and so may one on the path -// the project was named by (a symlinked checkout, macOS's `/tmp`). +// the OS's realpath does (a link target it can't read as the OS does, one that isn't UTF-8), or +// the OS can't resolve it at all (a real path past PATH_MAX), the path is refused rather than +// trusted. `real` null with no `escape` means nothing is there. A link the project placed (a +// workspace package in node_modules, a linked `lib/` entry) may lead anywhere in the root, and +// so may one on the path the project was named by (a symlinked checkout, macOS's `/tmp`). export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { const realBase = realpathSync.native(baseDir) const named = resolve(baseDir) @@ -236,12 +241,13 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { if (owner === undefined) { const path = join(realBase, rel) let { abs, escape } = walk(realBase, rel.split('/'), 0) - // The OS's answer is the one a read gets: the walk must agree with it, or the path is refused. + // The OS's answer is the one a read gets: the walk must agree with it, or the path is refused, + // as it is when the OS can't resolve it at all, though a read may still get through. // (Past its last link the walk's path is spelled as given; with none, it's `path` itself.) - const os = realpathOrNull(path) + const { real: os, missing } = osRealpath(path) if (escape === null) { const walked = abs === null ? null : abs === path ? os : realpathOrNull(abs) - if (walked !== os) escape = { link: rel, root: null, why: 'unresolved' } + if (walked !== os || (os === null && !missing)) escape = { link: rel, root: null, why: 'unresolved' } abs = os } const real = abs === null ? null : toRel(abs) diff --git a/stasis/src/loaders/solidity.js b/stasis/src/loaders/solidity.js index 3f8102c0..29abb5db 100644 --- a/stasis/src/loaders/solidity.js +++ b/stasis/src/loaders/solidity.js @@ -179,23 +179,26 @@ export async function readRemappingsFile(mappingFile, { env = process.env, forge // `libs` are forge's lib dirs whenever the root has a foundry.toml (an absolute import inside a // library resolves against it); `ownership` tells the dependencies' files from the project's // (solidityOwnership: forge's libs, Soldeer's `dependencies/`, git submodules, node_modules); -// `files` the project-relative config files read; `envUsed` the environment variables that -// shaped the result. +// `files` the project-relative config files read (`../` for one outside the project); `envUsed` +// the environment variables that shaped the result. export async function discoverSolidityConfig(baseDir, { mappingFile, env = process.env } = {}) { const forge = isFile(join(baseDir, FOUNDRY_TOML)) if (forge && !mappingFile) return foundryProject(baseDir, { env }) const { libs, profiled } = forge ? foundryLibs(baseDir, { env }) : { libs: [], profiled: false } const ownership = projectOwnership(baseDir, libs, { soldeer: forge }) - const within = (abs) => { + // Project-relative, by real path when not inside lexically; `../` when outside the project (for + // --manifests to refuse: it can't be carried). + const projectRelative = (abs) => { const rel = toPosix(relative(baseDir, abs)) - return rel.startsWith('..') || isAbsolute(rel) ? [] : [rel] + if (!rel.startsWith('..') && !isAbsolute(rel)) return rel + return toPosix(relative(realpathOrNull(baseDir) ?? baseDir, realpathOrNull(abs) ?? abs)) } if (mappingFile) { const abs = resolve(baseDir, mappingFile) const { remappings, files, profiled: mappingProfiled } = readMapping(abs, { env, forge }) // The profile picks the mapping file's remappings (a .toml) or the root foundry.toml's libs. const envUsed = profiled || mappingProfiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : [] - return { remappings, libs, ownership, files: files.flatMap(within), envUsed } + return { remappings, libs, ownership, files: files.map(projectRelative), envUsed } } const txt = join(baseDir, REMAPPINGS_TXT) const remappings = isFile(txt) ? readMapping(txt, { env, forge }).remappings : [] diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 5e28e8a2..14e09c20 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -776,6 +776,81 @@ test('buildSolidityBundle fails on an invalid remapping, the project\'s or a dep t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/dep/src/D.sol', 'src/A.sol']) })) +test('buildSolidityBundle refuses a dependency config whose real path the OS can\'t resolve (past PATH_MAX)', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + '.env': 'PRIVATE_KEY=0xabc\n', + // Were the .env read as the dependency's remappings.txt, `PRIVATE_KEY/` would map here. + 'src/A.sol': 'import "evil/E.sol";\nimport "PRIVATE_KEY/Y.sol";\n', + 'lib/evil/src/E.sol': 'contract E {}\n', + 'lib/evil/foundry.toml': '[profile.default]\n', + 'lib/evil/0xabc/Y.sol': 'contract Y {}\n', + }) + // lib/evil/remappings.txt -> a chain of 22 dirs with 200-char names, each hop short, ending in a + // link to the project's .env: readable, but its real path is past PATH_MAX (4096). + const seg = (i) => `${'d'.repeat(200)}${i}` + const levels = 22 + const cwd = process.cwd() + try { + process.chdir(join(tmp, 'lib/evil')) + symlinkSync(`${seg(0)}/n`, 'remappings.txt') + for (let i = 0; i < levels; i++) { + mkdirSync(seg(i)) + process.chdir(seg(i)) + symlinkSync(i + 1 < levels ? `${seg(i + 1)}/n` : `${'../'.repeat(levels + 2)}.env`, 'n') + } + } finally { + process.chdir(cwd) + } + t.assert.equal(readFileSync(join(tmp, 'lib/evil/remappings.txt'), 'utf8'), 'PRIVATE_KEY=0xabc\n') + await Promise.all([false, true].map(async (manifests) => { + const { lines } = await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests, env: {} }), + (err) => err.message.includes('Unresolved import: PRIVATE_KEY/Y.sol from src/A.sol'), + )) + t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's") && l.includes('lib/evil/remappings.txt')), lines.join('\n')) + })) +})) + +test('buildSolidityBundle resolves an `extends` through a symlink as forge does, and carries the file it read', withTmp(async (t, tmp) => { + writeProject(tmp, { + // `sub` is a link to real/in: forge reads real/in/../base.toml, i.e. real/base.toml, not base.toml. + 'foundry.toml': '[profile.default]\nextends = "sub/../base.toml"\n', + 'base.toml': '[profile.default]\nremappings = ["x/=lib/textual/"]\n', + 'real/base.toml': '[profile.default]\nremappings = ["x/=lib/physical/"]\n', + 'real/in/.keep': '', + 'src/A.sol': 'import "x/X.sol";\n', + 'lib/textual/X.sol': 'contract T {}\n', + 'lib/physical/X.sol': 'contract P {}\n', + }) + symlinkSync('real/in', join(tmp, 'sub')) + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} }) + t.assert.equal(bundle.imports.get('solidity').get('src/A.sol').get('x/X.sol'), 'lib/physical/X.sol') + t.assert.ok(bundle.sources.has('real/base.toml') && !bundle.sources.has('base.toml')) +})) + +test('buildSolidityBundle with manifests fails on a config the resolution read but can\'t carry', withTmp(async (t, tmp) => { + const proj = join(tmp, 'proj') + writeProject(proj, { 'src/A.sol': 'contract A {}\n' }) + writeFileSync(join(tmp, 'shared-base.toml'), '[profile.default]\nsrc = "src"\n') + for (const [base, why] of [ + ['base.env', '.env files and hardhat.config.* are never carried'], + ['.env.toml', '.env files and hardhat.config.* are never carried'], + ['Base.ENV', '.env files and hardhat.config.* are never carried'], + ['.env.local', '.env files and hardhat.config.* are never carried'], + ['../shared-base.toml', 'it lies outside the bundle root'], + ]) { + if (!base.startsWith('../')) writeFileSync(join(proj, base), '[profile.default]\nsrc = "src"\n') + writeFileSync(join(proj, 'foundry.toml'), `[profile.default]\nextends = "${base}"\n`) + // eslint-disable-next-line no-await-in-loop -- each run rewrites foundry.toml + await t.assert.rejects(() => buildSolidityBundle({ cwd: proj, entries: ['src'], manifests: true, env: {} }), { message: `--manifests can't carry ${base}, which the Solidity resolution read: ${why}` }) + // Without --manifests there's nothing to carry: the resolution is forge's. + // eslint-disable-next-line no-await-in-loop -- each run rewrites foundry.toml + const bundle = await buildSolidityBundle({ cwd: proj, entries: ['src'], env: {} }) + t.assert.deepEqual([...bundle.sources.keys()], ['src/A.sol']) + } +})) + test('buildSolidityBundle with --mapping bundles when forge would reject the root foundry.toml', withTmp(async (t, tmp) => { writeProject(tmp, { 'foundry.toml': '[profile.default]\nextends = "missing.toml"\n', From 3d4ccdd196b91a2400946f3ef0930f5267a3897b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 13:53:27 +0000 Subject: [PATCH 08/20] refactor(bundle): simplify the Solidity loader's config reading, file naming and manifests - One projectRelative (solidity-ownership.js) names the config files read, for foundry.js and solidity.js alike: as spelled when inside the project, by real path after a `..` or an absolute or /proc/self/cwd lib. A /proc/self/cwd lib's nested config was named `../../proc/...` and failed --manifests; an `extends` base through a symlinked dir is now carried under the path forge reads it by. - The ownership walk already refuses a path the OS can't resolve, so a dependency's config read no longer needs existsSync pre-checks: nothing there is left for the read to find missing. - ownership.assert replaces the refuse-and-throw copies (readableBy, the entry check). - solidityManifests: one carry() for the required configs and the optional manifests, posixPathEscapes for the outside-root check. - packageLookup serves --package-json and assembleCodeBundle's default too; isExtends uses isPlainObject; cargo.js's readFileOrNull is no longer exported (its other callers moved to readUtf8OrNull). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 7 +- stasis/src/cmd/bundle.js | 109 ++++++++++------------- stasis/src/loaders/cargo.js | 2 +- stasis/src/loaders/foundry.js | 40 ++++----- stasis/src/loaders/solidity-ownership.js | 47 ++++++---- stasis/src/loaders/solidity.js | 16 +--- tests/bundle-cmd.test.js | 17 ++++ 7 files changed, 123 insertions(+), 115 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index c9f53381..964bd724 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -442,9 +442,10 @@ is the project's own code. The config files are read, not bundled. `--manifests` bundles the build description too: every config file the resolution read, whatever it's called (an -`extends = "base.conf"`, a `--mapping=remaps`; an `extends` base by the real -path of the file read), the root's `foundry.lock`, `soldeer.lock`, `.gitmodules` -and `package.json`, and the `package.json`, `foundry.toml` and `remappings.txt` +`extends = "base.conf"`, a `--mapping=remaps`), by its path in the project (by +its real path once a `..` or an absolute or `/proc/self/cwd` lib leads +elsewhere), the root's `foundry.lock`, `soldeer.lock`, `.gitmodules` and +`package.json`, and the `package.json`, `foundry.toml` and `remappings.txt` of every package the bundle holds files of — `json` for a `package.json`, `resource` otherwise, so `stasis extract` restores them. They are carried as written, as `--package-json` carries `package.json`: stasis doesn't edit them, diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index 9dc5d4f0..b698e633 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -14,7 +14,7 @@ import { State } from '@exodus/stasis-core/state' import { brotliOptions } from '@exodus/stasis-core/brotli' import { sha512integrity } from '@exodus/stasis-core/state-util' import { detectRepo, findPackageMetadata, jsonError, normalizeEntries, packageType, readJson, readModuleManifest, readPackageJson } from '@exodus/stasis-core/bundle-util' -import { RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, isDotEnvFile, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPodspec, isSkippedNativeWalkDir, moduleFileKey, parseResourcesOption, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util' +import { RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, isDotEnvFile, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPodspec, isSkippedNativeWalkDir, moduleFileKey, parseResourcesOption, posixPathEscapes, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util' import { diskHost } from '@exodus/stasis-core/host' import { SOLIDITY_PACKAGE_MANIFESTS, @@ -100,7 +100,7 @@ function parseGithubSubmodules(baseDir) { // Classify a Solidity file's dep bucket: Soldeer (`dependencies/-/`) or a // github submodule (`lib/`, via `.gitmodules`), else null to defer to the node_modules/ -// workspace logic. `check` vets a package.json path before it is read (readableBy). +// workspace logic. `check` vets a package.json path before it is read (ownership.assert). function makeSolidityClassifier(baseDir, check) { const submodules = parseGithubSubmodules(baseDir) const versions = new Map() // a submodule's package.json version, read once @@ -153,13 +153,13 @@ function executableSources(baseDir, sources, host) { // nearest package.json (node_modules -> `npm`-tagged bucket, workspace -> its dir, none -> // "." with the placeholder identity); a node_modules file whose nearest package.json is the // workspace root is rejected, not mislabeled; `packageOf(path)` finds that package.json -// (findPackageMetadata, strict, by default). `classifyDep(path)` optionally places a file +// (packageLookup, strict, by default). `classifyDep(path)` optionally places a file // directly (non-node_modules ecosystems like Soldeer/github); null defers. `format` tags // every file; `formats` (Map) overrides it per file. `resolutions` values are // a flat target string or a Map; both round-trip untouched. function assembleCodeBundle({ baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep, host, - packageOf = (path) => findPackageMetadata(baseDir, path, { strict: true, host }), + packageOf = packageLookup(baseDir, { strict: true, host }), }) { const modules = new Map() const ensureBucket = (dir, name, version, bucketEcosystem) => { @@ -216,73 +216,63 @@ function assembleCodeBundle({ // Files never carried, whatever reads them: `.env` files, and Hardhat's config, which is code. const neverCarried = (rel) => isDotEnvFile(rel) || posix.basename(rel).startsWith('hardhat.config.') -// A check for readPackageJson: throws for a path `ownership` refuses (see solidityOwnership), as a -// package.json that decides a file's package may not be read through a planted link. -const readableBy = (ownership) => (rel) => { - const { reason } = ownership.of(rel) - if (reason) throw new Error(`Refusing ${rel}: ${reason}`) -} - -// findPackageMetadata (strict, `check`ed) once per directory, the only thing its answer depends on. -function packageLookup(baseDir, check) { +// findPackageMetadata (with `options`) once per directory, the only thing its answer depends on. +function packageLookup(baseDir, options) { const byDir = new Map() return (path) => { const dir = posix.dirname(path) - if (!byDir.has(dir)) byDir.set(dir, findPackageMetadata(baseDir, path, { strict: true, check })) + if (!byDir.has(dir)) byDir.set(dir, findPackageMetadata(baseDir, path, options)) return byDir.get(dir) } } -// The build-description files of a Solidity bundle (--manifests), as Map: `configFiles` -// (what discoverSolidityConfig read, whatever they're called: each must be carried) plus the -// SOLIDITY_*_MANIFESTS that exist, for the root and for each package dir `classifyDep`/`packageOf` -// places a bundled source in. Files inside the root only, never a neverCarried one, and none whose -// path `ownership` refuses (see solidityOwnership). Carried as written: whatever they hold (an RPC -// URL with its API key, an Etherscan key, a URL's credentials) is in the bundle too, as with -// --package-json. +// The build-description files of a Solidity bundle (--manifests), as Map, sorted: +// `configFiles` (what discoverSolidityConfig read, whatever they're called), each of which must be +// carried -- one outside the root, neverCarried, refused or gone is an error, as the bundle +// couldn't reproduce the resolution without it -- plus the SOLIDITY_*_MANIFESTS that exist, for the +// root and for each package dir `classifyDep`/`packageOf` places a bundled source in, but none +// whose path `ownership` refuses (see solidityOwnership). Carried as written: whatever they hold +// (an RPC URL with its API key, an Etherscan key, a URL's credentials) is in the bundle too, as +// with --package-json. function solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership }) { - // Every config the resolution read is carried, or the bundle couldn't be reproduced: one that - // can't be (outside the root, never carried, refused) is an error, not a skip. - const unreproducible = (rel, why) => new Error(`--manifests can't carry ${rel}, which the Solidity resolution read: ${why}`) - for (const rel of configFiles) { - if (rel.startsWith('../') || posix.isAbsolute(rel)) throw unreproducible(rel, 'it lies outside the bundle root') - if (neverCarried(rel)) throw unreproducible(rel, '.env files and hardhat.config.* are never carried') - } - const required = new Set(configFiles) - const wanted = new Set([...configFiles, ...SOLIDITY_ROOT_MANIFESTS]) - const dirs = new Set() - for (const path of sources.keys()) { - const dep = classifyDep(path) - if (dep) dirs.add(dep.bucketDir) - const meta = packageOf(path) - if (meta) dirs.add(meta.pkgDir) - } - for (const dir of dirs) { - for (const name of SOLIDITY_PACKAGE_MANIFESTS) wanted.add(moduleFileKey(dir, name)) - } - const realBase = host.realpath(baseDir) - const out = new Map() - for (const rel of [...wanted].toSorted()) { - if (sources.has(rel)) continue + const realBase = realpathSync(baseDir) + // `{ text }`, or `{ why }` it can't be carried (null: nothing is there). + const carry = (rel) => { const { reason } = ownership.of(rel) - if (reason) { - if (required.has(rel)) throw unreproducible(rel, reason) - console.warn(`[stasis] Not carrying ${rel}: ${reason}`) - continue - } + if (reason) return { why: reason } let buf try { assertRealPathWithinBase(realBase, baseDir, rel, host) buf = host.readFile(join(baseDir, rel)) } catch (err) { if (err.code !== 'ENOENT' && err.code !== 'EISDIR') throw err - if (required.has(rel)) throw unreproducible(rel, 'it is gone') - continue + return { why: null } } if (!isUtf8(buf)) throw new Error(`Solidity manifest is not valid UTF-8: ${rel}`) - out.set(rel, buf.toString('utf8')) + return { text: buf.toString('utf8') } + } + const unreproducible = (rel, why) => new Error(`--manifests can't carry ${rel}, which the Solidity resolution read: ${why}`) + const out = new Map() + for (const rel of configFiles) { + if (posixPathEscapes(rel)) throw unreproducible(rel, 'it lies outside the bundle root') + if (neverCarried(rel)) throw unreproducible(rel, '.env files and hardhat.config.* are never carried') + if (sources.has(rel)) continue + const { text, why } = carry(rel) + if (text === undefined) throw unreproducible(rel, why ?? 'it is gone') + out.set(rel, text) + } + const optional = new Set(SOLIDITY_ROOT_MANIFESTS) + for (const path of sources.keys()) { + const dirs = [classifyDep(path)?.bucketDir, packageOf(path)?.pkgDir].filter((d) => d !== undefined) + for (const dir of dirs) for (const name of SOLIDITY_PACKAGE_MANIFESTS) optional.add(moduleFileKey(dir, name)) + } + for (const rel of optional) { + if (sources.has(rel) || out.has(rel)) continue + const { text, why } = carry(rel) + if (why) console.warn(`[stasis] Not carrying ${rel}: ${why}`) + if (text !== undefined) out.set(rel, text) } - return out + return new Map([...out.keys()].toSorted().map((rel) => [rel, out.get(rel)])) } // An entry `stasis bundle` takes for a directory: one that is, or an extensionless path that @@ -341,9 +331,8 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin throw new Error(`Solidity bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`) } - const check = readableBy(ownership) - const classifyDep = makeSolidityClassifier(baseDir, check) - const packageOf = packageLookup(baseDir, check) + const classifyDep = makeSolidityClassifier(baseDir, ownership.assert) + const packageOf = packageLookup(baseDir, { strict: true, check: ownership.assert }) const bundled = new Map(sources) const formats = new Map() if (manifests) { @@ -955,17 +944,15 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields, // --package-json: fold each bundled module's package.json into `sources` (and its integrity into // the companion lockfile) even when the scan never reached it. Buckets are the same ones // assembleCodeBundle derives (findPackageMetadata -> pkgDir, else the '.' workspace bucket); - // findPackageMetadata is memoized per directory so a package's many files don't each re-walk to the + // packageLookup memoizes it per directory so a package's many files don't each re-walk to the // same manifest. readModuleManifest applies the read/validate rules shared with the State path // (containment, UTF-8-aborts); no identity check here -- these buckets are all fresh from disk. if (packageJSON) { - const metaByDir = new Map() + const packageOf = packageLookup(baseDir, { host }) const pkgDirs = new Set() for (const abs of reached) { const rel = toRel(abs) - const dir = dirname(rel) - if (!metaByDir.has(dir)) metaByDir.set(dir, findPackageMetadata(baseDir, rel, { host })) - const meta = metaByDir.get(dir) + const meta = packageOf(rel) if (meta) pkgDirs.add(meta.pkgDir) else if (!splitNodeModulesPath(rel)) pkgDirs.add('.') } diff --git a/stasis/src/loaders/cargo.js b/stasis/src/loaders/cargo.js index 5a0483fe..38791fa0 100644 --- a/stasis/src/loaders/cargo.js +++ b/stasis/src/loaders/cargo.js @@ -25,7 +25,7 @@ export function isFile(path) { } } -export function readFileOrNull(file) { +function readFileOrNull(file) { try { return readFileSync(file, 'utf8') } catch { diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index 7763973f..a6b2de79 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -20,7 +20,7 @@ import { readText } from '@exodus/stasis-core/bundle-util' import { diskHost } from '@exodus/stasis-core/host' import { toPosix } from '@exodus/stasis-core/util' import { isDir } from '../resolve-typescript.js' -import { projectOwnership, readUtf8OrNull, realpathOrNull } from './solidity-ownership.js' +import { projectOwnership, projectRelative, readUtf8OrNull, realpathOrNull } from './solidity-ownership.js' import { isTomlTable, readToml } from './toml.js' export const FOUNDRY_TOML = 'foundry.toml' @@ -469,7 +469,7 @@ function mergeExtended(base, local, strategy) { // forge's `Extends`: a path, or `{ path, strategy? }`. const EXTEND_STRATEGIES = new Set(['extend-arrays', 'replace-arrays', 'no-collision']) const isExtends = (v) => typeof v === 'string' - || (v !== null && typeof v === 'object' && !Array.isArray(v) && typeof v.path === 'string' && (v.strategy === undefined || EXTEND_STRATEGIES.has(v.strategy))) + || (isTomlTable(v) && typeof v.path === 'string' && (v.strategy === undefined || EXTEND_STRATEGIES.has(v.strategy))) // A foundry.toml's profiles, with the selected profile's `extends` base merged in (forge's // `TomlFileProvider`). `files` lists what was read; `topLevel` is the file's own (see @@ -477,11 +477,9 @@ const isExtends = (v) => typeof v === 'string' // config: see findNestedFoundryRemappings) refuses the file or its base: a dependency's config may // not read the project's files. function readFoundryProfiles(file, profile, { readable } = {}) { - const none = { profiles: new Map(), topLevel: new Map(), files: [] } - if (!existsSync(file)) return none if (readable && !readable(file)) throw new ConfigRefused(`${file}: refusing to read it, a link out of the dependency`) const text = readUtf8OrNull(file) - if (text === null) return none + if (text === null) return { profiles: new Map(), topLevel: new Map(), files: [] } let { profiles, topLevel } = parseFoundryToml(text, file) const files = [file] const ext = profiles.get(profile)?.get('extends') @@ -491,11 +489,9 @@ function readFoundryProfiles(file, profile, { readable } = {}) { // Joined as forge joins it, not normalized: the read resolves a `..` after a symlink the way // forge's does (from where the link leads), not textually. const baseFile = rustJoin(posix.dirname(file), extPath) - const missing = () => new ConfigRefused(`${file}: the inherited config file does not exist: ${extPath}`) - if (!existsSync(baseFile)) throw missing() if (readable && !readable(baseFile)) throw new ConfigRefused(`${file}: refusing to extend ${extPath}, which lies outside the dependency`) const baseText = readUtf8OrNull(baseFile) - if (baseText === null) throw missing() + if (baseText === null) throw new ConfigRefused(`${file}: the inherited config file does not exist: ${extPath}`) const base = parseFoundryToml(baseText, baseFile).profiles if (base.get(profile)?.has('extends')) { throw new ConfigRefused(`${file}: nested inheritance is not allowed (${extPath} has an 'extends' field in profile '${profile}')`) @@ -505,7 +501,7 @@ function readFoundryProfiles(file, profile, { readable } = {}) { if (collisions.length > 0) throw new ConfigRefused(`${file}: key collision in profile '${profile}' when extending ${extPath}: ${collisions.join(', ')}`) } profiles = mergeExtended(base, profiles, strategy) - files.push(canonicalize(baseFile) ?? baseFile) // the file read, by its real path + files.push(baseFile) } return { profiles, topLevel, files } } @@ -645,8 +641,8 @@ function loadNestedConfig(canonical, profile, readable) { return null } const txt = rustJoin(canonical, REMAPPINGS_TXT) - const allowed = existsSync(txt) && readable(txt) - if (existsSync(txt) && !allowed) console.warn(`[loader.solidity] Skipping a dependency's ${txt}: it is a link out of the dependency`) + const allowed = readable(txt) // (true when nothing is there) + if (!allowed) console.warn(`[loader.solidity] Skipping a dependency's ${txt}: it is a link out of the dependency`) const text = allowed ? readUtf8OrNull(txt) : null return { src: config.src, @@ -668,7 +664,8 @@ function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) // dir): judged by the path from the root, the lexical one or else the canonical one (an absolute // lib, `/proc/self/cwd/...`). It may read its own files and other dependencies'; a dependency // outside the root reads nothing, and one a dependency's `libs` named must be a dependency itself - // (not the project's own dir passed off as one). Callers ask only about a file that exists. + // (not the project's own dir passed off as one). Nothing there (the OS agrees: + // solidityOwnership) is left for the read to find missing. const readable = (entry) => (file) => { const dir = stripPrefix(entry.path, root) ?? stripPrefix(entry.canonical, canonicalRoot) if (dir === null || (entry.viaDependency && !ownership.of(dir).dependency)) return false @@ -676,9 +673,8 @@ function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) // resolve as the read does); one not under it (an absolute path elsewhere) lies outside it. if (!file.startsWith(`${entry.canonical}/`)) return false const o = ownership.of(`${dir}/${file.slice(entry.canonical.length + 1)}`) - // No real path -- nothing there, or nothing the OS can resolve -- is refused too. - if (o.real === null || o.escape !== null || o.outside) return false - return o.dependency || pathStartsWith(rustJoin(canonicalRoot, o.real), entry.canonical) + if (o.escape !== null || o.outside) return false + return o.real === null || o.dependency || pathStartsWith(rustJoin(canonicalRoot, o.real), entry.canonical) } // A BTreeSet popped in (canonical, path) order. const pending = new Map() @@ -865,9 +861,10 @@ export function foundryProject(baseDir, { env = process.env } = {}) { const envName = env.DAPP_REMAPPINGS !== undefined ? 'DAPP_REMAPPINGS' : env.FOUNDRY_REMAPPINGS !== undefined ? 'FOUNDRY_REMAPPINGS' : null const envRemappings = envName === null ? [] : parseRemappingLines(env[envName], { label: envName }) - const txt = readUtf8OrNull(rustJoin(root, REMAPPINGS_TXT)) - if (txt !== null) files.add(rustJoin(root, REMAPPINGS_TXT)) - const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, { label: rustJoin(root, REMAPPINGS_TXT) })), ...config.remappings] + const txtFile = rustJoin(root, REMAPPINGS_TXT) + const txt = readUtf8OrNull(txtFile) + if (txt !== null) files.add(txtFile) + const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, { label: txtFile })), ...config.remappings] const provided = providerRemappings(root, { userRemappings, libs: config.libs, autoDetect: config.autoDetect, profile, files, ownership }) .map((r) => displayRelative(relativePreservingBoundary(r, root))) @@ -884,10 +881,9 @@ export function foundryProject(baseDir, { env = process.env } = {}) { .filter(Boolean) .map(toSolcRemapping) - // Project-relative; one read from outside the root (an `extends = "../base.toml"`) stays as its - // `../` path, for --manifests to refuse (it can't be carried). - const canonicalRoot = canonicalize(root) ?? root - const relFiles = [...files].map((f) => stripPrefix(f, root) ?? stripPrefix(f, canonicalRoot) ?? posix.relative(canonicalRoot, f)).filter((f) => f !== '') + // One read from outside the root (an `extends = "../base.toml"`) stays `../`, for --manifests to + // refuse (it can't be carried). + const relFiles = [...files].map((f) => projectRelative(root, f)) const envUsed = [...(profiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : []), ...(envName === null ? [] : [envName])] return { remappings, libs: config.libs, files: relFiles, envUsed, ownership } } diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index 311f954d..24da7f6c 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -19,6 +19,19 @@ const toSlashes = (p) => (sep === '\\' ? p.replaceAll('\\', '/') : p) // `p`'s real path as the OS resolves it (realpath(3): the filesystem's own spelling), or null. export const realpathOrNull = (p) => osRealpath(p).real +// A path relative to a dir (slashes) that stays inside it. +const inRoot = (rel) => rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel) + +// `abs`, a file the resolution read, relative to the project `root` (slashes): as spelled when that +// lies inside it with no `..` to resolve (a linked lib's files keep the lib's path), else by real +// paths -- where the read went (an absolute or `/proc/self/cwd` lib; a `..` after a symlink) -- +// `../` when outside the project. +export function projectRelative(root, abs) { + const rel = toSlashes(relative(root, abs)) + if (inRoot(rel) && !toSlashes(abs).split('/').includes('..')) return rel + return toSlashes(relative(realpathOrNull(root) ?? root, realpathOrNull(abs) ?? abs)) +} + // realpath(3) of `p`: `{ real }`, or `{ real: null, missing }`, `missing` false when something is // there that the OS can't resolve (a real path past PATH_MAX, a loop, a dir it may not search). function osRealpath(p) { @@ -121,30 +134,29 @@ const TARGET_SEPARATORS = sep === '\\' ? /[\\/]/u : /\//u // Who owns each project-relative path, decided from how it resolves on disk. The dependencies are // every `node_modules/` (`@scope/`), each entry of the `dirs` (forge's libs, Soldeer's // `dependencies/`; a linked entry is the dependency where it points, as a symlinked -// `lib/forge-std`), and the `packages` (git submodules). `of(path)` gives `{ real, outside, -// dependency, escape }`: +// `lib/forge-std`), and the `packages` (git submodules). `assert(path)` throws for a path `of` +// refuses; `of(path)` gives `{ real, outside, dependency, escape }`: // - `real`: the real path, spelled as the filesystem spells it (project-relative; null when // nothing is there), `outside` when it's out of the root; // - `dependency`: the real path lies in a dependency, however the path got there (a project's // `src/vendor -> ../lib/dep/src` holds the dependency's code); // - `escape`: `{ link, root, why }` (and `reason`, saying so) when the path may not be read: it -// crosses a symlink that no one -// trusted placed -- one planted inside the dependency `root` that leads out of it to anything but -// another dependency (`lib/evil/src/Evil.sol -> ../../../.env`), or one outside the project -// (`root` null) that leads back into it (a dependency linked from elsewhere: `lib/evil -> -// ../../shared/evil` holding `Evil.sol -> ../../proj/.env`) -- or (`why: 'unresolved'`) the walk -// below can't vouch for it: it resolves the path link by link, and where that doesn't land where -// the OS's realpath does (a link target it can't read as the OS does, one that isn't UTF-8), or -// the OS can't resolve it at all (a real path past PATH_MAX), the path is refused rather than -// trusted. `real` null with no `escape` means nothing is there. A link the project placed (a -// workspace package in node_modules, a linked `lib/` entry) may lead anywhere in the root, and -// so may one on the path the project was named by (a symlinked checkout, macOS's `/tmp`). +// crosses a symlink that no one trusted placed -- one planted inside the dependency `root` that +// leads out of it to anything but another dependency (`lib/evil/src/Evil.sol -> ../../../.env`), +// or one outside the project (`root` null) that leads back into it (a dependency linked from +// elsewhere: `lib/evil -> ../../shared/evil` holding `Evil.sol -> ../../proj/.env`) -- or +// (`why: 'unresolved'`) the walk below can't vouch for it: it resolves the path link by link, and +// where that doesn't land where the OS's realpath does (a link target it can't read as the OS +// does, one that isn't UTF-8), or the OS can't resolve it at all (a real path past PATH_MAX), the +// path is refused rather than trusted. `real` null with no `escape` means nothing is there. A +// link the project placed (a workspace package in node_modules, a linked `lib/` entry) may lead +// anywhere in the root, and so may one on the path the project was named by (a symlinked +// checkout, macOS's `/tmp`). export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { const realBase = realpathSync.native(baseDir) const named = resolve(baseDir) const onNamedPath = (abs) => named === abs || named.startsWith(abs.endsWith(sep) ? abs : `${abs}${sep}`) const toRel = (abs) => toSlashes(relative(realBase, abs)) || '.' - const inRoot = (rel) => rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel) const inside = (rel) => rel !== '.' && inRoot(rel) const under = (rel, dir) => rel === dir || rel.startsWith(`${dir}/`) const clean = (d) => posix.normalize(toSlashes(d)).replace(/\/+$/u, '') @@ -262,7 +274,12 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { } return owner } - return { of } + // Throws, saying why, for a path `of` refuses; `what` names it (`'entry '`). + const assert = (rel, what = '') => { + const { reason } = of(rel) + if (reason) throw new Error(`Refusing ${what}${rel}: ${reason}`) + } + return { of, assert } } // The ownership of the project at `baseDir` given its lib dirs (`soldeer`: forge's `dependencies/` diff --git a/stasis/src/loaders/solidity.js b/stasis/src/loaders/solidity.js index 29abb5db..dcc4dfe0 100644 --- a/stasis/src/loaders/solidity.js +++ b/stasis/src/loaders/solidity.js @@ -27,7 +27,7 @@ import { readFoundryTomlRemappings, toSolcRemapping, } from './foundry.js' -import { projectOwnership, readUtf8OrNull, realpathOrNull, solidityOwnership } from './solidity-ownership.js' +import { projectOwnership, projectRelative, readUtf8OrNull, realpathOrNull, solidityOwnership } from './solidity-ownership.js' // --- Import scan ------------------------------------------------------------------------------ @@ -186,19 +186,12 @@ export async function discoverSolidityConfig(baseDir, { mappingFile, env = proce if (forge && !mappingFile) return foundryProject(baseDir, { env }) const { libs, profiled } = forge ? foundryLibs(baseDir, { env }) : { libs: [], profiled: false } const ownership = projectOwnership(baseDir, libs, { soldeer: forge }) - // Project-relative, by real path when not inside lexically; `../` when outside the project (for - // --manifests to refuse: it can't be carried). - const projectRelative = (abs) => { - const rel = toPosix(relative(baseDir, abs)) - if (!rel.startsWith('..') && !isAbsolute(rel)) return rel - return toPosix(relative(realpathOrNull(baseDir) ?? baseDir, realpathOrNull(abs) ?? abs)) - } if (mappingFile) { const abs = resolve(baseDir, mappingFile) const { remappings, files, profiled: mappingProfiled } = readMapping(abs, { env, forge }) // The profile picks the mapping file's remappings (a .toml) or the root foundry.toml's libs. const envUsed = profiled || mappingProfiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : [] - return { remappings, libs, ownership, files: files.map(projectRelative), envUsed } + return { remappings, libs, ownership, files: files.map((f) => projectRelative(baseDir, f)), envUsed } } const txt = join(baseDir, REMAPPINGS_TXT) const remappings = isFile(txt) ? readMapping(txt, { env, forge }).remappings : [] @@ -363,10 +356,7 @@ export async function collectSolidityFilesFromDisk(baseDir, entries, remappings, const sources = new Map() const knownEntries = new Set(entries) const realBase = realpathSync(baseDir) - for (const entry of entries) { - const { reason } = ownership.of(entry) - if (reason) throw new Error(`Refusing entry ${entry}: ${reason}`) - } + for (const entry of entries) ownership.assert(entry, 'entry ') const processWave = async (wave) => { const toLoad = [...new Set(wave)].filter((p) => !sources.has(p)) diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 14e09c20..f34d7a39 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -628,6 +628,23 @@ test('buildSolidityBundle refuses a dependency config reached through an absolut } })) +test('buildSolidityBundle with manifests carries the config of a /proc lib by its path in the project', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\nlibs = ["/proc/self/cwd/lib"]\n', + 'src/A.sol': 'contract A {}\n', + 'lib/x/foundry.toml': '[profile.default]\n', + 'lib/x/remappings.txt': 'y/=src/\n', + }) + const cwd = process.cwd() + process.chdir(tmp) + try { + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} }) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['foundry.toml', 'lib/x/foundry.toml', 'lib/x/remappings.txt', 'src/A.sol']) + } finally { + process.chdir(cwd) + } +})) + test('buildSolidityBundle refuses a path the ownership walk reads differently from the OS', withTmp(async (t, tmp) => { writeProject(tmp, { 'foundry.toml': '[profile.default]\n', From 6659cf70a95dbf6509ba3053dfc78345abfc8edc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 11:24:54 +0000 Subject: [PATCH 09/20] test(bundle): expect the messages of main's TOML parser Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- tests/bundle-cmd.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index f34d7a39..033289e7 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -754,19 +754,19 @@ test('buildSolidityBundle fails on a foundry.toml that isn\'t TOML, naming the f }) await captureStderr(() => t.assert.rejects( () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), - { name: 'TomlError', message: `${join(realpathSync(tmp), 'lib/dep/foundry.toml')}:2: unterminated array` }, + { name: 'TomlError', message: `${join(realpathSync(tmp), 'lib/dep/foundry.toml')}: expected "," or "]", found the end of the text at line 3` }, )) // ...and so is its `extends` base. writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\nextends = "base.toml"\n', 'lib/dep/base.toml': '[profile.default]\nsrc = "src" junk\n' }) await captureStderr(() => t.assert.rejects( () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), - { name: 'TomlError', message: `${join(realpathSync(tmp), 'lib/dep/base.toml')}:2: unexpected text after the value` }, + { name: 'TomlError', message: `${join(realpathSync(tmp), 'lib/dep/base.toml')}: expected the end of the line, found "junk" at line 2` }, )) // With a pinned mapping file, the root foundry.toml is still read for its lib dirs. writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\n', 'foundry.toml': '[profile.default]\nlibs = ["lib"\n', 'remappings.txt': 'dep/=lib/dep/src/\n' }) await captureStderr(() => t.assert.rejects( () => buildSolidityBundle({ cwd: tmp, entries: ['src'], mappingFile: 'remappings.txt', env: {} }), - { name: 'TomlError', message: `${join(tmp, 'foundry.toml')}:2: unterminated array` }, + { name: 'TomlError', message: `${join(tmp, 'foundry.toml')}: expected "," or "]", found the end of the text at line 3` }, )) })) From 9a958aa80f3099701f40506312102043f62f86a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 11:45:49 +0000 Subject: [PATCH 10/20] fix(bundle): never read stdin as a config; name and record every config read Blocking: - A link whose end the OS can't name (/proc/self/fd/0 or /dev/stdin on an open pipe) counted as "nothing there", so a dependency's remappings.txt, foundry.toml or extends base linked to it was read: stasis's stdin became the config, and the bundle hung on an open pipe. A path is missing now only when nothing is there at all; otherwise an unresolvable one is refused. Config reads also open the file without blocking and read only a regular file, so a FIFO, socket or device (the project's own link to /dev/stdin included) is an error naming it. - A config whose real path the OS can't give (past PATH_MAX) was named by its textually normalized path, so --manifests carried another file. It keeps the name it was read by and --manifests refuses it. - With --mapping, the root foundry.toml and its extends base, read for the lib dirs, are recorded: --manifests carries them, or fails on one it can't carry. Also: - The ownership walk resolved a `..` in the path it was asked about textually for the OS cross-check: a dependency's extends through its own symlink (sub/../base.toml) was falsely refused. - A package.json with a byte-order mark is read, as npm reads it, instead of aborting the bundle. - Bash, Rust and JS bundles walk past a malformed package.json again; only Solidity's package lookup is strict. - A refused dependency config says why (the ownership reason, not always "a link out of the dependency"), and config messages name files from the project root instead of by absolute path. - --manifests tags only a package.json `json`; another config (--mapping=remaps.json) is a `resource`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 31 ++-- stasis-core/src/bundle-util.js | 12 +- stasis/src/cmd/bundle.js | 19 +- stasis/src/loaders/foundry.js | 126 ++++++++------ stasis/src/loaders/solidity-ownership.js | 68 ++++++-- stasis/src/loaders/solidity.js | 25 +-- tests/bundle-cmd.test.js | 211 +++++++++++++++++++---- tests/solidity-loader.test.js | 14 +- 8 files changed, 364 insertions(+), 142 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index 964bd724..c0175193 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -367,9 +367,11 @@ A `foundry.toml` or `extends` base that isn't TOML, a config that isn't UTF-8 or `{ path, strategy }`), and an invalid remapping (a `remappings.txt` line or `FOUNDRY_REMAPPINGS` entry that isn't `[context:]prefix=target`, or a `remappings` value that isn't an array of such strings), is an error naming - the file, whosever it is and in every mode: nothing falls back to a default - (forge refuses these too, but quietly skips a dependency's `foundry.toml` it - can't read). A `remappings.txt` line is trimmed as forge trims it, so a + the file (from the root), whosever it is and in every mode: nothing falls back + to a default (forge refuses these too, but quietly skips a dependency's + `foundry.toml` it can't read). So is a config that isn't a regular file: a + FIFO, a device or a link to one (`remappings.txt -> /dev/stdin`) is never + read, so the bundle can't stall on it or take the process's input as config. A `remappings.txt` line is trimmed as forge trims it, so a byte-order mark stays part of the first remapping. A dependency's config forge rejects for its settings (a missing `extends` base, nested inheritance) is skipped with a warning, as forge skips it. @@ -400,7 +402,8 @@ A `foundry.toml` or `extends` base that isn't TOML, a config that isn't UTF-8 `foundry.toml` still gives the `libs` (the default ones, warned, when forge would reject the file), and `FOUNDRY_PROFILE` picking them is reported (one that isn't a profile of the `foundry.toml` is warned about instead, as without - `--mapping`). + `--mapping`); that `foundry.toml` and its `extends` base count among the + config files read. Dependencies are input the project didn't write, so whatever resolves an import, the result must be a `.sol` file inside the bundle root (an `import ".env";` or @@ -423,18 +426,23 @@ dependency linked from elsewhere, `lib/evil -> ../../shared/evil`, holding a link to the project's `.env`). Links are followed one by one and the result checked against the OS's own realpath: a path the two resolve differently (a link target that isn't UTF-8, one whose `\` the OS reads as part of a name), or -one the OS can't resolve at all (a real path past `PATH_MAX`), is refused, not -trusted. An `extends` path is joined as forge joins it and resolved by the OS, -so a `..` after a symlink leads where forge's does. Whoever's import, entry or +one the OS can't resolve at all (a real path past `PATH_MAX`, a link whose end it +can't name: `/proc/self/fd/0` or `/dev/stdin` on a pipe), is refused, not +trusted; only a path with nothing there counts as missing. An `extends` path is +joined as forge joins it and resolved by the OS, so a `..` after a symlink leads +where forge's does, in the project's config and a dependency's alike. Whoever's import, entry or manifest the path is, the import is refused, the entry rejected, the manifest not carried, and a dependency's own `foundry.toml`, `extends` base or `remappings.txt` skipped with a warning (one that is another dependency's file is read). A dependency's config reaches only what the path from the root does: one found through an absolute or `/proc/self/cwd` lib is judged by its real path, a dependency outside the root reads nothing, and a dir a dependency's -`libs` names must be a dependency itself. A `package.json` that decides a file's package is refused the same way -when a dependency planted it as a link, and one that doesn't parse is an error -naming it (not quoting it) rather than giving its files to the parent package. A +`libs` names must be a dependency itself; a config refused says why. A +`package.json` that decides a file's package is refused the same way when a +dependency planted it as a link, and one that doesn't parse (a leading +byte-order mark is skipped, as npm skips it) is an error naming it (not quoting +it) rather than giving its files to the parent package; other bundles walk past +a malformed one, as they always have. A link the project placed (a workspace package linked into `node_modules`, a linked `lib/` entry, `src/vendor`) may lead anywhere in the root, and so may one on the path the project was named by (a symlinked checkout); a workspace package @@ -444,7 +452,8 @@ The config files are read, not bundled. `--manifests` bundles the build description too: every config file the resolution read, whatever it's called (an `extends = "base.conf"`, a `--mapping=remaps`), by its path in the project (by its real path once a `..` or an absolute or `/proc/self/cwd` lib leads -elsewhere), the root's `foundry.lock`, `soldeer.lock`, `.gitmodules` and +elsewhere; one whose real path the OS can't give, past `PATH_MAX`, is refused: +normalized, its name would be another file's), the root's `foundry.lock`, `soldeer.lock`, `.gitmodules` and `package.json`, and the `package.json`, `foundry.toml` and `remappings.txt` of every package the bundle holds files of — `json` for a `package.json`, `resource` otherwise, so `stasis extract` restores them. They are carried as diff --git a/stasis-core/src/bundle-util.js b/stasis-core/src/bundle-util.js index a35954fa..639475ed 100644 --- a/stasis-core/src/bundle-util.js +++ b/stasis-core/src/bundle-util.js @@ -41,17 +41,17 @@ export function findPackageMetadata(baseDir, fileRelPath, { strict = false, chec } } -// The package.json at `rel` (under `baseDir`), parsed; null when there's none, or when it doesn't -// parse -- unless `strict`, then that throws, saying where with the parser's line and column but -// never its message, which quotes the text (a file that isn't JSON may be anything, a secret -// included). `check(rel)`, when given, sees the path before it is read, and may throw to refuse it. -// Read through `host`. +// The package.json at `rel` (under `baseDir`), parsed (a leading byte-order mark skipped, as npm +// and Node skip it); null when there's none, or when it doesn't parse -- unless `strict`, then that +// throws, saying where with the parser's line and column but never its message, which quotes the +// text (a file that isn't JSON may be anything, a secret included). `check(rel)`, when given, sees +// the path before it is read, and may throw to refuse it. Read through `host`. export function readPackageJson(baseDir, rel, { strict = false, check, host = diskHost } = {}) { const file = join(baseDir, rel) if (!host.stat(file)?.isFile()) return null check?.(rel) try { - return JSON.parse(host.readFile(file).toString('utf8')) + return JSON.parse(host.readFile(file).toString('utf8').replace(/^\uFEFF/u, '')) } catch (err) { if (!strict) return null const at = /\(line \d+ column \d+\)/u.exec(err.message)?.[0] diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index b698e633..ecd685cb 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -24,7 +24,7 @@ import { discoverSolidityConfig, expandSolidityEntries, } from '../loaders/solidity.js' -import { readGitmodules } from '../loaders/solidity-ownership.js' +import { readGitmodules, readRegularFileOrNull } from '../loaders/solidity-ownership.js' import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js' import { buildRustTree, collectRustFilesFromDisk } from '../loaders/rust.js' import { VENDOR_DIR as CARGO_VENDOR_DIR, createCargoContext } from '../loaders/cargo.js' @@ -153,13 +153,13 @@ function executableSources(baseDir, sources, host) { // nearest package.json (node_modules -> `npm`-tagged bucket, workspace -> its dir, none -> // "." with the placeholder identity); a node_modules file whose nearest package.json is the // workspace root is rejected, not mislabeled; `packageOf(path)` finds that package.json -// (packageLookup, strict, by default). `classifyDep(path)` optionally places a file -// directly (non-node_modules ecosystems like Soldeer/github); null defers. `format` tags +// (packageLookup by default, which walks past a malformed one). `classifyDep(path)` optionally +// places a file directly (non-node_modules ecosystems like Soldeer/github); null defers. `format` tags // every file; `formats` (Map) overrides it per file. `resolutions` values are // a flat target string or a Map; both round-trip untouched. function assembleCodeBundle({ baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep, host, - packageOf = packageLookup(baseDir, { strict: true, host }), + packageOf = packageLookup(baseDir, { host }), }) { const modules = new Map() const ensureBucket = (dir, name, version, bucketEcosystem) => { @@ -242,12 +242,13 @@ function solidityManifests(baseDir, sources, configFiles, { classifyDep, package if (reason) return { why: reason } let buf try { - assertRealPathWithinBase(realBase, baseDir, rel, host) - buf = host.readFile(join(baseDir, rel)) + assertRealPathWithinBase(realBase, baseDir, rel) + buf = readRegularFileOrNull(join(baseDir, rel), rel) } catch (err) { - if (err.code !== 'ENOENT' && err.code !== 'EISDIR') throw err - return { why: null } + if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err + buf = null } + if (buf === null) return { why: null } if (!isUtf8(buf)) throw new Error(`Solidity manifest is not valid UTF-8: ${rel}`) return { text: buf.toString('utf8') } } @@ -338,7 +339,7 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin if (manifests) { for (const [path, text] of solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership })) { bundled.set(path, text) - formats.set(path, path.endsWith('.json') ? 'json' : 'resource') + formats.set(path, posix.basename(path) === 'package.json' ? 'json' : 'resource') } } diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index a6b2de79..ccf71b94 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -74,7 +74,14 @@ function canonicalize(p) { return real === null ? null : toPosix(real) } -const isSymlinkPath = (p, host) => { +// How messages name a file of the project at `root` (absolute POSIX): from the root, by its lexical +// or its canonical path, else as given. +export function shownFrom(root) { + const canonicalRoot = canonicalize(root) ?? root + return (abs) => stripPrefix(abs, root) || stripPrefix(abs, canonicalRoot) || abs +} + +const isSymlinkPath = (p) => { try { return host.readlink(p) !== null } catch { @@ -473,32 +480,35 @@ const isExtends = (v) => typeof v === 'string' // A foundry.toml's profiles, with the selected profile's `extends` base merged in (forge's // `TomlFileProvider`). `files` lists what was read; `topLevel` is the file's own (see -// parseFoundryToml). Throws where forge refuses the config, and where `readable` (a dependency's -// config: see findNestedFoundryRemappings) refuses the file or its base: a dependency's config may -// not read the project's files. -function readFoundryProfiles(file, profile, { readable } = {}) { - if (readable && !readable(file)) throw new ConfigRefused(`${file}: refusing to read it, a link out of the dependency`) - const text = readUtf8OrNull(file) +// parseFoundryToml). Throws where forge refuses the config, and where `refused` (a dependency's +// config: see findNestedFoundryRemappings) gives a reason not to read the file or its base: a +// dependency's config may not read the project's files. Messages name files `show(file)`. +function readFoundryProfiles(file, profile, { refused = () => null, show = (f) => f } = {}) { + const name = show(file) + const refusal = refused(file) + if (refusal) throw new ConfigRefused(`${name}: refusing to read it: ${refusal}`) + const text = readUtf8OrNull(file, name) if (text === null) return { profiles: new Map(), topLevel: new Map(), files: [] } - let { profiles, topLevel } = parseFoundryToml(text, file) + let { profiles, topLevel } = parseFoundryToml(text, name) const files = [file] const ext = profiles.get(profile)?.get('extends') if (ext !== undefined) { - if (!isExtends(ext)) throw new Error(`${file}: \`extends\` must be a path, or a table with a \`path\` and an optional \`strategy\` (${[...EXTEND_STRATEGIES].join(', ')})`) + if (!isExtends(ext)) throw new Error(`${name}: \`extends\` must be a path, or a table with a \`path\` and an optional \`strategy\` (${[...EXTEND_STRATEGIES].join(', ')})`) const { path: extPath, strategy = 'extend-arrays' } = typeof ext === 'string' ? { path: ext } : ext // Joined as forge joins it, not normalized: the read resolves a `..` after a symlink the way // forge's does (from where the link leads), not textually. const baseFile = rustJoin(posix.dirname(file), extPath) - if (readable && !readable(baseFile)) throw new ConfigRefused(`${file}: refusing to extend ${extPath}, which lies outside the dependency`) - const baseText = readUtf8OrNull(baseFile) - if (baseText === null) throw new ConfigRefused(`${file}: the inherited config file does not exist: ${extPath}`) - const base = parseFoundryToml(baseText, baseFile).profiles + const baseRefusal = refused(baseFile) + if (baseRefusal) throw new ConfigRefused(`${name}: refusing to extend ${extPath}: ${baseRefusal}`) + const baseText = readUtf8OrNull(baseFile, show(baseFile)) + if (baseText === null) throw new ConfigRefused(`${name}: the inherited config file does not exist: ${extPath}`) + const base = parseFoundryToml(baseText, show(baseFile)).profiles if (base.get(profile)?.has('extends')) { - throw new ConfigRefused(`${file}: nested inheritance is not allowed (${extPath} has an 'extends' field in profile '${profile}')`) + throw new ConfigRefused(`${name}: nested inheritance is not allowed (${extPath} has an 'extends' field in profile '${profile}')`) } if (strategy === 'no-collision') { const collisions = [...(profiles.get(profile)?.keys() ?? [])].filter((k) => k !== 'extends' && base.get(profile)?.has(k)) - if (collisions.length > 0) throw new ConfigRefused(`${file}: key collision in profile '${profile}' when extending ${extPath}: ${collisions.join(', ')}`) + if (collisions.length > 0) throw new ConfigRefused(`${name}: key collision in profile '${profile}' when extending ${extPath}: ${collisions.join(', ')}`) } profiles = mergeExtended(base, profiles, strategy) files.push(baseFile) @@ -529,9 +539,11 @@ export function foundryTomlRemappings(text, profile = 'default') { // The same for a foundry.toml file, with its `extends` base: what `--mapping=foundry.toml` takes. // `files` lists what was read; `profiled` whether the selected `profile` is one of the file's. -export function readFoundryTomlRemappings(file, profile = 'default') { - const read = readFoundryProfiles(toPosix(resolve(file)), profile) - return { remappings: profileRemappings(read, profile, file), files: read.files, profiled: hasProfile(read.profiles, profile) } +// Messages name files `show(file)`. +export function readFoundryTomlRemappings(file, profile = 'default', { show = (f) => f } = {}) { + const abs = toPosix(resolve(file)) + const read = readFoundryProfiles(abs, profile, { show }) + return { remappings: profileRemappings(read, profile, show(abs)), files: read.files, profiled: hasProfile(read.profiles, profile) } } // Whether the selected `profile` is one of `profiles` (not the default, which always applies). @@ -559,15 +571,15 @@ function detectLibs(root, host) { // The selected profile's settings for a Foundry project at `root` (absolute POSIX), defaults // filled in the way forge fills them. `remappings` are the profile's own, unnormalized; an invalid -// one throws (configRemappings). `readable`: see readFoundryProfiles. -function loadFoundryConfig(root, profile, { readable } = {}) { +// one throws (configRemappings). `refused`, `show`: see readFoundryProfiles. +function loadFoundryConfig(root, profile, { refused, show = (f) => f } = {}) { const file = rustJoin(root, FOUNDRY_TOML) - const { profiles, files } = readFoundryProfiles(file, profile, { readable }) + const { profiles, files } = readFoundryProfiles(file, profile, { refused, show }) const dict = selectProfile(profiles, profile) // A setting of the wrong type throws, as forge refuses the config: no quiet default. const setting = (key, ok, what) => { const value = dict.get(key) - if (value !== undefined && !ok(value)) throw new Error(`${file}: \`${key}\` must be ${what}`) + if (value !== undefined && !ok(value)) throw new Error(`${show(file)}: \`${key}\` must be ${what}`) return value } const isString = (v) => typeof v === 'string' @@ -578,7 +590,7 @@ function loadFoundryConfig(root, profile, { readable } = {}) { test: setting('test', isString, 'a string') ?? 'test', script: setting('script', isString, 'a string') ?? 'script', libs: setting('libs', (v) => Array.isArray(v) && v.every(isString), 'an array of strings') ?? detectLibs(root), - remappings: dict.has('remappings') ? configRemappings(dict.get('remappings'), file) : [], + remappings: dict.has('remappings') ? configRemappings(dict.get('remappings'), show(file)) : [], autoDetect: setting('auto_detect_remappings', (v) => typeof v === 'boolean', 'a boolean') !== false, } } @@ -627,30 +639,30 @@ function rebaseNested(r, canonical, lexical) { // A dependency's config as forge's `load_nested_config` reads it: remappings rebased onto its // canonical root, its remappings.txt, its src and libs. Null when forge would reject the config, -// or when `readable` refuses it or its `extends` base (warned: ConfigRefused); a remappings.txt it +// or when `refused` refuses it or its `extends` base (warned: ConfigRefused); a remappings.txt it // refuses is skipped (warned). One that isn't TOML or holds an invalid remapping throws: forge // refuses a bad remappings.txt line too, and skips a foundry.toml it can't read, which here is an -// error rather than a config quietly left out. -function loadNestedConfig(canonical, profile, readable) { +// error rather than a config quietly left out. `refused`, `show`: see readFoundryProfiles. +function loadNestedConfig(canonical, profile, { refused, show }) { let config try { - config = loadFoundryConfig(canonical, profile, { readable }) + config = loadFoundryConfig(canonical, profile, { refused, show }) } catch (err) { if (!(err instanceof ConfigRefused)) throw err console.warn(`[loader.solidity] Skipping a dependency's config: ${err.message}`) return null } const txt = rustJoin(canonical, REMAPPINGS_TXT) - const allowed = readable(txt) // (true when nothing is there) - if (!allowed) console.warn(`[loader.solidity] Skipping a dependency's ${txt}: it is a link out of the dependency`) - const text = allowed ? readUtf8OrNull(txt) : null + const refusal = refused(txt) // (null when nothing is there) + if (refusal) console.warn(`[loader.solidity] Skipping a dependency's ${show(txt)}: ${refusal}`) + const text = refusal ? null : readUtf8OrNull(txt, show(txt)) return { src: config.src, libs: config.libs, files: [...config.files, ...(text === null ? [] : [txt])], // `sanitized()` roots them, then `Remapping::from` makes the path absolute and slash-terminated. remappings: config.remappings.map((r) => fromRelative(relativePreservingBoundary(fromRelative({ ...r, path: { parent: null, path: r.path } }), canonical))), - fileRemappings: text === null ? [] : parseRemappingLines(text, { label: txt }), + fileRemappings: text === null ? [] : parseRemappingLines(text, { label: show(txt) }), } } @@ -660,22 +672,28 @@ function loadNestedConfig(canonical, profile, readable) { // solidityOwnership), as forge would find them from its lexical path. function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) { const canonicalRoot = canonicalize(root) ?? root - // Whether the config of the dependency at `entry` may read `file` (a path from its canonical - // dir): judged by the path from the root, the lexical one or else the canonical one (an absolute - // lib, `/proc/self/cwd/...`). It may read its own files and other dependencies'; a dependency - // outside the root reads nothing, and one a dependency's `libs` named must be a dependency itself - // (not the project's own dir passed off as one). Nothing there (the OS agrees: + const shown = shownFrom(root) + // Why the config of the dependency at `entry` may not read `file` (a path from its canonical + // dir), or null: judged by the path from the root, the lexical one or else the canonical one (an + // absolute lib, `/proc/self/cwd/...`). It may read its own files and other dependencies'; a + // dependency outside the root reads nothing, and one a dependency's `libs` named must be a + // dependency itself (not the project's own dir passed off as one). Nothing there (the OS agrees: // solidityOwnership) is left for the read to find missing. - const readable = (entry) => (file) => { + const refused = (entry) => (file) => { const dir = stripPrefix(entry.path, root) ?? stripPrefix(entry.canonical, canonicalRoot) - if (dir === null || (entry.viaDependency && !ownership.of(dir).dependency)) return false + if (dir === null) return 'the dependency lies outside the project root' + if (entry.viaDependency && !ownership.of(dir).dependency) return `${dir}, which a dependency's \`libs\` names, isn't a dependency` // `file` as joined under the dependency's dir (an `extends` path unnormalized, for the walk to // resolve as the read does); one not under it (an absolute path elsewhere) lies outside it. - if (!file.startsWith(`${entry.canonical}/`)) return false + if (!file.startsWith(`${entry.canonical}/`)) return 'it lies outside the dependency' const o = ownership.of(`${dir}/${file.slice(entry.canonical.length + 1)}`) - if (o.escape !== null || o.outside) return false - return o.real === null || o.dependency || pathStartsWith(rustJoin(canonicalRoot, o.real), entry.canonical) + if (o.reason) return o.reason + if (o.outside) return `it resolves to ${o.real}, outside the project root` + if (o.real === null || o.dependency || pathStartsWith(rustJoin(canonicalRoot, o.real), entry.canonical)) return null + return `it resolves to the project's own ${o.real}` } + // Messages name a dependency's files under its lexical path, from the root. + const show = (entry) => (file) => shown(rustJoin(entry.path, stripPrefix(file, entry.canonical) ?? file)) // A BTreeSet popped in (canonical, path) order. const pending = new Map() const addPending = (e) => pending.set(`${e.canonical}\0${e.path}`, e) @@ -692,7 +710,7 @@ function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) pending.delete(key) if (entry.canonical === canonicalRoot) continue if (!configs.has(entry.canonical)) { - const config = loadNestedConfig(entry.canonical, profile, readable(entry)) + const config = loadNestedConfig(entry.canonical, profile, { refused: refused(entry), show: show(entry) }) configs.set(entry.canonical, config) // Record what was read under the dependency's lexical path (where the bundle sees it). for (const f of config?.files ?? []) files.add(rustJoin(entry.path, stripPrefix(f, entry.canonical) ?? f)) @@ -827,21 +845,22 @@ function providerRemappings(root, { userRemappings, libs, autoDetect, profile, f return all.intoInner() } -// The lib dirs `forge build` uses for the Foundry project at `baseDir`, `{ libs, profiled }`: the -// selected profile's `libs` (`profiled` when that profile is the file's), else the detected ones; -// also those, warned, when forge would reject the foundry.toml's settings (ConfigRefused; with a -// pinned mapping file, nothing else is read from it). A foundry.toml that isn't TOML or holds an -// invalid remapping throws. +// The lib dirs `forge build` uses for the Foundry project at `baseDir`, `{ libs, profiled, files }`: +// the selected profile's `libs` (`profiled` when that profile is the file's), else the detected +// ones; also those, warned, when forge would reject the foundry.toml's settings (ConfigRefused; with +// a pinned mapping file, nothing else is read from it). `files` lists the config files read (the +// foundry.toml, its `extends` base). A foundry.toml that isn't TOML or holds an invalid remapping +// throws. export function foundryLibs(baseDir, { env = process.env } = {}) { const root = toPosix(resolve(baseDir)) const profile = foundryProfile(env) try { - const config = loadFoundryConfig(root, profile) - return { libs: config.libs, profiled: profileApplies(config.profiles, profile) } + const config = loadFoundryConfig(root, profile, { show: shownFrom(root) }) + return { libs: config.libs, profiled: profileApplies(config.profiles, profile), files: config.files } } catch (err) { if (!(err instanceof ConfigRefused)) throw err console.warn(`[loader.solidity] Using the default lib dirs: ${err.message}`) - return { libs: detectLibs(root), profiled: false } + return { libs: detectLibs(root), profiled: false, files: [rustJoin(root, FOUNDRY_TOML)] } } } @@ -853,8 +872,9 @@ export function foundryLibs(baseDir, { env = process.env } = {}) { // FOUNDRY_REMAPPINGS / DAPP_REMAPPINGS. export function foundryProject(baseDir, { env = process.env } = {}) { const root = toPosix(resolve(baseDir)) + const show = shownFrom(root) const profile = foundryProfile(env) - const config = loadFoundryConfig(root, profile) + const config = loadFoundryConfig(root, profile, { show }) const profiled = profileApplies(config.profiles, profile) const ownership = projectOwnership(baseDir, config.libs, { soldeer: true }) const files = new Set(config.files) @@ -862,9 +882,9 @@ export function foundryProject(baseDir, { env = process.env } = {}) { const envName = env.DAPP_REMAPPINGS !== undefined ? 'DAPP_REMAPPINGS' : env.FOUNDRY_REMAPPINGS !== undefined ? 'FOUNDRY_REMAPPINGS' : null const envRemappings = envName === null ? [] : parseRemappingLines(env[envName], { label: envName }) const txtFile = rustJoin(root, REMAPPINGS_TXT) - const txt = readUtf8OrNull(txtFile) + const txt = readUtf8OrNull(txtFile, REMAPPINGS_TXT) if (txt !== null) files.add(txtFile) - const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, { label: txtFile })), ...config.remappings] + const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, { label: REMAPPINGS_TXT })), ...config.remappings] const provided = providerRemappings(root, { userRemappings, libs: config.libs, autoDetect: config.autoDetect, profile, files, ownership }) .map((r) => displayRelative(relativePreservingBoundary(r, root))) diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index 24da7f6c..ef285766 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -4,7 +4,7 @@ // out of itself is never followed. import { isUtf8 } from 'node:buffer' -import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync } from 'node:fs' +import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync } from 'node:fs' import { isAbsolute, join, parse, posix, relative, resolve, sep } from 'node:path' import { hasNodeModulesSegment } from '@exodus/stasis-core/util' @@ -25,34 +25,71 @@ const inRoot = (rel) => rel !== '..' && !rel.startsWith('../') && !isAbsolute(re // `abs`, a file the resolution read, relative to the project `root` (slashes): as spelled when that // lies inside it with no `..` to resolve (a linked lib's files keep the lib's path), else by real // paths -- where the read went (an absolute or `/proc/self/cwd` lib; a `..` after a symlink) -- -// `../` when outside the project. +// `../` when outside the project. One whose real path the OS can't give (past PATH_MAX) keeps the +// path it was read by, `..` and all, for solidityOwnership to refuse: normalized, it would name +// another file. export function projectRelative(root, abs) { const rel = toSlashes(relative(root, abs)) if (inRoot(rel) && !toSlashes(abs).split('/').includes('..')) return rel - return toSlashes(relative(realpathOrNull(root) ?? root, realpathOrNull(abs) ?? abs)) + const real = realpathOrNull(abs) + if (real !== null) return toSlashes(relative(realpathOrNull(root) ?? root, real)) + const prefix = `${resolve(root)}${sep}` + return abs.startsWith(prefix) ? toSlashes(abs.slice(prefix.length)) : rel } -// realpath(3) of `p`: `{ real }`, or `{ real: null, missing }`, `missing` false when something is -// there that the OS can't resolve (a real path past PATH_MAX, a loop, a dir it may not search). +const NO_ENTRY = new Set(['ENOENT', 'ENOTDIR']) + +// realpath(3) of `p`: `{ real }`, or `{ real: null, missing }`, `missing` only when nothing is +// there at all. The OS may fail to resolve what is there -- a real path past PATH_MAX, a loop, a +// link whose end it can't name (`/proc/self/fd/0` on a pipe), a dir it may not search -- and a +// read may still get through. function osRealpath(p) { try { return { real: realpathSync.native(p), missing: false } } catch (err) { - return { real: null, missing: err.code === 'ENOENT' || err.code === 'ENOTDIR' } + return { real: null, missing: NO_ENTRY.has(err.code) && !lexists(p) } } } -// A config file's text, or null when there's no file. One that isn't UTF-8 throws: forge and git -// refuse it, and a text read with U+FFFD in it isn't the one they read. A byte-order mark stays. -export function readUtf8OrNull(file) { - let buf +function lexists(p) { try { - buf = readFileSync(file) + lstatSync(p) + return true } catch (err) { - if (err.code === 'ENOENT' || err.code === 'ENOTDIR' || err.code === 'EISDIR') return null + if (NO_ENTRY.has(err.code)) return false throw err } - if (!isUtf8(buf)) throw new Error(`${file}: not valid UTF-8`) +} + +// `file`'s bytes, or null when there's no file (a directory counts as none). It's opened without +// blocking and read only when it's a regular file: a FIFO, a socket, a device or a link to one +// (`/dev/stdin`) throws, naming it `label`, rather than stalling or reading the process's input. +export function readRegularFileOrNull(file, label = file) { + let fd + try { + fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)) + } catch (err) { + if (NO_ENTRY.has(err.code) || err.code === 'EISDIR') return null + if (err.code === 'ENXIO') throw new Error(`${label}: not a regular file`, { cause: err }) // a socket + throw err + } + try { + const stat = fstatSync(fd) + if (stat.isDirectory()) return null + if (!stat.isFile()) throw new Error(`${label}: not a regular file`) + return readFileSync(fd) + } finally { + closeSync(fd) + } +} + +// A config file's text, or null when there's no file (readRegularFileOrNull). One that isn't UTF-8 +// throws: forge and git refuse it, and a text read with U+FFFD in it isn't the one they read. A +// byte-order mark stays. Errors name it `label`. +export function readUtf8OrNull(file, label = file) { + const buf = readRegularFileOrNull(file, label) + if (buf === null) return null + if (!isUtf8(buf)) throw new Error(`${label}: not valid UTF-8`) return buf.toString('utf8') } @@ -115,7 +152,7 @@ export function parseGitmodules(text) { } // The submodules of the project at `baseDir` (its `.gitmodules`, see parseGitmodules). -export const readGitmodules = (baseDir) => parseGitmodules(readUtf8OrNull(join(baseDir, '.gitmodules')) ?? '') +export const readGitmodules = (baseDir) => parseGitmodules(readUtf8OrNull(join(baseDir, '.gitmodules'), '.gitmodules') ?? '') // --- Ownership -------------------------------------------------------------------------------- @@ -251,7 +288,8 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { const of = (rel) => { let owner = owners.get(rel) if (owner === undefined) { - const path = join(realBase, rel) + // As given, not normalized: the OS resolves a `..` after a link from where the link leads. + const path = rel === '' ? realBase : `${realBase}${sep}${rel}` let { abs, escape } = walk(realBase, rel.split('/'), 0) // The OS's answer is the one a read gets: the walk must agree with it, or the path is refused, // as it is when the OS can't resolve it at all, though a read may still get through. diff --git a/stasis/src/loaders/solidity.js b/stasis/src/loaders/solidity.js index dcc4dfe0..c3b5ded3 100644 --- a/stasis/src/loaders/solidity.js +++ b/stasis/src/loaders/solidity.js @@ -25,6 +25,7 @@ import { foundryTomlRemappings, parseRemappingLines, readFoundryTomlRemappings, + shownFrom, toSolcRemapping, } from './foundry.js' import { projectOwnership, projectRelative, readUtf8OrNull, realpathOrNull, solidityOwnership } from './solidity-ownership.js' @@ -149,15 +150,15 @@ export function parseRemappingsFromToml(tomlContent, { env = process.env } = {}) // Read a mapping file -> its remappings as listed (no discovery around it) and the files read. A // foundry.toml (its selected profile, with its `extends` base) is forge's, and so is a // remappings.txt when `forge` says forge reads it: slash-terminated the way forge reads them. -// Otherwise (solc, Hardhat) a remappings.txt applies as written. -function readMapping(mappingFile, { env, forge }) { +// Otherwise (solc, Hardhat) a remappings.txt applies as written. Messages name files `show(file)`. +function readMapping(mappingFile, { env, forge, show = (f) => f }) { if (mappingFile.endsWith('.toml')) { - const { remappings, files, profiled } = readFoundryTomlRemappings(mappingFile, foundryProfile(env)) + const { remappings, files, profiled } = readFoundryTomlRemappings(mappingFile, foundryProfile(env), { show }) return { remappings: remappings.map(toSolcRemapping), files, profiled } } - const text = readUtf8OrNull(mappingFile) - if (text === null) throw new Error(`${mappingFile}: no such file`) - const listed = parseRemappingLines(text, { label: mappingFile, emptyPath: !forge }) + const text = readUtf8OrNull(mappingFile, show(mappingFile)) + if (text === null) throw new Error(`${show(mappingFile)}: no such file`) + const listed = parseRemappingLines(text, { label: show(mappingFile), emptyPath: !forge }) return { remappings: listed.map(forge ? toSolcRemapping : toLoaderRemapping), files: [mappingFile] } } @@ -184,17 +185,19 @@ export async function readRemappingsFile(mappingFile, { env = process.env, forge export async function discoverSolidityConfig(baseDir, { mappingFile, env = process.env } = {}) { const forge = isFile(join(baseDir, FOUNDRY_TOML)) if (forge && !mappingFile) return foundryProject(baseDir, { env }) - const { libs, profiled } = forge ? foundryLibs(baseDir, { env }) : { libs: [], profiled: false } + const { libs, profiled, files: libsFiles } = forge ? foundryLibs(baseDir, { env }) : { libs: [], profiled: false, files: [] } const ownership = projectOwnership(baseDir, libs, { soldeer: forge }) + const show = shownFrom(toPosix(resolve(baseDir))) if (mappingFile) { const abs = resolve(baseDir, mappingFile) - const { remappings, files, profiled: mappingProfiled } = readMapping(abs, { env, forge }) - // The profile picks the mapping file's remappings (a .toml) or the root foundry.toml's libs. + const { remappings, files, profiled: mappingProfiled } = readMapping(abs, { env, forge, show }) + // The profile picks the mapping file's remappings (a .toml) or the root foundry.toml's libs: the + // files read are both's. const envUsed = profiled || mappingProfiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : [] - return { remappings, libs, ownership, files: files.map((f) => projectRelative(baseDir, f)), envUsed } + return { remappings, libs, ownership, files: [...new Set([...files, ...libsFiles].map((f) => projectRelative(baseDir, f)))], envUsed } } const txt = join(baseDir, REMAPPINGS_TXT) - const remappings = isFile(txt) ? readMapping(txt, { env, forge }).remappings : [] + const remappings = isFile(txt) ? readMapping(txt, { env, forge, show }).remappings : [] return { remappings, libs, ownership, files: isFile(txt) ? [REMAPPINGS_TXT] : [], envUsed: [] } } diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 033289e7..e13f098d 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -1,5 +1,5 @@ import { test } from 'node:test' -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -438,6 +438,59 @@ const captureStderr = async (fn) => { } } +// `dir/name` -> a chain of 22 dirs with 200-char names, one short hop each (`n -> next/n`), the last +// hop `n -> last(levels)`; `atBottom()` runs in the deepest dir. Each hop resolves, but the chain's +// real path is past PATH_MAX (4096). +function linkPastPathMax(dir, name, last, atBottom = () => {}) { + const seg = (i) => `${'d'.repeat(200)}${i}` + const levels = 22 + const cwd = process.cwd() + try { + process.chdir(dir) + symlinkSync(`${seg(0)}/n`, name) + for (let i = 0; i < levels; i++) { + mkdirSync(seg(i)) + process.chdir(seg(i)) + symlinkSync(i + 1 < levels ? `${seg(i + 1)}/n` : last(levels), 'n') + } + atBottom() + } finally { + process.chdir(cwd) + } +} + +// buildSolidityBundle on `cwd` in a child whose stdin is an anonymous pipe left open (`sleep` holds +// its other end), so a read of stdin never ends: the child's output lines (its warnings, then `OK` +// and the bundled paths, or `ERR` and the error), or a rejection when it hangs. +function bundleWithOpenStdin(cwd) { + const script = [ + `import { buildSolidityBundle } from ${JSON.stringify(new URL('../stasis/src/cmd/bundle.js', import.meta.url).href)}`, + 'try {', + ` const bundle = await buildSolidityBundle({ cwd: ${JSON.stringify(cwd)}, entries: ['src'], env: {} })`, + " console.log('OK', [...bundle.sources.keys()].join(' '))", + '} catch (err) {', + " console.log('ERR', err.message)", + '}', + "console.log('DONE')", + ].join('\n') + return new Promise((resolve, reject) => { + const child = spawn('sh', ['-c', 'sleep 60 2>/dev/null | "$0" --input-type=module -e "$1" 2>&1', process.execPath, script], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) + let out = '' + const end = (settle) => { + clearTimeout(timer) + try { + process.kill(-child.pid, 'SIGKILL') + } catch {} + settle() + } + const timer = setTimeout(() => end(() => reject(new Error(`hung reading stdin, after: ${out}`))), 20_000) + child.stdout.on('data', (chunk) => { + out += chunk + if (out.endsWith('DONE\n')) end(() => resolve(out.slice(0, -'DONE\n'.length).trimEnd().split('\n'))) + }) + }) +} + test('buildSolidityBundle refuses an import of a non-.sol file, however it is spelled', withTmp(async (t, tmp) => { writeProject(tmp, { 'foundry.toml': '[profile.default]\n', @@ -564,7 +617,7 @@ test('buildSolidityBundle with manifests carries no dependency config reached th const { result: bundle, lines } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} })) t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['.gitmodules', 'foundry.toml', 'lib/evil/foundry.toml', 'lib/evil/src/E.sol', 'lib/evil2/src/E.sol', 'src/A.sol']) // Nor are they read as its config. - t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's") && l.includes('lib/evil/remappings.txt: it is a link out of the dependency'))) + t.assert.ok(lines.includes("[loader.solidity] Skipping a dependency's lib/evil/remappings.txt: lib/evil/remappings.txt is a link out of the dependency lib/evil"), lines.join('\n')) t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's config") && l.includes('lib/evil2/foundry.toml: refusing to read it'))) t.assert.ok(lines.some((l) => l === '[stasis] Not carrying lib/evil/remappings.txt: lib/evil/remappings.txt is a link out of the dependency lib/evil')) })) @@ -672,7 +725,7 @@ test('buildSolidityBundle refuses a path the ownership walk reads differently fr test('buildSolidityBundle fails on a config that isn\'t UTF-8 or holds a mistyped setting, as forge does', withTmp(async (t, tmp) => { writeProject(tmp, { 'foundry.toml': '[profile.default]\n', 'src/A.sol': 'import "x/X.sol";\n', 'lib/x/X.sol': 'contract X {}\n', 'deps/x/X.sol': 'contract Y {}\n' }) writeFileSync(join(tmp, 'remappings.txt'), Buffer.concat([Buffer.from('x/=lib/x'), Buffer.from([0xff]), Buffer.from('/\n')])) - await captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: `${join(tmp, 'remappings.txt')}: not valid UTF-8` })) + await captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: 'remappings.txt: not valid UTF-8' })) rmSync(join(tmp, 'remappings.txt')) for (const [setting, message] of [ ['libs = "deps"', '`libs` must be an array of strings'], @@ -682,7 +735,7 @@ test('buildSolidityBundle fails on a config that isn\'t UTF-8 or holds a mistype ]) { writeFileSync(join(tmp, 'foundry.toml'), `[profile.default]\n${setting}\n`) // eslint-disable-next-line no-await-in-loop -- each run rewrites foundry.toml - await captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: `${join(tmp, 'foundry.toml')}: ${message}` })) + await captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: `foundry.toml: ${message}` })) } })) @@ -754,24 +807,23 @@ test('buildSolidityBundle fails on a foundry.toml that isn\'t TOML, naming the f }) await captureStderr(() => t.assert.rejects( () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), - { name: 'TomlError', message: `${join(realpathSync(tmp), 'lib/dep/foundry.toml')}: expected "," or "]", found the end of the text at line 3` }, + { name: 'TomlError', message: 'lib/dep/foundry.toml: expected "," or "]", found the end of the text at line 3' }, )) // ...and so is its `extends` base. writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\nextends = "base.toml"\n', 'lib/dep/base.toml': '[profile.default]\nsrc = "src" junk\n' }) await captureStderr(() => t.assert.rejects( () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), - { name: 'TomlError', message: `${join(realpathSync(tmp), 'lib/dep/base.toml')}: expected the end of the line, found "junk" at line 2` }, + { name: 'TomlError', message: 'lib/dep/base.toml: expected the end of the line, found "junk" at line 2' }, )) // With a pinned mapping file, the root foundry.toml is still read for its lib dirs. writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\n', 'foundry.toml': '[profile.default]\nlibs = ["lib"\n', 'remappings.txt': 'dep/=lib/dep/src/\n' }) await captureStderr(() => t.assert.rejects( () => buildSolidityBundle({ cwd: tmp, entries: ['src'], mappingFile: 'remappings.txt', env: {} }), - { name: 'TomlError', message: `${join(tmp, 'foundry.toml')}: expected "," or "]", found the end of the text at line 3` }, + { name: 'TomlError', message: 'foundry.toml: expected "," or "]", found the end of the text at line 3' }, )) })) test('buildSolidityBundle fails on an invalid remapping, the project\'s or a dependency\'s, naming the file and line', withTmp(async (t, tmp) => { - const root = realpathSync(tmp) writeProject(tmp, { 'foundry.toml': '[profile.default]\n', 'remappings.txt': 'dep/=lib/dep/src/\n# not a remapping\n', @@ -779,15 +831,15 @@ test('buildSolidityBundle fails on an invalid remapping, the project\'s or a dep 'lib/dep/src/D.sol': 'contract D {}\n', }) const fails = (opts, message) => captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {}, ...opts }), { message })) - await fails({}, `${join(tmp, 'remappings.txt')}:2: invalid remapping "# not a remapping"`) + await fails({}, 'remappings.txt:2: invalid remapping "# not a remapping"') // As written for solc, and as a pinned mapping file, alike. - await fails({ mappingFile: 'remappings.txt' }, `${join(tmp, 'remappings.txt')}:2: invalid remapping "# not a remapping"`) + await fails({ mappingFile: 'remappings.txt' }, 'remappings.txt:2: invalid remapping "# not a remapping"') writeFileSync(join(tmp, 'remappings.txt'), 'dep/=lib/dep/src/\n') // forge skips a dependency's config holding one; here it's an error, not a config left out. writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\nremappings = ["x"]\n' }) - await fails({}, `${join(root, 'lib/dep/foundry.toml')}: \`remappings\`: invalid remapping "x"`) + await fails({}, 'lib/dep/foundry.toml: `remappings`: invalid remapping "x"') writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\n', 'lib/dep/remappings.txt': 'y/=src/\n=z\n' }) - await fails({}, `${join(root, 'lib/dep/remappings.txt')}:2: invalid remapping "=z"`) + await fails({}, 'lib/dep/remappings.txt:2: invalid remapping "=z"') writeFileSync(join(tmp, 'lib/dep/remappings.txt'), 'y/=src/\n') const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }) t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/dep/src/D.sol', 'src/A.sol']) @@ -803,22 +855,9 @@ test('buildSolidityBundle refuses a dependency config whose real path the OS can 'lib/evil/foundry.toml': '[profile.default]\n', 'lib/evil/0xabc/Y.sol': 'contract Y {}\n', }) - // lib/evil/remappings.txt -> a chain of 22 dirs with 200-char names, each hop short, ending in a - // link to the project's .env: readable, but its real path is past PATH_MAX (4096). - const seg = (i) => `${'d'.repeat(200)}${i}` - const levels = 22 - const cwd = process.cwd() - try { - process.chdir(join(tmp, 'lib/evil')) - symlinkSync(`${seg(0)}/n`, 'remappings.txt') - for (let i = 0; i < levels; i++) { - mkdirSync(seg(i)) - process.chdir(seg(i)) - symlinkSync(i + 1 < levels ? `${seg(i + 1)}/n` : `${'../'.repeat(levels + 2)}.env`, 'n') - } - } finally { - process.chdir(cwd) - } + // lib/evil/remappings.txt -> a chain ending in a link to the project's .env: readable, but its real + // path is past PATH_MAX. + linkPastPathMax(join(tmp, 'lib/evil'), 'remappings.txt', (levels) => `${'../'.repeat(levels + 2)}.env`) t.assert.equal(readFileSync(join(tmp, 'lib/evil/remappings.txt'), 'utf8'), 'PRIVATE_KEY=0xabc\n') await Promise.all([false, true].map(async (manifests) => { const { lines } = await captureStderr(() => t.assert.rejects( @@ -868,6 +907,120 @@ test('buildSolidityBundle with manifests fails on a config the resolution read b } })) +test('buildSolidityBundle with manifests refuses a config whose real path the OS can\'t give (past PATH_MAX), not another file of that name', withTmp(async (t, tmp) => { + writeProject(tmp, { + // `L/../base.toml`: L leads to a dir whose real path is past PATH_MAX, and forge reads the base + // beside that dir. Normalized, the name would be the root's base.toml: another file. + 'foundry.toml': '[profile.default]\nextends = "L/../base.toml"\n', + 'base.toml': '[profile.default]\nremappings = ["x/=lib/textual/"]\n', + 'src/A.sol': 'import "x/X.sol";\n', + 'lib/textual/X.sol': 'contract T {}\n', + 'lib/physical/X.sol': 'contract P {}\n', + }) + linkPastPathMax(tmp, 'L', () => 'in', () => { + mkdirSync('in') + writeFileSync('base.toml', '[profile.default]\nremappings = ["x/=lib/physical/"]\n') + }) + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }) + t.assert.equal(bundle.imports.get('solidity').get('src/A.sol').get('x/X.sol'), 'lib/physical/X.sol') + await t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} }), + { message: "--manifests can't carry L/../base.toml, which the Solidity resolution read: L/../base.toml crosses a link stasis can't follow the way the filesystem does" }, + ) +})) + +test('buildSolidityBundle never reads the process\'s stdin as a config, a dependency\'s or the project\'s', { skip: !existsSync('/proc/self/fd/0') }, withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'src/A.sol': 'contract A {}\n', + 'lib/a/foundry.toml': '[profile.default]\n', + 'lib/b/B.sol': '', + 'lib/c/foundry.toml': '[profile.default]\nextends = "base.toml"\n', + }) + // A pipe on stdin is a link whose end the OS can't name (`pipe:[N]`): refused, not read. + symlinkSync('/proc/self/fd/0', join(tmp, 'lib/a/remappings.txt')) + symlinkSync('/dev/stdin', join(tmp, 'lib/b/foundry.toml')) + symlinkSync('/proc/self/fd/0', join(tmp, 'lib/c/base.toml')) + const unresolved = (path) => `${path} crosses a link stasis can't follow the way the filesystem does` + t.assert.deepEqual(await bundleWithOpenStdin(tmp), [ + `[loader.solidity] Skipping a dependency's lib/a/remappings.txt: ${unresolved('lib/a/remappings.txt')}`, + `[loader.solidity] Skipping a dependency's config: lib/b/foundry.toml: refusing to read it: ${unresolved('lib/b/foundry.toml')}`, + `[loader.solidity] Skipping a dependency's config: lib/c/foundry.toml: refusing to extend base.toml: ${unresolved('lib/c/base.toml')}`, + 'OK src/A.sol', + ]) + // The project's own link to it, or a FIFO, is read only if it's a regular file: it isn't. + rmSync(join(tmp, 'lib'), { recursive: true }) + symlinkSync('/dev/stdin', join(tmp, 'remappings.txt')) + t.assert.deepEqual(await bundleWithOpenStdin(tmp), ['ERR remappings.txt: not a regular file']) + rmSync(join(tmp, 'remappings.txt')) + spawnSync('mkfifo', [join(tmp, 'remappings.txt')]) + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: 'remappings.txt: not a regular file' }) +})) + +test('buildSolidityBundle resolves a dependency\'s `extends` through its own symlink as forge does', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'src/A.sol': 'import "y/Y.sol";\n', + // `sub` is a link to real/in: forge reads real/base.toml; a `..` taken textually would refuse it. + 'lib/dep/foundry.toml': '[profile.default]\nextends = "sub/../base.toml"\n', + 'lib/dep/real/base.toml': '[profile.default]\nremappings = ["y/=src/"]\n', + 'lib/dep/real/in/.keep': '', + 'lib/dep/src/Y.sol': 'contract Y {}\n', + }) + symlinkSync('real/in', join(tmp, 'lib/dep/sub')) + const { result: bundle, lines } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} })) + t.assert.equal(bundle.imports.get('solidity').get('src/A.sol').get('y/Y.sol'), 'lib/dep/src/Y.sol') + t.assert.deepEqual(lines, []) +})) + +test('buildSolidityBundle with --mapping and manifests carries the root config read for its lib dirs, `extends` base included', withTmp(async (t, tmp) => { + const proj = join(tmp, 'proj') + writeProject(proj, { + 'foundry.toml': '[profile.default]\nextends = "base.toml"\n', + 'base.toml': '[profile.default]\nlibs = ["deps"]\n', + 'remappings.txt': 'x/=deps/x/\n', + 'src/A.sol': 'import "x/X.sol";\n', + 'deps/x/X.sol': 'contract X {}\n', + }) + const opts = { cwd: proj, entries: ['src'], mappingFile: 'remappings.txt', manifests: true, env: {} } + const bundle = await buildSolidityBundle(opts) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['base.toml', 'deps/x/X.sol', 'foundry.toml', 'remappings.txt', 'src/A.sol']) + // ...and fails on one it can't carry, as without --mapping. + writeFileSync(join(tmp, 'shared-base.toml'), '[profile.default]\nlibs = ["deps"]\n') + writeFileSync(join(proj, 'foundry.toml'), '[profile.default]\nextends = "../shared-base.toml"\n') + await t.assert.rejects(() => buildSolidityBundle(opts), { message: "--manifests can't carry ../shared-base.toml, which the Solidity resolution read: it lies outside the bundle root" }) +})) + +test('buildSolidityBundle with manifests tags a package.json `json`, any other config (`--mapping=remaps.json`) `resource`', withTmp(async (t, tmp) => { + writeProject(tmp, { 'package.json': '{ "name": "proj", "version": "1.0.0" }\n', 'remaps.json': 'x/=lib/x/\n', 'src/A.sol': 'import "x/X.sol";\n', 'lib/x/X.sol': 'contract X {}\n' }) + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], mappingFile: 'remaps.json', manifests: true, env: {} }) + t.assert.equal(bundle.formats.get('remaps.json'), 'resource') + t.assert.equal(bundle.formats.get('package.json'), 'json') +})) + +test('buildSolidityBundle reads a package.json with a byte-order mark, as npm does, and carries it as written', withTmp(async (t, tmp) => { + const pkg = '\uFEFF{ "name": "proj", "version": "1.0.0" }\n' + writeProject(tmp, { 'package.json': pkg, 'src/A.sol': 'contract A {}\n', 'node_modules/dep/package.json': '\uFEFF{ "name": "dep", "version": "2.0.0" }\n', 'node_modules/dep/D.sol': 'contract D {}\n' }) + writeFileSync(join(tmp, 'src/A.sol'), 'import "dep/D.sol";\n') + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} }) + t.assert.equal(bundle.sources.get('package.json'), pkg) + t.assert.deepEqual([...bundle.modules.keys()].toSorted(), ['.', 'node_modules/dep']) + t.assert.equal(bundle.modules.get('node_modules/dep').version, '2.0.0') +})) + +test('buildBashBundle and buildRustBundle walk past a malformed package.json, as they always have', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'run.sh': '#!/bin/sh\n. ./sub/lib.sh\n', + 'sub/lib.sh': 'echo hi\n', + 'sub/package.json': '{ "name": "sub",\n}\n', + 'src/main.rs': 'mod a;\nfn main() {}\n', + 'src/a.rs': '', + 'src/package.json': '{ bad', + }) + t.assert.deepEqual([...(await buildBashBundle({ cwd: tmp, entries: ['run.sh'] })).sources.keys()].toSorted(), ['run.sh', 'sub/lib.sh']) + t.assert.deepEqual([...(await buildRustBundle({ cwd: tmp, entries: ['src/main.rs'] })).sources.keys()].toSorted(), ['src/a.rs', 'src/main.rs']) +})) + test('buildSolidityBundle with --mapping bundles when forge would reject the root foundry.toml', withTmp(async (t, tmp) => { writeProject(tmp, { 'foundry.toml': '[profile.default]\nextends = "missing.toml"\n', @@ -936,7 +1089,7 @@ test('buildSolidityBundle with manifests carries configs as written, never `.env // `.env` and hardhat.config.* are never carried, and the submodule's `extends` reaching the // project's `.env` is neither read as config nor carried. for (const [, text] of bundle.sources) t.assert.doesNotMatch(text, /KEY[67]/u) - t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's config") && l.includes('outside the dependency'))) + t.assert.ok(lines.includes("[loader.solidity] Skipping a dependency's config: lib/dep/foundry.toml: refusing to extend ../../.env: it resolves to the project's own .env"), lines.join('\n')) })) test('buildSolidityBundle says when the environment shaped the resolution; buildBundle passes `env` on', withTmp(async (t, tmp) => { diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index 523221bd..3ec3be6d 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -5,8 +5,6 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { toPosix } from '@exodus/stasis-core/util' - import { applyRemappings, buildSolidityTree, @@ -540,7 +538,7 @@ test('foundryProject reads a profile\'s sub-tables however they are spelled: `ex t.assert.throws(() => foundryProject(dir, { env: {} }), { message: /key collision in profile 'default' when extending base\.toml: fuzz$/u }) })) -test('discoverSolidityConfig: --mapping takes exactly that file; no foundry.toml falls back to remappings.txt', withProject({ +test('discoverSolidityConfig: --mapping takes exactly that file\'s remappings; no foundry.toml falls back to remappings.txt', withProject({ 'foundry.toml': '[profile.default]\n', 'mapping.txt': '@m/=lib/m/\nforge-std=lib/forge-std/src\nconsole.sol=lib/forge-std/src/console.sol\n', 'lib/forge-std/src/Test.sol': '', @@ -549,7 +547,8 @@ test('discoverSolidityConfig: --mapping takes exactly that file; no foundry.toml const pinned = await discoverSolidityConfig(dir, { mappingFile: 'mapping.txt', env: {} }) // Slash-terminated as forge reads a remappings file. t.assert.deepEqual(pinned.remappings.map(show), ['@m/=lib/m/', 'forge-std/=lib/forge-std/src/', 'console.sol=lib/forge-std/src/console.sol']) - t.assert.deepEqual(pinned.files, ['mapping.txt']) + // ...and the root foundry.toml, read for its lib dirs. + t.assert.deepEqual(pinned.files, ['mapping.txt', 'foundry.toml']) t.assert.deepEqual((await discoverSolidityConfig(dir, { env: {} })).remappings.map(show), ['forge-std/=lib/forge-std/src/']) const plain = await discoverSolidityConfig(join(dir, 'plain'), { env: {} }) t.assert.deepEqual(plain.remappings.map(show), ['@p/=lib/p/']) @@ -773,10 +772,9 @@ test('an invalid remapping in a foundry.toml or remappings variable is an error, 'num/foundry.toml': '[profile.default]\nremappings = [1]\n', 'ok/foundry.toml': '[profile.default]\n', }, (t, dir) => { - const root = toPosix(dir) - t.assert.throws(() => foundryProject(dir, { env: {} }), { message: `${root}/foundry.toml: \`remappings\`: invalid remapping "nope"` }) - t.assert.throws(() => foundryProject(join(dir, 'list'), { env: {} }), { message: `${root}/list/foundry.toml: \`remappings\` is not an array of strings` }) - t.assert.throws(() => foundryProject(join(dir, 'num'), { env: {} }), { message: `${root}/num/foundry.toml: \`remappings\`: invalid remapping 1` }) + t.assert.throws(() => foundryProject(dir, { env: {} }), { message: 'foundry.toml: `remappings`: invalid remapping "nope"' }) + t.assert.throws(() => foundryProject(join(dir, 'list'), { env: {} }), { message: 'foundry.toml: `remappings` is not an array of strings' }) + t.assert.throws(() => foundryProject(join(dir, 'num'), { env: {} }), { message: 'foundry.toml: `remappings`: invalid remapping 1' }) t.assert.throws(() => foundryProject(join(dir, 'ok'), { env: { FOUNDRY_REMAPPINGS: 'x/=y/\nbad' } }), { message: 'FOUNDRY_REMAPPINGS:2: invalid remapping "bad"' }) t.assert.throws(() => foundryTomlRemappings('[profile.default]\nremappings = ["=x/"]\n'), { message: '`remappings`: invalid remapping "=x/"' }) })) From e786a00e89b3c2aeb397e848f575384230d7b7d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 12:11:11 +0000 Subject: [PATCH 11/20] refactor(bundle): one regular-file reader, carried files read where ownership vouched - readRegularFileOrNull moves to stasis-core's bundle-util, and readPackageJson reads through it: a package.json that is a FIFO no longer stalls Solidity's strict package lookup (it is an error naming it; the lenient lookups walk past it). - --manifests reads a carried file by the real path ownership resolved it to, and its containment check comes from that same answer, instead of re-resolving the name with a second realpath. - realpathOrNull no longer pays for the "nothing there" lstat it discards; the ownership walk runs that check only when it needs it. - One `lexical` rule names a dependency's files for messages and for the files read; shownFrom takes the canonical root already computed; the `show` defaults that never ran are gone (loadFoundryConfig names from the root by default), labels are required, and names are computed once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 4 +- stasis-core/src/bundle-util.js | 52 +++++++++++++++++++++--- stasis/src/cmd/bundle.js | 22 ++++------ stasis/src/loaders/foundry.js | 45 ++++++++++---------- stasis/src/loaders/solidity-ownership.js | 51 ++++++++--------------- stasis/src/loaders/solidity.js | 11 ++--- tests/bundle-cmd.test.js | 7 ++++ 7 files changed, 111 insertions(+), 81 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index c0175193..6dc63799 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -440,8 +440,8 @@ path, a dependency outside the root reads nothing, and a dir a dependency's `libs` names must be a dependency itself; a config refused says why. A `package.json` that decides a file's package is refused the same way when a dependency planted it as a link, and one that doesn't parse (a leading -byte-order mark is skipped, as npm skips it) is an error naming it (not quoting -it) rather than giving its files to the parent package; other bundles walk past +byte-order mark is skipped, as npm skips it) or isn't a regular file is an error +naming it (not quoting it) rather than giving its files to the parent package; other bundles walk past a malformed one, as they always have. A link the project placed (a workspace package linked into `node_modules`, a linked `lib/` entry, `src/vendor`) may lead anywhere in the root, and so may one diff --git a/stasis-core/src/bundle-util.js b/stasis-core/src/bundle-util.js index 639475ed..d06f9b16 100644 --- a/stasis-core/src/bundle-util.js +++ b/stasis-core/src/bundle-util.js @@ -1,4 +1,5 @@ import { isUtf8 } from 'node:buffer' +import { closeSync, constants, fstatSync, openSync, readFileSync } from 'node:fs' import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path' import { isValidRepoField } from './bundle.js' @@ -41,17 +42,56 @@ export function findPackageMetadata(baseDir, fileRelPath, { strict = false, chec } } +// The error codes that mean nothing is at a path. +export const NO_ENTRY = new Set(['ENOENT', 'ENOTDIR']) + +// `file`'s bytes, or null when there's no file (a directory counts as none). It's opened without +// blocking and read only when it's a regular file: a FIFO, a socket, a device or a link to one +// (`/dev/stdin`) throws, naming it `label`, rather than stalling or reading the process's input. +export function readRegularFileOrNull(file, label) { + let fd + try { + fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)) + } catch (err) { + if (NO_ENTRY.has(err.code) || err.code === 'EISDIR') return null + if (err.code === 'ENXIO') throw new Error(`${label}: not a regular file`, { cause: err }) // a socket + throw err + } + try { + const stat = fstatSync(fd) + if (stat.isDirectory()) return null + if (!stat.isFile()) throw new Error(`${label}: not a regular file`) + return readFileSync(fd) + } finally { + closeSync(fd) + } +} + // The package.json at `rel` (under `baseDir`), parsed (a leading byte-order mark skipped, as npm -// and Node skip it); null when there's none, or when it doesn't parse -- unless `strict`, then that -// throws, saying where with the parser's line and column but never its message, which quotes the -// text (a file that isn't JSON may be anything, a secret included). `check(rel)`, when given, sees -// the path before it is read, and may throw to refuse it. Read through `host`. +// and Node skip it), read through `host`; null when there's none (a directory counts as none), or +// when it doesn't parse or isn't a regular file -- unless `strict`, then that throws, saying where +// with the parser's line and column but never its message, which quotes the text (a file that isn't +// JSON may be anything, a secret included). `check(rel)`, when given, sees the path before it is +// read, and may throw to refuse it. export function readPackageJson(baseDir, rel, { strict = false, check, host = diskHost } = {}) { const file = join(baseDir, rel) - if (!host.stat(file)?.isFile()) return null + const stat = host.stat(file) + if (stat === null || stat.isDirectory()) return null check?.(rel) + // A FIFO, a socket or a device (or a link to one) is never read: it could stall the bundle. + if (!stat.isFile()) { + if (!strict) return null + throw new Error(`${rel}: not a regular file`) + } + let text + try { + text = host.readFile(file).toString('utf8') + } catch (err) { + if (!strict) return null + throw err + } try { - return JSON.parse(host.readFile(file).toString('utf8').replace(/^\uFEFF/u, '')) + return JSON.parse(text.replace(/^\uFEFF/u, '')) } catch (err) { if (!strict) return null const at = /\(line \d+ column \d+\)/u.exec(err.message)?.[0] diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index ecd685cb..f4ec1533 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -13,7 +13,7 @@ import { createMetroResolver } from '../metro-resolver.js' import { State } from '@exodus/stasis-core/state' import { brotliOptions } from '@exodus/stasis-core/brotli' import { sha512integrity } from '@exodus/stasis-core/state-util' -import { detectRepo, findPackageMetadata, jsonError, normalizeEntries, packageType, readJson, readModuleManifest, readPackageJson } from '@exodus/stasis-core/bundle-util' +import { detectRepo, findPackageMetadata, normalizeEntries, packageType, readJson, readModuleManifest, readPackageJson, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util' import { RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, isDotEnvFile, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPodspec, isSkippedNativeWalkDir, moduleFileKey, parseResourcesOption, posixPathEscapes, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util' import { diskHost } from '@exodus/stasis-core/host' import { @@ -24,7 +24,7 @@ import { discoverSolidityConfig, expandSolidityEntries, } from '../loaders/solidity.js' -import { readGitmodules, readRegularFileOrNull } from '../loaders/solidity-ownership.js' +import { readGitmodules } from '../loaders/solidity-ownership.js' import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js' import { buildRustTree, collectRustFilesFromDisk } from '../loaders/rust.js' import { VENDOR_DIR as CARGO_VENDOR_DIR, createCargoContext } from '../loaders/cargo.js' @@ -236,19 +236,15 @@ function packageLookup(baseDir, options) { // with --package-json. function solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership }) { const realBase = realpathSync(baseDir) - // `{ text }`, or `{ why }` it can't be carried (null: nothing is there). + // `{ text }`, or `{ why }` it can't be carried (null: nothing is there). Read by the real path + // `ownership` resolved `rel` to, so what's carried is the file it vouched for. const carry = (rel) => { - const { reason } = ownership.of(rel) + const { reason, real, outside } = ownership.of(rel) if (reason) return { why: reason } - let buf - try { - assertRealPathWithinBase(realBase, baseDir, rel) - buf = readRegularFileOrNull(join(baseDir, rel), rel) - } catch (err) { - if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err - buf = null - } - if (buf === null) return { why: null } + if (real === null) return { why: null } + if (outside) throw new Error(`Refusing to follow symlink escaping bundle root: ${rel} -> ${resolve(realBase, real)}`) + const buf = readRegularFileOrNull(join(realBase, real), rel) + if (buf === null) return { why: null } // a directory if (!isUtf8(buf)) throw new Error(`Solidity manifest is not valid UTF-8: ${rel}`) return { text: buf.toString('utf8') } } diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index ccf71b94..c166256d 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -76,8 +76,7 @@ function canonicalize(p) { // How messages name a file of the project at `root` (absolute POSIX): from the root, by its lexical // or its canonical path, else as given. -export function shownFrom(root) { - const canonicalRoot = canonicalize(root) ?? root +export function shownFrom(root, canonicalRoot = canonicalize(root) ?? root) { return (abs) => stripPrefix(abs, root) || stripPrefix(abs, canonicalRoot) || abs } @@ -483,7 +482,7 @@ const isExtends = (v) => typeof v === 'string' // parseFoundryToml). Throws where forge refuses the config, and where `refused` (a dependency's // config: see findNestedFoundryRemappings) gives a reason not to read the file or its base: a // dependency's config may not read the project's files. Messages name files `show(file)`. -function readFoundryProfiles(file, profile, { refused = () => null, show = (f) => f } = {}) { +function readFoundryProfiles(file, profile, { refused = () => null, show }) { const name = show(file) const refusal = refused(file) if (refusal) throw new ConfigRefused(`${name}: refusing to read it: ${refusal}`) @@ -500,9 +499,10 @@ function readFoundryProfiles(file, profile, { refused = () => null, show = (f) = const baseFile = rustJoin(posix.dirname(file), extPath) const baseRefusal = refused(baseFile) if (baseRefusal) throw new ConfigRefused(`${name}: refusing to extend ${extPath}: ${baseRefusal}`) - const baseText = readUtf8OrNull(baseFile, show(baseFile)) + const baseName = show(baseFile) + const baseText = readUtf8OrNull(baseFile, baseName) if (baseText === null) throw new ConfigRefused(`${name}: the inherited config file does not exist: ${extPath}`) - const base = parseFoundryToml(baseText, show(baseFile)).profiles + const base = parseFoundryToml(baseText, baseName).profiles if (base.get(profile)?.has('extends')) { throw new ConfigRefused(`${name}: nested inheritance is not allowed (${extPath} has an 'extends' field in profile '${profile}')`) } @@ -540,7 +540,7 @@ export function foundryTomlRemappings(text, profile = 'default') { // The same for a foundry.toml file, with its `extends` base: what `--mapping=foundry.toml` takes. // `files` lists what was read; `profiled` whether the selected `profile` is one of the file's. // Messages name files `show(file)`. -export function readFoundryTomlRemappings(file, profile = 'default', { show = (f) => f } = {}) { +export function readFoundryTomlRemappings(file, profile, { show }) { const abs = toPosix(resolve(file)) const read = readFoundryProfiles(abs, profile, { show }) return { remappings: profileRemappings(read, profile, show(abs)), files: read.files, profiled: hasProfile(read.profiles, profile) } @@ -571,15 +571,17 @@ function detectLibs(root, host) { // The selected profile's settings for a Foundry project at `root` (absolute POSIX), defaults // filled in the way forge fills them. `remappings` are the profile's own, unnormalized; an invalid -// one throws (configRemappings). `refused`, `show`: see readFoundryProfiles. -function loadFoundryConfig(root, profile, { refused, show = (f) => f } = {}) { +// one throws (configRemappings). `refused`, `show` (from the root by default): see +// readFoundryProfiles. +function loadFoundryConfig(root, profile, { refused, show = shownFrom(root) } = {}) { const file = rustJoin(root, FOUNDRY_TOML) + const name = show(file) const { profiles, files } = readFoundryProfiles(file, profile, { refused, show }) const dict = selectProfile(profiles, profile) // A setting of the wrong type throws, as forge refuses the config: no quiet default. const setting = (key, ok, what) => { const value = dict.get(key) - if (value !== undefined && !ok(value)) throw new Error(`${show(file)}: \`${key}\` must be ${what}`) + if (value !== undefined && !ok(value)) throw new Error(`${name}: \`${key}\` must be ${what}`) return value } const isString = (v) => typeof v === 'string' @@ -590,7 +592,7 @@ function loadFoundryConfig(root, profile, { refused, show = (f) => f } = {}) { test: setting('test', isString, 'a string') ?? 'test', script: setting('script', isString, 'a string') ?? 'script', libs: setting('libs', (v) => Array.isArray(v) && v.every(isString), 'an array of strings') ?? detectLibs(root), - remappings: dict.has('remappings') ? configRemappings(dict.get('remappings'), show(file)) : [], + remappings: dict.has('remappings') ? configRemappings(dict.get('remappings'), name) : [], autoDetect: setting('auto_detect_remappings', (v) => typeof v === 'boolean', 'a boolean') !== false, } } @@ -653,16 +655,17 @@ function loadNestedConfig(canonical, profile, { refused, show }) { return null } const txt = rustJoin(canonical, REMAPPINGS_TXT) + const txtName = show(txt) const refusal = refused(txt) // (null when nothing is there) - if (refusal) console.warn(`[loader.solidity] Skipping a dependency's ${show(txt)}: ${refusal}`) - const text = refusal ? null : readUtf8OrNull(txt, show(txt)) + if (refusal) console.warn(`[loader.solidity] Skipping a dependency's ${txtName}: ${refusal}`) + const text = refusal ? null : readUtf8OrNull(txt, txtName) return { src: config.src, libs: config.libs, files: [...config.files, ...(text === null ? [] : [txt])], // `sanitized()` roots them, then `Remapping::from` makes the path absolute and slash-terminated. remappings: config.remappings.map((r) => fromRelative(relativePreservingBoundary(fromRelative({ ...r, path: { parent: null, path: r.path } }), canonical))), - fileRemappings: text === null ? [] : parseRemappingLines(text, { label: show(txt) }), + fileRemappings: text === null ? [] : parseRemappingLines(text, { label: txtName }), } } @@ -672,7 +675,10 @@ function loadNestedConfig(canonical, profile, { refused, show }) { // solidityOwnership), as forge would find them from its lexical path. function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) { const canonicalRoot = canonicalize(root) ?? root - const shown = shownFrom(root) + const shown = shownFrom(root, canonicalRoot) + // A dependency's file (a path from its canonical dir) under its lexical path: where the bundle + // sees it, and how messages name it. + const lexical = (entry, file) => rustJoin(entry.path, stripPrefix(file, entry.canonical) ?? file) // Why the config of the dependency at `entry` may not read `file` (a path from its canonical // dir), or null: judged by the path from the root, the lexical one or else the canonical one (an // absolute lib, `/proc/self/cwd/...`). It may read its own files and other dependencies'; a @@ -692,8 +698,7 @@ function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) if (o.real === null || o.dependency || pathStartsWith(rustJoin(canonicalRoot, o.real), entry.canonical)) return null return `it resolves to the project's own ${o.real}` } - // Messages name a dependency's files under its lexical path, from the root. - const show = (entry) => (file) => shown(rustJoin(entry.path, stripPrefix(file, entry.canonical) ?? file)) + const show = (entry) => (file) => shown(lexical(entry, file)) // A BTreeSet popped in (canonical, path) order. const pending = new Map() const addPending = (e) => pending.set(`${e.canonical}\0${e.path}`, e) @@ -712,8 +717,7 @@ function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) if (!configs.has(entry.canonical)) { const config = loadNestedConfig(entry.canonical, profile, { refused: refused(entry), show: show(entry) }) configs.set(entry.canonical, config) - // Record what was read under the dependency's lexical path (where the bundle sees it). - for (const f of config?.files ?? []) files.add(rustJoin(entry.path, stripPrefix(f, entry.canonical) ?? f)) + for (const f of config?.files ?? []) files.add(lexical(entry, f)) } const config = configs.get(entry.canonical) if (!config) continue @@ -855,7 +859,7 @@ export function foundryLibs(baseDir, { env = process.env } = {}) { const root = toPosix(resolve(baseDir)) const profile = foundryProfile(env) try { - const config = loadFoundryConfig(root, profile, { show: shownFrom(root) }) + const config = loadFoundryConfig(root, profile) return { libs: config.libs, profiled: profileApplies(config.profiles, profile), files: config.files } } catch (err) { if (!(err instanceof ConfigRefused)) throw err @@ -872,9 +876,8 @@ export function foundryLibs(baseDir, { env = process.env } = {}) { // FOUNDRY_REMAPPINGS / DAPP_REMAPPINGS. export function foundryProject(baseDir, { env = process.env } = {}) { const root = toPosix(resolve(baseDir)) - const show = shownFrom(root) const profile = foundryProfile(env) - const config = loadFoundryConfig(root, profile, { show }) + const config = loadFoundryConfig(root, profile) const profiled = profileApplies(config.profiles, profile) const ownership = projectOwnership(baseDir, config.libs, { soldeer: true }) const files = new Set(config.files) diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index ef285766..9661f276 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -4,9 +4,10 @@ // out of itself is never followed. import { isUtf8 } from 'node:buffer' -import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync } from 'node:fs' +import { lstatSync, readdirSync, readlinkSync, realpathSync } from 'node:fs' import { isAbsolute, join, parse, posix, relative, resolve, sep } from 'node:path' +import { NO_ENTRY, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util' import { hasNodeModulesSegment } from '@exodus/stasis-core/util' import { isDir } from '../resolve-typescript.js' @@ -17,7 +18,13 @@ const toSlashes = (p) => (sep === '\\' ? p.replaceAll('\\', '/') : p) // --- Reading -------------------------------------------------------------------------------- // `p`'s real path as the OS resolves it (realpath(3): the filesystem's own spelling), or null. -export const realpathOrNull = (p) => osRealpath(p).real +export function realpathOrNull(p) { + try { + return realpathSync.native(p) + } catch { + return null + } +} // A path relative to a dir (slashes) that stays inside it. const inRoot = (rel) => rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel) @@ -37,8 +44,6 @@ export function projectRelative(root, abs) { return abs.startsWith(prefix) ? toSlashes(abs.slice(prefix.length)) : rel } -const NO_ENTRY = new Set(['ENOENT', 'ENOTDIR']) - // realpath(3) of `p`: `{ real }`, or `{ real: null, missing }`, `missing` only when nothing is // there at all. The OS may fail to resolve what is there -- a real path past PATH_MAX, a loop, a // link whose end it can't name (`/proc/self/fd/0` on a pipe), a dir it may not search -- and a @@ -61,32 +66,10 @@ function lexists(p) { } } -// `file`'s bytes, or null when there's no file (a directory counts as none). It's opened without -// blocking and read only when it's a regular file: a FIFO, a socket, a device or a link to one -// (`/dev/stdin`) throws, naming it `label`, rather than stalling or reading the process's input. -export function readRegularFileOrNull(file, label = file) { - let fd - try { - fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)) - } catch (err) { - if (NO_ENTRY.has(err.code) || err.code === 'EISDIR') return null - if (err.code === 'ENXIO') throw new Error(`${label}: not a regular file`, { cause: err }) // a socket - throw err - } - try { - const stat = fstatSync(fd) - if (stat.isDirectory()) return null - if (!stat.isFile()) throw new Error(`${label}: not a regular file`) - return readFileSync(fd) - } finally { - closeSync(fd) - } -} - -// A config file's text, or null when there's no file (readRegularFileOrNull). One that isn't UTF-8 -// throws: forge and git refuse it, and a text read with U+FFFD in it isn't the one they read. A -// byte-order mark stays. Errors name it `label`. -export function readUtf8OrNull(file, label = file) { +// A config file's text, or null when there's no file (readRegularFileOrNull: a regular file only). +// One that isn't UTF-8 throws: forge and git refuse it, and a text read with U+FFFD in it isn't the +// one they read. A byte-order mark stays. Errors name it `label`. +export function readUtf8OrNull(file, label) { const buf = readRegularFileOrNull(file, label) if (buf === null) return null if (!isUtf8(buf)) throw new Error(`${label}: not valid UTF-8`) @@ -291,11 +274,11 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { // As given, not normalized: the OS resolves a `..` after a link from where the link leads. const path = rel === '' ? realBase : `${realBase}${sep}${rel}` let { abs, escape } = walk(realBase, rel.split('/'), 0) - // The OS's answer is the one a read gets: the walk must agree with it, or the path is refused, - // as it is when the OS can't resolve it at all, though a read may still get through. - // (Past its last link the walk's path is spelled as given; with none, it's `path` itself.) - const { real: os, missing } = osRealpath(path) if (escape === null) { + // The OS's answer is the one a read gets: the walk must agree with it, or the path is + // refused, as it is when the OS can't resolve it at all, though a read may still get through. + // (Past its last link the walk's path is spelled as given; with none, it's `path` itself.) + const { real: os, missing } = osRealpath(path) const walked = abs === null ? null : abs === path ? os : realpathOrNull(abs) if (walked !== os || (os === null && !missing)) escape = { link: rel, root: null, why: 'unresolved' } abs = os diff --git a/stasis/src/loaders/solidity.js b/stasis/src/loaders/solidity.js index c3b5ded3..43548dde 100644 --- a/stasis/src/loaders/solidity.js +++ b/stasis/src/loaders/solidity.js @@ -156,9 +156,10 @@ function readMapping(mappingFile, { env, forge, show = (f) => f }) { const { remappings, files, profiled } = readFoundryTomlRemappings(mappingFile, foundryProfile(env), { show }) return { remappings: remappings.map(toSolcRemapping), files, profiled } } - const text = readUtf8OrNull(mappingFile, show(mappingFile)) - if (text === null) throw new Error(`${show(mappingFile)}: no such file`) - const listed = parseRemappingLines(text, { label: show(mappingFile), emptyPath: !forge }) + const name = show(mappingFile) + const text = readUtf8OrNull(mappingFile, name) + if (text === null) throw new Error(`${name}: no such file`) + const listed = parseRemappingLines(text, { label: name, emptyPath: !forge }) return { remappings: listed.map(forge ? toSolcRemapping : toLoaderRemapping), files: [mappingFile] } } @@ -197,8 +198,8 @@ export async function discoverSolidityConfig(baseDir, { mappingFile, env = proce return { remappings, libs, ownership, files: [...new Set([...files, ...libsFiles].map((f) => projectRelative(baseDir, f)))], envUsed } } const txt = join(baseDir, REMAPPINGS_TXT) - const remappings = isFile(txt) ? readMapping(txt, { env, forge, show }).remappings : [] - return { remappings, libs, ownership, files: isFile(txt) ? [REMAPPINGS_TXT] : [], envUsed: [] } + if (!isFile(txt)) return { remappings: [], libs, ownership, files: [], envUsed: [] } + return { remappings: readMapping(txt, { env, forge, show }).remappings, libs, ownership, files: [REMAPPINGS_TXT], envUsed: [] } } // Solc's remapping choice for the source unit `name` imported from `fromFile`: among the diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index e13f098d..0f034b49 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -957,6 +957,13 @@ test('buildSolidityBundle never reads the process\'s stdin as a config, a depend await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: 'remappings.txt: not a regular file' }) })) +test('buildSolidityBundle never stalls on a package.json that isn\'t a regular file', withTmp(async (t, tmp) => { + writeProject(tmp, { 'contracts/A.sol': 'import "pkg/P.sol";\n', 'node_modules/pkg/P.sol': 'contract P {}\n' }) + // A FIFO: read blocking, it would wait for a writer forever. + spawnSync('mkfifo', [join(tmp, 'node_modules/pkg/package.json')]) + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['contracts'], env: {} }), { message: 'node_modules/pkg/package.json: not a regular file' }) +})) + test('buildSolidityBundle resolves a dependency\'s `extends` through its own symlink as forge does', withTmp(async (t, tmp) => { writeProject(tmp, { 'foundry.toml': '[profile.default]\n', From 9823ab5b54d43f6ed35d3313689570ddb2deaaa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:06:30 +0000 Subject: [PATCH 12/20] refactor(bundle): read .gitmodules with @preventive/lockfile's parseGitmodules Our hand-written reader agreed with git where git reads a .gitmodules one way, and picked an answer where it doesn't: a key twice (git's submodule commands read the first, git config the last; we took the last), a second section, `[submodule.x]`, merged or taken silently. It also took a path outside the repository (`../x`) or out of normal form (`./lib/x`, which the bucket classifier then didn't match). The library reads it as git's config.c does and refuses each of those, and a url that isn't a host's (one relative to the superproject's remote, or none); the error names .gitmodules. Its paths are in normal form, so the callers' trailing-slash and empty-path guards go. The test running a copy of stasis without oxc-parser vendors @exodus/bytes too, the library's dependency, which its foundry.js entry needs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 7 ++- stasis/src/cmd/bundle.js | 6 +- stasis/src/loaders/solidity-ownership.js | 72 +++++------------------- tests/bundle-cmd.test.js | 25 ++++++-- tests/solidity-loader.test.js | 48 ++++++++++------ 5 files changed, 74 insertions(+), 84 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index 6dc63799..c162d4f6 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -412,8 +412,11 @@ decided by where it really is, spelled as the filesystem spells it (on a case-insensitive one, `LIB/evil` is `lib/evil`). The dependencies are the entries of forge's `libs` (an absolute one by its real path; a symlinked `lib/forge-std` is the dependency where it points), Soldeer's `dependencies/`, -git submodules (`.gitmodules` read as git reads it: quoted and escaped paths, -and a key on its section header's line) and every `node_modules` package; a file +git submodules (`.gitmodules` read with `@preventive/lockfile`'s reader, as git +reads it: one git reads two ways — a key twice, a second section, +`[submodule.x]` — a path outside the repository or not in normal form, or a url +that isn't a host's, relative to the superproject's or missing, is an error +naming it) and every `node_modules` package; a file is a dependency's when its real path lies in one, however the path got there (`src/vendor -> ../lib/dep/src` holds the dependency's code). An import from a dependency must land on a dependency's file too: it may import its own files and diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index f4ec1533..ad9d96ed 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -87,13 +87,13 @@ function githubSlug(url) { return m ? `${m[1]}/${m[2]}` : null } -// `.gitmodules` (as git reads it) -> Map, github.com submodules +// `.gitmodules` (readGitmodules) -> Map, github.com submodules // only. function parseGithubSubmodules(baseDir) { const byPath = new Map() for (const { path, url, branch } of readGitmodules(baseDir)) { - const name = path && url ? githubSlug(url) : null - if (name) byPath.set(path.replace(/\/+$/u, ''), { name, branch }) + const name = githubSlug(url) + if (name) byPath.set(path, { name, branch }) } return byPath } diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index 9661f276..06cafbcd 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -7,6 +7,7 @@ import { isUtf8 } from 'node:buffer' import { lstatSync, readdirSync, readlinkSync, realpathSync } from 'node:fs' import { isAbsolute, join, parse, posix, relative, resolve, sep } from 'node:path' +import { LockfileError, parseGitmodules } from '@preventive/lockfile/foundry.js' import { NO_ENTRY, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util' import { hasNodeModulesSegment } from '@exodus/stasis-core/util' import { isDir } from '../resolve-typescript.js' @@ -78,65 +79,22 @@ export function readUtf8OrNull(file, label) { // --- .gitmodules ------------------------------------------------------------------------------ -const GIT_ESCAPES = { n: '\n', t: '\t', b: '\b' } - -// A git-config value as git reads it: `"` quotes (dropped), `\` escapes, a `#`/`;` comment outside -// quotes, and whitespace trimmed at both ends outside quotes. -function gitConfigValue(raw) { - let out = '' - let held = '' // unquoted whitespace, kept only if more value follows - let quoted = false - for (let i = 0; i < raw.length; i++) { - const ch = raw[i] - if (ch === '\\') { - const next = raw[++i] ?? '' - out += held + (GIT_ESCAPES[next] ?? next) - held = '' - } else if (ch === '"') { - quoted = !quoted - } else if (!quoted && (ch === '#' || ch === ';')) { - break - } else if (!quoted && (ch === ' ' || ch === '\t')) { - if (out !== '') held += ch - } else { - out += held + ch - held = '' - } - } - return out -} - -// `.gitmodules` text -> its submodules, `{ name, path, url, branch }` (those set), as git reads the -// file: keys case-insensitive, values unquoted and unescaped, a line ending in `\` continued, a -// key after a section header on its line (`[submodule "x"] path = lib/x`), and a submodule's -// sections merged by name. -export function parseGitmodules(text) { - const byName = new Map() - let cur = null - const lines = text.split(/\r?\n/u) - for (let i = 0; i < lines.length; i++) { - let line = lines[i] - while (/(?:^|[^\\])(?:\\\\)*\\$/u.test(line) && i + 1 < lines.length) line = line.slice(0, -1) + lines[++i] - const header = /^\s*\[\s*([\w.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\]/u.exec(line) - if (header) { - const section = header[1].toLowerCase() - let name = null - if (section === 'submodule' && header[2] !== undefined) name = header[2].replaceAll(/\\(.)/gu, '$1') - else if (section.startsWith('submodule.')) name = header[1].slice('submodule.'.length) - cur = name === null ? null : (byName.get(name) ?? byName.set(name, { name }).get(name)) - line = line.slice(header[0].length) - } - const pair = cur && /^\s*([a-z][\w-]*)\s*(?:=(.*))?$/iu.exec(line) - if (!pair) continue - const key = pair[1].toLowerCase() - if (key === 'path' || key === 'url' || key === 'branch') cur[key] = gitConfigValue(pair[2] ?? '') +// The submodules of the project at `baseDir`, `{ path, url, branch }` (`branch` when set), from its +// `.gitmodules` as @preventive/lockfile reads it: as git does, refusing what git reads two ways (a +// key twice, a second section, `[submodule.x]`), a path outside the repository or not in normal +// form, and a url that isn't a host's (one relative to the superproject's remote, or none). One it +// refuses throws, naming the file. +export function readGitmodules(baseDir) { + const text = readUtf8OrNull(join(baseDir, '.gitmodules'), '.gitmodules') + if (text === null) return [] + try { + return Object.values(parseGitmodules(text)) + } catch (err) { + if (!(err instanceof LockfileError)) throw err + throw new Error(`.gitmodules: ${err.message}`, { cause: err }) } - return [...byName.values()] } -// The submodules of the project at `baseDir` (its `.gitmodules`, see parseGitmodules). -export const readGitmodules = (baseDir) => parseGitmodules(readUtf8OrNull(join(baseDir, '.gitmodules'), '.gitmodules') ?? '') - // --- Ownership -------------------------------------------------------------------------------- const readdirOrEmpty = (dir) => { @@ -306,7 +264,7 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { // The ownership of the project at `baseDir` given its lib dirs (`soldeer`: forge's `dependencies/` // holds dependencies too), with its git submodules. export const projectOwnership = (baseDir, libs, { soldeer = false } = {}) => - solidityOwnership(baseDir, { dirs: [...libs, ...(soldeer ? ['dependencies'] : [])], packages: readGitmodules(baseDir).map((s) => s.path).filter(Boolean) }) + solidityOwnership(baseDir, { dirs: [...libs, ...(soldeer ? ['dependencies'] : [])], packages: readGitmodules(baseDir).map((s) => s.path) }) // Why a path is refused (see solidityOwnership). function escapeReason(path, { link, root, why }) { diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 0f034b49..4f3c3f4e 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -957,6 +957,18 @@ test('buildSolidityBundle never reads the process\'s stdin as a config, a depend await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: 'remappings.txt: not a regular file' }) })) +test('buildSolidityBundle fails on a .gitmodules git reads two ways, or with a url that isn\'t a host\'s, naming it', withTmp(async (t, tmp) => { + writeProject(tmp, { 'foundry.toml': '[profile.default]\n', 'src/A.sol': 'contract A {}\n' }) + for (const [text, message] of [ + ['[submodule "x"]\n\tpath = lib/x\n\tpath = lib/y\n\turl = https://github.com/o/x\n', 'x.path: twice, of which git\'s submodule commands read the first and git config the last, at line 3'], + ['[submodule "x"]\n\tpath = lib/x\n\turl = ../x.git\n', 'x.url: "../x.git" is relative to the superproject\'s remote, which only a clone of it knows'], + ]) { + writeFileSync(join(tmp, '.gitmodules'), text) + // eslint-disable-next-line no-await-in-loop -- each run rewrites .gitmodules + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: `.gitmodules: ${message}` }) + } +})) + test('buildSolidityBundle never stalls on a package.json that isn\'t a regular file', withTmp(async (t, tmp) => { writeProject(tmp, { 'contracts/A.sol': 'import "pkg/P.sol";\n', 'node_modules/pkg/P.sol': 'contract P {}\n' }) // A FIFO: read blocking, it would wait for a writer forever. @@ -3321,9 +3333,10 @@ test('CLI: bundle (JS) fails loudly when the oxc-parser dependency is missing', // exited 0 with no warning at all. The setup error must propagate with its // install hint instead. Exercised against a copy of stasis whose node_modules // carries only the zero-dep @exodus/stasis-core (so the moved-module shims - // resolve) and @preventive/lockfile (whose TOML parser the loaders import), so - // the bundle command loads, but no oxc-parser, so the lazy lookup (createRequire - // from src/scan.js) genuinely misses. + // resolve) and @preventive/lockfile (whose TOML and .gitmodules readers the + // loaders import) with its one dependency, @exodus/bytes, so the bundle command + // loads, but no oxc-parser, so the lazy lookup (createRequire from src/scan.js) + // genuinely misses. const stasisCopy = join(tmp, 'stasis') mkdirSync(stasisCopy) for (const entry of ['bin', 'src']) cpSync(join(here, '..', 'stasis', entry), join(stasisCopy, entry), { recursive: true }) @@ -3334,8 +3347,10 @@ test('CLI: bundle (JS) fails loudly when the oxc-parser dependency is missing', mkdirSync(coreDest, { recursive: true }) for (const entry of ['bin', 'src']) cpSync(join(here, '..', 'stasis-core', entry), join(coreDest, entry), { recursive: true }) cpSync(join(here, '..', 'stasis-core', 'package.json'), join(coreDest, 'package.json')) - // pnpm links it from its store: the real directory is what gets copied. - cpSync(realpathSync(join(here, '..', 'stasis', 'node_modules', '@preventive', 'lockfile')), join(stasisCopy, 'node_modules', '@preventive', 'lockfile'), { recursive: true }) + // pnpm links them from its store: the real directories are what get copied. + const lockfile = realpathSync(join(here, '..', 'stasis', 'node_modules', '@preventive', 'lockfile')) + cpSync(lockfile, join(stasisCopy, 'node_modules', '@preventive', 'lockfile'), { recursive: true }) + cpSync(realpathSync(join(lockfile, '..', '..', '@exodus', 'bytes')), join(stasisCopy, 'node_modules', '@exodus', 'bytes'), { recursive: true }) const proj = join(tmp, 'proj') mkdirSync(proj) jsProject(proj, { 'file.mjs': 'export * from "@noble/ciphers/_arx.js"\n' }) diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index 3ec3be6d..9dbdd005 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -19,7 +19,7 @@ import { resolveSolImport, } from '../stasis/src/loaders/solidity.js' import { findRemappingsWithContext, foundryProject, foundryTomlRemappings } from '../stasis/src/loaders/foundry.js' -import { parseGitmodules, solidityOwnership } from '../stasis/src/loaders/solidity-ownership.js' +import { readGitmodules, solidityOwnership } from '../stasis/src/loaders/solidity-ownership.js' const fixtures = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'solidity-bundle') @@ -733,32 +733,46 @@ test('solidityOwnership judges the path as the filesystem spells it (a case-inse } }) -test('parseGitmodules reads .gitmodules as git does: quotes, escapes, comments, key case, continuations', (t) => { - const text = [ +test('readGitmodules reads .gitmodules as git does: quotes, escapes, comments, key case, continuations', withProject({ + '.gitmodules': [ '[submodule "a"]', '\tpath = "vendor/a" ; a comment', '\tURL = https://github.com/o/a', + '\tbranch = "v1"', '[submodule "b"]', '\tpath = lib/b\\', 'x', - '\turl = "https://github.com/o/b" # comment', - '[core]', - '\tpath = not/a/submodule', - '[submodule "a"]', - '\tbranch = "v1 \\"x\\""', - '[submodule.c]', - '\tpath = lib/c ', + '\turl = "git@github.com:o/b.git" # comment', // A key may follow its section header on the line. '[submodule "d"] path = vendor/d', + '\turl = https://github.com/o/d', '', - ].join('\n') - t.assert.deepEqual(parseGitmodules(text), [ - { name: 'a', path: 'vendor/a', url: 'https://github.com/o/a', branch: 'v1 "x"' }, - { name: 'b', path: 'lib/bx', url: 'https://github.com/o/b' }, - { name: 'c', path: 'lib/c' }, - { name: 'd', path: 'vendor/d' }, + ].join('\n'), +}, (t, dir) => { + t.assert.deepEqual(readGitmodules(dir), [ + { path: 'vendor/a', url: 'https://github.com/o/a', branch: 'v1' }, + { path: 'lib/bx', url: 'git@github.com:o/b.git', branch: undefined }, + { path: 'vendor/d', url: 'https://github.com/o/d', branch: undefined }, ]) -}) + t.assert.deepEqual(readGitmodules(join(dir, 'none')), []) +})) + +test('readGitmodules refuses what git reads two ways, a path out of normal form, and a url that is not a host\'s', withProject({}, (t, dir) => { + const url = '\turl = https://github.com/o/x\n' + for (const [text, message] of [ + // git's submodule commands read the first `path`, git config the last. + [`[submodule "x"]\n\tpath = lib/x\n\tpath = lib/y\n${url}`, 'x.path: twice, of which git\'s submodule commands read the first and git config the last, at line 3'], + [`[submodule "x"]\n\tpath = lib/x\n[submodule "x"]\n${url}`, 'x: a second section, at line 3, where git writes one'], + [`[submodule.x]\n\tpath = lib/x\n${url}`, 'a section of the form [submodule.name], whose name git lowercases, where .gitmodules has [submodule "name"] alone, at line 1'], + [`[submodule "x"]\n\tpath = ./lib/x\n${url}`, 'x.path: "./lib/x" is not a relative path in normal form'], + [`[submodule "x"]\n\tpath = ../x\n${url}`, 'x.path: "../x" is outside the repository, where git writes no submodule'], + ['[submodule "x"]\n\tpath = lib/x\n\turl = ../x.git\n', 'x.url: "../x.git" is relative to the superproject\'s remote, which only a clone of it knows'], + ['[submodule "x"]\n\tpath = lib/x\n', 'x.url: expected a url, without which git cannot clone the submodule'], + ]) { + writeFileSync(join(dir, '.gitmodules'), text) + t.assert.throws(() => readGitmodules(dir), { message: `.gitmodules: ${message}` }) + } +})) test('a remappings.txt taken as written (solc) may map a prefix to nothing', (t) => { const remappings = parseRemappings('x/=\nctx:y/=\n') From da445f79af1f0601da1c818287f3e9bcb775afee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:56:09 +0000 Subject: [PATCH 13/20] fix(bundle): take a submodule's url as written (parseGitmodules checkUrls: false) @preventive/lockfile 1.0.0-alpha.4 (#193) gives parseGitmodules a checkUrls option. stasis reads a url only to name a GitHub submodule's bucket, so a url relative to the superproject's remote (`../x.git`), a path, or none no longer fails every Solidity bundle of the project: the submodule is still a dependency, just without a GitHub name. What git reads two ways, a path out of the repository or normal form, and a url git ignores (starting with `-`) or that isn't one are still refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 5 +++-- stasis/src/cmd/bundle.js | 2 +- stasis/src/loaders/solidity-ownership.js | 13 +++++++------ tests/bundle-cmd.test.js | 22 ++++++++++++++-------- tests/solidity-loader.test.js | 14 +++++++++++--- 5 files changed, 36 insertions(+), 20 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index c162d4f6..7e813bae 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -415,8 +415,9 @@ entries of forge's `libs` (an absolute one by its real path; a symlinked git submodules (`.gitmodules` read with `@preventive/lockfile`'s reader, as git reads it: one git reads two ways — a key twice, a second section, `[submodule.x]` — a path outside the repository or not in normal form, or a url -that isn't a host's, relative to the superproject's or missing, is an error -naming it) and every `node_modules` package; a file +git ignores (starting with `-`) is an error naming it; a url relative to the +superproject's remote, or none, is taken as written, the submodule then having +no GitHub name to bucket it by) and every `node_modules` package; a file is a dependency's when its real path lies in one, however the path got there (`src/vendor -> ../lib/dep/src` holds the dependency's code). An import from a dependency must land on a dependency's file too: it may import its own files and diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index ad9d96ed..f3a9d488 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -92,7 +92,7 @@ function githubSlug(url) { function parseGithubSubmodules(baseDir) { const byPath = new Map() for (const { path, url, branch } of readGitmodules(baseDir)) { - const name = githubSlug(url) + const name = url === undefined ? null : githubSlug(url) if (name) byPath.set(path, { name, branch }) } return byPath diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index 06cafbcd..728d05f0 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -79,16 +79,17 @@ export function readUtf8OrNull(file, label) { // --- .gitmodules ------------------------------------------------------------------------------ -// The submodules of the project at `baseDir`, `{ path, url, branch }` (`branch` when set), from its -// `.gitmodules` as @preventive/lockfile reads it: as git does, refusing what git reads two ways (a -// key twice, a second section, `[submodule.x]`), a path outside the repository or not in normal -// form, and a url that isn't a host's (one relative to the superproject's remote, or none). One it -// refuses throws, naming the file. +// The submodules of the project at `baseDir`, `{ path, url, branch }` (`url` and `branch` when set), +// from its `.gitmodules` as @preventive/lockfile reads it: as git does, refusing what git reads two +// ways (a key twice, a second section, `[submodule.x]`), a path outside the repository or not in +// normal form, and a url git ignores (starting with `-`) or that isn't one (a space in it). A url is +// taken as written otherwise, relative to the superproject's remote or a path: it only names a +// GitHub submodule's bucket. One it refuses throws, naming the file. export function readGitmodules(baseDir) { const text = readUtf8OrNull(join(baseDir, '.gitmodules'), '.gitmodules') if (text === null) return [] try { - return Object.values(parseGitmodules(text)) + return Object.values(parseGitmodules(text, { checkUrls: false })) } catch (err) { if (!(err instanceof LockfileError)) throw err throw new Error(`.gitmodules: ${err.message}`, { cause: err }) diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 4f3c3f4e..7d94c282 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -957,15 +957,21 @@ test('buildSolidityBundle never reads the process\'s stdin as a config, a depend await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: 'remappings.txt: not a regular file' }) })) -test('buildSolidityBundle fails on a .gitmodules git reads two ways, or with a url that isn\'t a host\'s, naming it', withTmp(async (t, tmp) => { - writeProject(tmp, { 'foundry.toml': '[profile.default]\n', 'src/A.sol': 'contract A {}\n' }) - for (const [text, message] of [ - ['[submodule "x"]\n\tpath = lib/x\n\tpath = lib/y\n\turl = https://github.com/o/x\n', 'x.path: twice, of which git\'s submodule commands read the first and git config the last, at line 3'], - ['[submodule "x"]\n\tpath = lib/x\n\turl = ../x.git\n', 'x.url: "../x.git" is relative to the superproject\'s remote, which only a clone of it knows'], - ]) { - writeFileSync(join(tmp, '.gitmodules'), text) +test('buildSolidityBundle fails on a .gitmodules git reads two ways, naming it, and takes a submodule\'s url as written', withTmp(async (t, tmp) => { + writeProject(tmp, { 'foundry.toml': '[profile.default]\n', 'src/A.sol': 'import "x/X.sol";\n', 'lib/x/src/X.sol': 'contract X {}\n' }) + writeFileSync(join(tmp, '.gitmodules'), '[submodule "x"]\n\tpath = lib/x\n\tpath = lib/y\n\turl = https://github.com/o/x\n') + await t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + { message: ".gitmodules: x.path: twice, of which git's submodule commands read the first and git config the last, at line 3" }, + ) + // A url relative to the superproject's remote, or none, still makes lib/x a submodule: a + // dependency, but not one with a GitHub name to bucket it by. + for (const url of ['\turl = ../x.git\n', '']) { + writeFileSync(join(tmp, '.gitmodules'), `[submodule "x"]\n\tpath = lib/x\n${url}`) // eslint-disable-next-line no-await-in-loop -- each run rewrites .gitmodules - await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: `.gitmodules: ${message}` }) + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/x/src/X.sol', 'src/A.sol']) + t.assert.equal(bundle.modules.get('lib/x'), undefined) } })) diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index 9dbdd005..3dc3d827 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -746,6 +746,12 @@ test('readGitmodules reads .gitmodules as git does: quotes, escapes, comments, k // A key may follow its section header on the line. '[submodule "d"] path = vendor/d', '\turl = https://github.com/o/d', + // A url relative to the superproject's remote, and none: git reads both, and so does stasis. + '[submodule "e"]', + '\tpath = lib/e', + '\turl = ../e.git', + '[submodule "f"]', + '\tpath = lib/f', '', ].join('\n'), }, (t, dir) => { @@ -753,11 +759,13 @@ test('readGitmodules reads .gitmodules as git does: quotes, escapes, comments, k { path: 'vendor/a', url: 'https://github.com/o/a', branch: 'v1' }, { path: 'lib/bx', url: 'git@github.com:o/b.git', branch: undefined }, { path: 'vendor/d', url: 'https://github.com/o/d', branch: undefined }, + { path: 'lib/e', url: '../e.git', branch: undefined }, + { path: 'lib/f', url: undefined, branch: undefined }, ]) t.assert.deepEqual(readGitmodules(join(dir, 'none')), []) })) -test('readGitmodules refuses what git reads two ways, a path out of normal form, and a url that is not a host\'s', withProject({}, (t, dir) => { +test('readGitmodules refuses what git reads two ways, a path out of normal form, and a url git ignores', withProject({}, (t, dir) => { const url = '\turl = https://github.com/o/x\n' for (const [text, message] of [ // git's submodule commands read the first `path`, git config the last. @@ -766,8 +774,8 @@ test('readGitmodules refuses what git reads two ways, a path out of normal form, [`[submodule.x]\n\tpath = lib/x\n${url}`, 'a section of the form [submodule.name], whose name git lowercases, where .gitmodules has [submodule "name"] alone, at line 1'], [`[submodule "x"]\n\tpath = ./lib/x\n${url}`, 'x.path: "./lib/x" is not a relative path in normal form'], [`[submodule "x"]\n\tpath = ../x\n${url}`, 'x.path: "../x" is outside the repository, where git writes no submodule'], - ['[submodule "x"]\n\tpath = lib/x\n\turl = ../x.git\n', 'x.url: "../x.git" is relative to the superproject\'s remote, which only a clone of it knows'], - ['[submodule "x"]\n\tpath = lib/x\n', 'x.url: expected a url, without which git cannot clone the submodule'], + ['[submodule "x"]\n\tpath = lib/x\n\turl = -oProxy=x\n', 'x.url: "-oProxy=x" starts with "-", which git ignores the url for'], + ['[submodule "x"]\n\tpath = lib/x\n\turl = "https://github.com/o/x y"\n', 'x.url: "https://github.com/o/x y" is not a repository URL'], ]) { writeFileSync(join(dir, '.gitmodules'), text) t.assert.throws(() => readGitmodules(dir), { message: `.gitmodules: ${message}` }) From d0e868416354852899afd443aded8726ffd3382d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:08:57 +0000 Subject: [PATCH 14/20] refactor(bundle): read .gitmodules once; one Solidity-entry rule; one package lookup per JS bundle - projectOwnership keeps the submodules it read (`ownership.submodules`), and the Solidity classifier takes them from there instead of reading and parsing .gitmodules a second time: ownership and bucket naming see one list. - isSolidityEntry is the one "a .sol file or a directory entry" rule, for the CLI, classifyEntries and buildSolidityBundle. - The JS bundler's --package-json pass and assembleCodeBundle share one packageLookup, instead of each walking the same directories. - readPackageJson decides strict-or-null once; parseJson holds the content-free JSON error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- stasis-core/src/bundle-util.js | 22 ++++++------- stasis/bin/stasis.js | 4 +-- stasis/src/cmd/bundle.js | 42 +++++++++++++----------- stasis/src/loaders/solidity-ownership.js | 17 +++++----- 4 files changed, 45 insertions(+), 40 deletions(-) diff --git a/stasis-core/src/bundle-util.js b/stasis-core/src/bundle-util.js index d06f9b16..f75a8aaf 100644 --- a/stasis-core/src/bundle-util.js +++ b/stasis-core/src/bundle-util.js @@ -78,22 +78,22 @@ export function readPackageJson(baseDir, rel, { strict = false, check, host = di const stat = host.stat(file) if (stat === null || stat.isDirectory()) return null check?.(rel) - // A FIFO, a socket or a device (or a link to one) is never read: it could stall the bundle. - if (!stat.isFile()) { - if (!strict) return null - throw new Error(`${rel}: not a regular file`) - } - let text try { - text = host.readFile(file).toString('utf8') + // A FIFO, a socket or a device (or a link to one) is never read: it could stall the bundle. + if (!stat.isFile()) throw new Error(`${rel}: not a regular file`) + return parseJson(host.readFile(file).toString('utf8').replace(/^\uFEFF/u, ''), rel) } catch (err) { - if (!strict) return null - throw err + if (strict) throw err + return null } +} + +// JSON.parse, throwing where the text breaks (the parser's line and column) but never the parser's +// message, which quotes the text. +function parseJson(text, rel) { try { - return JSON.parse(text.replace(/^\uFEFF/u, '')) + return JSON.parse(text) } catch (err) { - if (!strict) return null const at = /\(line \d+ column \d+\)/u.exec(err.message)?.[0] // eslint-disable-next-line preserve-caught-error -- the parser's error quotes the file throw new Error(`${rel} is not valid JSON${at ? ` ${at}` : ''}`) diff --git a/stasis/bin/stasis.js b/stasis/bin/stasis.js index b74fdb3b..a8444c93 100755 --- a/stasis/bin/stasis.js +++ b/stasis/bin/stasis.js @@ -250,10 +250,10 @@ if (command === '-v' || command === '--version') { if (argv.length === 0) usage('Nothing to bundle: no entry file given') // A directory entry stands for the .sol files under it (Solidity only); an extensionless path // that doesn't exist is a missing one (skipped with a warning). - const { directoryEntryError, isDirEntry } = await import('../src/cmd/bundle.js') + const { directoryEntryError, isSolidityEntry } = await import('../src/cmd/bundle.js') const dirError = directoryEntryError(argv) if (dirError !== null) usage(`Error: ${dirError}`) - const allSol = argv.every((f) => f.endsWith('.sol') || isDirEntry(resolve(f))) + const allSol = argv.every((f) => isSolidityEntry(f)) const allPhp = argv.every((f) => f.endsWith('.php')) const allJs = argv.every((f) => /\.(?:js|cjs|mjs|ts|cts|mts)$/u.test(f)) const allBash = argv.every((f) => /\.(?:sh|bash)$/u.test(f)) diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index f3a9d488..813beb83 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -24,7 +24,6 @@ import { discoverSolidityConfig, expandSolidityEntries, } from '../loaders/solidity.js' -import { readGitmodules } from '../loaders/solidity-ownership.js' import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js' import { buildRustTree, collectRustFilesFromDisk } from '../loaders/rust.js' import { VENDOR_DIR as CARGO_VENDOR_DIR, createCargoContext } from '../loaders/cargo.js' @@ -87,22 +86,22 @@ function githubSlug(url) { return m ? `${m[1]}/${m[2]}` : null } -// `.gitmodules` (readGitmodules) -> Map, github.com submodules -// only. -function parseGithubSubmodules(baseDir) { +// The github.com ones of `submodules` (readGitmodules), as Map. +function githubSubmodules(submodules) { const byPath = new Map() - for (const { path, url, branch } of readGitmodules(baseDir)) { - const name = url === undefined ? null : githubSlug(url) + for (const { path, url, branch } of submodules) { + const name = url && githubSlug(url) if (name) byPath.set(path, { name, branch }) } return byPath } // Classify a Solidity file's dep bucket: Soldeer (`dependencies/-/`) or a -// github submodule (`lib/`, via `.gitmodules`), else null to defer to the node_modules/ -// workspace logic. `check` vets a package.json path before it is read (ownership.assert). -function makeSolidityClassifier(baseDir, check) { - const submodules = parseGithubSubmodules(baseDir) +// github submodule (`lib/`, via the `.gitmodules` `ownership` read), else null to defer to the +// node_modules/workspace logic. `ownership.assert` vets a package.json path before it is read. +function makeSolidityClassifier(baseDir, ownership) { + const submodules = githubSubmodules(ownership.submodules) + const check = ownership.assert const versions = new Map() // a submodule's package.json version, read once return (path) => { if (path.startsWith('dependencies/')) { @@ -276,6 +275,9 @@ function solidityManifests(baseDir, sources, configFiles, { classifyDep, package // doesn't exist (a project without `script/` still bundles with `src test script`). export const isDirEntry = (abs, host = diskHost) => isDir(abs, host) || (extname(abs) === '' && host.stat(abs) === null) +// Whether `entry` (resolved against `cwd`) is a Solidity bundle's: a .sol file or a directory entry. +export const isSolidityEntry = (entry, cwd = process.cwd(), host = diskHost) => entry.endsWith('.sol') || isDirEntry(resolve(cwd, entry), host) + // What's wrong with `entries`' directory entries (resolved against `cwd`), or null: a directory // entry stands for the .sol files under it, so it goes with Solidity entries only; and entries // that are all missing extensionless paths are a mistyped file, not a project without those dirs. @@ -306,7 +308,7 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin const baseDir = resolve(cwd) const normalized = normalizeEntries(entries, cwd) for (const e of normalized) { - if (!e.endsWith('.sol') && !isDirEntry(join(baseDir, e), host)) throw new Error(`buildSolidityBundle: not a .sol file or directory: ${e}`) + if (!isSolidityEntry(e, baseDir)) throw new Error(`buildSolidityBundle: not a .sol file or directory: ${e}`) } const expanded = expandSolidityEntries(baseDir, normalized, host) @@ -328,7 +330,7 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin throw new Error(`Solidity bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`) } - const classifyDep = makeSolidityClassifier(baseDir, ownership.assert) + const classifyDep = makeSolidityClassifier(baseDir, ownership) const packageOf = packageLookup(baseDir, { strict: true, check: ownership.assert }) const bundled = new Map(sources) const formats = new Map() @@ -939,13 +941,14 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields, } // --package-json: fold each bundled module's package.json into `sources` (and its integrity into - // the companion lockfile) even when the scan never reached it. Buckets are the same ones - // assembleCodeBundle derives (findPackageMetadata -> pkgDir, else the '.' workspace bucket); - // packageLookup memoizes it per directory so a package's many files don't each re-walk to the - // same manifest. readModuleManifest applies the read/validate rules shared with the State path - // (containment, UTF-8-aborts); no identity check here -- these buckets are all fresh from disk. + // the companion lockfile) even when the scan never reached it. Buckets are the ones + // assembleCodeBundle derives, from the same `packageOf` (findPackageMetadata -> pkgDir, else the + // '.' workspace bucket; packageLookup memoizes it per directory so a package's many files don't + // each re-walk to the same manifest). readModuleManifest applies the read/validate rules shared + // with the State path (containment, UTF-8-aborts); no identity check here -- these buckets are + // all fresh from disk. + const packageOf = packageLookup(baseDir, { host }) if (packageJSON) { - const packageOf = packageLookup(baseDir, { host }) const pkgDirs = new Set() for (const abs of reached) { const rel = toRel(abs) @@ -990,6 +993,7 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields, workspaceVersion: rootPkg.version ?? '0.0.0', conditionKey: '*', host, + packageOf, }) // The companion lockfile mirrors the bundle, swapping file content for its integrity. @@ -1021,7 +1025,7 @@ function classifyEntries(name, { cwd = process.cwd(), entries, mappingFile, mani const dirError = directoryEntryError(entries, cwd, host) if (dirError !== null) throw new Error(`${name}: ${dirError}`) let kind - if (entries.every((e) => e.endsWith('.sol') || isDirEntry(resolve(cwd, e), host))) kind = 'sol' + if (entries.every((e) => isSolidityEntry(e, cwd, host))) kind = 'sol' else if (entries.every((e) => e.endsWith('.php'))) kind = 'php' else if (entries.every((e) => JS_EXTS.has(extname(e)))) kind = 'js' else if (entries.every((e) => BASH_EXTS.has(extname(e)))) kind = 'bash' diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index 728d05f0..23dd22e0 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -80,11 +80,9 @@ export function readUtf8OrNull(file, label) { // --- .gitmodules ------------------------------------------------------------------------------ // The submodules of the project at `baseDir`, `{ path, url, branch }` (`url` and `branch` when set), -// from its `.gitmodules` as @preventive/lockfile reads it: as git does, refusing what git reads two -// ways (a key twice, a second section, `[submodule.x]`), a path outside the repository or not in -// normal form, and a url git ignores (starting with `-`) or that isn't one (a space in it). A url is -// taken as written otherwise, relative to the superproject's remote or a path: it only names a -// GitHub submodule's bucket. One it refuses throws, naming the file. +// from its `.gitmodules` as @preventive/lockfile reads it (as git does, refusing what git reads two +// ways). A url is taken as written (`checkUrls: false`): relative to the superproject's remote, a +// path or none, as it only names a GitHub submodule's bucket. A refusal names the file. export function readGitmodules(baseDir) { const text = readUtf8OrNull(join(baseDir, '.gitmodules'), '.gitmodules') if (text === null) return [] @@ -263,9 +261,12 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { } // The ownership of the project at `baseDir` given its lib dirs (`soldeer`: forge's `dependencies/` -// holds dependencies too), with its git submodules. -export const projectOwnership = (baseDir, libs, { soldeer = false } = {}) => - solidityOwnership(baseDir, { dirs: [...libs, ...(soldeer ? ['dependencies'] : [])], packages: readGitmodules(baseDir).map((s) => s.path) }) +// holds dependencies too), with its git submodules, which it keeps as `submodules` (readGitmodules). +export function projectOwnership(baseDir, libs, { soldeer = false } = {}) { + const submodules = readGitmodules(baseDir) + const dirs = [...libs, ...(soldeer ? ['dependencies'] : [])] + return { ...solidityOwnership(baseDir, { dirs, packages: submodules.map((s) => s.path) }), submodules } +} // Why a path is refused (see solidityOwnership). function escapeReason(path, { link, root, why }) { From 1403dc48f0815ab47aed0fa1196519945783fd9b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:30:47 +0000 Subject: [PATCH 15/20] fix(bundle): never carry a config under a normalized name it wasn't read by; hardhat.config.* in any case - When the OS can't give a config's real path (past PATH_MAX), its name kept the `..` it was read by only if the path started with `/` as spelled; any other spelling of the root (`/./sub/..`, a doubled `/`, the root's real path) fell back to the normalized name, and --manifests carried the root's base.toml where forge read another file. The root is now stripped component by component, as given or by its real path, keeping `..`; a path from neither stays absolute, and --manifests refuses it as unresolvable. - neverCarried's hardhat.config.* rule ignores case, as the .env one does: an `extends = "HARDHAT.CONFIG.TOML"` is no longer carried. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- stasis/src/cmd/bundle.js | 7 +++++-- stasis/src/loaders/solidity-ownership.js | 22 ++++++++++++++++------ tests/bundle-cmd.test.js | 22 +++++++++++++++------- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index 813beb83..1f3b3215 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -212,8 +212,9 @@ function assembleCodeBundle({ }).withReason('bundle') } -// Files never carried, whatever reads them: `.env` files, and Hardhat's config, which is code. -const neverCarried = (rel) => isDotEnvFile(rel) || posix.basename(rel).startsWith('hardhat.config.') +// Files never carried, whatever reads them: `.env` files, and Hardhat's config, which is code +// (both however they're cased). +const neverCarried = (rel) => isDotEnvFile(rel) || posix.basename(rel).toLowerCase().startsWith('hardhat.config.') // findPackageMetadata (with `options`) once per directory, the only thing its answer depends on. function packageLookup(baseDir, options) { @@ -250,6 +251,8 @@ function solidityManifests(baseDir, sources, configFiles, { classifyDep, package const unreproducible = (rel, why) => new Error(`--manifests can't carry ${rel}, which the Solidity resolution read: ${why}`) const out = new Map() for (const rel of configFiles) { + // Absolute: the OS couldn't give its real path (see projectRelative). + if (posix.isAbsolute(rel)) throw unreproducible(rel, "its real path can't be resolved") if (posixPathEscapes(rel)) throw unreproducible(rel, 'it lies outside the bundle root') if (neverCarried(rel)) throw unreproducible(rel, '.env files and hardhat.config.* are never carried') if (sources.has(rel)) continue diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index 23dd22e0..043a5059 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -33,16 +33,26 @@ const inRoot = (rel) => rel !== '..' && !rel.startsWith('../') && !isAbsolute(re // `abs`, a file the resolution read, relative to the project `root` (slashes): as spelled when that // lies inside it with no `..` to resolve (a linked lib's files keep the lib's path), else by real // paths -- where the read went (an absolute or `/proc/self/cwd` lib; a `..` after a symlink) -- -// `../` when outside the project. One whose real path the OS can't give (past PATH_MAX) keeps the -// path it was read by, `..` and all, for solidityOwnership to refuse: normalized, it would name -// another file. +// `../` when outside the project. One whose real path the OS can't give (past PATH_MAX) is never +// normalized, which could name another file: it keeps the path it was read by, `..` and all, from +// the root however that's spelled (as given or by its real path), for solidityOwnership to refuse, +// or else stays absolute, a name --manifests refuses as unresolvable. export function projectRelative(root, abs) { const rel = toSlashes(relative(root, abs)) if (inRoot(rel) && !toSlashes(abs).split('/').includes('..')) return rel const real = realpathOrNull(abs) - if (real !== null) return toSlashes(relative(realpathOrNull(root) ?? root, real)) - const prefix = `${resolve(root)}${sep}` - return abs.startsWith(prefix) ? toSlashes(abs.slice(prefix.length)) : rel + const realRoot = realpathOrNull(root) + if (real !== null) return toSlashes(relative(realRoot ?? root, real)) + return below(resolve(root), abs) ?? (realRoot === null ? null : below(realRoot, abs)) ?? toSlashes(abs) +} + +// `abs` from `dir`, component by component as spelled (empty and `.` ones dropped, `..` kept), or +// null when it doesn't start with `dir`'s components. +function below(dir, abs) { + const parts = (p) => toSlashes(p).split('/').filter((c) => c !== '' && c !== '.') + const d = parts(dir) + const a = parts(abs) + return d.length < a.length && d.every((c, i) => a[i] === c) ? a.slice(d.length).join('/') : null } // realpath(3) of `p`: `{ real }`, or `{ real: null, missing }`, `missing` only when nothing is diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 7d94c282..c5a4e280 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -2,7 +2,7 @@ import { test } from 'node:test' import { spawn, spawnSync } from 'node:child_process' import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { stripVTControlCharacters } from 'node:util' import { brotliCompressSync, brotliDecompressSync } from 'node:zlib' @@ -894,6 +894,8 @@ test('buildSolidityBundle with manifests fails on a config the resolution read b ['.env.toml', '.env files and hardhat.config.* are never carried'], ['Base.ENV', '.env files and hardhat.config.* are never carried'], ['.env.local', '.env files and hardhat.config.* are never carried'], + ['HARDHAT.CONFIG.TOML', '.env files and hardhat.config.* are never carried'], + ['Hardhat.config.toml', '.env files and hardhat.config.* are never carried'], ['../shared-base.toml', 'it lies outside the bundle root'], ]) { if (!base.startsWith('../')) writeFileSync(join(proj, base), '[profile.default]\nsrc = "src"\n') @@ -921,12 +923,18 @@ test('buildSolidityBundle with manifests refuses a config whose real path the OS mkdirSync('in') writeFileSync('base.toml', '[profile.default]\nremappings = ["x/=lib/physical/"]\n') }) - const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }) - t.assert.equal(bundle.imports.get('solidity').get('src/A.sol').get('x/X.sol'), 'lib/physical/X.sol') - await t.assert.rejects( - () => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} }), - { message: "--manifests can't carry L/../base.toml, which the Solidity resolution read: L/../base.toml crosses a link stasis can't follow the way the filesystem does" }, - ) + // However the path is spelled, from the root: as given, through `./`, or with a doubled `/`. + for (const extendsPath of ['L/../base.toml', `${tmp}/./L/../base.toml`, `${dirname(tmp)}//${basename(tmp)}/L/../base.toml`]) { + writeFileSync(join(tmp, 'foundry.toml'), `[profile.default]\nextends = "${extendsPath}"\n`) + // eslint-disable-next-line no-await-in-loop -- each run rewrites foundry.toml + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }) + t.assert.equal(bundle.imports.get('solidity').get('src/A.sol').get('x/X.sol'), 'lib/physical/X.sol') + // eslint-disable-next-line no-await-in-loop -- each run rewrites foundry.toml + await t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} }), + { message: "--manifests can't carry L/../base.toml, which the Solidity resolution read: L/../base.toml crosses a link stasis can't follow the way the filesystem does" }, + ) + } })) test('buildSolidityBundle never reads the process\'s stdin as a config, a dependency\'s or the project\'s', { skip: !existsSync('/proc/self/fd/0') }, withTmp(async (t, tmp) => { From 0c200d1274216b1bdae6ed1affae3a822e1b7f90 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:33:34 +0000 Subject: [PATCH 16/20] fix(bundle): a .sol file that isn't UTF-8 is refused, not bundled with U+FFFD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solidity sources (and a .sol.txt listing) were read with readFile(…, 'utf8'), which turns a stray byte (Latin-1's \xe9) into U+FFFD: the bundle held text that isn't the file's. They're read as bytes now and decoded with @exodus/bytes' strict utf8toString (decodeUtf8, a byte-order mark kept), as the config files and carried manifests are too; bytes that aren't UTF-8 are an error naming the file. A strict (Solidity) package.json lookup refuses one the same way; the lenient ones decode as before. @exodus/bytes becomes a direct dependency of stasis (stasis-core stays dependency-free). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 4 +++- pnpm-lock.yaml | 3 +++ stasis-core/src/bundle-util.js | 6 +++++- stasis/package.json | 1 + stasis/src/cmd/bundle.js | 4 ++-- stasis/src/loaders/solidity-ownership.js | 21 +++++++++++++++------ stasis/src/loaders/solidity.js | 6 +++--- tests/bundle-cmd.test.js | 20 ++++++++++++++++---- 8 files changed, 48 insertions(+), 17 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index 7e813bae..a1009635 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -362,7 +362,9 @@ resolve the way solc does under the project's build tool: copy of a package; aliases of the project's own `src`/`test`/`script` dirs are dropped, and `auto_detect_remappings = false` turns detection off. A `foundry.toml` or `extends` base that isn't TOML, a config that isn't UTF-8 - (`foundry.toml`, `remappings.txt`, `.gitmodules`), a setting of the wrong type + (`foundry.toml`, `remappings.txt`, `.gitmodules`) — or a `.sol` file that isn't, + which solc refuses and the bundle won't hold with U+FFFD in place of its + bytes — a setting of the wrong type (`libs = "deps"`, a `src` that isn't a string, an `extends` that isn't a path or `{ path, strategy }`), and an invalid remapping (a `remappings.txt` line or `FOUNDRY_REMAPPINGS` entry that isn't `[context:]prefix=target`, or a diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c762d22b..ee44e0fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: stasis: dependencies: + '@exodus/bytes': + specifier: ^1.16.0 + version: 1.16.0 '@exodus/stasis-core': specifier: 1.0.0-beta.4 version: link:../stasis-core diff --git a/stasis-core/src/bundle-util.js b/stasis-core/src/bundle-util.js index f75a8aaf..3168af21 100644 --- a/stasis-core/src/bundle-util.js +++ b/stasis-core/src/bundle-util.js @@ -81,7 +81,11 @@ export function readPackageJson(baseDir, rel, { strict = false, check, host = di try { // A FIFO, a socket or a device (or a link to one) is never read: it could stall the bundle. if (!stat.isFile()) throw new Error(`${rel}: not a regular file`) - return parseJson(host.readFile(file).toString('utf8').replace(/^\uFEFF/u, ''), rel) + const bytes = host.readFile(file) + // Strict, it's read as the file's own text or not at all; lenient lookups decode it as they always + // have (a stray byte as U+FFFD). + if (strict && !isUtf8(bytes)) throw new Error(`${rel}: not valid UTF-8`) + return parseJson(bytes.toString('utf8').replace(/^\uFEFF/u, ''), rel) } catch (err) { if (strict) throw err return null diff --git a/stasis/package.json b/stasis/package.json index 1e11fe95..c655e922 100644 --- a/stasis/package.json +++ b/stasis/package.json @@ -84,6 +84,7 @@ }, "homepage": "https://github.com/ExodusOSS/stasis#readme", "dependencies": { + "@exodus/bytes": "^1.16.0", "@exodus/stasis-core": "1.0.0-beta.4", "@exodus/stasis-plugins": "1.0.0-beta.4", "@preventive/archive": "1.0.0-beta.3", diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index 1f3b3215..940033ed 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -24,6 +24,7 @@ import { discoverSolidityConfig, expandSolidityEntries, } from '../loaders/solidity.js' +import { decodeUtf8 } from '../loaders/solidity-ownership.js' import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js' import { buildRustTree, collectRustFilesFromDisk } from '../loaders/rust.js' import { VENDOR_DIR as CARGO_VENDOR_DIR, createCargoContext } from '../loaders/cargo.js' @@ -245,8 +246,7 @@ function solidityManifests(baseDir, sources, configFiles, { classifyDep, package if (outside) throw new Error(`Refusing to follow symlink escaping bundle root: ${rel} -> ${resolve(realBase, real)}`) const buf = readRegularFileOrNull(join(realBase, real), rel) if (buf === null) return { why: null } // a directory - if (!isUtf8(buf)) throw new Error(`Solidity manifest is not valid UTF-8: ${rel}`) - return { text: buf.toString('utf8') } + return { text: decodeUtf8(buf, rel) } } const unreproducible = (rel, why) => new Error(`--manifests can't carry ${rel}, which the Solidity resolution read: ${why}`) const out = new Map() diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index 043a5059..e99d7097 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -7,6 +7,7 @@ import { isUtf8 } from 'node:buffer' import { lstatSync, readdirSync, readlinkSync, realpathSync } from 'node:fs' import { isAbsolute, join, parse, posix, relative, resolve, sep } from 'node:path' +import { utf8toString } from '@exodus/bytes/utf8.js' import { LockfileError, parseGitmodules } from '@preventive/lockfile/foundry.js' import { NO_ENTRY, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util' import { hasNodeModulesSegment } from '@exodus/stasis-core/util' @@ -77,14 +78,22 @@ function lexists(p) { } } -// A config file's text, or null when there's no file (readRegularFileOrNull: a regular file only). -// One that isn't UTF-8 throws: forge and git refuse it, and a text read with U+FFFD in it isn't the -// one they read. A byte-order mark stays. Errors name it `label`. +// `bytes` as UTF-8 text, a byte-order mark kept. Bytes that aren't UTF-8 throw, naming them +// `label`, rather than read with U+FFFD in their place: forge, solc and git refuse such a file, and +// the text bundled or read must be the file's own. +export function decodeUtf8(bytes, label) { + try { + return utf8toString(bytes) + } catch (err) { + throw new Error(`${label}: not valid UTF-8`, { cause: err }) + } +} + +// A config file's text (decodeUtf8), or null when there's no file (readRegularFileOrNull: a regular +// file only). Errors name it `label`. export function readUtf8OrNull(file, label) { const buf = readRegularFileOrNull(file, label) - if (buf === null) return null - if (!isUtf8(buf)) throw new Error(`${label}: not valid UTF-8`) - return buf.toString('utf8') + return buf === null ? null : decodeUtf8(buf, label) } // --- .gitmodules ------------------------------------------------------------------------------ diff --git a/stasis/src/loaders/solidity.js b/stasis/src/loaders/solidity.js index 43548dde..96ba61ef 100644 --- a/stasis/src/loaders/solidity.js +++ b/stasis/src/loaders/solidity.js @@ -28,7 +28,7 @@ import { shownFrom, toSolcRemapping, } from './foundry.js' -import { projectOwnership, projectRelative, readUtf8OrNull, realpathOrNull, solidityOwnership } from './solidity-ownership.js' +import { decodeUtf8, projectOwnership, projectRelative, readUtf8OrNull, realpathOrNull, solidityOwnership } from './solidity-ownership.js' // --- Import scan ------------------------------------------------------------------------------ @@ -369,7 +369,7 @@ export async function collectSolidityFilesFromDisk(baseDir, entries, remappings, toLoad.map(async (relPath) => { try { assertRealPathWithinBase(realBase, baseDir, relPath) - return [relPath, await readFile(join(baseDir, relPath), 'utf8')] + return [relPath, decodeUtf8(await readFile(join(baseDir, relPath)), relPath)] } catch (err) { if (err.code === 'ENOENT') { console.warn(`[loader.solidity] Missing import: ${relPath}`) @@ -472,7 +472,7 @@ function assertWithinBase(baseDir, candidate, label) { // a mapping line, the remappings are discovered as for `stasis bundle` (discoverSolidityConfig). export async function loadSolidity(solTxtFile, { env = process.env } = {}) { const baseDir = dirname(resolve(solTxtFile)) - const listing = await readFile(solTxtFile, 'utf8') + const listing = decodeUtf8(await readFile(solTxtFile), solTxtFile) const lines = listing.split('\n').map((l) => l.trim()).filter(Boolean) if (lines.length === 0) throw new Error(`Empty Solidity listing: ${solTxtFile}`) diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index c5a4e280..3b500e23 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -983,6 +983,18 @@ test('buildSolidityBundle fails on a .gitmodules git reads two ways, naming it, } })) +test('buildSolidityBundle refuses a .sol file that isn\'t UTF-8, rather than bundle it with U+FFFD in it', withTmp(async (t, tmp) => { + // \xe9 alone is Latin-1's é: solc refuses it, and the bundle must hold the file's own text. + writeProject(tmp, { 'src/A.sol': 'import "./B.sol";\ncontract A {}\n' }) + writeFileSync(join(tmp, 'src/B.sol'), Buffer.from('// caf\xe9\ncontract B {}\n', 'latin1')) + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src/A.sol'], env: {} }), { message: 'src/B.sol: not valid UTF-8' }) + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src/B.sol'], env: {} }), { message: 'src/B.sol: not valid UTF-8' }) + // A byte-order mark is UTF-8: kept, as written. + writeFileSync(join(tmp, 'src/B.sol'), '\uFEFFcontract B {}\n') + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src/A.sol'], env: {} }) + t.assert.equal(bundle.sources.get('src/B.sol'), '\uFEFFcontract B {}\n') +})) + test('buildSolidityBundle never stalls on a package.json that isn\'t a regular file', withTmp(async (t, tmp) => { writeProject(tmp, { 'contracts/A.sol': 'import "pkg/P.sol";\n', 'node_modules/pkg/P.sol': 'contract P {}\n' }) // A FIFO: read blocking, it would wait for a writer forever. @@ -3347,10 +3359,10 @@ test('CLI: bundle (JS) fails loudly when the oxc-parser dependency is missing', // exited 0 with no warning at all. The setup error must propagate with its // install hint instead. Exercised against a copy of stasis whose node_modules // carries only the zero-dep @exodus/stasis-core (so the moved-module shims - // resolve) and @preventive/lockfile (whose TOML and .gitmodules readers the - // loaders import) with its one dependency, @exodus/bytes, so the bundle command - // loads, but no oxc-parser, so the lazy lookup (createRequire from src/scan.js) - // genuinely misses. + // resolve), @preventive/lockfile (whose TOML and .gitmodules readers the loaders + // import) and @exodus/bytes (its dependency, and the loaders' UTF-8 decoder), so + // the bundle command loads, but no oxc-parser, so the lazy lookup (createRequire + // from src/scan.js) genuinely misses. const stasisCopy = join(tmp, 'stasis') mkdirSync(stasisCopy) for (const entry of ['bin', 'src']) cpSync(join(here, '..', 'stasis', entry), join(stasisCopy, entry), { recursive: true }) From 9f6fef0eb0b4db09a2b515ac53997c318f876d76 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:40:15 +0000 Subject: [PATCH 17/20] fix(bundle): a .gitmodules the library refuses warns, never fails the bundle Every Solidity bundle reads .gitmodules (Foundry, --mapping and Hardhat mode alike), for the submodules' paths (ownership) and urls (naming), so one entry the strict reader refuses stopped it, though git takes many of them: `update = none`, an `active` key, a `[core]` or `[include]` section, a tab in a value. main bundled all of these. A file @preventive/lockfile refuses is now warned about and read a `[submodule "name"]` section at a time: each submodule's first `path`, `url` and `branch` (as git's submodule commands read them; a second section of the name merged), each read by the library alone. A branch, then a url, that still doesn't read is dropped with a warning, and a submodule whose path doesn't (`./lib/x`, `../x`, a `[submodule.x]`) is skipped with one. A file the library reads is read as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 13 +++-- stasis/src/loaders/solidity-ownership.js | 72 ++++++++++++++++++++++-- tests/bundle-cmd.test.js | 27 +++++++-- tests/solidity-loader.test.js | 43 +++++++++----- 4 files changed, 127 insertions(+), 28 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index a1009635..b5bdd7f8 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -415,11 +415,14 @@ case-insensitive one, `LIB/evil` is `lib/evil`). The dependencies are the entries of forge's `libs` (an absolute one by its real path; a symlinked `lib/forge-std` is the dependency where it points), Soldeer's `dependencies/`, git submodules (`.gitmodules` read with `@preventive/lockfile`'s reader, as git -reads it: one git reads two ways — a key twice, a second section, -`[submodule.x]` — a path outside the repository or not in normal form, or a url -git ignores (starting with `-`) is an error naming it; a url relative to the -superproject's remote, or none, is taken as written, the submodule then having -no GitHub name to bucket it by) and every `node_modules` package; a file +reads it; a url relative to the superproject's remote, or none, is taken as +written, the submodule then having no GitHub name to bucket it by. A file the +reader refuses — something git reads two ways, or doesn't check, such as +`update = none`, `active` or a `[core]` section — never fails the bundle: it's +warned about and read a submodule at a time, each submodule's first `path`, `url` +and `branch`, dropping with a warning a branch or url that doesn't read, and a +submodule whose path doesn't, such as `./lib/x`) and every `node_modules` +package; a file is a dependency's when its real path lies in one, however the path got there (`src/vendor -> ../lib/dep/src` holds the dependency's code). An import from a dependency must land on a dependency's file too: it may import its own files and diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index e99d7097..13c652c3 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -99,9 +99,11 @@ export function readUtf8OrNull(file, label) { // --- .gitmodules ------------------------------------------------------------------------------ // The submodules of the project at `baseDir`, `{ path, url, branch }` (`url` and `branch` when set), -// from its `.gitmodules` as @preventive/lockfile reads it (as git does, refusing what git reads two -// ways). A url is taken as written (`checkUrls: false`): relative to the superproject's remote, a -// path or none, as it only names a GitHub submodule's bucket. A refusal names the file. +// from its `.gitmodules` as @preventive/lockfile reads it (as git does). A url is taken as written +// (`checkUrls: false`): relative to the superproject's remote, a path or none, as it only names a +// GitHub submodule's bucket. A file the library refuses -- something git reads two ways, or that it +// doesn't check (`update = none`, `active`, a `[core]` section) -- never fails the bundle: it's +// warned about and read submodule by submodule (gitmodulesLeniently). export function readGitmodules(baseDir) { const text = readUtf8OrNull(join(baseDir, '.gitmodules'), '.gitmodules') if (text === null) return [] @@ -109,10 +111,72 @@ export function readGitmodules(baseDir) { return Object.values(parseGitmodules(text, { checkUrls: false })) } catch (err) { if (!(err instanceof LockfileError)) throw err - throw new Error(`.gitmodules: ${err.message}`, { cause: err }) + const { submodules, notes } = gitmodulesLeniently(text) + if (!notes.some((note) => note.startsWith(`${err.message};`))) notes.unshift(`${err.message}; reading it submodule by submodule`) + for (const note of notes) console.warn(`[loader.solidity] .gitmodules: ${note}`) + return submodules } } +// The keys a submodule is read for; past `path`, they're dropped in this order to read the rest. +const SUBMODULE_KEYS = new Set(['path', 'url', 'branch']) +const DROPPABLE = ['branch', 'url'] + +// `.gitmodules` text the library refused as a whole, read a `[submodule "name"]` section at a time: +// its first `path`, `url` and `branch` (the first, as git's submodule commands read it; the +// sections of one name merged), each submodule then read by the library alone. One that still +// doesn't read loses its branch, then its url, then is skipped. `notes` say what was dropped. +function gitmodulesLeniently(text) { + const sections = new Map() // the name, as written in the header -> Map + const notes = [] + let keys = null // the current section's, when it's a submodule's + let lines = null // the kept key's lines, while it runs on (a line ending in `\`) + let continued = false + for (let line of text.split(/\r?\n/u)) { + const runsOn = continued + continued = /(?:^|[^\\])(?:\\\\)*\\$/u.test(line) + if (runsOn) { + lines?.push(line) + continue + } + lines = null + const header = /^\s*\[\s*([\w.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\]/u.exec(line) + if (header) { + const section = header[1].toLowerCase() + const name = section === 'submodule' ? header[2] : undefined + if (name === undefined && section.startsWith('submodule.')) notes.push(`[${header[1]}], a section git reads with its name lowercased; skipping it`) + keys = name === undefined ? null : (sections.get(name) ?? sections.set(name, new Map()).get(name)) + line = line.slice(header[0].length) // a key may follow on the line + } + const key = keys && /^\s*([A-Za-z][\w-]*)\s*(?:=|$)/u.exec(line)?.[1].toLowerCase() + if (SUBMODULE_KEYS.has(key) && !keys.has(key)) keys.set(key, (lines = [line])) + } + const submodules = [] + for (const [name, kept] of sections) { + const read = () => Object.values(parseGitmodules([`[submodule "${name}"]`, ...[...kept.values()].flat(), ''].join('\n'), { checkUrls: false })) + let first = null + const dropped = [] + for (;;) { + try { + submodules.push(...read()) + if (first !== null) notes.push(`${first.message}; ignoring its ${dropped.join(' and ')}`) + break + } catch (err) { + if (!(err instanceof LockfileError)) throw err + first ??= err + const next = DROPPABLE.find((key) => kept.has(key)) + if (next === undefined) { + notes.push(`${first.message}; skipping the submodule`) + break + } + kept.delete(next) + dropped.push(next) + } + } + } + return { submodules, notes } +} + // --- Ownership -------------------------------------------------------------------------------- const readdirOrEmpty = (dir) => { diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 3b500e23..01a841c4 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -965,13 +965,28 @@ test('buildSolidityBundle never reads the process\'s stdin as a config, a depend await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: 'remappings.txt: not a regular file' }) })) -test('buildSolidityBundle fails on a .gitmodules git reads two ways, naming it, and takes a submodule\'s url as written', withTmp(async (t, tmp) => { - writeProject(tmp, { 'foundry.toml': '[profile.default]\n', 'src/A.sol': 'import "x/X.sol";\n', 'lib/x/src/X.sol': 'contract X {}\n' }) - writeFileSync(join(tmp, '.gitmodules'), '[submodule "x"]\n\tpath = lib/x\n\tpath = lib/y\n\turl = https://github.com/o/x\n') - await t.assert.rejects( +test('buildSolidityBundle never fails on a .gitmodules the library refuses, and takes a submodule\'s url as written', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'src/A.sol': 'import "x/X.sol";\nimport "../vendor/evil/E.sol";\n', + 'lib/x/src/X.sol': 'contract X {}\n', + 'secret/K.sol': 'contract K {}\n', + // git registers vendor/evil (`update = none` makes git submodule update skip it); stasis reads + // it submodule by submodule, and vendor/evil stays a dependency, its link out refused. + '.gitmodules': '[core]\n\tbare = false\n[submodule "vendor/evil"]\n\tpath = vendor/evil\n\turl = https://github.com/e/evil\n\tupdate = none\n\tactive = true\n', + }) + mkdirSync(join(tmp, 'vendor/evil'), { recursive: true }) + symlinkSync('../../secret/K.sol', join(tmp, 'vendor/evil/E.sol')) + const { lines } = await captureStderr(() => t.assert.rejects( () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), - { message: ".gitmodules: x.path: twice, of which git's submodule commands read the first and git config the last, at line 3" }, - ) + (err) => err.message.includes('refused: vendor/evil/E.sol is a link out of the dependency vendor/evil'), + )) + t.assert.ok(lines.includes('[loader.solidity] .gitmodules: a section of [core] where .gitmodules has [submodule "name"] alone, at line 1; reading it submodule by submodule'), lines.join('\n')) + rmSync(join(tmp, 'vendor/evil/E.sol')) + writeFileSync(join(tmp, 'vendor/evil/E.sol'), 'contract E {}\n') + const { result: named } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} })) + t.assert.equal(named.modules.get('vendor/evil').name, 'e/evil') + writeFileSync(join(tmp, 'src/A.sol'), 'import "x/X.sol";\n') // A url relative to the superproject's remote, or none, still makes lib/x a submodule: a // dependency, but not one with a GitHub name to bucket it by. for (const url of ['\turl = ../x.git\n', '']) { diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index 3dc3d827..a4bb26ca 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -765,20 +765,37 @@ test('readGitmodules reads .gitmodules as git does: quotes, escapes, comments, k t.assert.deepEqual(readGitmodules(join(dir, 'none')), []) })) -test('readGitmodules refuses what git reads two ways, a path out of normal form, and a url git ignores', withProject({}, (t, dir) => { - const url = '\turl = https://github.com/o/x\n' - for (const [text, message] of [ - // git's submodule commands read the first `path`, git config the last. - [`[submodule "x"]\n\tpath = lib/x\n\tpath = lib/y\n${url}`, 'x.path: twice, of which git\'s submodule commands read the first and git config the last, at line 3'], - [`[submodule "x"]\n\tpath = lib/x\n[submodule "x"]\n${url}`, 'x: a second section, at line 3, where git writes one'], - [`[submodule.x]\n\tpath = lib/x\n${url}`, 'a section of the form [submodule.name], whose name git lowercases, where .gitmodules has [submodule "name"] alone, at line 1'], - [`[submodule "x"]\n\tpath = ./lib/x\n${url}`, 'x.path: "./lib/x" is not a relative path in normal form'], - [`[submodule "x"]\n\tpath = ../x\n${url}`, 'x.path: "../x" is outside the repository, where git writes no submodule'], - ['[submodule "x"]\n\tpath = lib/x\n\turl = -oProxy=x\n', 'x.url: "-oProxy=x" starts with "-", which git ignores the url for'], - ['[submodule "x"]\n\tpath = lib/x\n\turl = "https://github.com/o/x y"\n', 'x.url: "https://github.com/o/x y" is not a repository URL'], - ]) { +test('readGitmodules reads what the library refuses submodule by submodule, warning what it drops', withProject({}, (t, dir) => { + const read = (text) => { writeFileSync(join(dir, '.gitmodules'), text) - t.assert.throws(() => readGitmodules(dir), { message: `.gitmodules: ${message}` }) + const warnings = [] + const warn = console.warn + console.warn = (line) => warnings.push(line.replace('[loader.solidity] .gitmodules: ', '')) + try { + return { submodules: readGitmodules(dir), warnings } + } finally { + console.warn = warn + } + } + const x = '[submodule "x"]\n\tpath = lib/x\n\turl = https://github.com/o/x\n' + const lenient = 'reading it submodule by submodule' + for (const [text, submodules, warnings] of [ + // What git reads but the library doesn't check: kept, bar the keys stasis doesn't use. + [`${x}\tupdate = none\n\tactive = true\n`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], [`x: unsupported field "active"; ${lenient}`]], + [`[core]\n\tbare = false\n[include]\n\tpath = more\n${x}`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], [`a section of [core] where .gitmodules has [submodule "name"] alone, at line 1; ${lenient}`]], + // What git reads two ways: the first, as git's submodule commands read it. + [`${x}\turl = https://github.com/o/y\n[submodule "x"]\n\tbranch = main\n`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: 'main' }], [`x.url: twice, of which git's submodule commands read the first and git config the last, at line 4; ${lenient}`]], + // A branch or url that doesn't read is dropped; a path that doesn't, or a [submodule.x], the submodule. + [`${x}\tbranch = "v1 x"\n`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], ['x.branch: "v1 x" is not a branch or tag name git takes; ignoring its branch']], + ['[submodule "x"]\n\tpath = lib/x\n\turl = -oProxy=x\n', [{ path: 'lib/x', url: undefined, branch: undefined }], ['x.url: "-oProxy=x" starts with "-", which git ignores the url for; ignoring its url']], + [`[submodule "y"]\n\tpath = ./lib/y\n${x}`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], ['y.path: "./lib/y" is not a relative path in normal form; skipping the submodule']], + [`[submodule "y"]\n\tpath = ../y\n${x}`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], ['y.path: "../y" is outside the repository, where git writes no submodule; skipping the submodule']], + [`[submodule.y]\n\tpath = lib/y\n${x}`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], [ + `a section of the form [submodule.name], whose name git lowercases, where .gitmodules has [submodule "name"] alone, at line 1; ${lenient}`, + '[submodule.y], a section git reads with its name lowercased; skipping it', + ]], + ]) { + t.assert.deepEqual(read(text), { submodules, warnings }, text) } })) From a68e5c7f5b7261911f4e73812d4cea8e7b231a9b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:54:49 +0000 Subject: [PATCH 18/20] fix(bundle): a submodule whose .gitmodules path doesn't read stays a dependency The lenient .gitmodules read skipped a submodule whose path the library refuses (`./deps/x`, `deps/x/`, or under a `[submodule.x]` header), and its directory became the project's own code: outside forge's libs, a link planted in it to the project's .env was followed and the .env carried as deps/x/src/Evil.sol. It fails closed now: the path is read as git reads the value (quotes, escapes, a comment, a line run on), and one that normalizes to a directory inside the repository keeps that directory a dependency, unnamed, with a warning; only a path outside it is skipped. A `[submodule.x]` section is read as git reads it, `[submodule "x"]`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 10 ++-- stasis/src/loaders/solidity-ownership.js | 71 ++++++++++++++++++++---- tests/bundle-cmd.test.js | 17 ++++++ tests/solidity-loader.test.js | 10 ++-- 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index b5bdd7f8..e7a8b8bb 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -419,10 +419,12 @@ reads it; a url relative to the superproject's remote, or none, is taken as written, the submodule then having no GitHub name to bucket it by. A file the reader refuses — something git reads two ways, or doesn't check, such as `update = none`, `active` or a `[core]` section — never fails the bundle: it's -warned about and read a submodule at a time, each submodule's first `path`, `url` -and `branch`, dropping with a warning a branch or url that doesn't read, and a -submodule whose path doesn't, such as `./lib/x`) and every `node_modules` -package; a file +warned about and read a submodule at a time (`[submodule.x]` as git reads it, +`[submodule "x"]`), each submodule's first `path`, `url` and `branch`, dropping +with a warning a branch or url that doesn't read. One whose path doesn't fails +closed: a path naming a directory inside the repository, such as `./lib/x` or +`lib/x/`, still makes it a dependency, unnamed, and only one outside it is +skipped) and every `node_modules` package; a file is a dependency's when its real path lies in one, however the path got there (`src/vendor -> ../lib/dep/src` holds the dependency's code). An import from a dependency must land on a dependency's file too: it may import its own files and diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index 13c652c3..bdc50a66 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -122,10 +122,13 @@ export function readGitmodules(baseDir) { const SUBMODULE_KEYS = new Set(['path', 'url', 'branch']) const DROPPABLE = ['branch', 'url'] -// `.gitmodules` text the library refused as a whole, read a `[submodule "name"]` section at a time: -// its first `path`, `url` and `branch` (the first, as git's submodule commands read it; the -// sections of one name merged), each submodule then read by the library alone. One that still -// doesn't read loses its branch, then its url, then is skipped. `notes` say what was dropped. +// `.gitmodules` text the library refused as a whole, read a submodule section at a time (`[submodule +// "name"]`, or `[submodule.name]` with the name lowercased, as git reads it): its first `path`, +// `url` and `branch` (the first, as git's submodule commands read it; the sections of one name +// merged), each submodule then read by the library alone. One that still doesn't read loses its +// branch, then its url; one whose path doesn't read fails closed: its directory, when the path +// names one inside the repository (`./lib/x`, `lib/x/`), is still a dependency, just unnamed, and +// else the submodule is skipped. `notes` say what was dropped. function gitmodulesLeniently(text) { const sections = new Map() // the name, as written in the header -> Map const notes = [] @@ -143,8 +146,11 @@ function gitmodulesLeniently(text) { const header = /^\s*\[\s*([\w.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\]/u.exec(line) if (header) { const section = header[1].toLowerCase() - const name = section === 'submodule' ? header[2] : undefined - if (name === undefined && section.startsWith('submodule.')) notes.push(`[${header[1]}], a section git reads with its name lowercased; skipping it`) + let name = section === 'submodule' ? header[2] : undefined + if (name === undefined && section.startsWith('submodule.')) { + name = section.slice('submodule.'.length) + notes.push(`[${header[1]}], a section git reads as [submodule "${name}"]; reading it as that`) + } keys = name === undefined ? null : (sections.get(name) ?? sections.set(name, new Map()).get(name)) line = line.slice(header[0].length) // a key may follow on the line } @@ -165,18 +171,63 @@ function gitmodulesLeniently(text) { if (!(err instanceof LockfileError)) throw err first ??= err const next = DROPPABLE.find((key) => kept.has(key)) - if (next === undefined) { + if (next !== undefined) { + kept.delete(next) + dropped.push(next) + continue + } + const path = kept.has('path') ? normalSubmodulePath(kept.get('path')) : null + if (path === null) { notes.push(`${first.message}; skipping the submodule`) - break + } else { + submodules.push({ path, url: undefined, branch: undefined }) + notes.push(`${first.message}; still taking ${path} as a dependency, unnamed`) } - kept.delete(next) - dropped.push(next) + break } } } return { submodules, notes } } +// A `path = ...` key's lines -> the directory it names, normalized, when that lies inside the +// repository (else null): `./lib/x` and `lib/x/` are lib/x. +function normalSubmodulePath(lines) { + const raw = lines.map((l, i) => (i < lines.length - 1 ? l.slice(0, -1) : l)).join('') // a `\` runs on + const eq = raw.indexOf('=') + if (eq === -1) return null + const path = posix.normalize(gitConfigValue(raw.slice(eq + 1))).replace(/\/+$/u, '') + return path !== '' && path !== '.' && inRoot(path) ? path : null +} + +const GIT_ESCAPES = { n: '\n', t: '\t', b: '\b' } + +// A git-config value as git reads it: `"` quotes (dropped), `\` escapes, a `#`/`;` comment outside +// quotes, and whitespace trimmed at both ends outside quotes. +function gitConfigValue(raw) { + let out = '' + let held = '' // unquoted whitespace, kept only if more value follows + let quoted = false + for (let i = 0; i < raw.length; i++) { + const ch = raw[i] + if (ch === '\\') { + const next = raw[++i] ?? '' + out += held + (GIT_ESCAPES[next] ?? next) + held = '' + } else if (ch === '"') { + quoted = !quoted + } else if (!quoted && (ch === '#' || ch === ';')) { + break + } else if (!quoted && (ch === ' ' || ch === '\t')) { + if (out !== '') held += ch + } else { + out += held + ch + held = '' + } + } + return out +} + // --- Ownership -------------------------------------------------------------------------------- const readdirOrEmpty = (dir) => { diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 01a841c4..e9fe329e 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -998,6 +998,23 @@ test('buildSolidityBundle never fails on a .gitmodules the library refuses, and } })) +test('buildSolidityBundle keeps a submodule whose .gitmodules path doesn\'t read a dependency, failing closed', withTmp(async (t, tmp) => { + // deps/x is outside forge's libs: only .gitmodules makes it a dependency, and a planted link in + // it to the project's .env must stay refused however its path is spelled. + writeProject(tmp, { 'foundry.toml': '[profile.default]\nremappings = ["x/=deps/x/src/"]\n', '.env': 'PRIVATE_KEY=0xabc\n', 'src/A.sol': 'import "x/Evil.sol";\n' }) + mkdirSync(join(tmp, 'deps/x/src'), { recursive: true }) + symlinkSync('../../../.env', join(tmp, 'deps/x/src/Evil.sol')) + for (const section of ['[submodule "x"]\n\tpath = ./deps/x\n', '[submodule "x"]\n\tpath = deps/x/\n', '[submodule.x]\n\tpath = deps/x\n']) { + writeFileSync(join(tmp, '.gitmodules'), `${section}\turl = https://github.com/e/x\n`) + // eslint-disable-next-line no-await-in-loop -- each run rewrites .gitmodules + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + (err) => err.message.includes('refused: deps/x/src/Evil.sol is a link out of the dependency deps/x'), + section, + )) + } +})) + test('buildSolidityBundle refuses a .sol file that isn\'t UTF-8, rather than bundle it with U+FFFD in it', withTmp(async (t, tmp) => { // \xe9 alone is Latin-1's é: solc refuses it, and the bundle must hold the file's own text. writeProject(tmp, { 'src/A.sol': 'import "./B.sol";\ncontract A {}\n' }) diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index a4bb26ca..e5cbf2cf 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -785,14 +785,16 @@ test('readGitmodules reads what the library refuses submodule by submodule, warn [`[core]\n\tbare = false\n[include]\n\tpath = more\n${x}`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], [`a section of [core] where .gitmodules has [submodule "name"] alone, at line 1; ${lenient}`]], // What git reads two ways: the first, as git's submodule commands read it. [`${x}\turl = https://github.com/o/y\n[submodule "x"]\n\tbranch = main\n`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: 'main' }], [`x.url: twice, of which git's submodule commands read the first and git config the last, at line 4; ${lenient}`]], - // A branch or url that doesn't read is dropped; a path that doesn't, or a [submodule.x], the submodule. + // A branch or url that doesn't read is dropped. A path that doesn't fails closed: its directory, + // inside the repository, is still a dependency (unnamed); one outside it, the submodule is dropped. [`${x}\tbranch = "v1 x"\n`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], ['x.branch: "v1 x" is not a branch or tag name git takes; ignoring its branch']], ['[submodule "x"]\n\tpath = lib/x\n\turl = -oProxy=x\n', [{ path: 'lib/x', url: undefined, branch: undefined }], ['x.url: "-oProxy=x" starts with "-", which git ignores the url for; ignoring its url']], - [`[submodule "y"]\n\tpath = ./lib/y\n${x}`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], ['y.path: "./lib/y" is not a relative path in normal form; skipping the submodule']], + [`[submodule "y"]\n\tpath = "./lib/y/" # vendored\n${x}`, [{ path: 'lib/y', url: undefined, branch: undefined }, { path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], ['y.path: "./lib/y/" is not a relative path in normal form; still taking lib/y as a dependency, unnamed']], [`[submodule "y"]\n\tpath = ../y\n${x}`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], ['y.path: "../y" is outside the repository, where git writes no submodule; skipping the submodule']], - [`[submodule.y]\n\tpath = lib/y\n${x}`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], [ + // A [submodule.Y] is read as git reads it: [submodule "y"]. + [`[submodule.Y]\n\tpath = lib/y\n${x}`, [{ path: 'lib/y', url: undefined, branch: undefined }, { path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], [ `a section of the form [submodule.name], whose name git lowercases, where .gitmodules has [submodule "name"] alone, at line 1; ${lenient}`, - '[submodule.y], a section git reads with its name lowercased; skipping it', + '[submodule.Y], a section git reads as [submodule "y"]; reading it as that', ]], ]) { t.assert.deepEqual(read(text), { submodules, warnings }, text) From 8931078d5ab3da13522e418057205b142229d148 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 21:16:14 +0000 Subject: [PATCH 19/20] fix(bundle): a .gitmodules git refuses is an error, not read past The lenient reader for a .gitmodules the library refuses matched section headers with a regex and dropped any it didn't match: `[submodule.deps/x]`, `[submodule "deps/x"` with no `]`, `[submodule deps/x]`, or such a header after a good section. The submodule's directory then became the project's own code, so a planted link in it to .env was trusted and bundled. The lenient path now reads the file the way git's config.c does, character by character. It refuses exactly what git refuses ("bad config line"): a malformed header, a key followed by something other than `=`, a value with no closing quote, an unknown escape, or a stray character. Such a file is an error that names its line. It fails closed and is no stricter than git. A differential fuzz against `git config -f --list -z` (16,000 files) shows the same files refused and the same keys and values read. The library refuses every file git refuses, so the main path can't bypass the check. The reader also replaces the regex scan's approximations. Sections whose names differ only in escaping are merged, as git merges them. A `\` at the end of a comment no longer runs onto the next line. `[submodule.x "y"]` is read as submodule "x.y". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- doc/file-formats.md | 6 +- stasis/src/loaders/solidity-ownership.js | 224 ++++++++++++++++------- tests/bundle-cmd.test.js | 23 +++ tests/solidity-loader.test.js | 34 ++++ 4 files changed, 222 insertions(+), 65 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index e7a8b8bb..67a3ff73 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -424,7 +424,11 @@ warned about and read a submodule at a time (`[submodule.x]` as git reads it, with a warning a branch or url that doesn't read. One whose path doesn't fails closed: a path naming a directory inside the repository, such as `./lib/x` or `lib/x/`, still makes it a dependency, unnamed, and only one outside it is -skipped) and every `node_modules` package; a file +skipped. A `.gitmodules` git itself refuses — a "bad config line", such as a +header `[submodule x]`, `[submodule.lib/x]` or `[submodule "x"` with no `]`, or +a value with no closing quote — is an error, as it is to git: read past, a +submodule's section would be lost, and its directory taken for the project's +own) and every `node_modules` package; a file is a dependency's when its real path lies in one, however the path got there (`src/vendor -> ../lib/dep/src` holds the dependency's code). An import from a dependency must land on a dependency's file too: it may import its own files and diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index bdc50a66..627c437b 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -103,7 +103,8 @@ export function readUtf8OrNull(file, label) { // (`checkUrls: false`): relative to the superproject's remote, a path or none, as it only names a // GitHub submodule's bucket. A file the library refuses -- something git reads two ways, or that it // doesn't check (`update = none`, `active`, a `[core]` section) -- never fails the bundle: it's -// warned about and read submodule by submodule (gitmodulesLeniently). +// warned about and read submodule by submodule (gitmodulesLeniently). One git itself refuses is an +// error: read past what git can't, a submodule's section would be lost, and its directory with it. export function readGitmodules(baseDir) { const text = readUtf8OrNull(join(baseDir, '.gitmodules'), '.gitmodules') if (text === null) return [] @@ -122,44 +123,28 @@ export function readGitmodules(baseDir) { const SUBMODULE_KEYS = new Set(['path', 'url', 'branch']) const DROPPABLE = ['branch', 'url'] -// `.gitmodules` text the library refused as a whole, read a submodule section at a time (`[submodule -// "name"]`, or `[submodule.name]` with the name lowercased, as git reads it): its first `path`, -// `url` and `branch` (the first, as git's submodule commands read it; the sections of one name -// merged), each submodule then read by the library alone. One that still doesn't read loses its -// branch, then its url; one whose path doesn't read fails closed: its directory, when the path -// names one inside the repository (`./lib/x`, `lib/x/`), is still a dependency, just unnamed, and -// else the submodule is skipped. `notes` say what was dropped. +// `.gitmodules` text the library refused as a whole, read as git reads it (readGitConfig) a +// submodule at a time: a key of `submodule..`, from `[submodule "name"]` or +// `[submodule.name]`, its first `path`, `url` and `branch` (the first, as git's submodule commands +// read it; the sections of one name merged), each submodule then read by the library alone. One that +// still doesn't read loses its branch, then its url; one whose path doesn't read fails closed: its +// directory, when the path names one inside the repository (`./lib/x`, `lib/x/`), is still a +// dependency, just unnamed, and else the submodule is skipped. `notes` say what was dropped. function gitmodulesLeniently(text) { - const sections = new Map() // the name, as written in the header -> Map + const sections = new Map() // a submodule's name -> Map const notes = [] - let keys = null // the current section's, when it's a submodule's - let lines = null // the kept key's lines, while it runs on (a line ending in `\`) - let continued = false - for (let line of text.split(/\r?\n/u)) { - const runsOn = continued - continued = /(?:^|[^\\])(?:\\\\)*\\$/u.test(line) - if (runsOn) { - lines?.push(line) - continue - } - lines = null - const header = /^\s*\[\s*([\w.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\]/u.exec(line) - if (header) { - const section = header[1].toLowerCase() - let name = section === 'submodule' ? header[2] : undefined - if (name === undefined && section.startsWith('submodule.')) { - name = section.slice('submodule.'.length) - notes.push(`[${header[1]}], a section git reads as [submodule "${name}"]; reading it as that`) - } - keys = name === undefined ? null : (sections.get(name) ?? sections.set(name, new Map()).get(name)) - line = line.slice(header[0].length) // a key may follow on the line - } - const key = keys && /^\s*([A-Za-z][\w-]*)\s*(?:=|$)/u.exec(line)?.[1].toLowerCase() - if (SUBMODULE_KEYS.has(key) && !keys.has(key)) keys.set(key, (lines = [line])) + for (const { section, subsection, header, keys } of readGitConfig(text)) { + const variable = subsection === undefined ? section : `${section}.${subsection}` + if (!variable?.startsWith('submodule.')) continue + const name = variable.slice('submodule.'.length) + if (section !== 'submodule') notes.push(`${header}, a section git reads as [submodule "${name}"]; reading it as that`) + const kept = sections.get(name) ?? sections.set(name, new Map()).get(name) + for (const entry of keys) if (SUBMODULE_KEYS.has(entry.key) && !kept.has(entry.key)) kept.set(entry.key, entry) } const submodules = [] for (const [name, kept] of sections) { - const read = () => Object.values(parseGitmodules([`[submodule "${name}"]`, ...[...kept.values()].flat(), ''].join('\n'), { checkUrls: false })) + const header = `[submodule "${name.replaceAll(/["\\]/gu, '\\$&')}"]` + const read = () => Object.values(parseGitmodules([header, ...[...kept.values()].map((entry) => entry.text), ''].join('\n'), { checkUrls: false })) let first = null const dropped = [] for (;;) { @@ -176,7 +161,7 @@ function gitmodulesLeniently(text) { dropped.push(next) continue } - const path = kept.has('path') ? normalSubmodulePath(kept.get('path')) : null + const path = normalSubmodulePath(kept.get('path')?.value) if (path === null) { notes.push(`${first.message}; skipping the submodule`) } else { @@ -190,42 +175,153 @@ function gitmodulesLeniently(text) { return { submodules, notes } } -// A `path = ...` key's lines -> the directory it names, normalized, when that lies inside the -// repository (else null): `./lib/x` and `lib/x/` are lib/x. -function normalSubmodulePath(lines) { - const raw = lines.map((l, i) => (i < lines.length - 1 ? l.slice(0, -1) : l)).join('') // a `\` runs on - const eq = raw.indexOf('=') - if (eq === -1) return null - const path = posix.normalize(gitConfigValue(raw.slice(eq + 1))).replace(/\/+$/u, '') +// A `path`'s value -> the directory it names, normalized, when that lies inside the repository (else +// null): `./lib/x` and `lib/x/` are lib/x. +function normalSubmodulePath(value) { + if (typeof value !== 'string') return null + const path = posix.normalize(value).replace(/\/+$/u, '') return path !== '' && path !== '.' && inRoot(path) ? path : null } -const GIT_ESCAPES = { n: '\n', t: '\t', b: '\b' } +// git's ctype, ASCII alone: only these are space, and a key is letters, digits and `-`, starting with +// a letter. +const GIT_SPACE = new Set([' ', '\t', '\n', '\r']) +const isGitAlpha = (char) => /^[A-Za-z]$/u.test(char) +const isGitKeyChar = (char) => /^[\dA-Za-z-]$/u.test(char) +const GIT_ESCAPES = { __proto__: null, t: '\t', b: '\b', n: '\n', '\\': '\\', '"': '"' } + +// `.gitmodules` text as git's config.c reads a config file, to the character, refusing what git +// refuses ("bad config line") and nothing more: its sections in order, each `{ section, subsection, +// header, keys }` -- the name lowercased, the subsection with a backslash's character for it, the +// header as written -- and each key `{ key, value, text }`: its name lowercased, its value (null for +// a key alone) and its text, from the key to the end of its value. Keys before any header are in a +// first section with no name. +function readGitConfig(text) { + const src = text.startsWith('\uFEFF') ? text.slice(1) : text // a byte-order mark git skips + let pos = 0 + let last = 0 // where the character last read starts + let line = 1 + let at = 1 // its line + let eof = false + // git's get_next_char: CRLF is a line end, a lone CR is space, and the end of the file a line end, + // read again at every call after. + const next = () => { + last = pos + at = line + if (pos >= src.length) { + eof = true + return '\n' + } + let char = src[pos++] + if (char === '\r' && src[pos] === '\n') char = src[pos++] + if (char === '\n') line++ + return char + } + const refuse = (what) => new Error(`.gitmodules: ${what} at line ${at}; git refuses such a file`) + + // git's get_base_var and get_extended_base_var: `[name]` or `[name "subsection"]`. + const readHeader = () => { + let section = '' + for (;;) { + const char = next() + if (eof) throw refuse('a section header with no closing "]"') + if (char === ']') break + if (GIT_SPACE.has(char)) return { section, subsection: readSubsection(char) } + if (!isGitKeyChar(char) && char !== '.') throw refuse("a character git doesn't take in a section name") + section += char.toLowerCase() + } + if (section === '') throw refuse('a section with no name') + return { section, subsection: undefined } + } + const readSubsection = (first) => { + let char = first + do { + if (char === '\n') throw refuse('a section header that runs past its line') + char = next() + } while (GIT_SPACE.has(char)) + if (char !== '"') throw refuse('a section name and then no quoted subsection') + let subsection = '' + for (char = next(); char !== '"'; char = next()) { + if (char === '\\') char = next() + if (char === '\n') throw refuse('a subsection with no closing quote') + subsection += char + } + if (next() !== ']') throw refuse('a subsection with no "]" right after it') + return subsection + } + // git's parse_value: quotes, escapes, a `\` that runs the value on, a comment outside quotes, and + // space outside quotes trimmed at both ends. + const readValue = () => { + let value = '' + let quoted = false + let comment = false + let trim = -1 // where the space at the end begins, outside quotes + for (;;) { + let char = next() + if (char === '\n') { + if (quoted) throw refuse('a value with no closing quote') + return trim === -1 ? value : value.slice(0, trim) + } + if (comment) continue + if (GIT_SPACE.has(char) && !quoted) { + if (value !== '') { + if (trim === -1) trim = value.length + value += char + } + continue + } + if (!quoted && (char === '#' || char === ';')) { + comment = true + continue + } + trim = -1 + if (char === '\\') { + char = next() + if (char === '\n') continue + if (!(char in GIT_ESCAPES)) throw refuse("an escape git doesn't read") + value += GIT_ESCAPES[char] + } else if (char === '"') { + quoted = !quoted + } else { + value += char + } + } + } + // git's get_value: a key, and `=` and its value or nothing. + const readKey = (first, start) => { + let key = first.toLowerCase() + let char = next() + for (; isGitKeyChar(char); char = next()) key += char.toLowerCase() // the end reads as a line end + while (char === ' ' || char === '\t') char = next() + let value = null + if (char !== '\n') { + if (char !== '=') throw refuse('a key and then neither "=" nor the end of its line') + value = readValue() + } + return { key, value, text: src.slice(start, last) } + } -// A git-config value as git reads it: `"` quotes (dropped), `\` escapes, a `#`/`;` comment outside -// quotes, and whitespace trimmed at both ends outside quotes. -function gitConfigValue(raw) { - let out = '' - let held = '' // unquoted whitespace, kept only if more value follows - let quoted = false - for (let i = 0; i < raw.length; i++) { - const ch = raw[i] - if (ch === '\\') { - const next = raw[++i] ?? '' - out += held + (GIT_ESCAPES[next] ?? next) - held = '' - } else if (ch === '"') { - quoted = !quoted - } else if (!quoted && (ch === '#' || ch === ';')) { - break - } else if (!quoted && (ch === ' ' || ch === '\t')) { - if (out !== '') held += ch + const sections = [{ section: undefined, subsection: undefined, header: undefined, keys: [] }] + let comment = false + for (;;) { + const char = next() + const start = last + if (char === '\n') { + if (eof) return sections + comment = false + } else if (comment || GIT_SPACE.has(char)) { + continue + } else if (char === '#' || char === ';') { + comment = true + } else if (char === '[') { + const { section, subsection } = readHeader() + sections.push({ section, subsection, header: src.slice(start, pos), keys: [] }) + } else if (isGitAlpha(char)) { + sections.at(-1).keys.push(readKey(char, start)) } else { - out += held + ch - held = '' + throw refuse('text where git reads a key, a section or a comment') } } - return out } // --- Ownership -------------------------------------------------------------------------------- diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index e9fe329e..cde86e10 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -1015,6 +1015,29 @@ test('buildSolidityBundle keeps a submodule whose .gitmodules path doesn\'t read } })) +test('buildSolidityBundle refuses a .gitmodules git refuses, so no submodule section is read past', withTmp(async (t, tmp) => { + // deps/x is outside forge's libs: only its .gitmodules section makes it a dependency. A header git + // doesn't read would lose that section, deps/x then the project's own, its link to .env trusted. + writeProject(tmp, { 'foundry.toml': '[profile.default]\nremappings = ["x/=deps/x/src/"]\n', '.env': 'PRIVATE_KEY=0xabc\n', 'src/A.sol': 'import "x/Evil.sol";\n' }) + mkdirSync(join(tmp, 'deps/x/src'), { recursive: true }) + symlinkSync('../../../.env', join(tmp, 'deps/x/src/Evil.sol')) + const x = '\n\tpath = deps/x\n\turl = https://github.com/e/x\n' + for (const [gitmodules, what, line] of [ + [`[submodule.deps/x]${x}`, "a character git doesn't take in a section name", 1], + [`[submodule "deps/x"${x}`, 'a subsection with no "]" right after it', 1], + [`[submodule deps/x]${x}`, 'a section name and then no quoted subsection', 1], + [`[submodule "y"]\n\tpath = lib/y\n\turl = https://github.com/o/y\n[submodule deps/x]${x}`, 'a section name and then no quoted subsection', 4], + ]) { + writeFileSync(join(tmp, '.gitmodules'), gitmodules) + // eslint-disable-next-line no-await-in-loop -- each run rewrites .gitmodules + await t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + { message: `.gitmodules: ${what} at line ${line}; git refuses such a file` }, + gitmodules, + ) + } +})) + test('buildSolidityBundle refuses a .sol file that isn\'t UTF-8, rather than bundle it with U+FFFD in it', withTmp(async (t, tmp) => { // \xe9 alone is Latin-1's é: solc refuses it, and the bundle must hold the file's own text. writeProject(tmp, { 'src/A.sol': 'import "./B.sol";\ncontract A {}\n' }) diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index e5cbf2cf..41f2dfc9 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -801,6 +801,40 @@ test('readGitmodules reads what the library refuses submodule by submodule, warn } })) +test('readGitmodules refuses a .gitmodules git refuses, rather than read past what git can\'t', withProject({}, (t, dir) => { + const x = '\n\tpath = lib/x\n' + for (const [text, what, line] of [ + // A header git doesn't read: past it, a submodule's keys would be lost, or taken for another's. + [`[submodule.lib/x]${x}`, "a character git doesn't take in a section name", 1], + [`[submodule "x"${x}`, 'a subsection with no "]" right after it', 1], + [`[submodule "x" ]${x}`, 'a subsection with no "]" right after it', 1], + [`[submodule x]${x}`, 'a section name and then no quoted subsection', 1], + [`[submodule "y"]\n\tpath = lib/y\n\tupdate = none\n[submodule x]${x}`, 'a section name and then no quoted subsection', 4], + [`[submodule\n"x"]${x}`, 'a section header that runs past its line', 1], + [`[submodule "x${x}`, 'a subsection with no closing quote', 1], + ['[]\n\tpath = lib/x\n', 'a section with no name', 1], + ['[submodule', 'a section header with no closing "]"', 1], + // A key, value or line git doesn't read. + ['[submodule "x"]\n\tpath # lib/x\n', 'a key and then neither "=" nor the end of its line', 2], + ['[submodule "x"]\n\tpath = "lib/x\n\turl = https://github.com/o/x\n', 'a value with no closing quote', 2], + ['[submodule "x"]\n\tpath = lib\\x\n', "an escape git doesn't read", 2], + ['[submodule "x"]\n\t./path = lib/x\n', 'text where git reads a key, a section or a comment', 2], + ]) { + writeFileSync(join(dir, '.gitmodules'), text) + t.assert.throws(() => readGitmodules(dir), { message: `.gitmodules: ${what} at line ${line}; git refuses such a file` }, text) + } + // What git reads, oddly, the library refuses and stasis reads as git does: sections of one name + // however it's escaped, merged, and a comment's `\` running nothing on. + writeFileSync(join(dir, '.gitmodules'), '[submodule "a\\x"]\n\tpath = lib/x # a comment \\\n[submodule "ax"]\n\turl = https://github.com/o/x\n\tupdate = none\n') + const warn = console.warn + console.warn = () => {} + try { + t.assert.deepEqual(readGitmodules(dir), [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }]) + } finally { + console.warn = warn + } +})) + test('a remappings.txt taken as written (solc) may map a prefix to nothing', (t) => { const remappings = parseRemappings('x/=\nctx:y/=\n') t.assert.deepEqual(remappings, [{ context: null, prefix: 'x/', target: '' }, { context: 'ctx', prefix: 'y/', target: '' }]) From 7ab19b9f7a49f2bddfc0128de4238fd3cce513be Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 22:01:38 +0000 Subject: [PATCH 20/20] refactor(bundle): read the Solidity project through host, as main's VFS bundles do Main (#194) threads a filesystem `host` through the Solidity loader so a bundle can be built from soldeer.lock in a Vfs. This branch rewrote the same code to decide ownership by real path, reading the disk directly. This commit carries `host` through that code: - The ownership walk reads links with `host.readlink`, lists directories with `host.readdir`, and checks its result against the host's realpath. On disk, the check still uses realpath(3), which gives the filesystem's own spelling. - Config files, carried manifests, .gitmodules, and the `package.json` files that bucket a source are read through `host`. - `readRegularFileOrNull` stats through the host and reads only a regular file. When the stat fails, a read says why, so a loop or an unsearchable directory is still an error rather than a missing file. - `discoverSolidityConfig`, `collectSolidityFilesFromDisk` and `readRemappingsFile` are synchronous, as on main. `readPackageJson` decodes with main's `packageJSONText`. The case-insensitive-filesystem test now emulates that filesystem with a host instead of patching node:fs. A new test checks that a bundle read through a Vfs host still refuses a dependency's link to the project's .env. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --- stasis-core/src/bundle-util.js | 38 ++++---- stasis/src/cmd/bundle.js | 30 ++++--- stasis/src/loaders/foundry.js | 72 ++++++++-------- stasis/src/loaders/solidity-ownership.js | 95 ++++++++++---------- stasis/src/loaders/solidity.js | 105 +++++++++++------------ tests/solidity-loader.test.js | 24 ++---- tests/vfs-bundle-host.test.js | 24 ++++++ 7 files changed, 203 insertions(+), 185 deletions(-) diff --git a/stasis-core/src/bundle-util.js b/stasis-core/src/bundle-util.js index 3168af21..4164193d 100644 --- a/stasis-core/src/bundle-util.js +++ b/stasis-core/src/bundle-util.js @@ -1,5 +1,4 @@ import { isUtf8 } from 'node:buffer' -import { closeSync, constants, fstatSync, openSync, readFileSync } from 'node:fs' import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path' import { isValidRepoField } from './bundle.js' @@ -45,26 +44,25 @@ export function findPackageMetadata(baseDir, fileRelPath, { strict = false, chec // The error codes that mean nothing is at a path. export const NO_ENTRY = new Set(['ENOENT', 'ENOTDIR']) -// `file`'s bytes, or null when there's no file (a directory counts as none). It's opened without -// blocking and read only when it's a regular file: a FIFO, a socket, a device or a link to one +// `file`'s bytes, read through `host`, or null when there's no file (a directory counts as none). +// It's read only when it's a regular file: a FIFO, a socket, a device or a link to one // (`/dev/stdin`) throws, naming it `label`, rather than stalling or reading the process's input. -export function readRegularFileOrNull(file, label) { - let fd - try { - fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)) - } catch (err) { - if (NO_ENTRY.has(err.code) || err.code === 'EISDIR') return null - if (err.code === 'ENXIO') throw new Error(`${label}: not a regular file`, { cause: err }) // a socket - throw err - } - try { - const stat = fstatSync(fd) - if (stat.isDirectory()) return null - if (!stat.isFile()) throw new Error(`${label}: not a regular file`) - return readFileSync(fd) - } finally { - closeSync(fd) +// What can't be stat'ed is read to say why: only a path with nothing there is no file, and a loop +// or a directory that may not be searched throws. +export function readRegularFileOrNull(file, label, host = diskHost) { + const stat = host.stat(file) + if (stat === null) { + try { + host.readFile(file) + } catch (err) { + if (NO_ENTRY.has(err.code)) return null + throw err + } + throw new Error(`${label}: not a regular file`) } + if (stat.isDirectory()) return null + if (!stat.isFile()) throw new Error(`${label}: not a regular file`) + return host.readFile(file) } // The package.json at `rel` (under `baseDir`), parsed (a leading byte-order mark skipped, as npm @@ -85,7 +83,7 @@ export function readPackageJson(baseDir, rel, { strict = false, check, host = di // Strict, it's read as the file's own text or not at all; lenient lookups decode it as they always // have (a stray byte as U+FFFD). if (strict && !isUtf8(bytes)) throw new Error(`${rel}: not valid UTF-8`) - return parseJson(bytes.toString('utf8').replace(/^\uFEFF/u, ''), rel) + return parseJson(packageJSONText(bytes), rel) } catch (err) { if (strict) throw err return null diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index 940033ed..39bd9c45 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -99,8 +99,9 @@ function githubSubmodules(submodules) { // Classify a Solidity file's dep bucket: Soldeer (`dependencies/-/`) or a // github submodule (`lib/`, via the `.gitmodules` `ownership` read), else null to defer to the -// node_modules/workspace logic. `ownership.assert` vets a package.json path before it is read. -function makeSolidityClassifier(baseDir, ownership) { +// node_modules/workspace logic. `ownership.assert` vets a package.json path before it is read +// through `host`. +function makeSolidityClassifier(baseDir, ownership, host) { const submodules = githubSubmodules(ownership.submodules) const check = ownership.assert const versions = new Map() // a submodule's package.json version, read once @@ -114,7 +115,7 @@ function makeSolidityClassifier(baseDir, ownership) { } for (const [sub, { name, branch }] of submodules) { if (path === sub || path.startsWith(`${sub}/`)) { - if (!versions.has(sub)) versions.set(sub, readPackageJson(baseDir, moduleFileKey(sub, 'package.json'), { strict: true, check })?.version) + if (!versions.has(sub)) versions.set(sub, readPackageJson(baseDir, moduleFileKey(sub, 'package.json'), { strict: true, check, host })?.version) return { bucketDir: sub, name, version: versions.get(sub) ?? branch ?? '0.0.0', ecosystem: 'github' } } } @@ -234,9 +235,9 @@ function packageLookup(baseDir, options) { // root and for each package dir `classifyDep`/`packageOf` places a bundled source in, but none // whose path `ownership` refuses (see solidityOwnership). Carried as written: whatever they hold // (an RPC URL with its API key, an Etherscan key, a URL's credentials) is in the bundle too, as -// with --package-json. -function solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership }) { - const realBase = realpathSync(baseDir) +// with --package-json. Read through `host`. +function solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership, host }) { + const realBase = host.realpath(baseDir) // `{ text }`, or `{ why }` it can't be carried (null: nothing is there). Read by the real path // `ownership` resolved `rel` to, so what's carried is the file it vouched for. const carry = (rel) => { @@ -244,7 +245,7 @@ function solidityManifests(baseDir, sources, configFiles, { classifyDep, package if (reason) return { why: reason } if (real === null) return { why: null } if (outside) throw new Error(`Refusing to follow symlink escaping bundle root: ${rel} -> ${resolve(realBase, real)}`) - const buf = readRegularFileOrNull(join(realBase, real), rel) + const buf = readRegularFileOrNull(join(realBase, real), rel, host) if (buf === null) return { why: null } // a directory return { text: decodeUtf8(buf, rel) } } @@ -311,15 +312,15 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin const baseDir = resolve(cwd) const normalized = normalizeEntries(entries, cwd) for (const e of normalized) { - if (!isSolidityEntry(e, baseDir)) throw new Error(`buildSolidityBundle: not a .sol file or directory: ${e}`) + if (!isSolidityEntry(e, baseDir, host)) throw new Error(`buildSolidityBundle: not a .sol file or directory: ${e}`) } const expanded = expandSolidityEntries(baseDir, normalized, host) - const { remappings, libs, ownership, files: configFiles, envUsed } = await discoverSolidityConfig(baseDir, { mappingFile, env }) + const { remappings, libs, ownership, files: configFiles, envUsed } = discoverSolidityConfig(baseDir, { mappingFile, env, host }) // The bundle doesn't record the environment, so say when it shaped the resolution. if (envUsed.length > 0) console.warn(`[stasis] Solidity imports resolved with ${envUsed.join(', ')} from the environment`) - const sources = await collectSolidityFilesFromDisk(baseDir, expanded, remappings, { libs, ownership }) - const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir, libs, ownership }) + const sources = collectSolidityFilesFromDisk(baseDir, expanded, remappings, { libs, ownership, host }) + const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir, libs, ownership, host }) // Bundles must be self-contained: fail on a missing entry or unresolved import. const issues = [] @@ -333,12 +334,12 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin throw new Error(`Solidity bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`) } - const classifyDep = makeSolidityClassifier(baseDir, ownership) - const packageOf = packageLookup(baseDir, { strict: true, check: ownership.assert }) + const classifyDep = makeSolidityClassifier(baseDir, ownership, host) + const packageOf = packageLookup(baseDir, { strict: true, check: ownership.assert, host }) const bundled = new Map(sources) const formats = new Map() if (manifests) { - for (const [path, text] of solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership })) { + for (const [path, text] of solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership, host })) { bundled.set(path, text) formats.set(path, posix.basename(path) === 'package.json' ? 'json' : 'resource') } @@ -356,6 +357,7 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin conditionKey: 'solidity', classifyDep, packageOf, + host, }) } diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index c166256d..38e7916d 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -13,10 +13,8 @@ // other keys (`FOUNDRY_PROFILE` and the remapping env vars are). The project is read through a // `host` (@exodus/stasis-core/host), the disk's by default. -import { existsSync, lstatSync, opendirSync, statSync } from 'node:fs' import { posix, resolve } from 'node:path' -import { readText } from '@exodus/stasis-core/bundle-util' import { diskHost } from '@exodus/stasis-core/host' import { toPosix } from '@exodus/stasis-core/util' import { isDir } from '../resolve-typescript.js' @@ -69,18 +67,18 @@ function cmpPath(a, b) { return x.length - y.length } -function canonicalize(p) { - const real = realpathOrNull(p) +function canonicalize(p, host) { + const real = realpathOrNull(p, host) return real === null ? null : toPosix(real) } -// How messages name a file of the project at `root` (absolute POSIX): from the root, by its lexical -// or its canonical path, else as given. -export function shownFrom(root, canonicalRoot = canonicalize(root) ?? root) { +// How messages name a file of the project at `root` (absolute POSIX, read through `host`): from the +// root, by its lexical or its canonical path, else as given. +export function shownFrom(root, host = diskHost, canonicalRoot = canonicalize(root, host) ?? root) { return (abs) => stripPrefix(abs, root) || stripPrefix(abs, canonicalRoot) || abs } -const isSymlinkPath = (p) => { +const isSymlinkPath = (p, host) => { try { return host.readlink(p) !== null } catch { @@ -482,11 +480,11 @@ const isExtends = (v) => typeof v === 'string' // parseFoundryToml). Throws where forge refuses the config, and where `refused` (a dependency's // config: see findNestedFoundryRemappings) gives a reason not to read the file or its base: a // dependency's config may not read the project's files. Messages name files `show(file)`. -function readFoundryProfiles(file, profile, { refused = () => null, show }) { +function readFoundryProfiles(file, profile, { refused = () => null, show, host }) { const name = show(file) const refusal = refused(file) if (refusal) throw new ConfigRefused(`${name}: refusing to read it: ${refusal}`) - const text = readUtf8OrNull(file, name) + const text = readUtf8OrNull(file, name, host) if (text === null) return { profiles: new Map(), topLevel: new Map(), files: [] } let { profiles, topLevel } = parseFoundryToml(text, name) const files = [file] @@ -500,7 +498,7 @@ function readFoundryProfiles(file, profile, { refused = () => null, show }) { const baseRefusal = refused(baseFile) if (baseRefusal) throw new ConfigRefused(`${name}: refusing to extend ${extPath}: ${baseRefusal}`) const baseName = show(baseFile) - const baseText = readUtf8OrNull(baseFile, baseName) + const baseText = readUtf8OrNull(baseFile, baseName, host) if (baseText === null) throw new ConfigRefused(`${name}: the inherited config file does not exist: ${extPath}`) const base = parseFoundryToml(baseText, baseName).profiles if (base.get(profile)?.has('extends')) { @@ -540,9 +538,9 @@ export function foundryTomlRemappings(text, profile = 'default') { // The same for a foundry.toml file, with its `extends` base: what `--mapping=foundry.toml` takes. // `files` lists what was read; `profiled` whether the selected `profile` is one of the file's. // Messages name files `show(file)`. -export function readFoundryTomlRemappings(file, profile, { show }) { +export function readFoundryTomlRemappings(file, profile, { show, host = diskHost }) { const abs = toPosix(resolve(file)) - const read = readFoundryProfiles(abs, profile, { show }) + const read = readFoundryProfiles(abs, profile, { show, host }) return { remappings: profileRemappings(read, profile, show(abs)), files: read.files, profiled: hasProfile(read.profiles, profile) } } @@ -571,12 +569,12 @@ function detectLibs(root, host) { // The selected profile's settings for a Foundry project at `root` (absolute POSIX), defaults // filled in the way forge fills them. `remappings` are the profile's own, unnormalized; an invalid -// one throws (configRemappings). `refused`, `show` (from the root by default): see +// one throws (configRemappings). `refused`, `show` (from the root by default), `host`: see // readFoundryProfiles. -function loadFoundryConfig(root, profile, { refused, show = shownFrom(root) } = {}) { +function loadFoundryConfig(root, profile, { refused, host, show = shownFrom(root, host) }) { const file = rustJoin(root, FOUNDRY_TOML) const name = show(file) - const { profiles, files } = readFoundryProfiles(file, profile, { refused, show }) + const { profiles, files } = readFoundryProfiles(file, profile, { refused, show, host }) const dict = selectProfile(profiles, profile) // A setting of the wrong type throws, as forge refuses the config: no quiet default. const setting = (key, ok, what) => { @@ -588,10 +586,10 @@ function loadFoundryConfig(root, profile, { refused, show = shownFrom(root) } = return { profiles, files, - src: setting('src', isString, 'a string') ?? findSourceDir(root), + src: setting('src', isString, 'a string') ?? findSourceDir(root, host), test: setting('test', isString, 'a string') ?? 'test', script: setting('script', isString, 'a string') ?? 'script', - libs: setting('libs', (v) => Array.isArray(v) && v.every(isString), 'an array of strings') ?? detectLibs(root), + libs: setting('libs', (v) => Array.isArray(v) && v.every(isString), 'an array of strings') ?? detectLibs(root, host), remappings: dict.has('remappings') ? configRemappings(dict.get('remappings'), name) : [], autoDetect: setting('auto_detect_remappings', (v) => typeof v === 'boolean', 'a boolean') !== false, } @@ -645,10 +643,10 @@ function rebaseNested(r, canonical, lexical) { // refuses is skipped (warned). One that isn't TOML or holds an invalid remapping throws: forge // refuses a bad remappings.txt line too, and skips a foundry.toml it can't read, which here is an // error rather than a config quietly left out. `refused`, `show`: see readFoundryProfiles. -function loadNestedConfig(canonical, profile, { refused, show }) { +function loadNestedConfig(canonical, profile, { refused, show, host }) { let config try { - config = loadFoundryConfig(canonical, profile, { refused, show }) + config = loadFoundryConfig(canonical, profile, { refused, show, host }) } catch (err) { if (!(err instanceof ConfigRefused)) throw err console.warn(`[loader.solidity] Skipping a dependency's config: ${err.message}`) @@ -658,7 +656,7 @@ function loadNestedConfig(canonical, profile, { refused, show }) { const txtName = show(txt) const refusal = refused(txt) // (null when nothing is there) if (refusal) console.warn(`[loader.solidity] Skipping a dependency's ${txtName}: ${refusal}`) - const text = refusal ? null : readUtf8OrNull(txt, txtName) + const text = refusal ? null : readUtf8OrNull(txt, txtName, host) return { src: config.src, libs: config.libs, @@ -673,9 +671,9 @@ function loadNestedConfig(canonical, profile, { refused, show }) { // dependency (transitively, through each one's own libs) that is a Foundry project. A dependency's // config reads only its own files and other dependencies' (by real path: `ownership`, see // solidityOwnership), as forge would find them from its lexical path. -function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) { - const canonicalRoot = canonicalize(root) ?? root - const shown = shownFrom(root, canonicalRoot) +function findNestedFoundryRemappings(root, libPaths, profile, files, ownership, host) { + const canonicalRoot = canonicalize(root, host) ?? root + const shown = shownFrom(root, host, canonicalRoot) // A dependency's file (a path from its canonical dir) under its lexical path: where the bundle // sees it, and how messages name it. const lexical = (entry, file) => rustJoin(entry.path, stripPrefix(file, entry.canonical) ?? file) @@ -715,7 +713,7 @@ function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) pending.delete(key) if (entry.canonical === canonicalRoot) continue if (!configs.has(entry.canonical)) { - const config = loadNestedConfig(entry.canonical, profile, { refused: refused(entry), show: show(entry) }) + const config = loadNestedConfig(entry.canonical, profile, { refused: refused(entry), show: show(entry), host }) configs.set(entry.canonical, config) for (const f of config?.files ?? []) files.add(lexical(entry, f)) } @@ -726,7 +724,7 @@ function findNestedFoundryRemappings(root, libPaths, profile, files, ownership) if (!entry.isSymlink && !seen.has(entry.canonical)) { seen.add(entry.canonical) for (const lib of config.libs) { - for (const e of foundryTomlDirEntries(rustJoin(entry.path, lib))) if (!e.isSymlink) addPending({ ...e, viaDependency: true }) + for (const e of foundryTomlDirEntries(rustJoin(entry.path, lib), host)) if (!e.isSymlink) addPending({ ...e, viaDependency: true }) } } // A custom (or missing) source dir isn't auto-detected: forge synthesizes `/=//`. @@ -796,12 +794,12 @@ const withOverlays = (authoritative, r) => { } // `RemappingsProvider::get_remappings`: the remappings in the order forge settles them. -function providerRemappings(root, { userRemappings, libs, autoDetect, profile, files, ownership }) { +function providerRemappings(root, { userRemappings, libs, autoDetect, profile, files, ownership, host }) { const authoritativeUser = userRemappings.map((r) => (r.context === null ? r : { ...r, context: rustJoin(root, r.context) })) const all = new Remappings([...userRemappings]) if (!autoDetect) return all.intoInner() - const nested = findNestedFoundryRemappings(root, libs, profile, files, ownership) + const nested = findNestedFoundryRemappings(root, libs, profile, files, ownership, host) const auto = { global: [], contextual: [] } for (const lib of libs) { const found = findRemappingsWithContext(rustJoin(root, lib), host) @@ -855,16 +853,16 @@ function providerRemappings(root, { userRemappings, libs, autoDetect, profile, f // a pinned mapping file, nothing else is read from it). `files` lists the config files read (the // foundry.toml, its `extends` base). A foundry.toml that isn't TOML or holds an invalid remapping // throws. -export function foundryLibs(baseDir, { env = process.env } = {}) { +export function foundryLibs(baseDir, { env = process.env, host = diskHost } = {}) { const root = toPosix(resolve(baseDir)) const profile = foundryProfile(env) try { - const config = loadFoundryConfig(root, profile) + const config = loadFoundryConfig(root, profile, { host }) return { libs: config.libs, profiled: profileApplies(config.profiles, profile), files: config.files } } catch (err) { if (!(err instanceof ConfigRefused)) throw err console.warn(`[loader.solidity] Using the default lib dirs: ${err.message}`) - return { libs: detectLibs(root), profiled: false, files: [rustJoin(root, FOUNDRY_TOML)] } + return { libs: detectLibs(root, host), profiled: false, files: [rustJoin(root, FOUNDRY_TOML)] } } } @@ -874,22 +872,22 @@ export function foundryLibs(baseDir, { env = process.env } = {}) { // environment variables that shaped them; `ownership` its files' owners (see solidityOwnership), // which also confines what a dependency's config reads. `env` supplies FOUNDRY_PROFILE and // FOUNDRY_REMAPPINGS / DAPP_REMAPPINGS. -export function foundryProject(baseDir, { env = process.env } = {}) { +export function foundryProject(baseDir, { env = process.env, host = diskHost } = {}) { const root = toPosix(resolve(baseDir)) const profile = foundryProfile(env) - const config = loadFoundryConfig(root, profile) + const config = loadFoundryConfig(root, profile, { host }) const profiled = profileApplies(config.profiles, profile) - const ownership = projectOwnership(baseDir, config.libs, { soldeer: true }) + const ownership = projectOwnership(baseDir, config.libs, { soldeer: true, host }) const files = new Set(config.files) const envName = env.DAPP_REMAPPINGS !== undefined ? 'DAPP_REMAPPINGS' : env.FOUNDRY_REMAPPINGS !== undefined ? 'FOUNDRY_REMAPPINGS' : null const envRemappings = envName === null ? [] : parseRemappingLines(env[envName], { label: envName }) const txtFile = rustJoin(root, REMAPPINGS_TXT) - const txt = readUtf8OrNull(txtFile, REMAPPINGS_TXT) + const txt = readUtf8OrNull(txtFile, REMAPPINGS_TXT, host) if (txt !== null) files.add(txtFile) const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, { label: REMAPPINGS_TXT })), ...config.remappings] - const provided = providerRemappings(root, { userRemappings, libs: config.libs, autoDetect: config.autoDetect, profile, files, ownership }) + const provided = providerRemappings(root, { userRemappings, libs: config.libs, autoDetect: config.autoDetect, profile, files, ownership, host }) .map((r) => displayRelative(relativePreservingBoundary(r, root))) // `forge build` re-reads them as config remappings, dropping aliases of its own input dirs. @@ -906,7 +904,7 @@ export function foundryProject(baseDir, { env = process.env } = {}) { // One read from outside the root (an `extends = "../base.toml"`) stays `../`, for --manifests to // refuse (it can't be carried). - const relFiles = [...files].map((f) => projectRelative(root, f)) + const relFiles = [...files].map((f) => projectRelative(root, f, host)) const envUsed = [...(profiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : []), ...(envName === null ? [] : [envName])] return { remappings, libs: config.libs, files: relFiles, envUsed, ownership } } diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js index 627c437b..eff7e58f 100644 --- a/stasis/src/loaders/solidity-ownership.js +++ b/stasis/src/loaders/solidity-ownership.js @@ -3,13 +3,13 @@ // (foundry.js) and the bundler's --manifests. Dependencies are untrusted input: a link one plants // out of itself is never followed. -import { isUtf8 } from 'node:buffer' -import { lstatSync, readdirSync, readlinkSync, realpathSync } from 'node:fs' +import { realpathSync } from 'node:fs' import { isAbsolute, join, parse, posix, relative, resolve, sep } from 'node:path' import { utf8toString } from '@exodus/bytes/utf8.js' import { LockfileError, parseGitmodules } from '@preventive/lockfile/foundry.js' import { NO_ENTRY, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util' +import { diskHost } from '@exodus/stasis-core/host' import { hasNodeModulesSegment } from '@exodus/stasis-core/util' import { isDir } from '../resolve-typescript.js' @@ -19,10 +19,14 @@ const toSlashes = (p) => (sep === '\\' ? p.replaceAll('\\', '/') : p) // --- Reading -------------------------------------------------------------------------------- -// `p`'s real path as the OS resolves it (realpath(3): the filesystem's own spelling), or null. -export function realpathOrNull(p) { +// `p`'s real path as `host` resolves it, which throws when it can't: on disk, as the OS does +// (realpath(3): the filesystem's own spelling, which Node's realpathSync doesn't give). +const realpathIn = (host, p) => (host === diskHost ? realpathSync.native(p) : host.realpath(p)) + +// `p`'s real path (realpathIn), or null. +export function realpathOrNull(p, host = diskHost) { try { - return realpathSync.native(p) + return realpathIn(host, p) } catch { return null } @@ -37,12 +41,12 @@ const inRoot = (rel) => rel !== '..' && !rel.startsWith('../') && !isAbsolute(re // `../` when outside the project. One whose real path the OS can't give (past PATH_MAX) is never // normalized, which could name another file: it keeps the path it was read by, `..` and all, from // the root however that's spelled (as given or by its real path), for solidityOwnership to refuse, -// or else stays absolute, a name --manifests refuses as unresolvable. -export function projectRelative(root, abs) { +// or else stays absolute, a name --manifests refuses as unresolvable. Real paths are `host`'s. +export function projectRelative(root, abs, host = diskHost) { const rel = toSlashes(relative(root, abs)) if (inRoot(rel) && !toSlashes(abs).split('/').includes('..')) return rel - const real = realpathOrNull(abs) - const realRoot = realpathOrNull(root) + const real = realpathOrNull(abs, host) + const realRoot = realpathOrNull(root, host) if (real !== null) return toSlashes(relative(realRoot ?? root, real)) return below(resolve(root), abs) ?? (realRoot === null ? null : below(realRoot, abs)) ?? toSlashes(abs) } @@ -56,21 +60,22 @@ function below(dir, abs) { return d.length < a.length && d.every((c, i) => a[i] === c) ? a.slice(d.length).join('/') : null } -// realpath(3) of `p`: `{ real }`, or `{ real: null, missing }`, `missing` only when nothing is -// there at all. The OS may fail to resolve what is there -- a real path past PATH_MAX, a loop, a +// realpathIn `host` of `p`: `{ real }`, or `{ real: null, missing }`, `missing` only when nothing +// is there at all. The OS may fail to resolve what is there -- a real path past PATH_MAX, a loop, a // link whose end it can't name (`/proc/self/fd/0` on a pipe), a dir it may not search -- and a // read may still get through. -function osRealpath(p) { +function osRealpath(p, host) { try { - return { real: realpathSync.native(p), missing: false } + return { real: realpathIn(host, p), missing: false } } catch (err) { - return { real: null, missing: NO_ENTRY.has(err.code) && !lexists(p) } + return { real: null, missing: NO_ENTRY.has(err.code) && !lexists(p, host) } } } -function lexists(p) { +// Whether anything is at `p` itself, a link not followed (`host.readlink` throws when nothing is). +function lexists(p, host) { try { - lstatSync(p) + host.readlink(p) return true } catch (err) { if (NO_ENTRY.has(err.code)) return false @@ -90,9 +95,9 @@ export function decodeUtf8(bytes, label) { } // A config file's text (decodeUtf8), or null when there's no file (readRegularFileOrNull: a regular -// file only). Errors name it `label`. -export function readUtf8OrNull(file, label) { - const buf = readRegularFileOrNull(file, label) +// file only, read through `host`). Errors name it `label`. +export function readUtf8OrNull(file, label, host = diskHost) { + const buf = readRegularFileOrNull(file, label, host) return buf === null ? null : decodeUtf8(buf, label) } @@ -105,8 +110,8 @@ export function readUtf8OrNull(file, label) { // doesn't check (`update = none`, `active`, a `[core]` section) -- never fails the bundle: it's // warned about and read submodule by submodule (gitmodulesLeniently). One git itself refuses is an // error: read past what git can't, a submodule's section would be lost, and its directory with it. -export function readGitmodules(baseDir) { - const text = readUtf8OrNull(join(baseDir, '.gitmodules'), '.gitmodules') +export function readGitmodules(baseDir, host = diskHost) { + const text = readUtf8OrNull(join(baseDir, '.gitmodules'), '.gitmodules', host) if (text === null) return [] try { return Object.values(parseGitmodules(text, { checkUrls: false })) @@ -326,9 +331,9 @@ function readGitConfig(text) { // --- Ownership -------------------------------------------------------------------------------- -const readdirOrEmpty = (dir) => { +const readdirOrEmpty = (dir, host) => { try { - return readdirSync(dir, { withFileTypes: true }) + return host.readdir(dir) } catch { return [] } @@ -358,9 +363,10 @@ const TARGET_SEPARATORS = sep === '\\' ? /[\\/]/u : /\//u // path is refused rather than trusted. `real` null with no `escape` means nothing is there. A // link the project placed (a workspace package in node_modules, a linked `lib/` entry) may lead // anywhere in the root, and so may one on the path the project was named by (a symlinked -// checkout, macOS's `/tmp`). -export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { - const realBase = realpathSync.native(baseDir) +// checkout, macOS's `/tmp`). The project is read through `host`. +export function solidityOwnership(baseDir, { dirs = [], packages = [], host = diskHost } = {}) { + const realBase = realpathIn(host, baseDir) + const realOf = (p) => realpathOrNull(p, host) const named = resolve(baseDir) const onNamedPath = (abs) => named === abs || named.startsWith(abs.endsWith(sep) ? abs : `${abs}${sep}`) const toRel = (abs) => toSlashes(relative(realBase, abs)) || '.' @@ -372,20 +378,20 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { const holders = new Set() const roots = new Set() const addReal = (set, rel) => { - const real = realpathOrNull(join(baseDir, rel)) + const real = realOf(join(baseDir, rel)) if (real !== null && inside(toRel(real))) set.add(toRel(real)) } // A dir as the project names it: relative to the root, or (an absolute lib) by its real path. const projectDir = (d) => { if (!isAbsolute(d)) return clean(d) - const real = realpathOrNull(d) + const real = realOf(d) return real === null ? null : toRel(real) } for (const d of dirs.map(projectDir).filter((rel) => rel !== null && inside(rel))) { if (posix.basename(d) === 'node_modules') continue // a package's own rule, below holders.add(d) addReal(holders, d) - for (const e of readdirOrEmpty(join(baseDir, d))) if (e.isSymbolicLink() && isDir(join(baseDir, d, e.name))) addReal(roots, `${d}/${e.name}`) + for (const e of readdirOrEmpty(join(baseDir, d), host)) if (e.isSymbolicLink() && isDir(join(baseDir, d, e.name), host)) addReal(roots, `${d}/${e.name}`) } for (const p of packages.map(clean).filter(inside)) { roots.add(p) @@ -425,21 +431,21 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { const next = join(cur, part) let target try { - if (!lstatSync(next).isSymbolicLink()) { - cur = next - continue - } - const bytes = readlinkSync(next, { encoding: 'buffer' }) - if (!isUtf8(bytes)) return NOTHING // not a name a string path can spell: unresolved - target = bytes.toString('utf8') + target = host.readlink(next) } catch { return NOTHING } + if (target === null) { + cur = next + continue + } + // A target that isn't UTF-8 reads with U+FFFD in it: not a name a string path can spell. + if (target.includes('\uFFFD')) return NOTHING if (depth >= 40) return NOTHING // ELOOP const r = walk(isAbsolute(target) ? parse(target).root : cur, target.split(TARGET_SEPARATORS), depth + 1) if (r.abs === null || r.escape !== null) return r - const dir = realpathOrNull(cur) ?? cur - const abs = realpathOrNull(r.abs) + const dir = realOf(cur) ?? cur + const abs = realOf(r.abs) if (abs === null) return NOTHING const at = toRel(join(dir, part)) const to = toRel(abs) @@ -465,8 +471,8 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { // The OS's answer is the one a read gets: the walk must agree with it, or the path is // refused, as it is when the OS can't resolve it at all, though a read may still get through. // (Past its last link the walk's path is spelled as given; with none, it's `path` itself.) - const { real: os, missing } = osRealpath(path) - const walked = abs === null ? null : abs === path ? os : realpathOrNull(abs) + const { real: os, missing } = osRealpath(path, host) + const walked = abs === null ? null : abs === path ? os : realOf(abs) if (walked !== os || (os === null && !missing)) escape = { link: rel, root: null, why: 'unresolved' } abs = os } @@ -491,11 +497,12 @@ export function solidityOwnership(baseDir, { dirs = [], packages = [] } = {}) { } // The ownership of the project at `baseDir` given its lib dirs (`soldeer`: forge's `dependencies/` -// holds dependencies too), with its git submodules, which it keeps as `submodules` (readGitmodules). -export function projectOwnership(baseDir, libs, { soldeer = false } = {}) { - const submodules = readGitmodules(baseDir) +// holds dependencies too), with its git submodules, which it keeps as `submodules` (readGitmodules), +// read through `host`. +export function projectOwnership(baseDir, libs, { soldeer = false, host = diskHost } = {}) { + const submodules = readGitmodules(baseDir, host) const dirs = [...libs, ...(soldeer ? ['dependencies'] : [])] - return { ...solidityOwnership(baseDir, { dirs, packages: submodules.map((s) => s.path) }), submodules } + return { ...solidityOwnership(baseDir, { dirs, packages: submodules.map((s) => s.path), host }), submodules } } // Why a path is refused (see solidityOwnership). diff --git a/stasis/src/loaders/solidity.js b/stasis/src/loaders/solidity.js index 96ba61ef..f5c214da 100644 --- a/stasis/src/loaders/solidity.js +++ b/stasis/src/loaders/solidity.js @@ -6,13 +6,12 @@ // Hardhat's/Node's node_modules lookup. The mapping/config files are read, not added to `sources`. // Dependencies are untrusted input: an import only ever reaches a `.sol` file inside the project, // a dependency's imports only its own and other dependencies' files (by real path), and nothing -// is read through a link a dependency planted out of itself (solidityOwnership). +// is read through a link a dependency planted out of itself (solidityOwnership). The project is read +// through a `host` (@exodus/stasis-core/host), the disk's by default. -import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path' -import { readText } from '@exodus/stasis-core/bundle-util' import { diskHost } from '@exodus/stasis-core/host' import { assertRealPathWithinBase, toPosix } from '@exodus/stasis-core/util' import { isDir, isFile } from '../resolve-typescript.js' @@ -150,14 +149,15 @@ export function parseRemappingsFromToml(tomlContent, { env = process.env } = {}) // Read a mapping file -> its remappings as listed (no discovery around it) and the files read. A // foundry.toml (its selected profile, with its `extends` base) is forge's, and so is a // remappings.txt when `forge` says forge reads it: slash-terminated the way forge reads them. -// Otherwise (solc, Hardhat) a remappings.txt applies as written. Messages name files `show(file)`. -function readMapping(mappingFile, { env, forge, show = (f) => f }) { +// Otherwise (solc, Hardhat) a remappings.txt applies as written. Messages name files `show(file)`; +// files are read through `host`. +function readMapping(mappingFile, { env, forge, host, show = (f) => f }) { if (mappingFile.endsWith('.toml')) { - const { remappings, files, profiled } = readFoundryTomlRemappings(mappingFile, foundryProfile(env), { show }) + const { remappings, files, profiled } = readFoundryTomlRemappings(mappingFile, foundryProfile(env), { show, host }) return { remappings: remappings.map(toSolcRemapping), files, profiled } } const name = show(mappingFile) - const text = readUtf8OrNull(mappingFile, name) + const text = readUtf8OrNull(mappingFile, name, host) if (text === null) throw new Error(`${name}: no such file`) const listed = parseRemappingLines(text, { label: name, emptyPath: !forge }) return { remappings: listed.map(forge ? toSolcRemapping : toLoaderRemapping), files: [mappingFile] } @@ -165,8 +165,8 @@ function readMapping(mappingFile, { env, forge, show = (f) => f }) { // Read a foundry.toml/remappings.txt mapping file -> its remappings (see readMapping; `forge` // defaults to a remappings.txt applying as written). The file itself is not added to sources. -export async function readRemappingsFile(mappingFile, { env = process.env, forge = false } = {}) { - return readMapping(mappingFile, { env, forge }).remappings +export function readRemappingsFile(mappingFile, { env = process.env, forge = false, host = diskHost } = {}) { + return readMapping(mappingFile, { env, forge, host }).remappings } // --- Resolution --------------------------------------------------------------------------------- @@ -182,24 +182,24 @@ export async function readRemappingsFile(mappingFile, { env = process.env, forge // library resolves against it); `ownership` tells the dependencies' files from the project's // (solidityOwnership: forge's libs, Soldeer's `dependencies/`, git submodules, node_modules); // `files` the project-relative config files read (`../` for one outside the project); `envUsed` -// the environment variables that shaped the result. -export async function discoverSolidityConfig(baseDir, { mappingFile, env = process.env } = {}) { - const forge = isFile(join(baseDir, FOUNDRY_TOML)) - if (forge && !mappingFile) return foundryProject(baseDir, { env }) - const { libs, profiled, files: libsFiles } = forge ? foundryLibs(baseDir, { env }) : { libs: [], profiled: false, files: [] } - const ownership = projectOwnership(baseDir, libs, { soldeer: forge }) - const show = shownFrom(toPosix(resolve(baseDir))) +// the environment variables that shaped the result. The project is read through `host`. +export function discoverSolidityConfig(baseDir, { mappingFile, env = process.env, host = diskHost } = {}) { + const forge = isFile(join(baseDir, FOUNDRY_TOML), host) + if (forge && !mappingFile) return foundryProject(baseDir, { env, host }) + const { libs, profiled, files: libsFiles } = forge ? foundryLibs(baseDir, { env, host }) : { libs: [], profiled: false, files: [] } + const ownership = projectOwnership(baseDir, libs, { soldeer: forge, host }) + const show = shownFrom(toPosix(resolve(baseDir)), host) if (mappingFile) { const abs = resolve(baseDir, mappingFile) - const { remappings, files, profiled: mappingProfiled } = readMapping(abs, { env, forge, show }) + const { remappings, files, profiled: mappingProfiled } = readMapping(abs, { env, forge, host, show }) // The profile picks the mapping file's remappings (a .toml) or the root foundry.toml's libs: the // files read are both's. const envUsed = profiled || mappingProfiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : [] - return { remappings, libs, ownership, files: [...new Set([...files, ...libsFiles].map((f) => projectRelative(baseDir, f)))], envUsed } + return { remappings, libs, ownership, files: [...new Set([...files, ...libsFiles].map((f) => projectRelative(baseDir, f, host)))], envUsed } } const txt = join(baseDir, REMAPPINGS_TXT) - if (!isFile(txt)) return { remappings: [], libs, ownership, files: [], envUsed: [] } - return { remappings: readMapping(txt, { env, forge, show }).remappings, libs, ownership, files: [REMAPPINGS_TXT], envUsed: [] } + if (!isFile(txt, host)) return { remappings: [], libs, ownership, files: [], envUsed: [] } + return { remappings: readMapping(txt, { env, forge, host, show }).remappings, libs, ownership, files: [REMAPPINGS_TXT], envUsed: [] } } // Solc's remapping choice for the source unit `name` imported from `fromFile`: among the @@ -282,7 +282,7 @@ function nodeModulesFile(baseDir, spec, fromFile, host) { // Where an import resolves, as `{ path }`, or `{ reason }` when it may not be read (`reason: null`: // it names no file). See resolveSolImport. -function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [], ownership } = {}) { +function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [], ownership, host = diskHost } = {}) { const relativeImport = isRelativeImport(specifier) const name = relativeImport ? resolveRelativeImport(specifier, fromFile) : specifier if (name === null) return { reason: 'it climbs above the project root' } @@ -296,7 +296,7 @@ function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [ if (isAbsolute(path) || posix.isAbsolute(path) || path === '..' || path.startsWith('../')) return { reason: `it resolves to ${path}, outside the project root` } if (!path.endsWith('.sol')) return { reason: `it resolves to ${path}, which is not a .sol file` } if (!baseDir) return { path } - const own = ownership ?? solidityOwnership(baseDir) + const own = ownership ?? solidityOwnership(baseDir, { host }) const target = own.of(path) if (target.reason) return { reason: target.reason } // (A link out of the root is refused when the file is read.) @@ -312,7 +312,7 @@ function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [ // file path (Hardhat / Node). Returns null when nothing resolves, or when the result isn't a `.sol` // file inside the root, crosses a link a dependency planted out of itself, or is the project's own // file imported by a dependency's -- by real path, with `ownership` (solidityOwnership's; by default -// only node_modules holds dependencies). +// only node_modules holds dependencies). The project is read through `host`. export function resolveSolImport(specifier, fromFile, options = {}) { return resolveImport(specifier, fromFile, options).path ?? null } @@ -326,18 +326,18 @@ export const SOLIDITY_PACKAGE_MANIFESTS = ['package.json', FOUNDRY_TOML, REMAPPI // --- The walk ----------------------------------------------------------------------------------- // Build `{ sources, resolutions, missing }` from already-loaded Solidity sources plus remappings. -// Imports resolve as resolveSolImport does (`libs`, `ownership`: see there); as a final +// Imports resolve as resolveSolImport does (`libs`, `ownership`, `host`: see there); as a final // fallback a specifier naming no file but matching a stored key verbatim is accepted. `missing` // lists every `{ spec, from }` that didn't resolve or resolved outside `sources`, with the // `reason` when it was refused. -export function buildSolidityTree(sources, { remappings = [], baseDir, libs = [], ownership = baseDir && solidityOwnership(baseDir) } = {}) { +export function buildSolidityTree(sources, { remappings = [], baseDir, libs = [], host = diskHost, ownership = baseDir && solidityOwnership(baseDir, { host }) } = {}) { const resolutions = new Map() const missing = [] - const options = { remappings, baseDir, libs, dependencyDirs, host, realBase: baseDir && dependencyDirs ? host.realpath(baseDir) : undefined } + const options = { remappings, baseDir, libs, ownership, host } for (const [path, content] of sources) { const specMap = new Map() for (const spec of extractSolImports(content)) { - const r = resolveImport(spec, path, { remappings, baseDir, libs, ownership }) + const r = resolveImport(spec, path, options) let resolved = r.path && sources.has(r.path) ? r.path : null if (!resolved && !r.reason && sources.has(spec)) resolved = spec if (resolved) { @@ -352,30 +352,26 @@ export function buildSolidityTree(sources, { remappings = [], baseDir, libs = [] return { sources, resolutions, missing } } -// Walk the filesystem from `entries`, following resolved imports and reading each file once -// (same-wave reads run in parallel). Caller-listed entries are also accepted as verbatim -// non-relative import targets naming no file (Foundry-style `import "src/A.sol"`). An entry that -// crosses a dependency's link out of itself (see solidityOwnership) is refused. -export async function collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs = [], ownership = solidityOwnership(baseDir) } = {}) { +// Walk the project from `entries`, following resolved imports and reading each file once, a wave at +// a time: the files of one, then the imports they name. Caller-listed entries are also accepted as +// verbatim non-relative import targets naming no file (Foundry-style `import "src/A.sol"`). An entry +// that crosses a dependency's link out of itself (see solidityOwnership) is refused. The project is +// read through `host`. +export function collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs = [], host = diskHost, ownership = solidityOwnership(baseDir, { host }) } = {}) { const sources = new Map() const knownEntries = new Set(entries) - const realBase = realpathSync(baseDir) + const realBase = host.realpath(baseDir) + const options = { remappings, baseDir, libs, ownership, host } for (const entry of entries) ownership.assert(entry, 'entry ') - - const processWave = async (wave) => { - const toLoad = [...new Set(wave)].filter((p) => !sources.has(p)) - if (toLoad.length === 0) return - const reads = await Promise.all( - toLoad.map(async (relPath) => { - try { - assertRealPathWithinBase(realBase, baseDir, relPath) - return [relPath, decodeUtf8(await readFile(join(baseDir, relPath)), relPath)] - } catch (err) { - if (err.code === 'ENOENT') { - console.warn(`[loader.solidity] Missing import: ${relPath}`) - return null - } - throw err + for (let wave = entries; wave.length > 0;) { + const reads = [...new Set(wave)].filter((p) => !sources.has(p)).map((relPath) => { + try { + assertRealPathWithinBase(realBase, baseDir, relPath, host) + return [relPath, decodeUtf8(host.readFile(join(baseDir, relPath)), relPath)] + } catch (err) { + if (err.code === 'ENOENT') { + console.warn(`[loader.solidity] Missing import: ${relPath}`) + return null } throw err } @@ -386,7 +382,7 @@ export async function collectSolidityFilesFromDisk(baseDir, entries, remappings, const [relPath, content] = entry sources.set(relPath, content) for (const spec of extractSolImports(content)) { - const r = resolveImport(spec, relPath, { remappings, baseDir, libs, ownership }) + const r = resolveImport(spec, relPath, options) const resolved = r.path ?? (!r.reason && knownEntries.has(spec) ? spec : null) if (resolved) { if (!sources.has(resolved)) next.push(resolved) @@ -435,8 +431,9 @@ function solidityFilesUnder(baseDir, dir, host) { // Project-relative entries with each directory replaced by the `.sol` files under it (deduped, in // order). A `.sol` entry is kept as is (a missing one is reported by the walk); a directory that // is missing or holds no `.sol` file is skipped with a warning, as forge skips an absent `script/`, -// and it's an error only when no entry yields a file (when none exists, a mistyped path). -export function expandSolidityEntries(baseDir, entries) { +// and it's an error only when no entry yields a file (when none exists, a mistyped path). The +// project is read through `host`. +export function expandSolidityEntries(baseDir, entries, host = diskHost) { const out = new Set() const shown = (entry) => (entry === '.' ? './' : `${entry}/`) for (const e of entries) { @@ -451,7 +448,7 @@ export function expandSolidityEntries(baseDir, entries) { for (const f of files) out.add(f) } if (out.size === 0) { - if (entries.every((e) => !existsSync(join(baseDir, e)))) throw new Error(`No such file or directory: ${entries[0]}`) + if (entries.every((e) => host.stat(join(baseDir, e)) === null)) throw new Error(`No such file or directory: ${entries[0]}`) throw new Error(`No .sol files under ${entries.map((e) => shown(e === '' ? '.' : e)).join(', ')} (a directory entry stands for the Solidity sources under it)`) } return [...out] @@ -488,7 +485,7 @@ export async function loadSolidity(solTxtFile, { env = process.env } = {}) { const entries = lines.map((l) => l.replace(/^\.\//u, '')) for (const e of entries) assertWithinBase(baseDir, e, 'Entry path') - const { remappings, libs, ownership } = await discoverSolidityConfig(baseDir, { mappingFile, env }) - const sources = await collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs, ownership }) + const { remappings, libs, ownership } = discoverSolidityConfig(baseDir, { mappingFile, env }) + const sources = collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs, ownership }) return buildSolidityTree(sources, { remappings, baseDir, libs, ownership }) } diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index 41f2dfc9..15b12607 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -1,6 +1,5 @@ import { test } from 'node:test' -import fs, { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { syncBuiltinESMExports } from 'node:module' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -18,6 +17,7 @@ import { readRemappingsFile, resolveSolImport, } from '../stasis/src/loaders/solidity.js' +import { diskHost } from '@exodus/stasis-core/host' import { findRemappingsWithContext, foundryProject, foundryTomlRemappings } from '../stasis/src/loaders/foundry.js' import { readGitmodules, solidityOwnership } from '../stasis/src/loaders/solidity-ownership.js' @@ -707,28 +707,20 @@ test('solidityOwnership: a link from outside the root back into it is untrusted, }) test('solidityOwnership judges the path as the filesystem spells it (a case-insensitive one)', async (t) => { - // Emulate a case-insensitive filesystem under `tmp`, whose names are lowercase on disk. + // Emulate a case-insensitive filesystem under `tmp`, whose names are lowercase on disk: a host that + // reads every path there lowercased, and whose realpath gives the filesystem's spelling. const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'stasis-sol-'))) - const lower = (p) => (typeof p === 'string' && p.startsWith(tmp) ? tmp + p.slice(tmp.length).toLowerCase() : p) - const saved = {} - for (const name of ['lstatSync', 'statSync', 'readlinkSync', 'readdirSync']) { - saved[name] = fs[name] - fs[name] = (p, ...rest) => saved[name](lower(p), ...rest) - } - saved.native = fs.realpathSync.native - fs.realpathSync.native = (p, ...rest) => saved.native(lower(p), ...rest) - syncBuiltinESMExports() + const lower = (p) => (p.startsWith(tmp) ? tmp + p.slice(tmp.length).toLowerCase() : p) + const host = { ...diskHost, realpath: (p) => realpathSync.native(lower(p)) } + for (const name of ['stat', 'readFile', 'readdir', 'readlink']) host[name] = (p) => diskHost[name](lower(p)) try { mkdirSync(join(tmp, 'lib/evil/src'), { recursive: true }) writeFileSync(join(tmp, '.env'), 'K=1\n') symlinkSync('../../../.env', join(tmp, 'lib/evil/src/test.sol')) - const { of } = solidityOwnership(tmp, { dirs: ['lib'] }) + const { of } = solidityOwnership(tmp, { dirs: ['lib'], host }) // A dependency's remapping to `../../LIB/evil/src/` names the same link. for (const p of ['lib/evil/src/test.sol', 'LIB/evil/src/Test.sol', 'Lib/Evil/SRC/TEST.sol']) t.assert.equal(of(p).escape?.root, 'lib/evil', p) } finally { - for (const name of ['lstatSync', 'statSync', 'readlinkSync', 'readdirSync']) fs[name] = saved[name] - fs.realpathSync.native = saved.native - syncBuiltinESMExports() rmSync(tmp, { recursive: true, force: true }) } }) diff --git a/tests/vfs-bundle-host.test.js b/tests/vfs-bundle-host.test.js index 81159a39..8f1262e8 100644 --- a/tests/vfs-bundle-host.test.js +++ b/tests/vfs-bundle-host.test.js @@ -214,3 +214,27 @@ test('the disk host resolves exactly like require.resolve, including through sym t.assert.equal(diskHost.resolve(join(tmp, 'main.cjs'), './link/z', new Set(['require'])), join(tmp, 'real', 'z.js')) t.assert.equal(createNodeResolver(diskHost).resolve(join(tmp, 'main.cjs'), './link/z', new Set(['require'])), join(tmp, 'real', 'z.js')) })) + +test('a Solidity bundle read through a Vfs host holds a dependency to its own files, as on disk', async (t) => { + const { buildSolidityBundle } = await import('../stasis/src/cmd/bundle.js') + const vfs = write(new Vfs(), { + '/foundry.toml': '[profile.default]\n', + '/.env': 'PRIVATE_KEY=0xabc\n', + '/src/A.sol': 'import "evil/E.sol";\n', + '/lib/evil/src/E.sol': 'import "./Evil.sol";\n', + }) + // A link the dependency planted out of itself, to the project's .env. + vfs.symlink('../../../.env', '/lib/evil/src/Evil.sol') + const host = createVfsHost(vfs) + const build = () => buildSolidityBundle({ cwd: '/', entries: ['src'], env: {}, host }) + const warn = console.warn + console.warn = () => {} + try { + await t.assert.rejects(build, (err) => err.message.includes('refused: lib/evil/src/Evil.sol is a link out of the dependency lib/evil')) + vfs.unlink('/lib/evil/src/Evil.sol') + vfs.writeFile('/lib/evil/src/Evil.sol', 'contract Evil {}\n') + t.assert.deepEqual([...(await build()).sources.keys()].toSorted(), ['lib/evil/src/E.sol', 'lib/evil/src/Evil.sol', 'src/A.sol']) + } finally { + console.warn = warn + } +})