Repository navigation
feat(vfs-bundle): Solidity bundles from soldeer.lock; post-merge fixes to the vfs-bundle export - #194
Merged
Merged
Conversation
exo-nikita
force-pushed
the
claude/stasis-pnpm-lib
branch
4 times, most recently
from
October 1, 2026 21:09
d9d31f2 to
ac44d89
Compare
…oots as pnpm and yarn find them
`@exodus/stasis/vfs-bundle` takes `packageManager: 'soldeer'`: buildVfsBundle lays out the
dependencies folder `soldeer install` (0.12.0) makes from soldeer.lock with @preventive/deptree,
zips fetched from Soldeer's registry, checked against the lockfile's sha256 and cached only where
setCacheDir says, and builds the Solidity bundle `stasis bundle` builds over it, with `mappingFile`,
`manifests` and an `env` for FOUNDRY_PROFILE / FOUNDRY_REMAPPINGS (none from process.env). The
project's own dependencies folder is never read, nor any node_modules, which Soldeer installs none
of. What the install writes beside the folder is reproduced too: the remappings.txt Soldeer's
update would leave is served from the tree (checked against a real `soldeer install`), and a
foundry.toml it would edit (`dependencies` missing from libs, remappings kept in the config) is
refused. buildGitHubBundle takes 'soldeer' as well. loadNodeModules stays for 'pnpm' and 'yarn1'.
- The Solidity loaders (solidity.js, foundry.js) and buildSolidityBundle read the project through a
host, the disk's by default; the disk host's readdirUnsorted keeps forge's unsorted read_dir
order.
- vfsHost serves the directories a tree installs (each project's node_modules, or `dependencies`)
rather than node_modules alone.
- The directory a project is installed from is the one its package manager takes: pnpm's
workspace root (pnpm-workspace.yaml) over a nearer pnpm-lock.yaml; for yarn 1, the root whose
`workspaces` include cwd's package, else the nearest yarn.lock; for Soldeer, the nearest
foundry.toml or soldeer.toml, never above the git root. lockfileRoot (buildGitHubBundle's `repo`)
follows the same rule, and buildGitHubBundle downloads a directory alone only when its package
manager installs it from there (no pnpm-workspace.yaml, or for yarn 1 no package.json, above it).
- buildVfsBundle checks an entry against cwd, so one of a project under a node_modules directory is
checked before anything is fetched.
- buildBundle, bundleCommand and buildVfsBundle share one JS dispatch; buildBundle and
bundleCommand hand their `env` to it.
- pnpm-lock.yaml refusals are deptree's own, which name the file; yarn.lock and soldeer.lock
refusals are named here. setCacheDir is deptree's. A Vfs is any object with a Vfs's methods,
whatever copy of @preventive/vfs made it, and its errors are told by code.
- Every package.json is read past a byte order mark, as Node reads it: by the resolver, State,
packageType, findPackageMetadata and readJson (packageJSONText, in the zero-dependency core).
The resolver also takes an empty `main` for none, and refuses an encoded separator anywhere in an
exports target's URL, as Node 24 does.
- The package.json membership check is the JS package managers' alone.
- State's walk up to a package.json with a name looks for it by stat and never above its root,
rather than through findPackageJSON on a directory: on a Vfs, findPackageJSON('/') is
/package.json again, and on disk it can be the directory itself. A package.json that is no JSON
object is refused with ERR_INVALID_PACKAGE_CONFIG, as Node refuses it.
Tests: tests/fixtures/soldeer-bundle is a project whose soldeer.lock and remappings.txt, and the
dependencies folder beside it, were written by a real `soldeer install` of a package served as
Soldeer's registry serves it; its zip is seeded into the cache, so the tests fetch nothing. The
tree is checked against that install, and the bundle byte for byte against plain `stasis bundle`
over it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPSLumcb6Rdo4xXL5s5CpE
exo-nikita
force-pushed
the
claude/stasis-pnpm-lib
branch
2 times, most recently
from
October 1, 2026 21:40
ac44d89 to
0dee13b
Compare
exo-nikita
pushed a commit
that referenced
this pull request
Oct 1, 2026
…option checks Rebased onto #194 (Solidity from soldeer.lock), re-applied against its reshaped tree.js and buildVfsBundle: - createMetroResolver takes `host` and refuses one that isn't the disk (it loads the project's metro-resolver, which reads the disk). - checkVfsOptions(name, pm, packageManager, options): buildVfsBundle's up-front refusals (the package manager's kind alone, no metroResolver, every classifyEntries check), which buildGitHubBundle now runs over an empty tree before downloading anything. - `os`, `cpu` and `libc` for loadNodeModules, buildVfsBundle and buildGitHubBundle: the machine packages are matched against, this one's but for what is given; for another os, libc defaults to 'unknown'. checkTarget at each entry point. pnpm takes all three, yarn 1 os/cpu, Soldeer os: what one matches nothing against changes nothing. - A static test: the host-aware modules (scan, both resolvers, tsc's mapping, bundle-util, vfs-bundle/*, foundry.js) name node:fs in no form, and the pattern catches each form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C4wWtS6P5ji71GhZ56NZeh
This was referenced Oct 1, 2026
Merged
exo-nikita
pushed a commit
that referenced
this pull request
Oct 1, 2026
…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
ChALkeR
pushed a commit
that referenced
this pull request
Oct 2, 2026
…option checks (#197) Rebased onto #194 (Solidity from soldeer.lock), re-applied against its reshaped tree.js and buildVfsBundle: - createMetroResolver takes `host` and refuses one that isn't the disk (it loads the project's metro-resolver, which reads the disk). - checkVfsOptions(name, pm, packageManager, options): buildVfsBundle's up-front refusals (the package manager's kind alone, no metroResolver, every classifyEntries check), which buildGitHubBundle now runs over an empty tree before downloading anything. - `os`, `cpu` and `libc` for loadNodeModules, buildVfsBundle and buildGitHubBundle: the machine packages are matched against, this one's but for what is given; for another os, libc defaults to 'unknown'. checkTarget at each entry point. pnpm takes all three, yarn 1 os/cpu, Soldeer os: what one matches nothing against changes nothing. - A static test: the host-aware modules (scan, both resolvers, tsc's mapping, bundle-util, vfs-bundle/*, foundry.js) name node:fs in no form, and the pattern catches each form. Claude-Session: https://claude.ai/code/session_01C4wWtS6P5ji71GhZ56NZeh Co-authored-by: Claude <noreply@anthropic.com>
ChALkeR
pushed a commit
that referenced
this pull request
Oct 2, 2026
…valid configs are errors (#178) * 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 [<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 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * test(bundle): expect the messages of main's TOML parser Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 `<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 * fix(bundle): a .sol file that isn't UTF-8 is refused, not bundled with 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 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA --------- 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 #189: Soldeer support in
@exodus/stasis/vfs-bundle, plus fixes from a post-merge review.Soldeer
buildVfsBundle({ vfs, packageManager: 'soldeer', entries, mappingFile?, manifests?, env? })builds a Solidity bundle from the project in the Vfs.buildGitHubBundletakes'soldeer'too.dependenciesfolder thatsoldeer install(0.12.0) makes fromsoldeer.lock. Each zip comes from Soldeer's registry, is checked against the lockfile's sha256, and is cached only wheresetCacheDirpoints.stasis bundlebuilds over that folder.dependenciesfolder, and anynode_modules(Soldeer installs none, so none can be trusted).soldeer installwrites beside the folder is reproduced (vfs-bundle/soldeer.js):remappings.txtthat Soldeer's update would leave is served from the tree in place of the project's;foundry.tomlthat Soldeer would edit is refused:"dependencies"missing from[profile.default] libs, or remappings kept in the config that aren't as it leaves them;lib/target rewritten.envsuppliesFOUNDRY_PROFILE/FOUNDRY_REMAPPINGSand defaults to{}; nothing is taken fromprocess.env.lockfile(Solidity bundles have none).loadNodeModulesstays limited to'pnpm'and'yarn1'.To make this work:
solidity.js,foundry.js) andbuildSolidityBundleread the project through a host, the disk's by default.readdirUnsortedkeeps forge's unsortedread_dirorder for remapping detection.vfsHostserves whichever paths the tree installs (each project'snode_modules, ordependenciesplus the files written beside it), and hides a directory name a package manager doesn't reproduce.js/sol) and the directory it installs into, whichbuildVfsBundleuses for its checks and dispatch.foundry.tomlorsoldeer.toml, never above the git root, matching Soldeer'sfind_project_root.deptree gap, not fixed here: Soldeer's registry zips for forge-std 1.9.0, 1.9.1 and 1.9.2 use absolute entry names (
/LICENSE-APACHE, …).enclosed_namedrops the root.its zip cannot be read: entry name "/LICENSE-APACHE" is absolute./the way the zip crate does.Post-merge fixes
pnpm-workspace.yaml) wins over a nearerpnpm-lock.yaml.workspacesinclude cwd's package; otherwise the nearestyarn.lock.lockfileRoot, whichbuildGitHubBundleuses to set the bundle'srepolocation, follows the same rule.buildGitHubBundledownloads adirectoryalone only when its package manager installs it from there: nopnpm-workspace.yamlabove it (pnpm), nopackage.jsonabove it (yarn 1), or its ownfoundry.toml/soldeer.toml(Soldeer). Otherwise it downloads the whole repo.buildVfsBundlechecks entries relative to cwd. Before, an entry of a project under anode_modulesdirectory skipped the check and only failed after the fetch.buildBundle,bundleCommandandbuildVfsBundlenow share one JS dispatch.buildBundleandbundleCommandpass theirenvthrough it.pnpm-lock.yamlrefusals now come from deptree, which names the file; the stasis-side pre-parse is gone.yarn.lockandsoldeer.lockrefusals are named in stasis.setCacheDir: the export is now deptree's own.code.packageType,findPackageMetadataandreadJson, through onepackageJSONTextdecoder in the zero-dependency core. Before, the resolver accepted such a manifest and State then crashed on it.maincounts as none, and an encoded separator anywhere in an exports target's URL is refused. Node 24 does both; each is covered in therequire.resolveparity test.findPackageJSONon a directory. A package.json that is no JSON object is refused withERR_INVALID_PACKAGE_CONFIG. Before:/package.jsonlooped forever, becausefindPackageJSON('/')kept returning/package.json;findPackageJSONcan return the directory itself.Tests
tests/fixtures/soldeer-bundleholds a project that depends onstasis-sol-lib, a small package with its zip inregistry/.soldeer.lockandremappings.txtwere written by a realsoldeer install(0.12.0), run against a stand-in registry API serving that zip;dependencies/is the folder that install made.fetchreplaced by a throw.tests/vfs-bundle-soldeer.test.jschecks:stasis bundleover it, with and without--manifests;dependenciesfolder and an installednode_modulesare ignored;remappings.txtwritten, against real-install outputs, and thefoundry.tomlrefusals;repo.oxlintis clean. The full suite passes locally on Node 24.14 (2129 pass, 3 pre-existing skips). CI needs no new install step.🤖 Generated with Claude Code
https://claude.ai/code/session_01KPSLumcb6Rdo4xXL5s5CpE