fix(bundle): Solidity loader — decide file ownership by real path; invalid configs are errors - #178
Merged
Merged
Conversation
exo-nikita
force-pushed
the
claude/magical-davinci-bs3pob
branch
from
September 29, 2026 03:22
1b9aaca to
3a52659
Compare
exo-nikita
force-pushed
the
claude/magical-davinci-bs3pob
branch
from
September 29, 2026 09:05
3a52659 to
0563ffe
Compare
exo-nikita
force-pushed
the
claude/magical-davinci-bs3pob
branch
4 times, most recently
from
October 1, 2026 19:56
e1998ad to
ad8caa2
Compare
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 [<name>] 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…d 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…endency'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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…ct 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…d 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…oes; --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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
… 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…ig 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…wnership 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…itmodules 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…Urls: 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
… 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…ead 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 `<root>/` as spelled; any other spelling of the root (`<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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…h U+FFFD 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
… 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…FS 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
exo-nikita
force-pushed
the
claude/magical-davinci-bs3pob
branch
from
October 1, 2026 22:03
635f65a to
7ab19b9
Compare
exo-nikita
pushed a commit
that referenced
this pull request
Oct 2, 2026
buildGitHubBundle and suggestedEntries check their options against an empty Vfs, before anything is fetched: "what the tree is never decides them" (#197). #178's directoryEntryError then found every extensionless entry missing from that empty tree. So `src` with pnpm was refused as "no such file or directory" rather than "only JS bundles are built with pnpm", and main's "buildGitHubBundle reads nothing from disk" test fails. The pre-fetch checks now pass `fetched: false`, which skips the missing-entry errors. buildVfsBundle still checks entries against the project's own tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
ChALkeR
pushed a commit
that referenced
this pull request
Oct 2, 2026
…uote; green main after #178 (#201) * fix(vfs-bundle): check entries before the fetch without looking for them buildGitHubBundle and suggestedEntries check their options against an empty Vfs, before anything is fetched: "what the tree is never decides them" (#197). #178's directoryEntryError then found every extensionless entry missing from that empty tree. So `src` with pnpm was refused as "no such file or directory" rather than "only JS bundles are built with pnpm", and main's "buildGitHubBundle reads nothing from disk" test fails. The pre-fetch checks now pass `fetched: false`, which skips the missing-entry errors. buildVfsBundle still checks entries against the project's own tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * fix(bundle): a package.json that's there but can't be read is refused, as Node refuses it host.stat returns null for any failure, as Node's module lookup does. So every package.json reader took a link loop, or a file behind a directory that may not be searched, for no package.json and walked past it: - the strict Solidity reader, which bucketed a forge lib's files as the project's own; - the resolver the Vfs host uses, and the Vfs host's findPackageJSON; - State's walk up to a bucket's name, and its root discovery; - readModuleManifest. Node refuses such a package.json with ERR_INVALID_PACKAGE_CONFIG, and counts only "nothing there" as none, a link to nothing included. Real stat tells the two apart and host.stat doesn't. That's right for the module and lib probes, which treat any failure as absent as Node and Rust do, so stat stays as it is. When a package.json reader's stat fails, a read now says why: - statStrict, for configs and the strict package.json read, names the file from the project root (`lib/dep/package.json: can't be read (ELOOP)`). readRegularFileOrNull uses it too, instead of rethrowing the raw fs error with its absolute path. - packageJSONStat throws Node's ERR_INVALID_PACKAGE_CONFIG, for the resolver, the Vfs host's findPackageJSON and State. Lenient lookups (Bash, Rust, `stasis add`, --package-json) still walk past a malformed or unreadable one. A differential against Node's require.resolve and findPackageJSON now agrees on every case: missing, dangling, a directory, malformed, a self-loop, a loop through directories, and (unprivileged) a link into an unsearchable directory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * fix(bundle): invalid-remapping errors name the entry, never its text An invalid remapping's error quoted it. The text is normally a remapping the project wrote, but a file named as a mapping by mistake can hold anything: `--mapping=token.txt` printed `token.txt:1: invalid remapping "ghp_…"` to stderr, and into CI logs with it. A token pasted into FOUNDRY_REMAPPINGS did the same. Errors now give the location and the form the entry should have: `remappings.txt:2: invalid remapping, expected [context:]prefix=target`. A foundry.toml entry is named by its position (`` `remappings` entry 2 ``, `` `remappings` entry 1 is not a string ``). This is the last error of stasis's own that quoted file content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --------- Co-authored-by: Claude <noreply@anthropic.com>
ChALkeR
pushed a commit
that referenced
this pull request
Oct 2, 2026
…eft after #175 and #178 (#203) * fix(bundle): every path a .gitmodules gives a submodule is a dependency A .gitmodules that gives a submodule's `path` twice (the key repeated, or a second `[submodule "x"]` section) is one the library refuses, so it is read submodule by submodule. That reader kept the first value. git reads the last when it reads the checkout's .gitmodules, so the submodule it checks out sits at the second path. stasis took that directory for the project's own, trusted a link planted in it to .env, and bundled the file. The lenient reader now keeps the last `path`, `url` and `branch`, as git does for the checkout. A path or url starting with `-` is skipped, since git ignores those. Every other path given to the submodule is also taken as a dependency, unnamed, with a warning. git reads the first of two values when it reads .gitmodules from a commit, so neither directory may be treated as the project's own code. The library refuses every duplicate, so its strict path never reads one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * fix(vfs-bundle): a Vfs host reads a path as the OS does The Vfs host normalized every path with path.resolve before it resolved links, so a `..` after a symlink was taken textually. The OS, diskHost and forge take it from where the link leads. A Solidity bundle built from a Vfs (`stasis github-bundle`, `buildVfsBundle` with soldeer) read a root `extends = "cfg/link/../base.toml"`, or a `libs` entry with `..` after a link, from a different file than forge does, and carried that file. A dependency's extends through its own link was refused, because the ownership walk and the host's realpath disagreed. stat, readFile, readdir, readlink and realpath now take the path as spelled. realpath walks it one component at a time: a `.` or `..` is taken in the real directory before it, which must be a directory (ENOTDIR otherwise, as the OS gives), and a link target is resolved the same way. realpath also honors a trailing `/`, as realpath(3) does. Node's own lookups, findPackageJSON and resolve, still normalize their paths first, as Node does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * fix(bundle): read cargo's configs, workspace root and lockfile from the one directory cargo runs in The Cargo context assumed several working directories at once: the vendor directory came from the configs above the first entry's file, the [patch] tables from the configs above the workspace root, the rustflags from the bundle root's config alone. A member's .cargo/config.toml could so be taken for its [source] and ignored for its [patch] (cargo run in the member builds the patch's fork; the bundle bound the vendored copy), and a member's rustflags `--cfg` was presumed off. - One directory is taken as where cargo runs: the package directory of the first entry that isn't a vendored crate's (`cargo build` there, which finds its workspace root above), else the bundle root. The configs cargo reads from there -- that directory's and each one above it, above the bundle root too (read, never bundled; not $CARGO_HOME's) -- give the vendor directory, the [patch] tables, the rustflags, `cargo metadata`'s cwd and the configs --cargo-manifests carries. - [source] tables are merged key by key, the nearest config first, as cargo merges them: a nested workspace's `[source.vendored-sources] directory` beside the bundle root's `replace-with` is the nested one's directory, each path relative to the directory holding the `.cargo` of the config writing it. - The workspace root of a package is the nearest [workspace] above it that doesn't `exclude` it (cargo passes an excluding one over), whatever its `members` say: cargo either takes the package as a member there (a member's path dependency is one) or refuses to build it. A member bundled from its own directory below such a root above the bundle root now gets that root's resolver and inheritance when it is a member only as a path dependency. - The [patch] of the build's workspace root applies to every package of the build, an outer root's above the bundle root too (its paths relative to it). A lockfile beside a member whose workspace root lies above the bundle root isn't the build's: none is read, and cargo's resolver doesn't run. - --cargo-manifests carries the build's lockfile and configs only, not a path dependency's own Cargo.lock or .cargo/config.toml, which cargo never reads building the entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * fix(bundle): check a vendored Cargo.toml against .cargo-checksum.json before resolving with it A vendored file the bundle carries was checked against the checksums `cargo vendor` listed, but a vendored crate's Cargo.toml was only read: without --cargo-manifests it isn't carried, and an edited one (a feature turned on by default, a dependency added) drove the feature resolution and so the bundled files, where cargo refuses to build the crate ("the listed checksum of … Cargo.toml has changed"). Every vendored package the build takes in -- the copy a dependency resolves to on the replay, the lockfile's copies on cargo's resolver, the one vendored crate of a name for a file no manifest claims -- has its Cargo.toml checked first, once per package; one that isn't the listed file stops the bundle. Copies nothing resolves to stay unchecked, as cargo never reads them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * fix(bundle): report every dependency the build links that the bundle lacks, however the code names it The missing-crate report listed the crates the code names that don't resolve in-tree. A dependency whose lib name differs from its manifest key (`md-5`, used as `md5::compute`) and isn't vendored was never named as the package's, since its lib name lives in the manifest the bundle lacks, and so went unreported; so did a linked dependency no code names. The replay records each active dependency table -- the build's platforms (a maybe one too), an optional one once turned on -- that no package in-tree answers, and `cargo metadata` the dependencies it couldn't place in-tree. `stasis bundle` adds those of the bundled packages to the report, as `md-5 (a dependency of app 0.1.0)`, unless the code's name is already listed: dev-dependencies only for a test, bench or example entry, build-dependencies only with --cargo-manifests and a build script. Cargo's resolver needs every locked package in-tree, so there is nothing to add there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * fix(bundle): keep code a build compiles where cargo metadata's features or a build's cfgs can't be told apart Two presumptions dropped or ranked away code a build compiles: - --cargo took cargo metadata's one feature set per package -- the union over every build of the workspace, normal, dev and build dependencies on every platform -- as on for certain in the target's build and the host's alike, so `#[cfg(not(feature = "h"))]` code dropped out of the target build of a crate that only its build-dependency use turns `h` on for (cargo compiles it there). Each of those features is now on only maybe; one outside the union stays off. - A custom cfg is presumed off unless the package's build may set it. A build script printing a name it formats in part (`cargo::rustc-cfg=os_{}`) was read as setting `os_` alone, and one calling a build-dependency that prints the cfgs (cfg_aliases' `cfg_aliases!`, as nix and wgpu use it) as setting none -- so `#[cfg(os_linux)]` / `#[cfg(linux_like)]` candidates lost to their negation as certain. A name formatted in part now makes every custom cfg of the package undecided, and so does a build-dependency, or what it depends on in turn for the host, whose lib writes `rustc-cfg` outside full-line comments -- or that the bundle root lacks, which may for all the loader knows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * fix(bundle): keep an any(…) past 16 alternatives undecided, never certain anyOf returned no leaves for an `any(…)` of more than MAX_ALTERNATIVES alternatives -- the cap that keeps a dense cycle of cfg-gated globs from blowing up -- and no leaves means "holds always": the candidate under it became certain and every other one was dropped, without a warning. A cfg listing 17 platforms one way and the rest the other (cap17) mapped `crate::T` to the 17-platform file only, and a module 17 cfg-gated glob paths reach (cap2) hid the `#[cfg(target_os = "linux")] pub use lin::T;` that a linux build compiles. Past the cap, and for an `any` with an undecided alternative, the result is now one undecided leaf: no build decides it, no asker holds it (one under it included), it is no custom cfg and exclusive with nothing, its negation too. A candidate under it is only maybe the answer, so the edge is the cfg-keyed map of every candidate. Folding an `any` with such an alternative into one keeps the sets along a dense cycle few: cygen N=16 and the 14-module dense cycle resolve in tens of ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * fix(bundle): a local module gives way to a glob only when none of its files may be there walkPath let a child module give way to what else its module has of the name -- a glob's `imp` included -- when the module's first file was under a cfg the build rules out or a custom cfg it presumably lacks. A module with a fallback is there in every build all the same: `#[cfg_attr(loom, path = "loom_imp.rs")] mod imp;` is imp.rs without loom, and shadows the `imp` a `use other::*` brings in (cm1, a regression of review 10, which rustc confirms: the glob's `imp::X` lacks the method called). The same with a target: tokio's `cfg_has_atomic_u64! { mod imp; }` first, ruled out, and `cfg_not_has_atomic_u64! { mod imp; }` live (cm3). The module's files are now gathered per crate root, and it gives way only when every one of them is ruled out or doubtful: a `#[cfg(loom)] mod imp` alone still yields to the glob (cm2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * fix(bundle): resolve another crate's macro as its root does, and a path's lead to the crate Review 10 took another crate's macro from the first `#[macro_export]` definition of the name in that crate, whatever cfg its file was mounted under, and never followed a re-export: serde's docsrs-only copy of serde_core's macros (src/core/macros.rs, `#[cfg(docsrs)]`) answered serde_json's `forward_to_deserialize_any!`, which every other build gets through `pub use serde_core::forward_to_deserialize_any` (mx1 is the same in small; cargo builds it from inner). And `use helper::{helper, mk}` lost `mk!`: the path's lead resolved to the import of the macro `helper`, and the walk ended at the crate without looking in it. A macro of another crate (`use dep::mac;`, `dep::mac!(…)`, a `#[macro_use] extern crate`'s, a lead the module binds to the crate) is now looked up from that crate's root as a path there is, in the macro namespace, the cfgs judged by its own build: the crate root's `#[macro_export]` definitions are bindings of the name under their files' cfgs, ranked with its imports (a `pub use inner::m;` followed on into `inner`), and several that may each apply give the cfg-keyed map, for a bare call too. An import followed as a macro to a file that defines no macro of the name (`pub use util::helper;` of a fn) binds none. In the corpus, serde_json's three `forward_to_deserialize_any!` edges move to serde_core/src/macros.rs, and serde_derive gains `parse_macro_input!` and `format_ident!` (`use quote::{format_ident, quote}`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * fix(bundle): declare a template's mod and includes where rustc expands them A `mod` or include a `macro_rules!` body holds is made where the macro is invoked, and the loader placed it right only for a bare call from a file of the defining package, outside inline modules. Otherwise it stayed beside the definition -- carrying a file the build never reads and missing the one it does -- or was dropped silently: - invoked by path: `crate::decl!()` from src/sub.rs, or `$crate::decl!()` in another macro's body, declares src/sub/inner.rs (tm1, tm6), and `crate::embed!()`'s `include_str!("data.txt")` reads src/sub/data.txt (ti1, ti2). A path invocation now counts as one of the macro of its last segment, as a bare one does. - from another crate: `#[macro_use] extern crate defs; decl!();` declares the app's `inner` (tm3). A `#[macro_export]`ed macro's template `mod` is hosted in every crate invoking it; the test that kept it beside the definition asserted what rustc doesn't do, and now asserts what it does. - inline modules: invoked inside `pub mod outer { decl!(); }` the module is outer's (src/outer/inner.rs, tm9); defined inside `mod defs { … }` the definition's inline modules are no part of it (src/inner.rs, tm10). Each invocation keeps the inline modules around it, each template `mod` the depth of its definition. - nested templates: a `mod` (nt2) and the calls (nt3) in a `macro_rules!` that another body defines are that inner macro's, made where it is invoked, as its includes already were. Every case is checked against cargo: each fixture builds only from the file the loader now carries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * fix(bundle): follow a path through every module a segment may name The cfg-keyed map of candidates was recorded only for a path's last segment: a module the path went on through took the first candidate, so without a target `crate::imp::X` beside `#[cfg(windows)] use windows as imp;` / `#[cfg(unix)] use unix as imp;` was windows.rs alone, while a linux build compiles unix.rs (al2); likewise a module two cfg-gated globs bring in (al3), and `#[cfg(not(unix))] mod sys;` beside `#[cfg(unix)] use fallback as sys;`, where the module always won (al6). Several candidates of which a module is first now carry their branches, and walkPath walks the rest of the path through each, the files they lead to the edge's alternatives under the key of the module they go through; a single-file module under cfgs the asking file doesn't hold is one branch beside what else its module binds the name to (a `mod` with cfg variants, or under a custom cfg, stays the module). In the corpus, mio's `sys::udp::bind`, tokio's `os_impl::ctrl_c` and hashbrown's `self::imp::Group` now map each platform's file; under the linux target hashbrown keeps sse2.rs and the generic fallback a build without sse2 compiles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * fix(bundle): resolve a dead file as it would compile, a contradictory one to every candidate An asking file's cfg set is its own leaves plus the target's cfgs. For a file the target rules out -- tokio's atomic_u64_as_mutex.rs and its static_*.rs submodules under a 64-bit target -- the two contradict, every candidate looked compatible, and the first written won: `super::AtomicU64` resolved to atomic_u64_native.rs, a sibling variant, not as_mutex, the module `super` names there (au2). A file whose own cfgs contradict each other (static_once_cell.rs, mounted under `not(all(test, loom))` and `all(loom, test)`) had every candidate ruled out, and fell back to the module's first file (au3). A file the target rules out now asks under its own cfgs, as where it is compiled; one whose own cfgs contradict each other is compiled nowhere, so every candidate is compatible with it and none certain, and its paths map them all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * refactor(bundle): one helper for a path a cargo config or manifest writes projectRel takes a path relative to an absolute directory, as cargo does, for the configs' directories, their [source] and [patch] paths and the outer workspace's inherited paths, which joined an absolute path instead of resolving it. The outer workspace keeps its label as `file`, and the root's [patch] reads its paths and file off the manifest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * refactor(bundle): one workspace root for the lockfile, cargo's resolver, the feature resolver and [patch] buildPackage, memoized, is the run directory's package taken once the vendor directory is known; buildWorkspaceRoot is its root, and whether that lies above the bundle root is read in one place, buildLockPath. The feature resolver comes from that root too: a vendored crate an entry is in no longer sets it (cargo is never run there). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * refactor(bundle): one record of a dependency the build lacks, from the replay and from cargo metadata Both producers keep `{ dir, key, name, kinds }`, so lackingDependencies reads them in one loop; the packages whose dev-dependencies are linked are worked out once, for the replay and the report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * refactor(bundle): one scanner for the cfgs a build script prints, itself or through a crate it calls cfgsPrinted reads build scripts and the libs they call alike: full-line comments stripped (only once the raw text mentions a cfg print), a fixed `rustc-cfg=<name>` that name, a formatted one, the directive written apart from its name or autocfg any. So a helper printing one fixed name makes only that name maybe. The crates a build script links for the host are resolved before any lib is read: one the bundle lacks is "any" without I/O. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm * perf(bundle): check a vendored Cargo.toml once, where its manifest is read readManifest checks a vendored copy's Cargo.toml against its .cargo-checksum.json from the bytes tableOf read, before anything uses it; only the vendor directory's listings read copies unchecked, as cargo checks only the copies it builds. A checked file isn't hashed again, so --cargo-manifests no longer re-reads and re-hashes it, and a vendored entry's manifest is checked like a resolved one's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #177's post-merge review, plus the reviews of this PR itself. Rebased onto
mainafter #194 (Solidity bundles fromsoldeer.lock), so this PR uses@preventive/lockfile1.0.0-alpha.4 (#193) for TOML (#183) and.gitmodules, and contains no--manifestsredaction: carried files are included as written. The Solidity loader, the ownership walk included, reads the project through main's filesystemhost(#189, #194), so a bundle built from a Vfs gets the same containment as one read from disk. I reproduced each finding before fixing it, and each has a regression test that fails on the base it fixes.Ownership by real path
Whether a file belonged to a dependency used to be decided from its lexical path, with the real path checked only in some places.
loaders/solidity-ownership.jsnow resolves each path once, one component at a time, and decides its owner from where the file really is. The same check is used by import resolution, the file walk, entries,--manifests(which files may be carried), forge's nested-config discovery, and thepackage.jsonfiles that decide which package a source belongs to.Dependencies:
node_modules/<pkg>(and@scope/<pkg>), the entries of forge'slibs(an absolute one by its real path), Soldeer'sdependencies/, and git submodules. A symlinkedlib/entry counts as the dependency wherever it points. A file belongs to a dependency when its real path lies in one, however the path got there, so code reached throughsrc/vendor -> ../lib/dep/srcis the dependency's.A dependency's imports must land on its own files or another dependency's, never the project's.
Links nobody trusted placed are never followed. This covers:
lib/evil -> ../../shared/evil, that holds a link back to the project's.env.Following one of these is refused with the reason, whether it's reached as an import (including one routed by a dependency's remapping), an entry, a carried manifest, a
package.jsonused to assign a package, or the dependency's ownfoundry.toml,extendsbase orremappings.txt.Checked against the OS: the link-by-link walk's result is compared with the host's own
realpath: on disk, the OS'srealpath(3), which gives the filesystem's own spelling. A path the two resolve differently is refused rather than trusted. So is one the walk can't resolve but the OS can, and one the OS can't resolve at all: a real path pastPATH_MAX, or a link whose end it can't name, such as/proc/self/fd/0or/dev/stdinon an open pipe. A path counts as missing only when nothing is there at all. A..in a path is resolved by the OS from where the link leads, not textually. Link targets are split on the OS's separators only (on POSIX a\is part of a name), and a target that isn't UTF-8 counts as unresolved, not missing.A dependency's config reaches only what the path from the root does. One found through an absolute or
/proc/self/cwdlib is judged by its real path. A dependency outside the root reads nothing, and a directory a dependency'slibsnames must be a dependency itself (not the project's own directory passed off as one). A refused config says why, with the ownership reason.Case-insensitive filesystems: each path component takes the filesystem's own spelling, so
LIB/evilis checked aslib/evil(tested by emulation, with a host that lowercases paths)..gitmodulesis read once per bundle, with@preventive/lockfile'sparseGitmodules, which reads it the way git's config parser does. A URL is taken as written (checkUrls: false): one relative to the superproject's remote (../x.git), a path, or none. Stasis only uses the URL to name a GitHub submodule's bucket, so such a submodule is still a dependency, just without a GitHub name.A
.gitmodulesthe library refuses never fails the bundle, and never makes a submodule's directory the project's own code. It is warned about and read one submodule section at a time. This covers:update = none,active, a[core]or[include]section, a tab in a value;[submodule.x]header, which is read the way git reads it, as[submodule "x"].Each submodule's first
path,urlandbranchare kept, as git's submodule commands read them, and each is re-read by the library. A branch, then a URL, that still doesn't read is dropped with a warning. A submodule whose path doesn't read fails closed: if the path names a directory inside the repository (./deps/x,deps/x/), that directory is still a dependency, unnamed. Only a path outside the repository (../x) is skipped. So a planted link in such a directory, even outside forge's libs, stays refused.A
.gitmodulesgit itself refuses is an error, as it is to git. That means any "bad config line":[submodule x],[submodule.deps/x], or[submodule "x"with no];=;Reading past such a line could lose a submodule's section, and its directory would then count as the project's own code. The lenient read now follows git's
config.ccharacter by character and refuses exactly what git refuses. A differential fuzz againstgit config -f --list -z(16,000 files) matched git on every file, in both what's refused and the keys and values read.Links the project placed can point anywhere in the root. That covers a workspace package in
node_modules, a linkedlib/entry, and a link on the path the project was opened by (a symlinked checkout).Invalid input is an error
Each of these fails the bundle naming the file from the project root (or naming the variable), whether it's the project's, a dependency's, or read under
--mapping. Nothing falls back to a default:foundry.tomlorextendsbase that isn't TOML..solsource, a.sol.txtlisting, afoundry.toml, aremappings.txt, a--mappingfile or a.gitmodules. They're decoded strictly with@exodus/bytes(now a direct dependency ofstasis;stasis-corestays dependency-free), keeping a byte-order mark. Before, a.solfile was read withreadFile(…, 'utf8'), which bundles a stray\xe9as U+FFFD: text that isn't the file's.package.jsonthat isn't one is never read. A FIFO, a socket, a device, or a link to one (the project's ownremappings.txt -> /dev/stdin) can't stall the bundle or feed it the process's input. A config path the host can't stat is read to learn why: only "nothing there" counts as no file, so a loop or an unsearchable directory is still an error.src,test,script,libs,auto_detect_remappingsorextends(for examplelibs = "deps").FOUNDRY_REMAPPINGS/DAPP_REMAPPINGSentry that isn't[context:]prefix=target, or aremappingsvalue that isn't an array of such strings.package.json: one that decides a Solidity source's package but doesn't parse, isn't UTF-8, or isn't a regular file. A leading byte-order mark is skipped, as npm skips it. The error gives the file and position and never quotes the text, because the parser's own message would. Bash and Rust bundles,stasis addand JS--package-jsonstill walk past a malformed one, as onmain.Forge refuses all of these too, except that it silently skips a dependency's
foundry.tomlit can't read. A dependency's config that forge rejects for its settings (a missing or nestedextends, colliding keys, a link out of the dependency) is still skipped with a warning, as forge skips it. Only that class of error (ConfigRefused) is caught.A
remappings.txtline is trimmed the way forge trims it, so a byte-order mark stays part of the first remapping, as it does in forge.--manifests--manifestscarries every config file the resolution read, whatever it's called (extends = "base.conf",--mapping=remaps). With--mappingthat includes the rootfoundry.tomland itsextendsbase, read for the lib dirs. It also carries the lock files and thepackage.json,foundry.tomlandremappings.txtof each bundled package, all as written. Apackage.jsonis taggedjson; every other file is aresource, including a--mapping=remaps.json..envfiles andhardhat.config.*are never carried, however they're cased.A config file is carried under its path in the project. It is carried under its real path instead when a
.., or an absolute or/proc/self/cwdlib, leads somewhere else. Soextends = "cfg/base.toml"withcfg -> sharedis carried ascfg/base.toml, where forge will look for it afterstasis extract.A config whose real path the OS can't give (past
PATH_MAX) is never given a normalized name, since that could name another file. It keeps the name it was read by,..and all, however the root was spelled in the path (L/../base.toml,<root>/./L/../base.toml, a doubled/, the root's real path), and is refused. A path from no spelling of the root stays absolute and is refused as unresolvable.A config the resolution read that can't be carried fails
--manifestswith an error naming it, because without it the bundle couldn't reproduce the resolution. That covers one outside the bundle root (extends = "../shared-base.toml"), a.envone (base.env,.env.toml,Base.ENV,.env.local), ahardhat.config.*one in any case, one the ownership rules refuse, and one that's gone. Without--manifestsnothing is carried, and these bundle as forge resolves them.Other fixes
--mapping:foundry.tomlwhose settings forge would reject (a brokenextends) falls back to the default lib dirs, with a warning;FOUNDRY_PROFILEis reported only when it names a profile the rootfoundry.tomlhas, and warned about otherwise.extendspath is joined as forge joins it, not normalized, so a..after a symlink leads where forge's does, in the project's config and a dependency's alike (extends = "sub/../base.toml"withsub -> real/inreadsreal/base.toml, notbase.toml);[<name>]table'sextendsis ignored, as forge ignores it;x/=), as solc does.no such file or directory. The directory-entry rules are indirectoryEntryErrorandisSolidityEntry, used by both the CLI andbuildBundle.realpathOrNullis the one realpath helper forsolidity.js,foundry.jsand the ownership walk (realpath(3)on disk, the host's own otherwise);readRegularFileOrNull(in stasis-core, throughhost) is the one reader for config files and carried manifests, anddecodeUtf8the one strict UTF-8 decoder for Solidity sources and configs;readPackageJsonis the onepackage.jsonreader behindfindPackageMetadata, and it reads throughhost;projectRelativenames the config files read, for bothsolidity.jsandfoundry.js;ownership.assertis the one refuse-and-throw check, andownership.submodulesthe one parsed.gitmodules;packageLookupis the one per-directory package cache, shared by a JS bundle's--package-jsonpass and its buckets.Checks
extendsand test-case suites all match, as do 5,700 fuzzed projects across the runs and the symlinked-extendscases (the project's and a dependency's). The oracle also confirms forge refuses a badremappings.txtline, rejectslibs = "deps"and keeps a leading BOM. solc-js 0.8.30 confirms the empty remapping target..gitmodulesreader matchesgit config -f --list -zon 16,000 fuzzed files. The library also refuses every one of about 187,000 files git refuses, so the strict check can't be bypassed on the main path.node --run test(exodus-test, Node 24): 2173 pass, 0 fail, all 81 suites, main's Soldeer Vfs bundles included.node --run lint: clean..env. That test fails if the ownership walk reads the disk instead of the host.package.json.src/E.sol -> ../.env) is still trusted, because the project placed it;extendspath goes through isn't carried itself (bundles don't hold symlinks), only the file read;node_modulesstill can't be found from inside another package, as onmain.🤖 Generated with Claude Code
https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA