diff --git a/doc/file-formats.md b/doc/file-formats.md index b9e64dc1..67a3ff73 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -340,7 +340,8 @@ follows them: a symlinked directory that is one already on the walk is a loop and skipped, anything else is walked, so two links to one directory give two copies. A directory entry that is missing or holds no `.sol` file is skipped with a warning (a project without `script/` bundles with `src test script`); -only when no entry yields a file is it an error. Imports are found by a scan +only when no entry yields a file is it an error (when none exists, a mistyped +path: `no such file or directory`). Imports are found by a scan that skips comments (a `//` comment ends at `\n` or `\r`) and string literals (read as bytes: `\xNN` is one byte, and the path is those bytes as UTF-8), and resolve the way solc does under the project's build tool: @@ -360,13 +361,32 @@ resolve the way solc does under the project's build tool: including the contextual ones that scope a dependency's imports to its own copy of a package; aliases of the project's own `src`/`test`/`script` dirs are dropped, and `auto_detect_remappings = false` turns detection off. +A `foundry.toml` or `extends` base that isn't TOML, a config that isn't UTF-8 + (`foundry.toml`, `remappings.txt`, `.gitmodules`) — or a `.sol` file that isn't, + which solc refuses and the bundle won't hold with U+FFFD in place of its + bytes — a setting of the wrong type + (`libs = "deps"`, a `src` that isn't a string, an `extends` that isn't a path + or `{ path, strategy }`), and an invalid remapping (a `remappings.txt` line or + `FOUNDRY_REMAPPINGS` entry that isn't `[context:]prefix=target`, or a + `remappings` value that isn't an array of such strings), is an error naming + the file (from the root), whosever it is and in every mode: nothing falls back + to a default (forge refuses these too, but quietly skips a dependency's + `foundry.toml` it can't read). So is a config that isn't a regular file: a + FIFO, a device or a link to one (`remappings.txt -> /dev/stdin`) is never + read, so the bundle can't stall on it or take the process's input as config. A `remappings.txt` line is trimmed as forge trims it, so a + byte-order mark stays part of the first remapping. A dependency's config forge + rejects for its settings (a missing `extends` base, nested inheritance) is + skipped with a warning, as forge skips it. Profiles are `[profile.]` tables and the legacy top-level `[]` - ones (the former wins key by key); names match case-insensitively. Not + ones (the former wins key by key; `extends` counts only in the former, as in + forge); names match case-insensitively. Not read: `~/.foundry/foundry.toml`, `FOUNDRY_CONFIG` and the other `FOUNDRY_*` - overrides. When `FOUNDRY_PROFILE` or a remapping variable shapes the - result, `stasis bundle` says so on stderr; the bundle doesn't record it. + overrides. When `FOUNDRY_PROFILE` (a profile the `foundry.toml` has; one it + hasn't is warned about) or a remapping variable shapes the result, `stasis + bundle` says so on stderr; the bundle doesn't record it. Without a `foundry.toml`, a root `remappings.txt` applies as written, as solc - and Hardhat apply it (`@oz/=lib/oz` makes `@oz/X.sol` `lib/ozX.sol`). + and Hardhat apply it (`@oz/=lib/oz` makes `@oz/X.sol` `lib/ozX.sol`; `x/=` + makes `x/A.sol` `A.sol`). `--mapping=` replaces the remappings with exactly the ones that file lists: a `foundry.toml`'s selected profile (with its `extends` base; a `remappings` key outside any table is taken too), or a `remappings.txt`. A @@ -380,30 +400,88 @@ resolve the way solc does under the project's build tool: base path: `import "src/A.sol"`), then as a package file in `node_modules`, from the importer's directory up (Hardhat and Node: `hardhat/console.sol`, `@scope/pkg/contracts/X.sol`; a package's `exports` map doesn't apply to - Solidity files). `--mapping` changes none of these lookups. - -Dependencies are input the project didn't write, so whatever resolves an -import, the result must be a `.sol` file inside the bundle root (an `import -".env";` or a remapping to `/opt/x/` is refused, stating why), and an import -from a dependency — a file under forge's `libs`, Soldeer's `dependencies/`, a -git submodule or any `node_modules` — must land, by real path, on a dependency's -file too: a dependency may import another (forge-std's `ds-test`), never the -project's own files, whether through a relative path, a base-path lookup, its -own remappings or a symlink. A dependency's `foundry.toml` whose `extends` -lies outside it is skipped with a warning. + Solidity files). `--mapping` changes none of these lookups: a root + `foundry.toml` still gives the `libs` (the default ones, warned, when forge + would reject the file), and `FOUNDRY_PROFILE` picking them is reported (one + that isn't a profile of the `foundry.toml` is warned about instead, as without + `--mapping`); that `foundry.toml` and its `extends` base count among the + config files read. + +Dependencies are input the project didn't write, so whatever resolves an import, +the result must be a `.sol` file inside the bundle root (an `import ".env";` or +a remapping to `/opt/x/` is refused, stating why), and who owns a file is +decided by where it really is, spelled as the filesystem spells it (on a +case-insensitive one, `LIB/evil` is `lib/evil`). The dependencies are the +entries of forge's `libs` (an absolute one by its real path; a symlinked +`lib/forge-std` is the dependency where it points), Soldeer's `dependencies/`, +git submodules (`.gitmodules` read with `@preventive/lockfile`'s reader, as git +reads it; a url relative to the superproject's remote, or none, is taken as +written, the submodule then having no GitHub name to bucket it by. A file the +reader refuses — something git reads two ways, or doesn't check, such as +`update = none`, `active` or a `[core]` section — never fails the bundle: it's +warned about and read a submodule at a time (`[submodule.x]` as git reads it, +`[submodule "x"]`), each submodule's first `path`, `url` and `branch`, dropping +with a warning a branch or url that doesn't read. One whose path doesn't fails +closed: a path naming a directory inside the repository, such as `./lib/x` or +`lib/x/`, still makes it a dependency, unnamed, and only one outside it is +skipped. A `.gitmodules` git itself refuses — a "bad config line", such as a +header `[submodule x]`, `[submodule.lib/x]` or `[submodule "x"` with no `]`, or +a value with no closing quote — is an error, as it is to git: read past, a +submodule's section would be lost, and its directory taken for the project's +own) and every `node_modules` package; a file +is a dependency's when its real path lies in one, however the path got there +(`src/vendor -> ../lib/dep/src` holds the dependency's code). An import from a +dependency must land on a dependency's file too: it may import its own files and +another dependency's (forge-std's `ds-test`), never the project's, whether +through a relative path, a base-path lookup, its own remappings or a symlink. A +symlink no one trusted placed is never followed: one planted inside a dependency +that leads out of it to anything but another dependency (`lib/evil/src/Evil.sol +-> ../../../.env`), and one outside the project that leads back into it (a +dependency linked from elsewhere, `lib/evil -> ../../shared/evil`, holding a +link to the project's `.env`). Links are followed one by one and the result +checked against the OS's own realpath: a path the two resolve differently (a +link target that isn't UTF-8, one whose `\` the OS reads as part of a name), or +one the OS can't resolve at all (a real path past `PATH_MAX`, a link whose end it +can't name: `/proc/self/fd/0` or `/dev/stdin` on a pipe), is refused, not +trusted; only a path with nothing there counts as missing. An `extends` path is +joined as forge joins it and resolved by the OS, so a `..` after a symlink leads +where forge's does, in the project's config and a dependency's alike. Whoever's import, entry or +manifest the path is, the import is refused, the entry rejected, the manifest +not carried, and a dependency's own `foundry.toml`, `extends` base or +`remappings.txt` skipped with a warning (one that is another dependency's file +is read). A dependency's config reaches only what the path from the root does: +one found through an absolute or `/proc/self/cwd` lib is judged by its real +path, a dependency outside the root reads nothing, and a dir a dependency's +`libs` names must be a dependency itself; a config refused says why. A +`package.json` that decides a file's package is refused the same way when a +dependency planted it as a link, and one that doesn't parse (a leading +byte-order mark is skipped, as npm skips it) or isn't a regular file is an error +naming it (not quoting it) rather than giving its files to the parent package; other bundles walk past +a malformed one, as they always have. A +link the project placed (a workspace package linked into `node_modules`, a +linked `lib/` entry, `src/vendor`) may lead anywhere in the root, and so may one +on the path the project was named by (a symlinked checkout); a workspace package +is the project's own code. The config files are read, not bundled. `--manifests` bundles the build -description too: the `*.toml`/`*.txt` config files the resolution read, the -root's `foundry.lock`, `soldeer.lock`, `.gitmodules` and `package.json`, and the -`package.json`, `foundry.toml` and `remappings.txt` of every package the bundle -holds files of — `json` for a `package.json`, `resource` otherwise, so `stasis -extract` restores them. They are carried as written, as `--package-json` carries -`package.json`: stasis doesn't edit them, so whatever they hold — an -`eth_rpc_url` or `[rpc_endpoints]` URL with its API key, an `[etherscan]` key, -the credentials in a `.gitmodules` URL — is in the bundle too. Keep secrets in -the environment (`${VAR}` in `foundry.toml`) rather than in these files, or -don't pass `--manifests`. `hardhat.config.*`, being code, and `.env` files are -never carried. +description too: every config file the resolution read, whatever it's called (an +`extends = "base.conf"`, a `--mapping=remaps`), by its path in the project (by +its real path once a `..` or an absolute or `/proc/self/cwd` lib leads +elsewhere; one whose real path the OS can't give, past `PATH_MAX`, is refused: +normalized, its name would be another file's), the root's `foundry.lock`, `soldeer.lock`, `.gitmodules` and +`package.json`, and the `package.json`, `foundry.toml` and `remappings.txt` +of every package the bundle holds files of — `json` for a `package.json`, +`resource` otherwise, so `stasis extract` restores them. They are carried as +written, as `--package-json` carries `package.json`: stasis doesn't edit them, +so whatever they hold — an `eth_rpc_url` or `[rpc_endpoints]` URL with its API +key, an `[etherscan]` key, the credentials in a `.gitmodules` URL — is in the +bundle too. Keep secrets in the environment (`${VAR}` in `foundry.toml`) rather +than in these files, or don't pass `--manifests`. `hardhat.config.*`, being +code, and `.env` files are never carried. A config the resolution read that +can't be carried — one outside the bundle root (`extends = +"../shared-base.toml"`), a `.env` one (`base.env`, `.env.toml`, `.env.local`), +or one the ownership rules refuse — fails `--manifests`, naming it: without it +the bundle couldn't reproduce the resolution. Rust entries are crate roots (`src/main.rs`, `src/lib.rs`, `src/bin/*.rs`, `tests/*.rs`, …): their `mod` declarations resolve as siblings, as rustc does, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c762d22b..ee44e0fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: stasis: dependencies: + '@exodus/bytes': + specifier: ^1.16.0 + version: 1.16.0 '@exodus/stasis-core': specifier: 1.0.0-beta.4 version: link:../stasis-core diff --git a/stasis-core/src/bundle-util.js b/stasis-core/src/bundle-util.js index 4a10d3dd..4164193d 100644 --- a/stasis-core/src/bundle-util.js +++ b/stasis-core/src/bundle-util.js @@ -24,19 +24,15 @@ export function packageType(file, host = diskHost) { // Nearest package.json (walking up) that identifies a bucket; pkgDir is relative to baseDir ("." // at the root). Inside node_modules both name and version are required; a workspace package // outside node_modules may omit version (the name alone claims the bucket, matching -// State#locateModule). Null if none. -export function findPackageMetadata(baseDir, fileRelPath, host = diskHost) { +// State#locateModule). Null if none. A malformed one is walked past, or with `strict` throws +// (its files would otherwise land in the parent package); `check`, `host`: see readPackageJson. +export function findPackageMetadata(baseDir, fileRelPath, { strict = false, check, host = diskHost } = {}) { let dir = dirname(fileRelPath) while (true) { - const pkgPath = join(baseDir, dir, 'package.json') - if (host.stat(pkgPath)?.isFile()) { - try { - const pkg = JSON.parse(packageJSONText(host.readFile(pkgPath))) - if (pkg.name && (pkg.version || !hasNodeModulesSegment(toPosix(dir)))) { - // `?? undefined` folds a literal `"version": null` into the one absent-version spelling. - return { pkgDir: dir, name: pkg.name, version: pkg.version ?? undefined } - } - } catch { /* malformed -- keep walking */ } + const pkg = readPackageJson(baseDir, toPosix(join(dir, 'package.json')), { strict, check, host }) + if (pkg?.name && (pkg.version || !hasNodeModulesSegment(toPosix(dir)))) { + // `?? undefined` folds a literal `"version": null` into the one absent-version spelling. + return { pkgDir: dir, name: pkg.name, version: pkg.version ?? undefined } } if (dir === '.' || dir === '/' || dir === '') return null const parent = dirname(dir) @@ -45,6 +41,67 @@ export function findPackageMetadata(baseDir, fileRelPath, host = diskHost) { } } +// The error codes that mean nothing is at a path. +export const NO_ENTRY = new Set(['ENOENT', 'ENOTDIR']) + +// `file`'s bytes, read through `host`, or null when there's no file (a directory counts as none). +// It's read only when it's a regular file: a FIFO, a socket, a device or a link to one +// (`/dev/stdin`) throws, naming it `label`, rather than stalling or reading the process's input. +// What can't be stat'ed is read to say why: only a path with nothing there is no file, and a loop +// or a directory that may not be searched throws. +export function readRegularFileOrNull(file, label, host = diskHost) { + const stat = host.stat(file) + if (stat === null) { + try { + host.readFile(file) + } catch (err) { + if (NO_ENTRY.has(err.code)) return null + throw err + } + throw new Error(`${label}: not a regular file`) + } + if (stat.isDirectory()) return null + if (!stat.isFile()) throw new Error(`${label}: not a regular file`) + return host.readFile(file) +} + +// The package.json at `rel` (under `baseDir`), parsed (a leading byte-order mark skipped, as npm +// and Node skip it), read through `host`; null when there's none (a directory counts as none), or +// when it doesn't parse or isn't a regular file -- unless `strict`, then that throws, saying where +// with the parser's line and column but never its message, which quotes the text (a file that isn't +// JSON may be anything, a secret included). `check(rel)`, when given, sees the path before it is +// read, and may throw to refuse it. +export function readPackageJson(baseDir, rel, { strict = false, check, host = diskHost } = {}) { + const file = join(baseDir, rel) + const stat = host.stat(file) + if (stat === null || stat.isDirectory()) return null + check?.(rel) + try { + // A FIFO, a socket or a device (or a link to one) is never read: it could stall the bundle. + if (!stat.isFile()) throw new Error(`${rel}: not a regular file`) + const bytes = host.readFile(file) + // Strict, it's read as the file's own text or not at all; lenient lookups decode it as they always + // have (a stray byte as U+FFFD). + if (strict && !isUtf8(bytes)) throw new Error(`${rel}: not valid UTF-8`) + return parseJson(packageJSONText(bytes), rel) + } catch (err) { + if (strict) throw err + return null + } +} + +// JSON.parse, throwing where the text breaks (the parser's line and column) but never the parser's +// message, which quotes the text. +function parseJson(text, rel) { + try { + return JSON.parse(text) + } catch (err) { + const at = /\(line \d+ column \d+\)/u.exec(err.message)?.[0] + // eslint-disable-next-line preserve-caught-error -- the parser's error quotes the file + throw new Error(`${rel} is not valid JSON${at ? ` ${at}` : ''}`) + } +} + export function normalizeEntries(entries, cwd) { const baseDir = resolve(cwd) return entries.map((e) => { diff --git a/stasis/bin/stasis.js b/stasis/bin/stasis.js index dbd70abe..a8444c93 100755 --- a/stasis/bin/stasis.js +++ b/stasis/bin/stasis.js @@ -3,7 +3,7 @@ import { spawn } from 'node:child_process' import { once } from 'node:events' import { fileURLToPath } from 'node:url' -import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path' +import { basename, dirname, isAbsolute, join, resolve } from 'node:path' import { existsSync, realpathSync } from 'node:fs' import { homedir, constants as osConstants } from 'node:os' import assert from 'node:assert/strict' @@ -250,15 +250,10 @@ if (command === '-v' || command === '--version') { if (argv.length === 0) usage('Nothing to bundle: no entry file given') // A directory entry stands for the .sol files under it (Solidity only); an extensionless path // that doesn't exist is a missing one (skipped with a warning). - const { isDir } = await import('../src/resolve-typescript.js') - const dirEntries = argv.filter((f) => isDir(resolve(f)) || (extname(f) === '' && !existsSync(resolve(f)))) - const allSol = argv.every((f) => f.endsWith('.sol') || dirEntries.includes(f)) - if (dirEntries.length > 0 && !allSol) { - const missing = dirEntries.find((f) => !existsSync(resolve(f))) - usage(missing === undefined - ? `Error: a directory entry is only supported for Solidity bundles (it stands for the .sol files under it): ${dirEntries[0]}` - : `Error: no such file or directory: ${missing}`) - } + const { directoryEntryError, isSolidityEntry } = await import('../src/cmd/bundle.js') + const dirError = directoryEntryError(argv) + if (dirError !== null) usage(`Error: ${dirError}`) + const allSol = argv.every((f) => isSolidityEntry(f)) const allPhp = argv.every((f) => f.endsWith('.php')) const allJs = argv.every((f) => /\.(?:js|cjs|mjs|ts|cts|mts)$/u.test(f)) const allBash = argv.every((f) => /\.(?:sh|bash)$/u.test(f)) diff --git a/stasis/package.json b/stasis/package.json index 1698a00d..c655e922 100644 --- a/stasis/package.json +++ b/stasis/package.json @@ -43,6 +43,7 @@ "src/loaders/foundry.js", "src/loaders/rust.js", "src/loaders/solidity.js", + "src/loaders/solidity-ownership.js", "src/loaders/toml.js", "src/loaders/php.js", "src/lockfile.js", @@ -83,6 +84,7 @@ }, "homepage": "https://github.com/ExodusOSS/stasis#readme", "dependencies": { + "@exodus/bytes": "^1.16.0", "@exodus/stasis-core": "1.0.0-beta.4", "@exodus/stasis-plugins": "1.0.0-beta.4", "@preventive/archive": "1.0.0-beta.3", diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index 7c63e489..39bd9c45 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -13,8 +13,8 @@ import { createMetroResolver } from '../metro-resolver.js' import { State } from '@exodus/stasis-core/state' import { brotliOptions } from '@exodus/stasis-core/brotli' import { sha512integrity } from '@exodus/stasis-core/state-util' -import { detectRepo, findPackageMetadata, normalizeEntries, packageType, readJson, readModuleManifest, readText } from '@exodus/stasis-core/bundle-util' -import { RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPodspec, isSkippedNativeWalkDir, moduleFileKey, parseResourcesOption, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util' +import { detectRepo, findPackageMetadata, normalizeEntries, packageType, readJson, readModuleManifest, readPackageJson, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util' +import { RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, isDotEnvFile, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPodspec, isSkippedNativeWalkDir, moduleFileKey, parseResourcesOption, posixPathEscapes, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util' import { diskHost } from '@exodus/stasis-core/host' import { SOLIDITY_PACKAGE_MANIFESTS, @@ -24,6 +24,7 @@ import { discoverSolidityConfig, expandSolidityEntries, } from '../loaders/solidity.js' +import { decodeUtf8 } from '../loaders/solidity-ownership.js' import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js' import { buildRustTree, collectRustFilesFromDisk } from '../loaders/rust.js' import { VENDOR_DIR as CARGO_VENDOR_DIR, createCargoContext } from '../loaders/cargo.js' @@ -86,42 +87,24 @@ function githubSlug(url) { return m ? `${m[1]}/${m[2]}` : null } -// Parse `.gitmodules` (git-config INI) into Map, -// github.com submodules only. -function parseGithubSubmodules(baseDir, host) { +// The github.com ones of `submodules` (readGitmodules), as Map. +function githubSubmodules(submodules) { const byPath = new Map() - const text = readText(host, join(baseDir, '.gitmodules')) - if (!text) return byPath - let cur = null - const flush = () => { - if (cur?.path && cur?.url) { - const name = githubSlug(cur.url) - if (name) byPath.set(cur.path.replace(/\/+$/u, ''), { name, branch: cur.branch }) - } - cur = null + for (const { path, url, branch } of submodules) { + const name = url && githubSlug(url) + if (name) byPath.set(path, { name, branch }) } - for (const raw of text.split('\n')) { - const line = raw.trim() - if (line.startsWith('[')) { - flush() - cur = line.startsWith('[submodule') ? {} : null - continue - } - if (!cur) continue - const eq = line.indexOf('=') - if (eq === -1) continue - const key = line.slice(0, eq).trim() - if (key === 'path' || key === 'url' || key === 'branch') cur[key] = line.slice(eq + 1).trim() - } - flush() return byPath } // Classify a Solidity file's dep bucket: Soldeer (`dependencies/-/`) or a -// github submodule (`lib/`, via `.gitmodules`), else null to defer to the node_modules/ -// workspace logic. -function makeSolidityClassifier(baseDir, host) { - const submodules = parseGithubSubmodules(baseDir, host) +// github submodule (`lib/`, via the `.gitmodules` `ownership` read), else null to defer to the +// node_modules/workspace logic. `ownership.assert` vets a package.json path before it is read +// through `host`. +function makeSolidityClassifier(baseDir, ownership, host) { + const submodules = githubSubmodules(ownership.submodules) + const check = ownership.assert + const versions = new Map() // a submodule's package.json version, read once return (path) => { if (path.startsWith('dependencies/')) { const seg = path.slice('dependencies/'.length).split('/')[0] @@ -132,8 +115,8 @@ function makeSolidityClassifier(baseDir, host) { } for (const [sub, { name, branch }] of submodules) { if (path === sub || path.startsWith(`${sub}/`)) { - const pkg = readJson(join(baseDir, sub, 'package.json'), host) - return { bucketDir: sub, name, version: pkg?.version ?? branch ?? '0.0.0', ecosystem: 'github' } + if (!versions.has(sub)) versions.set(sub, readPackageJson(baseDir, moduleFileKey(sub, 'package.json'), { strict: true, check, host })?.version) + return { bucketDir: sub, name, version: versions.get(sub) ?? branch ?? '0.0.0', ecosystem: 'github' } } } return null @@ -170,12 +153,14 @@ function executableSources(baseDir, sources, host) { // Assemble a full-scope code Bundle shared by the non-JS bundlers. Files are bucketed by // nearest package.json (node_modules -> `npm`-tagged bucket, workspace -> its dir, none -> // "." with the placeholder identity); a node_modules file whose nearest package.json is the -// workspace root is rejected, not mislabeled. `classifyDep(path)` optionally places a file -// directly (non-node_modules ecosystems like Soldeer/github); null defers. `format` tags +// workspace root is rejected, not mislabeled; `packageOf(path)` finds that package.json +// (packageLookup by default, which walks past a malformed one). `classifyDep(path)` optionally +// places a file directly (non-node_modules ecosystems like Soldeer/github); null defers. `format` tags // every file; `formats` (Map) overrides it per file. `resolutions` values are // a flat target string or a Map; both round-trip untouched. function assembleCodeBundle({ baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep, host, + packageOf = packageLookup(baseDir, { host }), }) { const modules = new Map() const ensureBucket = (dir, name, version, bucketEcosystem) => { @@ -193,7 +178,7 @@ function assembleCodeBundle({ ensureBucket(dep.bucketDir, dep.name, dep.version, dep.ecosystem).files[fileInBucket(dep.bucketDir, path)] = content continue } - const meta = findPackageMetadata(baseDir, path, host) + const meta = packageOf(path) const inNodeModules = splitNodeModulesPath(path) !== null if (meta) { if (inNodeModules && !meta.pkgDir.includes('node_modules')) { @@ -229,44 +214,86 @@ function assembleCodeBundle({ }).withReason('bundle') } -// The build-description files of a Solidity bundle (--manifests), as Map: `configFiles` -// (what discoverSolidityConfig read, when named `*.toml`/`*.txt`) plus the SOLIDITY_*_MANIFESTS -// that exist, for the root and for each package dir `classifyDep`/package.json places a bundled -// source in. Files inside the root only, carried as written: whatever they hold (an RPC URL with -// its API key, an Etherscan key, a URL's credentials) is in the bundle too, as with --package-json. -function solidityManifests(baseDir, sources, configFiles, classifyDep, host) { - const wanted = new Set([...configFiles.filter((f) => f.endsWith('.toml') || f.endsWith('.txt')), ...SOLIDITY_ROOT_MANIFESTS]) - const dirs = new Set() - for (const path of sources.keys()) { - const dep = classifyDep(path) - if (dep) dirs.add(dep.bucketDir) - const meta = findPackageMetadata(baseDir, path, host) - if (meta) dirs.add(meta.pkgDir) - } - for (const dir of dirs) { - for (const name of SOLIDITY_PACKAGE_MANIFESTS) wanted.add(moduleFileKey(dir, name)) +// Files never carried, whatever reads them: `.env` files, and Hardhat's config, which is code +// (both however they're cased). +const neverCarried = (rel) => isDotEnvFile(rel) || posix.basename(rel).toLowerCase().startsWith('hardhat.config.') + +// findPackageMetadata (with `options`) once per directory, the only thing its answer depends on. +function packageLookup(baseDir, options) { + const byDir = new Map() + return (path) => { + const dir = posix.dirname(path) + if (!byDir.has(dir)) byDir.set(dir, findPackageMetadata(baseDir, path, options)) + return byDir.get(dir) } +} + +// The build-description files of a Solidity bundle (--manifests), as Map, sorted: +// `configFiles` (what discoverSolidityConfig read, whatever they're called), each of which must be +// carried -- one outside the root, neverCarried, refused or gone is an error, as the bundle +// couldn't reproduce the resolution without it -- plus the SOLIDITY_*_MANIFESTS that exist, for the +// root and for each package dir `classifyDep`/`packageOf` places a bundled source in, but none +// whose path `ownership` refuses (see solidityOwnership). Carried as written: whatever they hold +// (an RPC URL with its API key, an Etherscan key, a URL's credentials) is in the bundle too, as +// with --package-json. Read through `host`. +function solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership, host }) { const realBase = host.realpath(baseDir) + // `{ text }`, or `{ why }` it can't be carried (null: nothing is there). Read by the real path + // `ownership` resolved `rel` to, so what's carried is the file it vouched for. + const carry = (rel) => { + const { reason, real, outside } = ownership.of(rel) + if (reason) return { why: reason } + if (real === null) return { why: null } + if (outside) throw new Error(`Refusing to follow symlink escaping bundle root: ${rel} -> ${resolve(realBase, real)}`) + const buf = readRegularFileOrNull(join(realBase, real), rel, host) + if (buf === null) return { why: null } // a directory + return { text: decodeUtf8(buf, rel) } + } + const unreproducible = (rel, why) => new Error(`--manifests can't carry ${rel}, which the Solidity resolution read: ${why}`) const out = new Map() - for (const rel of [...wanted].toSorted()) { - if (sources.has(rel) || posix.isAbsolute(rel) || rel.startsWith('../')) continue - let buf - try { - assertRealPathWithinBase(realBase, baseDir, rel, host) - buf = host.readFile(join(baseDir, rel)) - } catch (err) { - if (err.code === 'ENOENT' || err.code === 'EISDIR') continue - throw err - } - if (!isUtf8(buf)) throw new Error(`Solidity manifest is not valid UTF-8: ${rel}`) - out.set(rel, buf.toString('utf8')) + for (const rel of configFiles) { + // Absolute: the OS couldn't give its real path (see projectRelative). + if (posix.isAbsolute(rel)) throw unreproducible(rel, "its real path can't be resolved") + if (posixPathEscapes(rel)) throw unreproducible(rel, 'it lies outside the bundle root') + if (neverCarried(rel)) throw unreproducible(rel, '.env files and hardhat.config.* are never carried') + if (sources.has(rel)) continue + const { text, why } = carry(rel) + if (text === undefined) throw unreproducible(rel, why ?? 'it is gone') + out.set(rel, text) + } + const optional = new Set(SOLIDITY_ROOT_MANIFESTS) + for (const path of sources.keys()) { + const dirs = [classifyDep(path)?.bucketDir, packageOf(path)?.pkgDir].filter((d) => d !== undefined) + for (const dir of dirs) for (const name of SOLIDITY_PACKAGE_MANIFESTS) optional.add(moduleFileKey(dir, name)) + } + for (const rel of optional) { + if (sources.has(rel) || out.has(rel)) continue + const { text, why } = carry(rel) + if (why) console.warn(`[stasis] Not carrying ${rel}: ${why}`) + if (text !== undefined) out.set(rel, text) } - return out + return new Map([...out.keys()].toSorted().map((rel) => [rel, out.get(rel)])) } // An entry `stasis bundle` takes for a directory: one that is, or an extensionless path that // doesn't exist (a project without `script/` still bundles with `src test script`). -const isDirEntry = (abs, host = diskHost) => isDir(abs, host) || (extname(abs) === '' && host.stat(abs) === null) +export const isDirEntry = (abs, host = diskHost) => isDir(abs, host) || (extname(abs) === '' && host.stat(abs) === null) + +// Whether `entry` (resolved against `cwd`) is a Solidity bundle's: a .sol file or a directory entry. +export const isSolidityEntry = (entry, cwd = process.cwd(), host = diskHost) => entry.endsWith('.sol') || isDirEntry(resolve(cwd, entry), host) + +// What's wrong with `entries`' directory entries (resolved against `cwd`), or null: a directory +// entry stands for the .sol files under it, so it goes with Solidity entries only; and entries +// that are all missing extensionless paths are a mistyped file, not a project without those dirs. +export function directoryEntryError(entries, cwd = process.cwd(), host = diskHost) { + const dirs = entries.filter((e) => isDirEntry(resolve(cwd, e), host)) + const absent = dirs.filter((e) => host.stat(resolve(cwd, e)) === null) + if (absent.length === entries.length) return `no such file or directory: ${absent[0]}` + if (dirs.length === 0 || entries.every((e) => e.endsWith('.sol') || dirs.includes(e))) return null + return absent.length > 0 + ? `no such file or directory: ${absent[0]}` + : `a directory entry is only supported for Solidity bundles (it stands for the .sol files under it): ${dirs[0]}` +} // Build an in-memory Bundle from entry .sol files and directories (a directory stands for the .sol // files under it, as forge's src/test/script dirs and Hardhat's contracts dir do; a missing or @@ -285,15 +312,15 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin const baseDir = resolve(cwd) const normalized = normalizeEntries(entries, cwd) for (const e of normalized) { - if (!e.endsWith('.sol') && !isDirEntry(join(baseDir, e), host)) throw new Error(`buildSolidityBundle: not a .sol file or directory: ${e}`) + if (!isSolidityEntry(e, baseDir, host)) throw new Error(`buildSolidityBundle: not a .sol file or directory: ${e}`) } const expanded = expandSolidityEntries(baseDir, normalized, host) - const { remappings, libs, dependencyDirs, files: configFiles, envUsed } = discoverSolidityConfig(baseDir, { mappingFile, env, host }) + const { remappings, libs, ownership, files: configFiles, envUsed } = discoverSolidityConfig(baseDir, { mappingFile, env, host }) // The bundle doesn't record the environment, so say when it shaped the resolution. if (envUsed.length > 0) console.warn(`[stasis] Solidity imports resolved with ${envUsed.join(', ')} from the environment`) - const sources = collectSolidityFilesFromDisk(baseDir, expanded, remappings, { libs, dependencyDirs, host }) - const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir, libs, dependencyDirs, host }) + const sources = collectSolidityFilesFromDisk(baseDir, expanded, remappings, { libs, ownership, host }) + const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir, libs, ownership, host }) // Bundles must be self-contained: fail on a missing entry or unresolved import. const issues = [] @@ -307,13 +334,14 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin throw new Error(`Solidity bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`) } - const classifyDep = makeSolidityClassifier(baseDir, host) + const classifyDep = makeSolidityClassifier(baseDir, ownership, host) + const packageOf = packageLookup(baseDir, { strict: true, check: ownership.assert, host }) const bundled = new Map(sources) const formats = new Map() if (manifests) { - for (const [path, text] of solidityManifests(baseDir, sources, configFiles, classifyDep, host)) { + for (const [path, text] of solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership, host })) { bundled.set(path, text) - formats.set(path, path.endsWith('.json') ? 'json' : 'resource') + formats.set(path, posix.basename(path) === 'package.json' ? 'json' : 'resource') } } @@ -328,6 +356,7 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin formats, conditionKey: 'solidity', classifyDep, + packageOf, host, }) } @@ -917,19 +946,18 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields, } // --package-json: fold each bundled module's package.json into `sources` (and its integrity into - // the companion lockfile) even when the scan never reached it. Buckets are the same ones - // assembleCodeBundle derives (findPackageMetadata -> pkgDir, else the '.' workspace bucket); - // findPackageMetadata is memoized per directory so a package's many files don't each re-walk to the - // same manifest. readModuleManifest applies the read/validate rules shared with the State path - // (containment, UTF-8-aborts); no identity check here -- these buckets are all fresh from disk. + // the companion lockfile) even when the scan never reached it. Buckets are the ones + // assembleCodeBundle derives, from the same `packageOf` (findPackageMetadata -> pkgDir, else the + // '.' workspace bucket; packageLookup memoizes it per directory so a package's many files don't + // each re-walk to the same manifest). readModuleManifest applies the read/validate rules shared + // with the State path (containment, UTF-8-aborts); no identity check here -- these buckets are + // all fresh from disk. + const packageOf = packageLookup(baseDir, { host }) if (packageJSON) { - const metaByDir = new Map() const pkgDirs = new Set() for (const abs of reached) { const rel = toRel(abs) - const dir = dirname(rel) - if (!metaByDir.has(dir)) metaByDir.set(dir, findPackageMetadata(baseDir, rel, host)) - const meta = metaByDir.get(dir) + const meta = packageOf(rel) if (meta) pkgDirs.add(meta.pkgDir) else if (!splitNodeModulesPath(rel)) pkgDirs.add('.') } @@ -970,6 +998,7 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields, workspaceVersion: rootPkg.version ?? '0.0.0', conditionKey: '*', host, + packageOf, }) // The companion lockfile mirrors the bundle, swapping file content for its integrity. @@ -998,15 +1027,11 @@ function classifyEntries(name, { cwd = process.cwd(), entries, mappingFile, mani if (!Array.isArray(entries) || entries.length === 0) { throw new Error(`${name}: at least one entry file is required`) } - const dirs = entries.filter((e) => isDirEntry(resolve(cwd, e), host)) - const files = entries.filter((e) => !dirs.includes(e)) + const dirError = directoryEntryError(entries, cwd, host) + if (dirError !== null) throw new Error(`${name}: ${dirError}`) let kind - if (files.every((e) => e.endsWith('.sol'))) kind = 'sol' - else if (dirs.length > 0) { - const missing = dirs.find((e) => host.stat(resolve(cwd, e)) === null) - if (missing !== undefined) throw new Error(`${name}: no such file or directory: ${missing}`) - throw new Error(`${name}: a directory entry is only supported for Solidity bundles (it stands for the .sol files under it): ${dirs[0]}`) - } else if (entries.every((e) => e.endsWith('.php'))) kind = 'php' + if (entries.every((e) => isSolidityEntry(e, cwd, host))) kind = 'sol' + else if (entries.every((e) => e.endsWith('.php'))) kind = 'php' else if (entries.every((e) => JS_EXTS.has(extname(e)))) kind = 'js' else if (entries.every((e) => BASH_EXTS.has(extname(e)))) kind = 'bash' else if (entries.every((e) => RUST_EXTS.has(extname(e)))) kind = 'rust' diff --git a/stasis/src/loaders/cargo.js b/stasis/src/loaders/cargo.js index 5a0483fe..38791fa0 100644 --- a/stasis/src/loaders/cargo.js +++ b/stasis/src/loaders/cargo.js @@ -25,7 +25,7 @@ export function isFile(path) { } } -export function readFileOrNull(file) { +function readFileOrNull(file) { try { return readFileSync(file, 'utf8') } catch { diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index 83d12e1c..38e7916d 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -15,10 +15,10 @@ import { posix, resolve } from 'node:path' -import { readText } from '@exodus/stasis-core/bundle-util' import { diskHost } from '@exodus/stasis-core/host' import { toPosix } from '@exodus/stasis-core/util' import { isDir } from '../resolve-typescript.js' +import { projectOwnership, projectRelative, readUtf8OrNull, realpathOrNull } from './solidity-ownership.js' import { isTomlTable, readToml } from './toml.js' export const FOUNDRY_TOML = 'foundry.toml' @@ -68,11 +68,14 @@ function cmpPath(a, b) { } function canonicalize(p, host) { - try { - return toPosix(host.realpath(p)) - } catch { - return null - } + const real = realpathOrNull(p, host) + return real === null ? null : toPosix(real) +} + +// How messages name a file of the project at `root` (absolute POSIX, read through `host`): from the +// root, by its lexical or its canonical path, else as given. +export function shownFrom(root, host = diskHost, canonicalRoot = canonicalize(root, host) ?? root) { + return (abs) => stripPrefix(abs, root) || stripPrefix(abs, canonicalRoot) || abs } const isSymlinkPath = (p, host) => { @@ -108,9 +111,14 @@ const readDir = (dir, host) => listDir(dir, host).filter((e) => !e.name.startsWi // --- Remapping values ---------------------------------------------------------------------- +// Rust's `str::trim`: Unicode White_Space only, so a byte-order mark (U+FEFF, which JS's `trim` +// takes) stays, as it does for forge. +const rustTrim = (s) => s.replaceAll(/^\p{White_Space}+|\p{White_Space}+$/gu, '') + // `[context:]name=path`, as forge (`Remapping::from_str`) and solc split it: at the first `=`, then -// the first `:` before it. An empty context is global; an empty name or path is invalid (null). -export function parseRemapping(entry) { +// the first `:` before it. An empty context is global; an empty name or path is invalid (null), +// but for solc (`emptyPath`) only an empty name is: `x/=` maps `x/A.sol` to `A.sol`. +export function parseRemapping(entry, { emptyPath = false } = {}) { const eq = entry.indexOf('=') if (eq === -1) return null let name = entry.slice(0, eq) @@ -121,23 +129,44 @@ export function parseRemapping(entry) { context = name.slice(0, colon) name = name.slice(colon + 1) } - if (name.trim() === '' || path.trim() === '') return null - if (context !== null && context.trim() === '') context = null + if (rustTrim(name) === '' || (!emptyPath && rustTrim(path) === '')) return null + if (context !== null && rustTrim(context) === '') context = null return { context, name, path } } -// A remappings.txt / env var body: one remapping per non-blank (trimmed) line; invalid lines -// (forge rejects the whole file on one) are skipped, and reported when a `label` names the source. -export function parseRemappingLines(text, label) { +// A remappings.txt / env var body: one remapping per non-blank (trimmed) line. A line that isn't +// one throws, naming `label` (the file or variable) and the line, as forge and solc refuse the +// file. `emptyPath`: see parseRemapping. +export function parseRemappingLines(text, { label = 'remappings', emptyPath = false } = {}) { const out = [] - for (const line of text.split('\n').map((l) => l.trim()).filter(Boolean)) { - const r = parseRemapping(line) - if (r) out.push(r) - else if (label !== undefined) console.warn(`[loader.solidity] Invalid remapping in ${label}: ${line}`) - } + text.split('\n').forEach((raw, i) => { + const line = rustTrim(raw) + if (line === '') return + const r = parseRemapping(line, { emptyPath }) + if (r === null) throw new Error(`${label}:${i + 1}: invalid remapping ${JSON.stringify(line)}`) + out.push(r) + }) return out } +// A foundry.toml's `remappings` value, parsed. One forge rejects -- not an array of strings, or an +// entry that isn't `[context:]name=path` -- throws, naming `file` when given. +function configRemappings(value, file) { + const where = `${file === null ? '' : `${file}: `}\`remappings\`` + if (!Array.isArray(value)) throw new Error(`${where} is not an array of strings`) + return value.map((entry) => { + const r = typeof entry === 'string' ? parseRemapping(entry) : null + if (r === null) throw new Error(`${where}: invalid remapping ${typeof entry === 'string' ? JSON.stringify(entry) : String(entry)}`) + return r + }) +} + +// A config forge refuses for its settings (a missing or nested `extends`, colliding keys) or that a +// dependency may not read (a link out of it): forge skips such a dependency's config, and so does +// loadNestedConfig. Anything else wrong with a config -- text that isn't TOML, an invalid +// remapping -- is another error, and fatal. +class ConfigRefused extends Error {} + // Forge's trailing `/` on a remapping's name and path, unless they end in `/` or `.sol`. const withSlash = (s) => (s.endsWith('/') || s.endsWith('.sol') ? s : `${s}/`) @@ -398,11 +427,11 @@ const STANDALONE_SECTIONS = new Set([ // foundry.toml -> `{ profiles, topLevel }`. `profiles` is Map> (profile // names lowercased, keys snake_cased as forge does) from the `[profile.]` tables and the -// legacy top-level `[]` ones forge still reads, the former winning key by key; a profile's -// sub-tables are its values like any other (`extends`, `fuzz`: forge compares them all for a -// `no-collision` extends). `topLevel` holds the values set outside any table (forge rejects those; -// a `--mapping` file may list its `remappings` there). Throws a TomlError naming `file` on text -// that isn't TOML, as forge refuses the file. +// legacy top-level `[]` ones forge still reads (not for `extends`), the former winning key +// by key; a profile's sub-tables are its values like any other (`extends`, `fuzz`: forge compares +// them all for a `no-collision` extends). `topLevel` holds the values set outside any table (forge +// rejects those; a `--mapping` file may list its `remappings` there). Throws a TomlError naming +// `file` on text that isn't TOML, as forge refuses the file. function parseFoundryToml(text, file = null) { const current = new Map() const legacy = new Map() @@ -410,7 +439,10 @@ function parseFoundryToml(text, file = null) { const read = (map, name, table) => { const profile = name.toLowerCase() const dict = map.get(profile) ?? map.set(profile, new Map()).get(profile) - for (const [key, value] of Object.entries(table)) dict.set(snakeCase(key), value) + for (const [key, value] of Object.entries(table)) { + const k = snakeCase(key) + if (k !== 'extends' || map === current) dict.set(k, value) // forge reads `extends` from `[profile.]` only + } } for (const [key, value] of Object.entries(readToml(text, file))) { if (!isTomlTable(value)) topLevel.set(snakeCase(key), value) @@ -438,32 +470,43 @@ function mergeExtended(base, local, strategy) { return out } +// forge's `Extends`: a path, or `{ path, strategy? }`. +const EXTEND_STRATEGIES = new Set(['extend-arrays', 'replace-arrays', 'no-collision']) +const isExtends = (v) => typeof v === 'string' + || (isTomlTable(v) && typeof v.path === 'string' && (v.strategy === undefined || EXTEND_STRATEGIES.has(v.strategy))) + // A foundry.toml's profiles, with the selected profile's `extends` base merged in (forge's // `TomlFileProvider`). `files` lists what was read; `topLevel` is the file's own (see -// parseFoundryToml). Throws where forge refuses the config, and where `confineTo` (a dependency's -// real root) doesn't hold the base: a dependency's config may not read the project's files. -function readFoundryProfiles(file, profile, { confineTo, host }) { - const text = readText(host, file) +// parseFoundryToml). Throws where forge refuses the config, and where `refused` (a dependency's +// config: see findNestedFoundryRemappings) gives a reason not to read the file or its base: a +// dependency's config may not read the project's files. Messages name files `show(file)`. +function readFoundryProfiles(file, profile, { refused = () => null, show, host }) { + const name = show(file) + const refusal = refused(file) + if (refusal) throw new ConfigRefused(`${name}: refusing to read it: ${refusal}`) + const text = readUtf8OrNull(file, name, host) if (text === null) return { profiles: new Map(), topLevel: new Map(), files: [] } - let { profiles, topLevel } = parseFoundryToml(text, file) + let { profiles, topLevel } = parseFoundryToml(text, name) const files = [file] const ext = profiles.get(profile)?.get('extends') - const extPath = typeof ext === 'string' ? ext : ext?.path - if (typeof extPath === 'string') { - const strategy = (typeof ext === 'object' && typeof ext.strategy === 'string') ? ext.strategy : 'extend-arrays' - const baseFile = toPosix(resolve(posix.dirname(file), extPath)) - if (confineTo !== undefined && !pathStartsWith(canonicalize(baseFile, host) ?? baseFile, confineTo)) { - throw new Error(`${file}: refusing to extend ${extPath}, which lies outside the dependency`) - } - const baseText = readText(host, baseFile) - if (baseText === null) throw new Error(`${file}: the inherited config file does not exist: ${extPath}`) - const base = parseFoundryToml(baseText, baseFile).profiles + if (ext !== undefined) { + if (!isExtends(ext)) throw new Error(`${name}: \`extends\` must be a path, or a table with a \`path\` and an optional \`strategy\` (${[...EXTEND_STRATEGIES].join(', ')})`) + const { path: extPath, strategy = 'extend-arrays' } = typeof ext === 'string' ? { path: ext } : ext + // Joined as forge joins it, not normalized: the read resolves a `..` after a symlink the way + // forge's does (from where the link leads), not textually. + const baseFile = rustJoin(posix.dirname(file), extPath) + const baseRefusal = refused(baseFile) + if (baseRefusal) throw new ConfigRefused(`${name}: refusing to extend ${extPath}: ${baseRefusal}`) + const baseName = show(baseFile) + const baseText = readUtf8OrNull(baseFile, baseName, host) + if (baseText === null) throw new ConfigRefused(`${name}: the inherited config file does not exist: ${extPath}`) + const base = parseFoundryToml(baseText, baseName).profiles if (base.get(profile)?.has('extends')) { - throw new Error(`${file}: nested inheritance is not allowed (${extPath} has an 'extends' field in profile '${profile}')`) + throw new ConfigRefused(`${name}: nested inheritance is not allowed (${extPath} has an 'extends' field in profile '${profile}')`) } if (strategy === 'no-collision') { const collisions = [...(profiles.get(profile)?.keys() ?? [])].filter((k) => k !== 'extends' && base.get(profile)?.has(k)) - if (collisions.length > 0) throw new Error(`${file}: key collision in profile '${profile}' when extending ${extPath}: ${collisions.join(', ')}`) + if (collisions.length > 0) throw new ConfigRefused(`${name}: key collision in profile '${profile}' when extending ${extPath}: ${collisions.join(', ')}`) } profiles = mergeExtended(base, profiles, strategy) files.push(baseFile) @@ -480,9 +523,12 @@ function selectProfile(profiles, profile) { } // The `remappings` a foundry.toml's profiles set for `profile` (`[profile.default]` overlaid by -// it), else the file's top-level `remappings` (a mapping file written for stasis), as written. -const profileRemappings = ({ profiles, topLevel }, profile) => - (stringList(selectProfile(profiles, profile).get('remappings')) ?? stringList(topLevel.get('remappings')) ?? []).map(parseRemapping).filter(Boolean) +// it), else the file's top-level `remappings` (a mapping file written for stasis), as written; an +// invalid one throws (configRemappings, naming `file`). +function profileRemappings({ profiles, topLevel }, profile, file = null) { + const value = selectProfile(profiles, profile).get('remappings') ?? topLevel.get('remappings') + return value === undefined ? [] : configRemappings(value, file) +} // A foundry.toml text's own remappings for `profile` (see profileRemappings). export function foundryTomlRemappings(text, profile = 'default') { @@ -490,10 +536,24 @@ export function foundryTomlRemappings(text, profile = 'default') { } // The same for a foundry.toml file, with its `extends` base: what `--mapping=foundry.toml` takes. -// `files` lists what was read. -export function readFoundryTomlRemappings(file, profile = 'default', host = diskHost) { - const read = readFoundryProfiles(toPosix(resolve(file)), profile, { host }) - return { remappings: profileRemappings(read, profile), files: read.files } +// `files` lists what was read; `profiled` whether the selected `profile` is one of the file's. +// Messages name files `show(file)`. +export function readFoundryTomlRemappings(file, profile, { show, host = diskHost }) { + const abs = toPosix(resolve(file)) + const read = readFoundryProfiles(abs, profile, { show, host }) + return { remappings: profileRemappings(read, profile, show(abs)), files: read.files, profiled: hasProfile(read.profiles, profile) } +} + +// Whether the selected `profile` is one of `profiles` (not the default, which always applies). +const hasProfile = (profiles, profile) => profile !== 'default' && profiles.has(profile) + +// hasProfile, for the root foundry.toml: one that isn't there is warned about (forge uses +// `[profile.default]` for it). +function profileApplies(profiles, profile) { + if (profile !== 'default' && !profiles.has(profile)) { + console.warn(`[loader.solidity] FOUNDRY_PROFILE=${profile} is not a profile in foundry.toml; using [profile.default]`) + } + return hasProfile(profiles, profile) } // `ProjectPathsConfig::find_source_dir`: `src` unless only `contracts` exists. @@ -507,26 +567,31 @@ function detectLibs(root, host) { return lib ? ['lib', 'node_modules'] : ['node_modules'] } -const stringList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : null) - // The selected profile's settings for a Foundry project at `root` (absolute POSIX), defaults -// filled in the way forge fills them. `remappings` are the profile's own, unnormalized. Null -// `remappings` means one didn't parse (forge rejects such a config). `confineTo`: see +// filled in the way forge fills them. `remappings` are the profile's own, unnormalized; an invalid +// one throws (configRemappings). `refused`, `show` (from the root by default), `host`: see // readFoundryProfiles. -function loadFoundryConfig(root, profile, { confineTo, host }) { - const { profiles, files } = readFoundryProfiles(rustJoin(root, FOUNDRY_TOML), profile, { confineTo, host }) +function loadFoundryConfig(root, profile, { refused, host, show = shownFrom(root, host) }) { + const file = rustJoin(root, FOUNDRY_TOML) + const name = show(file) + const { profiles, files } = readFoundryProfiles(file, profile, { refused, show, host }) const dict = selectProfile(profiles, profile) - const str = (k) => (typeof dict.get(k) === 'string' ? dict.get(k) : null) - const remappings = (stringList(dict.get('remappings')) ?? []).map(parseRemapping) + // A setting of the wrong type throws, as forge refuses the config: no quiet default. + const setting = (key, ok, what) => { + const value = dict.get(key) + if (value !== undefined && !ok(value)) throw new Error(`${name}: \`${key}\` must be ${what}`) + return value + } + const isString = (v) => typeof v === 'string' return { profiles, files, - src: str('src') ?? findSourceDir(root, host), - test: str('test') ?? 'test', - script: str('script') ?? 'script', - libs: stringList(dict.get('libs')) ?? detectLibs(root, host), - remappings: remappings.includes(null) ? null : remappings, - autoDetect: dict.get('auto_detect_remappings') !== false, + src: setting('src', isString, 'a string') ?? findSourceDir(root, host), + test: setting('test', isString, 'a string') ?? 'test', + script: setting('script', isString, 'a string') ?? 'script', + libs: setting('libs', (v) => Array.isArray(v) && v.every(isString), 'an array of strings') ?? detectLibs(root, host), + remappings: dict.has('remappings') ? configRemappings(dict.get('remappings'), name) : [], + autoDetect: setting('auto_detect_remappings', (v) => typeof v === 'boolean', 'a boolean') !== false, } } @@ -574,31 +639,64 @@ function rebaseNested(r, canonical, lexical) { // A dependency's config as forge's `load_nested_config` reads it: remappings rebased onto its // canonical root, its remappings.txt, its src and libs. Null when forge would reject the config, -// or when its `extends` reaches outside the dependency (warned). -function loadNestedConfig(canonical, profile, host) { +// or when `refused` refuses it or its `extends` base (warned: ConfigRefused); a remappings.txt it +// refuses is skipped (warned). One that isn't TOML or holds an invalid remapping throws: forge +// refuses a bad remappings.txt line too, and skips a foundry.toml it can't read, which here is an +// error rather than a config quietly left out. `refused`, `show`: see readFoundryProfiles. +function loadNestedConfig(canonical, profile, { refused, show, host }) { let config try { - config = loadFoundryConfig(canonical, profile, { confineTo: canonical, host }) + config = loadFoundryConfig(canonical, profile, { refused, show, host }) } catch (err) { + if (!(err instanceof ConfigRefused)) throw err console.warn(`[loader.solidity] Skipping a dependency's config: ${err.message}`) return null } - if (config.remappings === null) return null - const text = readText(host, rustJoin(canonical, REMAPPINGS_TXT)) + const txt = rustJoin(canonical, REMAPPINGS_TXT) + const txtName = show(txt) + const refusal = refused(txt) // (null when nothing is there) + if (refusal) console.warn(`[loader.solidity] Skipping a dependency's ${txtName}: ${refusal}`) + const text = refusal ? null : readUtf8OrNull(txt, txtName, host) return { src: config.src, libs: config.libs, - files: [...config.files, ...(text === null ? [] : [rustJoin(canonical, REMAPPINGS_TXT)])], + files: [...config.files, ...(text === null ? [] : [txt])], // `sanitized()` roots them, then `Remapping::from` makes the path absolute and slash-terminated. remappings: config.remappings.map((r) => fromRelative(relativePreservingBoundary(fromRelative({ ...r, path: { parent: null, path: r.path } }), canonical))), - fileRemappings: text === null ? [] : parseRemappingLines(text, rustJoin(canonical, REMAPPINGS_TXT)), + fileRemappings: text === null ? [] : parseRemappingLines(text, { label: txtName }), } } // `find_nested_foundry_remappings`: `[lexicalLibPath, remapping, isPackageEntry]` for every -// dependency (transitively, through each one's own libs) that is a Foundry project. -function findNestedFoundryRemappings(root, libPaths, profile, files, host) { +// dependency (transitively, through each one's own libs) that is a Foundry project. A dependency's +// config reads only its own files and other dependencies' (by real path: `ownership`, see +// solidityOwnership), as forge would find them from its lexical path. +function findNestedFoundryRemappings(root, libPaths, profile, files, ownership, host) { const canonicalRoot = canonicalize(root, host) ?? root + const shown = shownFrom(root, host, canonicalRoot) + // A dependency's file (a path from its canonical dir) under its lexical path: where the bundle + // sees it, and how messages name it. + const lexical = (entry, file) => rustJoin(entry.path, stripPrefix(file, entry.canonical) ?? file) + // Why the config of the dependency at `entry` may not read `file` (a path from its canonical + // dir), or null: judged by the path from the root, the lexical one or else the canonical one (an + // absolute lib, `/proc/self/cwd/...`). It may read its own files and other dependencies'; a + // dependency outside the root reads nothing, and one a dependency's `libs` named must be a + // dependency itself (not the project's own dir passed off as one). Nothing there (the OS agrees: + // solidityOwnership) is left for the read to find missing. + const refused = (entry) => (file) => { + const dir = stripPrefix(entry.path, root) ?? stripPrefix(entry.canonical, canonicalRoot) + if (dir === null) return 'the dependency lies outside the project root' + if (entry.viaDependency && !ownership.of(dir).dependency) return `${dir}, which a dependency's \`libs\` names, isn't a dependency` + // `file` as joined under the dependency's dir (an `extends` path unnormalized, for the walk to + // resolve as the read does); one not under it (an absolute path elsewhere) lies outside it. + if (!file.startsWith(`${entry.canonical}/`)) return 'it lies outside the dependency' + const o = ownership.of(`${dir}/${file.slice(entry.canonical.length + 1)}`) + if (o.reason) return o.reason + if (o.outside) return `it resolves to ${o.real}, outside the project root` + if (o.real === null || o.dependency || pathStartsWith(rustJoin(canonicalRoot, o.real), entry.canonical)) return null + return `it resolves to the project's own ${o.real}` + } + const show = (entry) => (file) => shown(lexical(entry, file)) // A BTreeSet popped in (canonical, path) order. const pending = new Map() const addPending = (e) => pending.set(`${e.canonical}\0${e.path}`, e) @@ -615,10 +713,9 @@ function findNestedFoundryRemappings(root, libPaths, profile, files, host) { pending.delete(key) if (entry.canonical === canonicalRoot) continue if (!configs.has(entry.canonical)) { - const config = loadNestedConfig(entry.canonical, profile, host) + const config = loadNestedConfig(entry.canonical, profile, { refused: refused(entry), show: show(entry), host }) configs.set(entry.canonical, config) - // Record what was read under the dependency's lexical path (where the bundle sees it). - for (const f of config?.files ?? []) files.add(rustJoin(entry.path, stripPrefix(f, entry.canonical) ?? f)) + for (const f of config?.files ?? []) files.add(lexical(entry, f)) } const config = configs.get(entry.canonical) if (!config) continue @@ -627,7 +724,7 @@ function findNestedFoundryRemappings(root, libPaths, profile, files, host) { if (!entry.isSymlink && !seen.has(entry.canonical)) { seen.add(entry.canonical) for (const lib of config.libs) { - for (const e of foundryTomlDirEntries(rustJoin(entry.path, lib), host)) if (!e.isSymlink) addPending(e) + for (const e of foundryTomlDirEntries(rustJoin(entry.path, lib), host)) if (!e.isSymlink) addPending({ ...e, viaDependency: true }) } } // A custom (or missing) source dir isn't auto-detected: forge synthesizes `/=//`. @@ -697,12 +794,12 @@ const withOverlays = (authoritative, r) => { } // `RemappingsProvider::get_remappings`: the remappings in the order forge settles them. -function providerRemappings(root, { userRemappings, libs, autoDetect, profile, files, host }) { +function providerRemappings(root, { userRemappings, libs, autoDetect, profile, files, ownership, host }) { const authoritativeUser = userRemappings.map((r) => (r.context === null ? r : { ...r, context: rustJoin(root, r.context) })) const all = new Remappings([...userRemappings]) if (!autoDetect) return all.intoInner() - const nested = findNestedFoundryRemappings(root, libs, profile, files, host) + const nested = findNestedFoundryRemappings(root, libs, profile, files, ownership, host) const auto = { global: [], contextual: [] } for (const lib of libs) { const found = findRemappingsWithContext(rustJoin(root, lib), host) @@ -750,34 +847,47 @@ function providerRemappings(root, { userRemappings, libs, autoDetect, profile, f return all.intoInner() } -// The lib dirs `forge build` uses for the Foundry project at `baseDir` (its selected profile's -// `libs`, else the detected ones). +// The lib dirs `forge build` uses for the Foundry project at `baseDir`, `{ libs, profiled, files }`: +// the selected profile's `libs` (`profiled` when that profile is the file's), else the detected +// ones; also those, warned, when forge would reject the foundry.toml's settings (ConfigRefused; with +// a pinned mapping file, nothing else is read from it). `files` lists the config files read (the +// foundry.toml, its `extends` base). A foundry.toml that isn't TOML or holds an invalid remapping +// throws. export function foundryLibs(baseDir, { env = process.env, host = diskHost } = {}) { - return loadFoundryConfig(toPosix(resolve(baseDir)), foundryProfile(env), { host }).libs + const root = toPosix(resolve(baseDir)) + const profile = foundryProfile(env) + try { + const config = loadFoundryConfig(root, profile, { host }) + return { libs: config.libs, profiled: profileApplies(config.profiles, profile), files: config.files } + } catch (err) { + if (!(err instanceof ConfigRefused)) throw err + console.warn(`[loader.solidity] Using the default lib dirs: ${err.message}`) + return { libs: detectLibs(root, host), profiled: false, files: [rustJoin(root, FOUNDRY_TOML)] } + } } // The Foundry project at `baseDir`: what `forge build` would use. `remappings` are // `{ context, prefix, target }` relative to the root, in forge's order; `libs` the lib dirs; -// `files` the config files read (project-relative, when inside the project); `envUsed` the -// environment variables that shaped them. `env` supplies FOUNDRY_PROFILE and FOUNDRY_REMAPPINGS / -// DAPP_REMAPPINGS. +// `files` the config files read (project-relative, `../` when outside the project); `envUsed` the +// environment variables that shaped them; `ownership` its files' owners (see solidityOwnership), +// which also confines what a dependency's config reads. `env` supplies FOUNDRY_PROFILE and +// FOUNDRY_REMAPPINGS / DAPP_REMAPPINGS. export function foundryProject(baseDir, { env = process.env, host = diskHost } = {}) { const root = toPosix(resolve(baseDir)) const profile = foundryProfile(env) const config = loadFoundryConfig(root, profile, { host }) - if (profile !== 'default' && !config.profiles.has(profile)) { - console.warn(`[loader.solidity] FOUNDRY_PROFILE=${profile} is not a profile in foundry.toml; using [profile.default]`) - } - if (config.remappings === null) throw new Error(`${rustJoin(root, FOUNDRY_TOML)}: invalid remapping in \`remappings\``) + const profiled = profileApplies(config.profiles, profile) + const ownership = projectOwnership(baseDir, config.libs, { soldeer: true, host }) const files = new Set(config.files) const envName = env.DAPP_REMAPPINGS !== undefined ? 'DAPP_REMAPPINGS' : env.FOUNDRY_REMAPPINGS !== undefined ? 'FOUNDRY_REMAPPINGS' : null - const envRemappings = envName === null ? [] : parseRemappingLines(env[envName], envName) - const txt = readText(host, rustJoin(root, REMAPPINGS_TXT)) - if (txt !== null) files.add(rustJoin(root, REMAPPINGS_TXT)) - const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, REMAPPINGS_TXT)), ...config.remappings] + const envRemappings = envName === null ? [] : parseRemappingLines(env[envName], { label: envName }) + const txtFile = rustJoin(root, REMAPPINGS_TXT) + const txt = readUtf8OrNull(txtFile, REMAPPINGS_TXT, host) + if (txt !== null) files.add(txtFile) + const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, { label: REMAPPINGS_TXT })), ...config.remappings] - const provided = providerRemappings(root, { userRemappings, libs: config.libs, autoDetect: config.autoDetect, profile, files, host }) + const provided = providerRemappings(root, { userRemappings, libs: config.libs, autoDetect: config.autoDetect, profile, files, ownership, host }) .map((r) => displayRelative(relativePreservingBoundary(r, root))) // `forge build` re-reads them as config remappings, dropping aliases of its own input dirs. @@ -792,7 +902,9 @@ export function foundryProject(baseDir, { env = process.env, host = diskHost } = .filter(Boolean) .map(toSolcRemapping) - const relFiles = [...files].map((f) => stripPrefix(f, root)).filter((f) => f !== null && f !== '') - const envUsed = [...(env.FOUNDRY_PROFILE ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : []), ...(envName === null ? [] : [envName])] - return { remappings, libs: config.libs, files: relFiles, envUsed } + // One read from outside the root (an `extends = "../base.toml"`) stays `../`, for --manifests to + // refuse (it can't be carried). + const relFiles = [...files].map((f) => projectRelative(root, f, host)) + const envUsed = [...(profiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : []), ...(envName === null ? [] : [envName])] + return { remappings, libs: config.libs, files: relFiles, envUsed, ownership } } diff --git a/stasis/src/loaders/solidity-ownership.js b/stasis/src/loaders/solidity-ownership.js new file mode 100644 index 00000000..eff7e58f --- /dev/null +++ b/stasis/src/loaders/solidity-ownership.js @@ -0,0 +1,513 @@ +// Who owns each file of a Solidity project -- the project or one of its dependencies -- decided by +// where the file really is, for the import resolution (solidity.js), forge's config discovery +// (foundry.js) and the bundler's --manifests. Dependencies are untrusted input: a link one plants +// out of itself is never followed. + +import { realpathSync } from 'node:fs' +import { isAbsolute, join, parse, posix, relative, resolve, sep } from 'node:path' + +import { utf8toString } from '@exodus/bytes/utf8.js' +import { LockfileError, parseGitmodules } from '@preventive/lockfile/foundry.js' +import { NO_ENTRY, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util' +import { diskHost } from '@exodus/stasis-core/host' +import { hasNodeModulesSegment } from '@exodus/stasis-core/util' +import { isDir } from '../resolve-typescript.js' + +// `/`-separated, as the loader's paths are: only Windows' separator is converted (on POSIX a `\\` is +// part of a name, and must not read as a directory boundary). +const toSlashes = (p) => (sep === '\\' ? p.replaceAll('\\', '/') : p) + +// --- Reading -------------------------------------------------------------------------------- + +// `p`'s real path as `host` resolves it, which throws when it can't: on disk, as the OS does +// (realpath(3): the filesystem's own spelling, which Node's realpathSync doesn't give). +const realpathIn = (host, p) => (host === diskHost ? realpathSync.native(p) : host.realpath(p)) + +// `p`'s real path (realpathIn), or null. +export function realpathOrNull(p, host = diskHost) { + try { + return realpathIn(host, p) + } catch { + return null + } +} + +// A path relative to a dir (slashes) that stays inside it. +const inRoot = (rel) => rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel) + +// `abs`, a file the resolution read, relative to the project `root` (slashes): as spelled when that +// lies inside it with no `..` to resolve (a linked lib's files keep the lib's path), else by real +// paths -- where the read went (an absolute or `/proc/self/cwd` lib; a `..` after a symlink) -- +// `../` when outside the project. One whose real path the OS can't give (past PATH_MAX) is never +// normalized, which could name another file: it keeps the path it was read by, `..` and all, from +// the root however that's spelled (as given or by its real path), for solidityOwnership to refuse, +// or else stays absolute, a name --manifests refuses as unresolvable. Real paths are `host`'s. +export function projectRelative(root, abs, host = diskHost) { + const rel = toSlashes(relative(root, abs)) + if (inRoot(rel) && !toSlashes(abs).split('/').includes('..')) return rel + const real = realpathOrNull(abs, host) + const realRoot = realpathOrNull(root, host) + if (real !== null) return toSlashes(relative(realRoot ?? root, real)) + return below(resolve(root), abs) ?? (realRoot === null ? null : below(realRoot, abs)) ?? toSlashes(abs) +} + +// `abs` from `dir`, component by component as spelled (empty and `.` ones dropped, `..` kept), or +// null when it doesn't start with `dir`'s components. +function below(dir, abs) { + const parts = (p) => toSlashes(p).split('/').filter((c) => c !== '' && c !== '.') + const d = parts(dir) + const a = parts(abs) + return d.length < a.length && d.every((c, i) => a[i] === c) ? a.slice(d.length).join('/') : null +} + +// realpathIn `host` of `p`: `{ real }`, or `{ real: null, missing }`, `missing` only when nothing +// is there at all. The OS may fail to resolve what is there -- a real path past PATH_MAX, a loop, a +// link whose end it can't name (`/proc/self/fd/0` on a pipe), a dir it may not search -- and a +// read may still get through. +function osRealpath(p, host) { + try { + return { real: realpathIn(host, p), missing: false } + } catch (err) { + return { real: null, missing: NO_ENTRY.has(err.code) && !lexists(p, host) } + } +} + +// Whether anything is at `p` itself, a link not followed (`host.readlink` throws when nothing is). +function lexists(p, host) { + try { + host.readlink(p) + return true + } catch (err) { + if (NO_ENTRY.has(err.code)) return false + throw err + } +} + +// `bytes` as UTF-8 text, a byte-order mark kept. Bytes that aren't UTF-8 throw, naming them +// `label`, rather than read with U+FFFD in their place: forge, solc and git refuse such a file, and +// the text bundled or read must be the file's own. +export function decodeUtf8(bytes, label) { + try { + return utf8toString(bytes) + } catch (err) { + throw new Error(`${label}: not valid UTF-8`, { cause: err }) + } +} + +// A config file's text (decodeUtf8), or null when there's no file (readRegularFileOrNull: a regular +// file only, read through `host`). Errors name it `label`. +export function readUtf8OrNull(file, label, host = diskHost) { + const buf = readRegularFileOrNull(file, label, host) + return buf === null ? null : decodeUtf8(buf, label) +} + +// --- .gitmodules ------------------------------------------------------------------------------ + +// The submodules of the project at `baseDir`, `{ path, url, branch }` (`url` and `branch` when set), +// from its `.gitmodules` as @preventive/lockfile reads it (as git does). A url is taken as written +// (`checkUrls: false`): relative to the superproject's remote, a path or none, as it only names a +// GitHub submodule's bucket. A file the library refuses -- something git reads two ways, or that it +// doesn't check (`update = none`, `active`, a `[core]` section) -- never fails the bundle: it's +// warned about and read submodule by submodule (gitmodulesLeniently). One git itself refuses is an +// error: read past what git can't, a submodule's section would be lost, and its directory with it. +export function readGitmodules(baseDir, host = diskHost) { + const text = readUtf8OrNull(join(baseDir, '.gitmodules'), '.gitmodules', host) + if (text === null) return [] + try { + return Object.values(parseGitmodules(text, { checkUrls: false })) + } catch (err) { + if (!(err instanceof LockfileError)) throw err + const { submodules, notes } = gitmodulesLeniently(text) + if (!notes.some((note) => note.startsWith(`${err.message};`))) notes.unshift(`${err.message}; reading it submodule by submodule`) + for (const note of notes) console.warn(`[loader.solidity] .gitmodules: ${note}`) + return submodules + } +} + +// The keys a submodule is read for; past `path`, they're dropped in this order to read the rest. +const SUBMODULE_KEYS = new Set(['path', 'url', 'branch']) +const DROPPABLE = ['branch', 'url'] + +// `.gitmodules` text the library refused as a whole, read as git reads it (readGitConfig) a +// submodule at a time: a key of `submodule..`, from `[submodule "name"]` or +// `[submodule.name]`, its first `path`, `url` and `branch` (the first, as git's submodule commands +// read it; the sections of one name merged), each submodule then read by the library alone. One that +// still doesn't read loses its branch, then its url; one whose path doesn't read fails closed: its +// directory, when the path names one inside the repository (`./lib/x`, `lib/x/`), is still a +// dependency, just unnamed, and else the submodule is skipped. `notes` say what was dropped. +function gitmodulesLeniently(text) { + const sections = new Map() // a submodule's name -> Map + const notes = [] + for (const { section, subsection, header, keys } of readGitConfig(text)) { + const variable = subsection === undefined ? section : `${section}.${subsection}` + if (!variable?.startsWith('submodule.')) continue + const name = variable.slice('submodule.'.length) + if (section !== 'submodule') notes.push(`${header}, a section git reads as [submodule "${name}"]; reading it as that`) + const kept = sections.get(name) ?? sections.set(name, new Map()).get(name) + for (const entry of keys) if (SUBMODULE_KEYS.has(entry.key) && !kept.has(entry.key)) kept.set(entry.key, entry) + } + const submodules = [] + for (const [name, kept] of sections) { + const header = `[submodule "${name.replaceAll(/["\\]/gu, '\\$&')}"]` + const read = () => Object.values(parseGitmodules([header, ...[...kept.values()].map((entry) => entry.text), ''].join('\n'), { checkUrls: false })) + let first = null + const dropped = [] + for (;;) { + try { + submodules.push(...read()) + if (first !== null) notes.push(`${first.message}; ignoring its ${dropped.join(' and ')}`) + break + } catch (err) { + if (!(err instanceof LockfileError)) throw err + first ??= err + const next = DROPPABLE.find((key) => kept.has(key)) + if (next !== undefined) { + kept.delete(next) + dropped.push(next) + continue + } + const path = normalSubmodulePath(kept.get('path')?.value) + if (path === null) { + notes.push(`${first.message}; skipping the submodule`) + } else { + submodules.push({ path, url: undefined, branch: undefined }) + notes.push(`${first.message}; still taking ${path} as a dependency, unnamed`) + } + break + } + } + } + return { submodules, notes } +} + +// A `path`'s value -> the directory it names, normalized, when that lies inside the repository (else +// null): `./lib/x` and `lib/x/` are lib/x. +function normalSubmodulePath(value) { + if (typeof value !== 'string') return null + const path = posix.normalize(value).replace(/\/+$/u, '') + return path !== '' && path !== '.' && inRoot(path) ? path : null +} + +// git's ctype, ASCII alone: only these are space, and a key is letters, digits and `-`, starting with +// a letter. +const GIT_SPACE = new Set([' ', '\t', '\n', '\r']) +const isGitAlpha = (char) => /^[A-Za-z]$/u.test(char) +const isGitKeyChar = (char) => /^[\dA-Za-z-]$/u.test(char) +const GIT_ESCAPES = { __proto__: null, t: '\t', b: '\b', n: '\n', '\\': '\\', '"': '"' } + +// `.gitmodules` text as git's config.c reads a config file, to the character, refusing what git +// refuses ("bad config line") and nothing more: its sections in order, each `{ section, subsection, +// header, keys }` -- the name lowercased, the subsection with a backslash's character for it, the +// header as written -- and each key `{ key, value, text }`: its name lowercased, its value (null for +// a key alone) and its text, from the key to the end of its value. Keys before any header are in a +// first section with no name. +function readGitConfig(text) { + const src = text.startsWith('\uFEFF') ? text.slice(1) : text // a byte-order mark git skips + let pos = 0 + let last = 0 // where the character last read starts + let line = 1 + let at = 1 // its line + let eof = false + // git's get_next_char: CRLF is a line end, a lone CR is space, and the end of the file a line end, + // read again at every call after. + const next = () => { + last = pos + at = line + if (pos >= src.length) { + eof = true + return '\n' + } + let char = src[pos++] + if (char === '\r' && src[pos] === '\n') char = src[pos++] + if (char === '\n') line++ + return char + } + const refuse = (what) => new Error(`.gitmodules: ${what} at line ${at}; git refuses such a file`) + + // git's get_base_var and get_extended_base_var: `[name]` or `[name "subsection"]`. + const readHeader = () => { + let section = '' + for (;;) { + const char = next() + if (eof) throw refuse('a section header with no closing "]"') + if (char === ']') break + if (GIT_SPACE.has(char)) return { section, subsection: readSubsection(char) } + if (!isGitKeyChar(char) && char !== '.') throw refuse("a character git doesn't take in a section name") + section += char.toLowerCase() + } + if (section === '') throw refuse('a section with no name') + return { section, subsection: undefined } + } + const readSubsection = (first) => { + let char = first + do { + if (char === '\n') throw refuse('a section header that runs past its line') + char = next() + } while (GIT_SPACE.has(char)) + if (char !== '"') throw refuse('a section name and then no quoted subsection') + let subsection = '' + for (char = next(); char !== '"'; char = next()) { + if (char === '\\') char = next() + if (char === '\n') throw refuse('a subsection with no closing quote') + subsection += char + } + if (next() !== ']') throw refuse('a subsection with no "]" right after it') + return subsection + } + // git's parse_value: quotes, escapes, a `\` that runs the value on, a comment outside quotes, and + // space outside quotes trimmed at both ends. + const readValue = () => { + let value = '' + let quoted = false + let comment = false + let trim = -1 // where the space at the end begins, outside quotes + for (;;) { + let char = next() + if (char === '\n') { + if (quoted) throw refuse('a value with no closing quote') + return trim === -1 ? value : value.slice(0, trim) + } + if (comment) continue + if (GIT_SPACE.has(char) && !quoted) { + if (value !== '') { + if (trim === -1) trim = value.length + value += char + } + continue + } + if (!quoted && (char === '#' || char === ';')) { + comment = true + continue + } + trim = -1 + if (char === '\\') { + char = next() + if (char === '\n') continue + if (!(char in GIT_ESCAPES)) throw refuse("an escape git doesn't read") + value += GIT_ESCAPES[char] + } else if (char === '"') { + quoted = !quoted + } else { + value += char + } + } + } + // git's get_value: a key, and `=` and its value or nothing. + const readKey = (first, start) => { + let key = first.toLowerCase() + let char = next() + for (; isGitKeyChar(char); char = next()) key += char.toLowerCase() // the end reads as a line end + while (char === ' ' || char === '\t') char = next() + let value = null + if (char !== '\n') { + if (char !== '=') throw refuse('a key and then neither "=" nor the end of its line') + value = readValue() + } + return { key, value, text: src.slice(start, last) } + } + + const sections = [{ section: undefined, subsection: undefined, header: undefined, keys: [] }] + let comment = false + for (;;) { + const char = next() + const start = last + if (char === '\n') { + if (eof) return sections + comment = false + } else if (comment || GIT_SPACE.has(char)) { + continue + } else if (char === '#' || char === ';') { + comment = true + } else if (char === '[') { + const { section, subsection } = readHeader() + sections.push({ section, subsection, header: src.slice(start, pos), keys: [] }) + } else if (isGitAlpha(char)) { + sections.at(-1).keys.push(readKey(char, start)) + } else { + throw refuse('text where git reads a key, a section or a comment') + } + } +} + +// --- Ownership -------------------------------------------------------------------------------- + +const readdirOrEmpty = (dir, host) => { + try { + return host.readdir(dir) + } catch { + return [] + } +} + +const NOTHING = { abs: null, escape: null } +// A link target's separators, as the OS reads them (a `\\` is part of a name on POSIX). +const TARGET_SEPARATORS = sep === '\\' ? /[\\/]/u : /\//u + +// Who owns each project-relative path, decided from how it resolves on disk. The dependencies are +// every `node_modules/` (`@scope/`), each entry of the `dirs` (forge's libs, Soldeer's +// `dependencies/`; a linked entry is the dependency where it points, as a symlinked +// `lib/forge-std`), and the `packages` (git submodules). `assert(path)` throws for a path `of` +// refuses; `of(path)` gives `{ real, outside, dependency, escape }`: +// - `real`: the real path, spelled as the filesystem spells it (project-relative; null when +// nothing is there), `outside` when it's out of the root; +// - `dependency`: the real path lies in a dependency, however the path got there (a project's +// `src/vendor -> ../lib/dep/src` holds the dependency's code); +// - `escape`: `{ link, root, why }` (and `reason`, saying so) when the path may not be read: it +// crosses a symlink that no one trusted placed -- one planted inside the dependency `root` that +// leads out of it to anything but another dependency (`lib/evil/src/Evil.sol -> ../../../.env`), +// or one outside the project (`root` null) that leads back into it (a dependency linked from +// elsewhere: `lib/evil -> ../../shared/evil` holding `Evil.sol -> ../../proj/.env`) -- or +// (`why: 'unresolved'`) the walk below can't vouch for it: it resolves the path link by link, and +// where that doesn't land where the OS's realpath does (a link target it can't read as the OS +// does, one that isn't UTF-8), or the OS can't resolve it at all (a real path past PATH_MAX), the +// path is refused rather than trusted. `real` null with no `escape` means nothing is there. A +// link the project placed (a workspace package in node_modules, a linked `lib/` entry) may lead +// anywhere in the root, and so may one on the path the project was named by (a symlinked +// checkout, macOS's `/tmp`). The project is read through `host`. +export function solidityOwnership(baseDir, { dirs = [], packages = [], host = diskHost } = {}) { + const realBase = realpathIn(host, baseDir) + const realOf = (p) => realpathOrNull(p, host) + const named = resolve(baseDir) + const onNamedPath = (abs) => named === abs || named.startsWith(abs.endsWith(sep) ? abs : `${abs}${sep}`) + const toRel = (abs) => toSlashes(relative(realBase, abs)) || '.' + const inside = (rel) => rel !== '.' && inRoot(rel) + const under = (rel, dir) => rel === dir || rel.startsWith(`${dir}/`) + const clean = (d) => posix.normalize(toSlashes(d)).replace(/\/+$/u, '') + + // Dirs whose entries are dependencies, and dependency dirs themselves; each by its real path too. + const holders = new Set() + const roots = new Set() + const addReal = (set, rel) => { + const real = realOf(join(baseDir, rel)) + if (real !== null && inside(toRel(real))) set.add(toRel(real)) + } + // A dir as the project names it: relative to the root, or (an absolute lib) by its real path. + const projectDir = (d) => { + if (!isAbsolute(d)) return clean(d) + const real = realOf(d) + return real === null ? null : toRel(real) + } + for (const d of dirs.map(projectDir).filter((rel) => rel !== null && inside(rel))) { + if (posix.basename(d) === 'node_modules') continue // a package's own rule, below + holders.add(d) + addReal(holders, d) + for (const e of readdirOrEmpty(join(baseDir, d), host)) if (e.isSymbolicLink() && isDir(join(baseDir, d, e.name), host)) addReal(roots, `${d}/${e.name}`) + } + for (const p of packages.map(clean).filter(inside)) { + roots.add(p) + addReal(roots, p) + } + const dependencyDirs = [...holders, ...roots] + const inDependency = (rel) => inside(rel) && (hasNodeModulesSegment(rel) || dependencyDirs.some((d) => under(rel, d))) + // The innermost dependency holding `rel`, a real path. + const rootOf = (rel) => { + if (!inside(rel)) return null + const parts = rel.split('/') + let best = null + const take = (r) => { + if (best === null || r.length > best.length) best = r + } + for (let i = 0; i < parts.length; i++) { + const end = i + (parts[i + 1]?.startsWith('@') ? 3 : 2) + if (parts[i] === 'node_modules' && end <= parts.length) take(parts.slice(0, end).join('/')) + } + for (const d of holders) if (rel.startsWith(`${d}/`)) take(`${d}/${rel.slice(d.length + 1).split('/')[0]}`) + for (const r of roots) if (under(rel, r)) take(r) + return best + } + + // Resolve `parts` from the real dir `start` as realpath does, checking each symlink crossed + // (and those its target crosses): `{ abs, escape }`, `abs` null when nothing is there, and + // spelled as given past the last link. A link's dir and target take the filesystem's spelling (a + // case-insensitive one finds `lib` for `LIB`) before their owners are judged. + const walk = (start, parts, depth) => { + let cur = start + for (const part of parts) { + if (part === '' || part === '.') continue + if (part === '..') { + cur = parse(cur).root === cur ? cur : join(cur, '..') + continue + } + const next = join(cur, part) + let target + try { + target = host.readlink(next) + } catch { + return NOTHING + } + if (target === null) { + cur = next + continue + } + // A target that isn't UTF-8 reads with U+FFFD in it: not a name a string path can spell. + if (target.includes('\uFFFD')) return NOTHING + if (depth >= 40) return NOTHING // ELOOP + const r = walk(isAbsolute(target) ? parse(target).root : cur, target.split(TARGET_SEPARATORS), depth + 1) + if (r.abs === null || r.escape !== null) return r + const dir = realOf(cur) ?? cur + const abs = realOf(r.abs) + if (abs === null) return NOTHING + const at = toRel(join(dir, part)) + const to = toRel(abs) + if (inside(at)) { + const root = rootOf(toRel(dir)) + if (root !== null && !under(to, root) && !inDependency(to)) return { abs, escape: { link: at, root } } + } else if (inRoot(to) && !onNamedPath(next)) { + return { abs, escape: { link: at, root: null } } + } + cur = abs + } + return { abs: cur, escape: null } + } + + const owners = new Map() + const of = (rel) => { + let owner = owners.get(rel) + if (owner === undefined) { + // As given, not normalized: the OS resolves a `..` after a link from where the link leads. + const path = rel === '' ? realBase : `${realBase}${sep}${rel}` + let { abs, escape } = walk(realBase, rel.split('/'), 0) + if (escape === null) { + // The OS's answer is the one a read gets: the walk must agree with it, or the path is + // refused, as it is when the OS can't resolve it at all, though a read may still get through. + // (Past its last link the walk's path is spelled as given; with none, it's `path` itself.) + const { real: os, missing } = osRealpath(path, host) + const walked = abs === null ? null : abs === path ? os : realOf(abs) + if (walked !== os || (os === null && !missing)) escape = { link: rel, root: null, why: 'unresolved' } + abs = os + } + const real = abs === null ? null : toRel(abs) + owner = { + real, + outside: real !== null && !inRoot(real), + dependency: real !== null && inDependency(real), + escape, + reason: escape && escapeReason(rel, escape), + } + owners.set(rel, owner) + } + return owner + } + // Throws, saying why, for a path `of` refuses; `what` names it (`'entry '`). + const assert = (rel, what = '') => { + const { reason } = of(rel) + if (reason) throw new Error(`Refusing ${what}${rel}: ${reason}`) + } + return { of, assert } +} + +// The ownership of the project at `baseDir` given its lib dirs (`soldeer`: forge's `dependencies/` +// holds dependencies too), with its git submodules, which it keeps as `submodules` (readGitmodules), +// read through `host`. +export function projectOwnership(baseDir, libs, { soldeer = false, host = diskHost } = {}) { + const submodules = readGitmodules(baseDir, host) + const dirs = [...libs, ...(soldeer ? ['dependencies'] : [])] + return { ...solidityOwnership(baseDir, { dirs, packages: submodules.map((s) => s.path), host }), submodules } +} + +// Why a path is refused (see solidityOwnership). +function escapeReason(path, { link, root, why }) { + if (why === 'unresolved') return `${path} crosses a link stasis can't follow the way the filesystem does` + const what = root === null ? 'a link from outside the project root back into it' : `a link out of the dependency ${root}` + return link === path ? `${path} is ${what}` : `it resolves to ${path} through ${link}, ${what}` +} diff --git a/stasis/src/loaders/solidity.js b/stasis/src/loaders/solidity.js index 3647fd45..f5c214da 100644 --- a/stasis/src/loaders/solidity.js +++ b/stasis/src/loaders/solidity.js @@ -5,13 +5,13 @@ // foundry.js), then a Foundry library's include path, solc's base path (the project root), and // Hardhat's/Node's node_modules lookup. The mapping/config files are read, not added to `sources`. // Dependencies are untrusted input: an import only ever reaches a `.sol` file inside the project, -// and a dependency's imports only other dependencies' files. The project is read through a `host` -// (@exodus/stasis-core/host), the disk's by default. +// a dependency's imports only its own and other dependencies' files (by real path), and nothing +// is read through a link a dependency planted out of itself (solidityOwnership). The project is read +// through a `host` (@exodus/stasis-core/host), the disk's by default. import { readFile } from 'node:fs/promises' import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path' -import { readText } from '@exodus/stasis-core/bundle-util' import { diskHost } from '@exodus/stasis-core/host' import { assertRealPathWithinBase, toPosix } from '@exodus/stasis-core/util' import { isDir, isFile } from '../resolve-typescript.js' @@ -24,8 +24,10 @@ import { foundryTomlRemappings, parseRemappingLines, readFoundryTomlRemappings, + shownFrom, toSolcRemapping, } from './foundry.js' +import { decodeUtf8, projectOwnership, projectRelative, readUtf8OrNull, realpathOrNull, solidityOwnership } from './solidity-ownership.js' // --- Import scan ------------------------------------------------------------------------------ @@ -129,21 +131,13 @@ export function extractSolImports(content) { // --- Remappings --------------------------------------------------------------------------------- -const realpathOrNull = (p, host) => { - try { - return host.realpath(p) - } catch { - return null - } -} - // Loader-side shape: `{ context, prefix, target }` (context null = global). const toLoaderRemapping = ({ context, name, path }) => ({ context, prefix: name, target: path }) // remappings.txt text -> remappings as written, one `[context:]prefix=target` per line (lines -// trimmed; blank and invalid lines skipped). +// trimmed, blank ones skipped; an empty target is solc's, valid). A line that isn't one throws. export function parseRemappings(content) { - return parseRemappingLines(content).map(toLoaderRemapping) + return parseRemappingLines(content, { emptyPath: true }).map(toLoaderRemapping) } // foundry.toml text -> the `remappings` of `[profile.default]`, overlaid by the selected profile's @@ -155,13 +149,17 @@ export function parseRemappingsFromToml(tomlContent, { env = process.env } = {}) // Read a mapping file -> its remappings as listed (no discovery around it) and the files read. A // foundry.toml (its selected profile, with its `extends` base) is forge's, and so is a // remappings.txt when `forge` says forge reads it: slash-terminated the way forge reads them. -// Otherwise (solc, Hardhat) a remappings.txt applies as written. -function readMapping(mappingFile, { env, forge, host }) { +// Otherwise (solc, Hardhat) a remappings.txt applies as written. Messages name files `show(file)`; +// files are read through `host`. +function readMapping(mappingFile, { env, forge, host, show = (f) => f }) { if (mappingFile.endsWith('.toml')) { - const { remappings, files } = readFoundryTomlRemappings(mappingFile, foundryProfile(env), host) - return { remappings: remappings.map(toSolcRemapping), files } + const { remappings, files, profiled } = readFoundryTomlRemappings(mappingFile, foundryProfile(env), { show, host }) + return { remappings: remappings.map(toSolcRemapping), files, profiled } } - const listed = parseRemappingLines(host.readFile(mappingFile).toString('utf8'), mappingFile) + const name = show(mappingFile) + const text = readUtf8OrNull(mappingFile, name, host) + if (text === null) throw new Error(`${name}: no such file`) + const listed = parseRemappingLines(text, { label: name, emptyPath: !forge }) return { remappings: listed.map(forge ? toSolcRemapping : toLoaderRemapping), files: [mappingFile] } } @@ -171,57 +169,37 @@ export function readRemappingsFile(mappingFile, { env = process.env, forge = fal return readMapping(mappingFile, { env, forge, host }).remappings } -// The directories of `.gitmodules`' submodules: dependencies, whatever their host. -function gitSubmodulePaths(baseDir, host) { - const text = readText(host, join(baseDir, '.gitmodules')) ?? '' - return [...text.matchAll(/^\s*path\s*=\s*(.+?)\s*$/gmu)].map((m) => m[1]) -} - -// Project-relative, clean, inside the root; each also by its real path (relative to the real root), -// so a symlink can't pass a project file off as a dependency's. -function dependencyDirsOf(baseDir, dirs, host) { - const realBase = host.realpath(baseDir) - const out = new Set() - for (const d of dirs) { - const rel = posix.normalize(toPosix(d)).replace(/\/+$/u, '') - if (rel === '.' || rel === '' || rel === '..' || rel.startsWith('../') || posix.isAbsolute(rel)) continue - out.add(rel) - const real = realpathOrNull(join(baseDir, rel), host) - if (real !== null) out.add(toPosix(relative(realBase, real))) - } - return [...out] -} +// --- Resolution --------------------------------------------------------------------------------- // What resolves the imports of the project at `baseDir`: -// `{ remappings, libs, dependencyDirs, files, envUsed }`. +// `{ remappings, libs, ownership, files, envUsed }`. // - `mappingFile` (foundry.toml / remappings.txt): exactly the remappings it lists (see readMapping). // - else, with a foundry.toml at the root: what `forge build` uses (foundry.js) -- remappings.txt, // the profile's remappings, dependencies' own configs, auto-detected `lib/` remappings and their // contexts. // - else a remappings.txt at the root (solc / Hardhat 3), taken as written. // `libs` are forge's lib dirs whenever the root has a foundry.toml (an absolute import inside a -// library resolves against it); `dependencyDirs` the dirs holding dependencies (forge's libs, -// Soldeer's `dependencies/`, git submodules; a `node_modules` dir always is one); `files` the -// project-relative config files read; `envUsed` the environment variables that shaped the result. +// library resolves against it); `ownership` tells the dependencies' files from the project's +// (solidityOwnership: forge's libs, Soldeer's `dependencies/`, git submodules, node_modules); +// `files` the project-relative config files read (`../` for one outside the project); `envUsed` +// the environment variables that shaped the result. The project is read through `host`. export function discoverSolidityConfig(baseDir, { mappingFile, env = process.env, host = diskHost } = {}) { const forge = isFile(join(baseDir, FOUNDRY_TOML), host) - const project = forge && !mappingFile ? foundryProject(baseDir, { env, host }) : null - const libs = project?.libs ?? (forge ? foundryLibs(baseDir, { env, host }) : []) - const dependencyDirs = dependencyDirsOf(baseDir, [...libs, ...(forge ? ['dependencies'] : []), ...gitSubmodulePaths(baseDir, host)], host) - if (project) return { remappings: project.remappings, libs, dependencyDirs, files: project.files, envUsed: project.envUsed } - const within = (abs) => { - const rel = toPosix(relative(baseDir, abs)) - return rel.startsWith('..') || isAbsolute(rel) ? [] : [rel] - } + if (forge && !mappingFile) return foundryProject(baseDir, { env, host }) + const { libs, profiled, files: libsFiles } = forge ? foundryLibs(baseDir, { env, host }) : { libs: [], profiled: false, files: [] } + const ownership = projectOwnership(baseDir, libs, { soldeer: forge, host }) + const show = shownFrom(toPosix(resolve(baseDir)), host) if (mappingFile) { const abs = resolve(baseDir, mappingFile) - const { remappings, files } = readMapping(abs, { env, forge, host }) - const envUsed = abs.endsWith('.toml') && env.FOUNDRY_PROFILE ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : [] - return { remappings, libs, dependencyDirs, files: files.flatMap(within), envUsed } + const { remappings, files, profiled: mappingProfiled } = readMapping(abs, { env, forge, host, show }) + // The profile picks the mapping file's remappings (a .toml) or the root foundry.toml's libs: the + // files read are both's. + const envUsed = profiled || mappingProfiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : [] + return { remappings, libs, ownership, files: [...new Set([...files, ...libsFiles].map((f) => projectRelative(baseDir, f, host)))], envUsed } } const txt = join(baseDir, REMAPPINGS_TXT) - const remappings = isFile(txt, host) ? readMapping(txt, { env, forge, host }).remappings : [] - return { remappings, libs, dependencyDirs, files: isFile(txt, host) ? [REMAPPINGS_TXT] : [], envUsed: [] } + if (!isFile(txt, host)) return { remappings: [], libs, ownership, files: [], envUsed: [] } + return { remappings: readMapping(txt, { env, forge, host, show }).remappings, libs, ownership, files: [REMAPPINGS_TXT], envUsed: [] } } // Solc's remapping choice for the source unit `name` imported from `fromFile`: among the @@ -302,13 +280,9 @@ function nodeModulesFile(baseDir, spec, fromFile, host) { } } -// Whether a project-relative path lies in a dependency: in a node_modules dir or one of `dirs`. -const inDependency = (rel, dirs) => rel.split('/').includes('node_modules') || dirs.some((d) => rel === d || rel.startsWith(`${d}/`)) - // Where an import resolves, as `{ path }`, or `{ reason }` when it may not be read (`reason: null`: -// it names no file). See resolveSolImport; `dependencyDirs` (discoverSolidityConfig's) turns on -// the dependency rule, which takes `realBase`, the real path of `baseDir`, where given. -function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [], dependencyDirs, host = diskHost, realBase } = {}) { +// it names no file). See resolveSolImport. +function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [], ownership, host = diskHost } = {}) { const relativeImport = isRelativeImport(specifier) const name = relativeImport ? resolveRelativeImport(specifier, fromFile) : specifier if (name === null) return { reason: 'it climbs above the project root' } @@ -321,12 +295,12 @@ function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [ if (path === null) return { reason: null } if (isAbsolute(path) || posix.isAbsolute(path) || path === '..' || path.startsWith('../')) return { reason: `it resolves to ${path}, outside the project root` } if (!path.endsWith('.sol')) return { reason: `it resolves to ${path}, which is not a .sol file` } - if (baseDir && dependencyDirs && inDependency(fromFile, dependencyDirs)) { - const real = realpathOrNull(join(baseDir, path), host) - const rel = real === null ? path : toPosix(relative(realBase ?? host.realpath(baseDir), real)) - if (rel.startsWith('..') || isAbsolute(rel)) return { reason: `it resolves to ${path}, outside the project root` } - if (!inDependency(rel, dependencyDirs)) return { reason: `a dependency may not import the project's own ${path}` } - } + if (!baseDir) return { path } + const own = ownership ?? solidityOwnership(baseDir, { host }) + const target = own.of(path) + if (target.reason) return { reason: target.reason } + // (A link out of the root is refused when the file is read.) + if (target.real !== null && !target.outside && !target.dependency && own.of(fromFile).dependency) return { reason: `a dependency may not import the project's own ${path}` } return { path } } @@ -336,8 +310,9 @@ function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [ // is then looked up, when `baseDir` is given, inside the importer's library (forge's include path; // `libs` are forge's lib dirs), as a project file (solc's base path), and through node_modules by // file path (Hardhat / Node). Returns null when nothing resolves, or when the result isn't a `.sol` -// file inside the root, or, with `dependencyDirs`, when a dependency's import lands (by real -// path) on a file that isn't a dependency's. +// file inside the root, crosses a link a dependency planted out of itself, or is the project's own +// file imported by a dependency's -- by real path, with `ownership` (solidityOwnership's; by default +// only node_modules holds dependencies). The project is read through `host`. export function resolveSolImport(specifier, fromFile, options = {}) { return resolveImport(specifier, fromFile, options).path ?? null } @@ -351,14 +326,14 @@ export const SOLIDITY_PACKAGE_MANIFESTS = ['package.json', FOUNDRY_TOML, REMAPPI // --- The walk ----------------------------------------------------------------------------------- // Build `{ sources, resolutions, missing }` from already-loaded Solidity sources plus remappings. -// Imports resolve as resolveSolImport does (`libs`, `dependencyDirs`: see there); as a final +// Imports resolve as resolveSolImport does (`libs`, `ownership`, `host`: see there); as a final // fallback a specifier naming no file but matching a stored key verbatim is accepted. `missing` // lists every `{ spec, from }` that didn't resolve or resolved outside `sources`, with the // `reason` when it was refused. -export function buildSolidityTree(sources, { remappings = [], baseDir, libs = [], dependencyDirs, host = diskHost } = {}) { +export function buildSolidityTree(sources, { remappings = [], baseDir, libs = [], host = diskHost, ownership = baseDir && solidityOwnership(baseDir, { host }) } = {}) { const resolutions = new Map() const missing = [] - const options = { remappings, baseDir, libs, dependencyDirs, host, realBase: baseDir && dependencyDirs ? host.realpath(baseDir) : undefined } + const options = { remappings, baseDir, libs, ownership, host } for (const [path, content] of sources) { const specMap = new Map() for (const spec of extractSolImports(content)) { @@ -377,19 +352,22 @@ export function buildSolidityTree(sources, { remappings = [], baseDir, libs = [] return { sources, resolutions, missing } } -// Walk the filesystem from `entries`, following resolved imports and reading each file once, a wave -// at a time: the files of one, then the imports they name. Caller-listed entries are also accepted -// as verbatim non-relative import targets naming no file (Foundry-style `import "src/A.sol"`). -export function collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs = [], dependencyDirs, host = diskHost } = {}) { +// Walk the project from `entries`, following resolved imports and reading each file once, a wave at +// a time: the files of one, then the imports they name. Caller-listed entries are also accepted as +// verbatim non-relative import targets naming no file (Foundry-style `import "src/A.sol"`). An entry +// that crosses a dependency's link out of itself (see solidityOwnership) is refused. The project is +// read through `host`. +export function collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs = [], host = diskHost, ownership = solidityOwnership(baseDir, { host }) } = {}) { const sources = new Map() const knownEntries = new Set(entries) const realBase = host.realpath(baseDir) - const options = { remappings, baseDir, libs, dependencyDirs, host, realBase } + const options = { remappings, baseDir, libs, ownership, host } + for (const entry of entries) ownership.assert(entry, 'entry ') for (let wave = entries; wave.length > 0;) { const reads = [...new Set(wave)].filter((p) => !sources.has(p)).map((relPath) => { try { assertRealPathWithinBase(realBase, baseDir, relPath, host) - return [relPath, host.readFile(join(baseDir, relPath)).toString('utf8')] + return [relPath, decodeUtf8(host.readFile(join(baseDir, relPath)), relPath)] } catch (err) { if (err.code === 'ENOENT') { console.warn(`[loader.solidity] Missing import: ${relPath}`) @@ -453,7 +431,8 @@ function solidityFilesUnder(baseDir, dir, host) { // Project-relative entries with each directory replaced by the `.sol` files under it (deduped, in // order). A `.sol` entry is kept as is (a missing one is reported by the walk); a directory that // is missing or holds no `.sol` file is skipped with a warning, as forge skips an absent `script/`, -// and it's an error only when no entry yields a file. +// and it's an error only when no entry yields a file (when none exists, a mistyped path). The +// project is read through `host`. export function expandSolidityEntries(baseDir, entries, host = diskHost) { const out = new Set() const shown = (entry) => (entry === '.' ? './' : `${entry}/`) @@ -469,6 +448,7 @@ export function expandSolidityEntries(baseDir, entries, host = diskHost) { for (const f of files) out.add(f) } if (out.size === 0) { + if (entries.every((e) => host.stat(join(baseDir, e)) === null)) throw new Error(`No such file or directory: ${entries[0]}`) throw new Error(`No .sol files under ${entries.map((e) => shown(e === '' ? '.' : e)).join(', ')} (a directory entry stands for the Solidity sources under it)`) } return [...out] @@ -489,7 +469,7 @@ function assertWithinBase(baseDir, candidate, label) { // a mapping line, the remappings are discovered as for `stasis bundle` (discoverSolidityConfig). export async function loadSolidity(solTxtFile, { env = process.env } = {}) { const baseDir = dirname(resolve(solTxtFile)) - const listing = await readFile(solTxtFile, 'utf8') + const listing = decodeUtf8(await readFile(solTxtFile), solTxtFile) const lines = listing.split('\n').map((l) => l.trim()).filter(Boolean) if (lines.length === 0) throw new Error(`Empty Solidity listing: ${solTxtFile}`) @@ -505,7 +485,7 @@ export async function loadSolidity(solTxtFile, { env = process.env } = {}) { const entries = lines.map((l) => l.replace(/^\.\//u, '')) for (const e of entries) assertWithinBase(baseDir, e, 'Entry path') - const { remappings, libs, dependencyDirs } = discoverSolidityConfig(baseDir, { mappingFile, env }) - const sources = collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs, dependencyDirs }) - return buildSolidityTree(sources, { remappings, baseDir, libs, dependencyDirs }) + const { remappings, libs, ownership } = discoverSolidityConfig(baseDir, { mappingFile, env }) + const sources = collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs, ownership }) + return buildSolidityTree(sources, { remappings, baseDir, libs, ownership }) } diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 47213e98..cde86e10 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -1,8 +1,8 @@ import { test } from 'node:test' -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { stripVTControlCharacters } from 'node:util' import { brotliCompressSync, brotliDecompressSync } from 'node:zlib' @@ -438,6 +438,59 @@ const captureStderr = async (fn) => { } } +// `dir/name` -> a chain of 22 dirs with 200-char names, one short hop each (`n -> next/n`), the last +// hop `n -> last(levels)`; `atBottom()` runs in the deepest dir. Each hop resolves, but the chain's +// real path is past PATH_MAX (4096). +function linkPastPathMax(dir, name, last, atBottom = () => {}) { + const seg = (i) => `${'d'.repeat(200)}${i}` + const levels = 22 + const cwd = process.cwd() + try { + process.chdir(dir) + symlinkSync(`${seg(0)}/n`, name) + for (let i = 0; i < levels; i++) { + mkdirSync(seg(i)) + process.chdir(seg(i)) + symlinkSync(i + 1 < levels ? `${seg(i + 1)}/n` : last(levels), 'n') + } + atBottom() + } finally { + process.chdir(cwd) + } +} + +// buildSolidityBundle on `cwd` in a child whose stdin is an anonymous pipe left open (`sleep` holds +// its other end), so a read of stdin never ends: the child's output lines (its warnings, then `OK` +// and the bundled paths, or `ERR` and the error), or a rejection when it hangs. +function bundleWithOpenStdin(cwd) { + const script = [ + `import { buildSolidityBundle } from ${JSON.stringify(new URL('../stasis/src/cmd/bundle.js', import.meta.url).href)}`, + 'try {', + ` const bundle = await buildSolidityBundle({ cwd: ${JSON.stringify(cwd)}, entries: ['src'], env: {} })`, + " console.log('OK', [...bundle.sources.keys()].join(' '))", + '} catch (err) {', + " console.log('ERR', err.message)", + '}', + "console.log('DONE')", + ].join('\n') + return new Promise((resolve, reject) => { + const child = spawn('sh', ['-c', 'sleep 60 2>/dev/null | "$0" --input-type=module -e "$1" 2>&1', process.execPath, script], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) + let out = '' + const end = (settle) => { + clearTimeout(timer) + try { + process.kill(-child.pid, 'SIGKILL') + } catch {} + settle() + } + const timer = setTimeout(() => end(() => reject(new Error(`hung reading stdin, after: ${out}`))), 20_000) + child.stdout.on('data', (chunk) => { + out += chunk + if (out.endsWith('DONE\n')) end(() => resolve(out.slice(0, -'DONE\n'.length).trimEnd().split('\n'))) + }) + }) +} + test('buildSolidityBundle refuses an import of a non-.sol file, however it is spelled', withTmp(async (t, tmp) => { writeProject(tmp, { 'foundry.toml': '[profile.default]\n', @@ -468,7 +521,8 @@ test('buildSolidityBundle keeps a dependency\'s imports inside the dependencies' symlinkSync(join(tmp, 'secrets'), join(tmp, 'lib/evil/src/linked')) await captureStderr(() => t.assert.rejects( () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), - (err) => ['steal/Keys.sol', 'script/Secrets.sol', './linked/Keys.sol'].every((spec) => err.message.includes(`Unresolved import: ${spec} from lib/evil/src/E.sol (refused: a dependency may not import the project's own`)), + (err) => ['steal/Keys.sol', 'script/Secrets.sol'].every((spec) => err.message.includes(`Unresolved import: ${spec} from lib/evil/src/E.sol (refused: a dependency may not import the project's own`)) + && err.message.includes('Unresolved import: ./linked/Keys.sol from lib/evil/src/E.sol (refused: it resolves to lib/evil/src/linked/Keys.sol through lib/evil/src/linked, a link out of the dependency lib/evil)'), )) // The project's own code may import its own files, and a dependency another dependency's. writeFileSync(join(tmp, 'lib/evil/src/E.sol'), 'import "ok/B.sol";\n') @@ -477,6 +531,617 @@ test('buildSolidityBundle keeps a dependency\'s imports inside the dependencies' t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/evil/src/E.sol', 'lib/ok/src/B.sol', 'script/Secrets.sol', 'src/A.sol']) })) +test('buildSolidityBundle never reads through a link a dependency planted out of itself', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + '.env': 'PRIVATE_KEY=0xabc\n', + 'src/A.sol': 'import "evil/Evil.sol";\n', + // A dependency's remapping may route the project's own `forge-std/` imports into it. + 'src/B.sol': 'import "forge-std/Test.sol";\n', + 'lib/evil/foundry.toml': '[profile.default]\n', + 'lib/evil/remappings.txt': 'forge-std/=src/\n', + }) + mkdirSync(join(tmp, 'lib/evil/src')) + symlinkSync('../../../.env', join(tmp, 'lib/evil/src/Evil.sol')) + symlinkSync('../../../.env', join(tmp, 'lib/evil/src/Test.sol')) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + (err) => err.message.includes('Unresolved import: evil/Evil.sol from src/A.sol (refused: lib/evil/src/Evil.sol is a link out of the dependency lib/evil)') + && err.message.includes('Unresolved import: forge-std/Test.sol from src/B.sol (refused: lib/evil/src/Test.sol is a link out of the dependency lib/evil)'), + )) + // Nor as an entry, nor when the project reaches the dependency through a link of its own. + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['lib/evil/src'], env: {} }), /Refusing entry lib\/evil\/src\/Evil\.sol: lib\/evil\/src\/Evil\.sol is a link out of the dependency lib\/evil/u) + writeProject(tmp, { 'src/A.sol': 'import "./vendor/Evil.sol";\n', 'src/B.sol': 'contract B {}\n' }) + symlinkSync('../lib/evil/src', join(tmp, 'src/vendor')) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src/A.sol'], env: {} }), + /refused: it resolves to src\/vendor\/Evil\.sol through lib\/evil\/src\/Evil\.sol, a link out of the dependency lib\/evil/u, + )) +})) + +test('buildSolidityBundle treats a dependency reached through a project link as the dependency', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'secrets/Keys.sol': 'contract Keys {}\n', + 'src/A.sol': 'import "./vendor/E.sol";\n', + 'lib/evil/src/E.sol': 'import "./F.sol";\nimport "../../secrets/Keys.sol";\n', + 'lib/evil/src/F.sol': 'contract F {}\n', + }) + symlinkSync('../lib/evil/src', join(tmp, 'src/vendor')) + const { lines } = await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src/A.sol'], env: {} }), + (err) => err.message.includes("Unresolved import: ../../secrets/Keys.sol from src/vendor/E.sol (refused: a dependency may not import the project's own secrets/Keys.sol)"), + )) + // Its own files are still its own. + t.assert.ok(!lines.some((l) => l.includes('./F.sol'))) +})) + +test('buildSolidityBundle lets a linked dependency (workspace package, symlinked lib/) import its own files', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'contracts/A.sol': 'import "@org/lib/A.sol";\n', + 'packages/lib/package.json': '{"name":"@org/lib","version":"1.0.0"}', + 'packages/lib/A.sol': 'import "./B.sol";\n', + 'packages/lib/B.sol': 'contract B {}\n', + }) + mkdirSync(join(tmp, 'node_modules/@org'), { recursive: true }) + symlinkSync('../../packages/lib', join(tmp, 'node_modules/@org/lib')) + let bundle = await buildSolidityBundle({ cwd: tmp, entries: ['contracts'], env: {} }) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['contracts/A.sol', 'node_modules/@org/lib/A.sol', 'node_modules/@org/lib/B.sol']) + + const forge = join(tmp, 'forge') + writeProject(forge, { + 'foundry.toml': '[profile.default]\n', + 'src/A.sol': 'import "forge-std/Test.sol";\nimport "solmate/S.sol";\n', + 'vendor/forge-std/src/Test.sol': 'import "./Vm.sol";\n', + 'vendor/forge-std/src/Vm.sol': 'contract Vm {}\n', + // Another dependency imports the linked one. + 'lib/solmate/src/S.sol': 'import "forge-std/Test.sol";\n', + }) + symlinkSync('../vendor/forge-std', join(forge, 'lib/forge-std')) + bundle = await buildSolidityBundle({ cwd: forge, entries: ['src'], env: {} }) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/forge-std/src/Test.sol', 'lib/forge-std/src/Vm.sol', 'lib/solmate/src/S.sol', 'src/A.sol']) +})) + +test('buildSolidityBundle with manifests carries no dependency config reached through its link out', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + '.env': 'PRIVATE_KEY=0xabc\n', + '.gitmodules': '[submodule "lib/evil"]\n\tpath = lib/evil\n\turl = https://github.com/e/evil\n', + 'src/A.sol': 'import "evil/E.sol";\nimport "evil2/E.sol";\n', + 'lib/evil/src/E.sol': 'contract E {}\n', + 'lib/evil/foundry.toml': '[profile.default]\n', + 'lib/evil2/src/E.sol': 'contract E {}\n', + }) + symlinkSync('../../.env', join(tmp, 'lib/evil/remappings.txt')) + symlinkSync('../../.env', join(tmp, 'lib/evil2/foundry.toml')) + const { result: bundle, lines } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} })) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['.gitmodules', 'foundry.toml', 'lib/evil/foundry.toml', 'lib/evil/src/E.sol', 'lib/evil2/src/E.sol', 'src/A.sol']) + // Nor are they read as its config. + t.assert.ok(lines.includes("[loader.solidity] Skipping a dependency's lib/evil/remappings.txt: lib/evil/remappings.txt is a link out of the dependency lib/evil"), lines.join('\n')) + t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's config") && l.includes('lib/evil2/foundry.toml: refusing to read it'))) + t.assert.ok(lines.some((l) => l === '[stasis] Not carrying lib/evil/remappings.txt: lib/evil/remappings.txt is a link out of the dependency lib/evil')) +})) + +test('buildSolidityBundle refuses a package.json a dependency planted as a link, without quoting what it leads to', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + '.env': 'PRIVATE_KEY=0xabc\n', + '.gitmodules': '[submodule "lib/evil"]\n\tpath = lib/evil\n\turl = https://github.com/e/evil\n', + 'src/A.sol': 'import "evil/E.sol";\n', + 'lib/evil/src/E.sol': 'contract E {}\n', + }) + symlinkSync('../../.env', join(tmp, 'lib/evil/package.json')) + await Promise.all([false, true].map((manifests) => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests, env: {} }), + (err) => err.message === 'Refusing lib/evil/package.json: lib/evil/package.json is a link out of the dependency lib/evil' && !String(err.cause ?? '').includes('0xabc'), + ))) +})) + +test('buildSolidityBundle fails on a package.json that doesn\'t parse, rather than giving its files to the parent package', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'contracts/A.sol': 'import "pkg/sub/B.sol";\n', + 'node_modules/pkg/package.json': '{"name":"pkg","version":"1.0.0"}', + 'node_modules/pkg/sub/package.json': '{ "name": SECRET }', + 'node_modules/pkg/sub/B.sol': 'contract B {}\n', + }) + // The error says where, never what: the parser's own message quotes the text. + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['contracts'], env: {} }), { message: 'node_modules/pkg/sub/package.json is not valid JSON' }) + writeFileSync(join(tmp, 'node_modules/pkg/sub/package.json'), '{\n "name": "sub",\n}\n') + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['contracts'], env: {} }), { message: 'node_modules/pkg/sub/package.json is not valid JSON (line 3 column 1)' }) +})) + +test('buildSolidityBundle refuses a dependency config reached through an absolute or /proc lib, and reads it from the root', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'secrets.toml': '# SECRET\n[profile.default]\n', + 'src/A.sol': 'import "dep/D.sol";\n', + 'lib/dep/src/D.sol': 'contract D {}\n', + // A dependency naming the project's own dir as a lib, through /proc/self/cwd: its "nested" + // config is the project's file, and may not extend the project's secrets. + 'lib/dep/foundry.toml': '[profile.default]\nlibs = ["/proc/self/cwd/sub"]\n', + 'sub/x/foundry.toml': '[profile.default]\nextends = "../../secrets.toml"\n', + }) + const root = realpathSync(tmp) + // A dependency's lib: the "dependency" is the project's dir, and isn't read at all. The root's + // own absolute lib: its entry is taken as a dependency, which may not extend the project's file. + for (const [foundry, refused] of [ + ['[profile.default]\n', 'sub/x/foundry.toml: refusing to read it'], + [`[profile.default]\nlibs = ["lib", "${join(root, 'sub')}"]\n`, 'sub/x/foundry.toml: refusing to extend ../../secrets.toml'], + ]) { + writeFileSync(join(tmp, 'foundry.toml'), foundry) + const cwd = process.cwd() + process.chdir(tmp) + try { + // eslint-disable-next-line no-await-in-loop -- each run rewrites foundry.toml and needs the cwd + const { result: bundle, lines } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} })) + t.assert.ok(!bundle.sources.has('secrets.toml')) + t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's config") && l.includes(refused)), lines.join('\n')) + } finally { + process.chdir(cwd) + } + } +})) + +test('buildSolidityBundle with manifests carries the config of a /proc lib by its path in the project', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\nlibs = ["/proc/self/cwd/lib"]\n', + 'src/A.sol': 'contract A {}\n', + 'lib/x/foundry.toml': '[profile.default]\n', + 'lib/x/remappings.txt': 'y/=src/\n', + }) + const cwd = process.cwd() + process.chdir(tmp) + try { + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} }) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['foundry.toml', 'lib/x/foundry.toml', 'lib/x/remappings.txt', 'src/A.sol']) + } finally { + process.chdir(cwd) + } +})) + +test('buildSolidityBundle refuses a path the ownership walk reads differently from the OS', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + '.env': 'PRIVATE_KEY=0xabc\n', + 'src/A.sol': 'import "evil/E.sol";\n', + // A `\` is part of a name on POSIX: `a\b` is one entry (a link to .env), not the harmless a/b. + 'lib/evil/src/a/b': 'contract Harmless {}\n', + }) + symlinkSync('../../../.env', join(tmp, 'lib/evil/src/a\\b')) + symlinkSync('a\\b', join(tmp, 'lib/evil/src/E.sol')) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + /refused: it resolves to lib\/evil\/src\/E\.sol through lib\/evil\/src\/a\\b, a link out of the dependency lib\/evil/u, + )) + // A link target that isn't UTF-8 names a file no string path can: refused, not taken as missing. + rmSync(join(tmp, 'lib/evil/src/E.sol')) + symlinkSync(Buffer.from([0xff]), Buffer.from(join(tmp, 'lib/evil/src/E.sol'))) + symlinkSync('../../../.env', Buffer.concat([Buffer.from(`${join(tmp, 'lib/evil/src')}/`), Buffer.from([0xff])])) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + /refused: lib\/evil\/src\/E\.sol crosses a link stasis can't follow the way the filesystem does/u, + )) +})) + +test('buildSolidityBundle fails on a config that isn\'t UTF-8 or holds a mistyped setting, as forge does', withTmp(async (t, tmp) => { + writeProject(tmp, { 'foundry.toml': '[profile.default]\n', 'src/A.sol': 'import "x/X.sol";\n', 'lib/x/X.sol': 'contract X {}\n', 'deps/x/X.sol': 'contract Y {}\n' }) + writeFileSync(join(tmp, 'remappings.txt'), Buffer.concat([Buffer.from('x/=lib/x'), Buffer.from([0xff]), Buffer.from('/\n')])) + await captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: 'remappings.txt: not valid UTF-8' })) + rmSync(join(tmp, 'remappings.txt')) + for (const [setting, message] of [ + ['libs = "deps"', '`libs` must be an array of strings'], + ['src = 1', '`src` must be a string'], + ['auto_detect_remappings = "no"', '`auto_detect_remappings` must be a boolean'], + ['extends = { path = "b.toml", strategy = "merge" }', '`extends` must be a path, or a table with a `path` and an optional `strategy` (extend-arrays, replace-arrays, no-collision)'], + ]) { + writeFileSync(join(tmp, 'foundry.toml'), `[profile.default]\n${setting}\n`) + // eslint-disable-next-line no-await-in-loop -- each run rewrites foundry.toml + await captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: `foundry.toml: ${message}` })) + } +})) + +test('buildSolidityBundle keeps a remappings.txt byte-order mark as forge does, and carries configs whatever they are called', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\nextends = "base.conf"\n', + 'base.conf': '[profile.default]\nsrc = "src"\n', + // forge's trim keeps U+FEFF, so this remapping's prefix is `x/`: `x/` stays lib/x's. + 'remappings.txt': 'x/=lib/other/\n', + 'remaps': 'x/=lib/x/\n', + 'src/A.sol': 'import "x/X.sol";\n', + 'lib/x/X.sol': 'contract X {}\n', + 'lib/other/X.sol': 'contract O {}\n', + }) + let bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} }) + t.assert.equal(bundle.imports.get('solidity').get('src/A.sol').get('x/X.sol'), 'lib/x/X.sol') + t.assert.equal(bundle.sources.get('base.conf'), '[profile.default]\nsrc = "src"\n') + bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, mappingFile: 'remaps', env: {} }) + t.assert.equal(bundle.sources.get('remaps'), 'x/=lib/x/\n') +})) + +test('buildSolidityBundle never follows a link from outside the root back into it', withTmp(async (t, tmp) => { + const proj = join(tmp, 'proj') + writeProject(proj, { 'foundry.toml': '[profile.default]\n', '.env': 'PRIVATE_KEY=0xabc\n', 'src/A.sol': 'import "evil/Evil.sol";\n' }) + mkdirSync(join(tmp, 'shared/evil/src'), { recursive: true }) + symlinkSync('../../../proj/.env', join(tmp, 'shared/evil/src/Evil.sol')) + mkdirSync(join(proj, 'lib')) + symlinkSync('../../shared/evil', join(proj, 'lib/evil')) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: proj, entries: ['src'], env: {} }), + /refused: it resolves to lib\/evil\/src\/Evil\.sol through \.\.\/shared\/evil\/src\/Evil\.sol, a link from outside the project root back into it/u, + )) +})) + +test('buildSolidityBundle reads .gitmodules paths as git does, so a quoted submodule is a dependency', withTmp(async (t, tmp) => { + writeProject(tmp, { + '.env': 'PRIVATE_KEY=0xabc\n', + '.gitmodules': '[submodule "evil"]\n\tpath = "vendor/evil"\n\turl = https://github.com/e/evil\n', + 'contracts/A.sol': 'import "../vendor/evil/E.sol";\n', + }) + mkdirSync(join(tmp, 'vendor/evil'), { recursive: true }) + symlinkSync('../../.env', join(tmp, 'vendor/evil/E.sol')) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['contracts'], env: {} }), + /refused: vendor\/evil\/E\.sol is a link out of the dependency vendor\/evil/u, + )) +})) + +test('buildSolidityBundle follows a dependency\'s config linked into another dependency', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'src/A.sol': 'import "x/X.sol";\n', + 'lib/a/foundry.toml': '[profile.default]\n', + 'lib/shared/remappings.txt': 'x/=../b/src/\n', + 'lib/b/src/X.sol': 'contract X {}\n', + }) + symlinkSync('../shared/remappings.txt', join(tmp, 'lib/a/remappings.txt')) + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/b/src/X.sol', 'src/A.sol']) +})) + +test('buildSolidityBundle fails on a foundry.toml that isn\'t TOML, naming the file and line', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'src/A.sol': 'import "dep/D.sol";\n', + 'lib/dep/src/D.sol': 'contract D {}\n', + // forge skips a dependency's config it can't read; here it's an error, not a config left out. + 'lib/dep/foundry.toml': '[profile.default]\nremappings = ["x/=y/"\n', + }) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + { name: 'TomlError', message: 'lib/dep/foundry.toml: expected "," or "]", found the end of the text at line 3' }, + )) + // ...and so is its `extends` base. + writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\nextends = "base.toml"\n', 'lib/dep/base.toml': '[profile.default]\nsrc = "src" junk\n' }) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + { name: 'TomlError', message: 'lib/dep/base.toml: expected the end of the line, found "junk" at line 2' }, + )) + // With a pinned mapping file, the root foundry.toml is still read for its lib dirs. + writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\n', 'foundry.toml': '[profile.default]\nlibs = ["lib"\n', 'remappings.txt': 'dep/=lib/dep/src/\n' }) + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], mappingFile: 'remappings.txt', env: {} }), + { name: 'TomlError', message: 'foundry.toml: expected "," or "]", found the end of the text at line 3' }, + )) +})) + +test('buildSolidityBundle fails on an invalid remapping, the project\'s or a dependency\'s, naming the file and line', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'remappings.txt': 'dep/=lib/dep/src/\n# not a remapping\n', + 'src/A.sol': 'import "dep/D.sol";\n', + 'lib/dep/src/D.sol': 'contract D {}\n', + }) + const fails = (opts, message) => captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {}, ...opts }), { message })) + await fails({}, 'remappings.txt:2: invalid remapping "# not a remapping"') + // As written for solc, and as a pinned mapping file, alike. + await fails({ mappingFile: 'remappings.txt' }, 'remappings.txt:2: invalid remapping "# not a remapping"') + writeFileSync(join(tmp, 'remappings.txt'), 'dep/=lib/dep/src/\n') + // forge skips a dependency's config holding one; here it's an error, not a config left out. + writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\nremappings = ["x"]\n' }) + await fails({}, 'lib/dep/foundry.toml: `remappings`: invalid remapping "x"') + writeProject(tmp, { 'lib/dep/foundry.toml': '[profile.default]\n', 'lib/dep/remappings.txt': 'y/=src/\n=z\n' }) + await fails({}, 'lib/dep/remappings.txt:2: invalid remapping "=z"') + writeFileSync(join(tmp, 'lib/dep/remappings.txt'), 'y/=src/\n') + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/dep/src/D.sol', 'src/A.sol']) +})) + +test('buildSolidityBundle refuses a dependency config whose real path the OS can\'t resolve (past PATH_MAX)', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + '.env': 'PRIVATE_KEY=0xabc\n', + // Were the .env read as the dependency's remappings.txt, `PRIVATE_KEY/` would map here. + 'src/A.sol': 'import "evil/E.sol";\nimport "PRIVATE_KEY/Y.sol";\n', + 'lib/evil/src/E.sol': 'contract E {}\n', + 'lib/evil/foundry.toml': '[profile.default]\n', + 'lib/evil/0xabc/Y.sol': 'contract Y {}\n', + }) + // lib/evil/remappings.txt -> a chain ending in a link to the project's .env: readable, but its real + // path is past PATH_MAX. + linkPastPathMax(join(tmp, 'lib/evil'), 'remappings.txt', (levels) => `${'../'.repeat(levels + 2)}.env`) + t.assert.equal(readFileSync(join(tmp, 'lib/evil/remappings.txt'), 'utf8'), 'PRIVATE_KEY=0xabc\n') + await Promise.all([false, true].map(async (manifests) => { + const { lines } = await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests, env: {} }), + (err) => err.message.includes('Unresolved import: PRIVATE_KEY/Y.sol from src/A.sol'), + )) + t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's") && l.includes('lib/evil/remappings.txt')), lines.join('\n')) + })) +})) + +test('buildSolidityBundle resolves an `extends` through a symlink as forge does, and carries the file it read', withTmp(async (t, tmp) => { + writeProject(tmp, { + // `sub` is a link to real/in: forge reads real/in/../base.toml, i.e. real/base.toml, not base.toml. + 'foundry.toml': '[profile.default]\nextends = "sub/../base.toml"\n', + 'base.toml': '[profile.default]\nremappings = ["x/=lib/textual/"]\n', + 'real/base.toml': '[profile.default]\nremappings = ["x/=lib/physical/"]\n', + 'real/in/.keep': '', + 'src/A.sol': 'import "x/X.sol";\n', + 'lib/textual/X.sol': 'contract T {}\n', + 'lib/physical/X.sol': 'contract P {}\n', + }) + symlinkSync('real/in', join(tmp, 'sub')) + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} }) + t.assert.equal(bundle.imports.get('solidity').get('src/A.sol').get('x/X.sol'), 'lib/physical/X.sol') + t.assert.ok(bundle.sources.has('real/base.toml') && !bundle.sources.has('base.toml')) +})) + +test('buildSolidityBundle with manifests fails on a config the resolution read but can\'t carry', withTmp(async (t, tmp) => { + const proj = join(tmp, 'proj') + writeProject(proj, { 'src/A.sol': 'contract A {}\n' }) + writeFileSync(join(tmp, 'shared-base.toml'), '[profile.default]\nsrc = "src"\n') + for (const [base, why] of [ + ['base.env', '.env files and hardhat.config.* are never carried'], + ['.env.toml', '.env files and hardhat.config.* are never carried'], + ['Base.ENV', '.env files and hardhat.config.* are never carried'], + ['.env.local', '.env files and hardhat.config.* are never carried'], + ['HARDHAT.CONFIG.TOML', '.env files and hardhat.config.* are never carried'], + ['Hardhat.config.toml', '.env files and hardhat.config.* are never carried'], + ['../shared-base.toml', 'it lies outside the bundle root'], + ]) { + if (!base.startsWith('../')) writeFileSync(join(proj, base), '[profile.default]\nsrc = "src"\n') + writeFileSync(join(proj, 'foundry.toml'), `[profile.default]\nextends = "${base}"\n`) + // eslint-disable-next-line no-await-in-loop -- each run rewrites foundry.toml + await t.assert.rejects(() => buildSolidityBundle({ cwd: proj, entries: ['src'], manifests: true, env: {} }), { message: `--manifests can't carry ${base}, which the Solidity resolution read: ${why}` }) + // Without --manifests there's nothing to carry: the resolution is forge's. + // eslint-disable-next-line no-await-in-loop -- each run rewrites foundry.toml + const bundle = await buildSolidityBundle({ cwd: proj, entries: ['src'], env: {} }) + t.assert.deepEqual([...bundle.sources.keys()], ['src/A.sol']) + } +})) + +test('buildSolidityBundle with manifests refuses a config whose real path the OS can\'t give (past PATH_MAX), not another file of that name', withTmp(async (t, tmp) => { + writeProject(tmp, { + // `L/../base.toml`: L leads to a dir whose real path is past PATH_MAX, and forge reads the base + // beside that dir. Normalized, the name would be the root's base.toml: another file. + 'foundry.toml': '[profile.default]\nextends = "L/../base.toml"\n', + 'base.toml': '[profile.default]\nremappings = ["x/=lib/textual/"]\n', + 'src/A.sol': 'import "x/X.sol";\n', + 'lib/textual/X.sol': 'contract T {}\n', + 'lib/physical/X.sol': 'contract P {}\n', + }) + linkPastPathMax(tmp, 'L', () => 'in', () => { + mkdirSync('in') + writeFileSync('base.toml', '[profile.default]\nremappings = ["x/=lib/physical/"]\n') + }) + // However the path is spelled, from the root: as given, through `./`, or with a doubled `/`. + for (const extendsPath of ['L/../base.toml', `${tmp}/./L/../base.toml`, `${dirname(tmp)}//${basename(tmp)}/L/../base.toml`]) { + writeFileSync(join(tmp, 'foundry.toml'), `[profile.default]\nextends = "${extendsPath}"\n`) + // eslint-disable-next-line no-await-in-loop -- each run rewrites foundry.toml + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }) + t.assert.equal(bundle.imports.get('solidity').get('src/A.sol').get('x/X.sol'), 'lib/physical/X.sol') + // eslint-disable-next-line no-await-in-loop -- each run rewrites foundry.toml + await t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} }), + { message: "--manifests can't carry L/../base.toml, which the Solidity resolution read: L/../base.toml crosses a link stasis can't follow the way the filesystem does" }, + ) + } +})) + +test('buildSolidityBundle never reads the process\'s stdin as a config, a dependency\'s or the project\'s', { skip: !existsSync('/proc/self/fd/0') }, withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'src/A.sol': 'contract A {}\n', + 'lib/a/foundry.toml': '[profile.default]\n', + 'lib/b/B.sol': '', + 'lib/c/foundry.toml': '[profile.default]\nextends = "base.toml"\n', + }) + // A pipe on stdin is a link whose end the OS can't name (`pipe:[N]`): refused, not read. + symlinkSync('/proc/self/fd/0', join(tmp, 'lib/a/remappings.txt')) + symlinkSync('/dev/stdin', join(tmp, 'lib/b/foundry.toml')) + symlinkSync('/proc/self/fd/0', join(tmp, 'lib/c/base.toml')) + const unresolved = (path) => `${path} crosses a link stasis can't follow the way the filesystem does` + t.assert.deepEqual(await bundleWithOpenStdin(tmp), [ + `[loader.solidity] Skipping a dependency's lib/a/remappings.txt: ${unresolved('lib/a/remappings.txt')}`, + `[loader.solidity] Skipping a dependency's config: lib/b/foundry.toml: refusing to read it: ${unresolved('lib/b/foundry.toml')}`, + `[loader.solidity] Skipping a dependency's config: lib/c/foundry.toml: refusing to extend base.toml: ${unresolved('lib/c/base.toml')}`, + 'OK src/A.sol', + ]) + // The project's own link to it, or a FIFO, is read only if it's a regular file: it isn't. + rmSync(join(tmp, 'lib'), { recursive: true }) + symlinkSync('/dev/stdin', join(tmp, 'remappings.txt')) + t.assert.deepEqual(await bundleWithOpenStdin(tmp), ['ERR remappings.txt: not a regular file']) + rmSync(join(tmp, 'remappings.txt')) + spawnSync('mkfifo', [join(tmp, 'remappings.txt')]) + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), { message: 'remappings.txt: not a regular file' }) +})) + +test('buildSolidityBundle never fails on a .gitmodules the library refuses, and takes a submodule\'s url as written', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'src/A.sol': 'import "x/X.sol";\nimport "../vendor/evil/E.sol";\n', + 'lib/x/src/X.sol': 'contract X {}\n', + 'secret/K.sol': 'contract K {}\n', + // git registers vendor/evil (`update = none` makes git submodule update skip it); stasis reads + // it submodule by submodule, and vendor/evil stays a dependency, its link out refused. + '.gitmodules': '[core]\n\tbare = false\n[submodule "vendor/evil"]\n\tpath = vendor/evil\n\turl = https://github.com/e/evil\n\tupdate = none\n\tactive = true\n', + }) + mkdirSync(join(tmp, 'vendor/evil'), { recursive: true }) + symlinkSync('../../secret/K.sol', join(tmp, 'vendor/evil/E.sol')) + const { lines } = await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + (err) => err.message.includes('refused: vendor/evil/E.sol is a link out of the dependency vendor/evil'), + )) + t.assert.ok(lines.includes('[loader.solidity] .gitmodules: a section of [core] where .gitmodules has [submodule "name"] alone, at line 1; reading it submodule by submodule'), lines.join('\n')) + rmSync(join(tmp, 'vendor/evil/E.sol')) + writeFileSync(join(tmp, 'vendor/evil/E.sol'), 'contract E {}\n') + const { result: named } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} })) + t.assert.equal(named.modules.get('vendor/evil').name, 'e/evil') + writeFileSync(join(tmp, 'src/A.sol'), 'import "x/X.sol";\n') + // A url relative to the superproject's remote, or none, still makes lib/x a submodule: a + // dependency, but not one with a GitHub name to bucket it by. + for (const url of ['\turl = ../x.git\n', '']) { + writeFileSync(join(tmp, '.gitmodules'), `[submodule "x"]\n\tpath = lib/x\n${url}`) + // eslint-disable-next-line no-await-in-loop -- each run rewrites .gitmodules + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/x/src/X.sol', 'src/A.sol']) + t.assert.equal(bundle.modules.get('lib/x'), undefined) + } +})) + +test('buildSolidityBundle keeps a submodule whose .gitmodules path doesn\'t read a dependency, failing closed', withTmp(async (t, tmp) => { + // deps/x is outside forge's libs: only .gitmodules makes it a dependency, and a planted link in + // it to the project's .env must stay refused however its path is spelled. + writeProject(tmp, { 'foundry.toml': '[profile.default]\nremappings = ["x/=deps/x/src/"]\n', '.env': 'PRIVATE_KEY=0xabc\n', 'src/A.sol': 'import "x/Evil.sol";\n' }) + mkdirSync(join(tmp, 'deps/x/src'), { recursive: true }) + symlinkSync('../../../.env', join(tmp, 'deps/x/src/Evil.sol')) + for (const section of ['[submodule "x"]\n\tpath = ./deps/x\n', '[submodule "x"]\n\tpath = deps/x/\n', '[submodule.x]\n\tpath = deps/x\n']) { + writeFileSync(join(tmp, '.gitmodules'), `${section}\turl = https://github.com/e/x\n`) + // eslint-disable-next-line no-await-in-loop -- each run rewrites .gitmodules + await captureStderr(() => t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + (err) => err.message.includes('refused: deps/x/src/Evil.sol is a link out of the dependency deps/x'), + section, + )) + } +})) + +test('buildSolidityBundle refuses a .gitmodules git refuses, so no submodule section is read past', withTmp(async (t, tmp) => { + // deps/x is outside forge's libs: only its .gitmodules section makes it a dependency. A header git + // doesn't read would lose that section, deps/x then the project's own, its link to .env trusted. + writeProject(tmp, { 'foundry.toml': '[profile.default]\nremappings = ["x/=deps/x/src/"]\n', '.env': 'PRIVATE_KEY=0xabc\n', 'src/A.sol': 'import "x/Evil.sol";\n' }) + mkdirSync(join(tmp, 'deps/x/src'), { recursive: true }) + symlinkSync('../../../.env', join(tmp, 'deps/x/src/Evil.sol')) + const x = '\n\tpath = deps/x\n\turl = https://github.com/e/x\n' + for (const [gitmodules, what, line] of [ + [`[submodule.deps/x]${x}`, "a character git doesn't take in a section name", 1], + [`[submodule "deps/x"${x}`, 'a subsection with no "]" right after it', 1], + [`[submodule deps/x]${x}`, 'a section name and then no quoted subsection', 1], + [`[submodule "y"]\n\tpath = lib/y\n\turl = https://github.com/o/y\n[submodule deps/x]${x}`, 'a section name and then no quoted subsection', 4], + ]) { + writeFileSync(join(tmp, '.gitmodules'), gitmodules) + // eslint-disable-next-line no-await-in-loop -- each run rewrites .gitmodules + await t.assert.rejects( + () => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} }), + { message: `.gitmodules: ${what} at line ${line}; git refuses such a file` }, + gitmodules, + ) + } +})) + +test('buildSolidityBundle refuses a .sol file that isn\'t UTF-8, rather than bundle it with U+FFFD in it', withTmp(async (t, tmp) => { + // \xe9 alone is Latin-1's é: solc refuses it, and the bundle must hold the file's own text. + writeProject(tmp, { 'src/A.sol': 'import "./B.sol";\ncontract A {}\n' }) + writeFileSync(join(tmp, 'src/B.sol'), Buffer.from('// caf\xe9\ncontract B {}\n', 'latin1')) + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src/A.sol'], env: {} }), { message: 'src/B.sol: not valid UTF-8' }) + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['src/B.sol'], env: {} }), { message: 'src/B.sol: not valid UTF-8' }) + // A byte-order mark is UTF-8: kept, as written. + writeFileSync(join(tmp, 'src/B.sol'), '\uFEFFcontract B {}\n') + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src/A.sol'], env: {} }) + t.assert.equal(bundle.sources.get('src/B.sol'), '\uFEFFcontract B {}\n') +})) + +test('buildSolidityBundle never stalls on a package.json that isn\'t a regular file', withTmp(async (t, tmp) => { + writeProject(tmp, { 'contracts/A.sol': 'import "pkg/P.sol";\n', 'node_modules/pkg/P.sol': 'contract P {}\n' }) + // A FIFO: read blocking, it would wait for a writer forever. + spawnSync('mkfifo', [join(tmp, 'node_modules/pkg/package.json')]) + await t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['contracts'], env: {} }), { message: 'node_modules/pkg/package.json: not a regular file' }) +})) + +test('buildSolidityBundle resolves a dependency\'s `extends` through its own symlink as forge does', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\n', + 'src/A.sol': 'import "y/Y.sol";\n', + // `sub` is a link to real/in: forge reads real/base.toml; a `..` taken textually would refuse it. + 'lib/dep/foundry.toml': '[profile.default]\nextends = "sub/../base.toml"\n', + 'lib/dep/real/base.toml': '[profile.default]\nremappings = ["y/=src/"]\n', + 'lib/dep/real/in/.keep': '', + 'lib/dep/src/Y.sol': 'contract Y {}\n', + }) + symlinkSync('real/in', join(tmp, 'lib/dep/sub')) + const { result: bundle, lines } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], env: {} })) + t.assert.equal(bundle.imports.get('solidity').get('src/A.sol').get('y/Y.sol'), 'lib/dep/src/Y.sol') + t.assert.deepEqual(lines, []) +})) + +test('buildSolidityBundle with --mapping and manifests carries the root config read for its lib dirs, `extends` base included', withTmp(async (t, tmp) => { + const proj = join(tmp, 'proj') + writeProject(proj, { + 'foundry.toml': '[profile.default]\nextends = "base.toml"\n', + 'base.toml': '[profile.default]\nlibs = ["deps"]\n', + 'remappings.txt': 'x/=deps/x/\n', + 'src/A.sol': 'import "x/X.sol";\n', + 'deps/x/X.sol': 'contract X {}\n', + }) + const opts = { cwd: proj, entries: ['src'], mappingFile: 'remappings.txt', manifests: true, env: {} } + const bundle = await buildSolidityBundle(opts) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['base.toml', 'deps/x/X.sol', 'foundry.toml', 'remappings.txt', 'src/A.sol']) + // ...and fails on one it can't carry, as without --mapping. + writeFileSync(join(tmp, 'shared-base.toml'), '[profile.default]\nlibs = ["deps"]\n') + writeFileSync(join(proj, 'foundry.toml'), '[profile.default]\nextends = "../shared-base.toml"\n') + await t.assert.rejects(() => buildSolidityBundle(opts), { message: "--manifests can't carry ../shared-base.toml, which the Solidity resolution read: it lies outside the bundle root" }) +})) + +test('buildSolidityBundle with manifests tags a package.json `json`, any other config (`--mapping=remaps.json`) `resource`', withTmp(async (t, tmp) => { + writeProject(tmp, { 'package.json': '{ "name": "proj", "version": "1.0.0" }\n', 'remaps.json': 'x/=lib/x/\n', 'src/A.sol': 'import "x/X.sol";\n', 'lib/x/X.sol': 'contract X {}\n' }) + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], mappingFile: 'remaps.json', manifests: true, env: {} }) + t.assert.equal(bundle.formats.get('remaps.json'), 'resource') + t.assert.equal(bundle.formats.get('package.json'), 'json') +})) + +test('buildSolidityBundle reads a package.json with a byte-order mark, as npm does, and carries it as written', withTmp(async (t, tmp) => { + const pkg = '\uFEFF{ "name": "proj", "version": "1.0.0" }\n' + writeProject(tmp, { 'package.json': pkg, 'src/A.sol': 'contract A {}\n', 'node_modules/dep/package.json': '\uFEFF{ "name": "dep", "version": "2.0.0" }\n', 'node_modules/dep/D.sol': 'contract D {}\n' }) + writeFileSync(join(tmp, 'src/A.sol'), 'import "dep/D.sol";\n') + const bundle = await buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} }) + t.assert.equal(bundle.sources.get('package.json'), pkg) + t.assert.deepEqual([...bundle.modules.keys()].toSorted(), ['.', 'node_modules/dep']) + t.assert.equal(bundle.modules.get('node_modules/dep').version, '2.0.0') +})) + +test('buildBashBundle and buildRustBundle walk past a malformed package.json, as they always have', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'run.sh': '#!/bin/sh\n. ./sub/lib.sh\n', + 'sub/lib.sh': 'echo hi\n', + 'sub/package.json': '{ "name": "sub",\n}\n', + 'src/main.rs': 'mod a;\nfn main() {}\n', + 'src/a.rs': '', + 'src/package.json': '{ bad', + }) + t.assert.deepEqual([...(await buildBashBundle({ cwd: tmp, entries: ['run.sh'] })).sources.keys()].toSorted(), ['run.sh', 'sub/lib.sh']) + t.assert.deepEqual([...(await buildRustBundle({ cwd: tmp, entries: ['src/main.rs'] })).sources.keys()].toSorted(), ['src/a.rs', 'src/main.rs']) +})) + +test('buildSolidityBundle with --mapping bundles when forge would reject the root foundry.toml', withTmp(async (t, tmp) => { + writeProject(tmp, { + 'foundry.toml': '[profile.default]\nextends = "missing.toml"\n', + 'remappings.txt': 'x/=lib/x/\n', + 'lib/x/X.sol': 'contract X {}\n', + 'src/A.sol': 'import "x/X.sol";\n', + }) + const { result: bundle, lines } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], mappingFile: 'remappings.txt', env: {} })) + t.assert.deepEqual([...bundle.sources.keys()].toSorted(), ['lib/x/X.sol', 'src/A.sol']) + t.assert.ok(lines.some((l) => l.includes('Using the default lib dirs'))) +})) + +test('a missing extensionless entry alone is a mistyped path, not a Solidity directory', withTmp(async (t, tmp) => { + writeProject(tmp, { 'index.js': '' }) + await t.assert.rejects(() => buildBundle({ cwd: tmp, entries: ['indx'] }), /buildBundle: no such file or directory: indx/u) + await captureStderr(() => t.assert.rejects(() => buildSolidityBundle({ cwd: tmp, entries: ['indx'] }), /No such file or directory: indx/u)) + const r = runCli(['bundle', 'indx'], { cwd: tmp }) + t.assert.equal(r.status, 1) + t.assert.match(r.stderr, /Error: no such file or directory: indx/u) +})) + test('buildSolidityBundle refuses a remapping target outside the project root', withTmp(async (t, tmp) => { writeProject(tmp, { 'foundry.toml': '[profile.default]\nremappings = ["x/=/opt/evil/", "up/=../elsewhere/"]\n', @@ -524,7 +1189,7 @@ test('buildSolidityBundle with manifests carries configs as written, never `.env // `.env` and hardhat.config.* are never carried, and the submodule's `extends` reaching the // project's `.env` is neither read as config nor carried. for (const [, text] of bundle.sources) t.assert.doesNotMatch(text, /KEY[67]/u) - t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's config") && l.includes('outside the dependency'))) + t.assert.ok(lines.includes("[loader.solidity] Skipping a dependency's config: lib/dep/foundry.toml: refusing to extend ../../.env: it resolves to the project's own .env"), lines.join('\n')) })) test('buildSolidityBundle says when the environment shaped the resolution; buildBundle passes `env` on', withTmp(async (t, tmp) => { @@ -2749,7 +3414,8 @@ test('CLI: bundle (JS) fails loudly when the oxc-parser dependency is missing', // exited 0 with no warning at all. The setup error must propagate with its // install hint instead. Exercised against a copy of stasis whose node_modules // carries only the zero-dep @exodus/stasis-core (so the moved-module shims - // resolve) and @preventive/lockfile (whose TOML parser the loaders import), so + // resolve), @preventive/lockfile (whose TOML and .gitmodules readers the loaders + // import) and @exodus/bytes (its dependency, and the loaders' UTF-8 decoder), so // the bundle command loads, but no oxc-parser, so the lazy lookup (createRequire // from src/scan.js) genuinely misses. const stasisCopy = join(tmp, 'stasis') @@ -2762,8 +3428,10 @@ test('CLI: bundle (JS) fails loudly when the oxc-parser dependency is missing', mkdirSync(coreDest, { recursive: true }) for (const entry of ['bin', 'src']) cpSync(join(here, '..', 'stasis-core', entry), join(coreDest, entry), { recursive: true }) cpSync(join(here, '..', 'stasis-core', 'package.json'), join(coreDest, 'package.json')) - // pnpm links it from its store: the real directory is what gets copied. - cpSync(realpathSync(join(here, '..', 'stasis', 'node_modules', '@preventive', 'lockfile')), join(stasisCopy, 'node_modules', '@preventive', 'lockfile'), { recursive: true }) + // pnpm links them from its store: the real directories are what get copied. + const lockfile = realpathSync(join(here, '..', 'stasis', 'node_modules', '@preventive', 'lockfile')) + cpSync(lockfile, join(stasisCopy, 'node_modules', '@preventive', 'lockfile'), { recursive: true }) + cpSync(realpathSync(join(lockfile, '..', '..', '@exodus', 'bytes')), join(stasisCopy, 'node_modules', '@exodus', 'bytes'), { recursive: true }) const proj = join(tmp, 'proj') mkdirSync(proj) jsProject(proj, { 'file.mjs': 'export * from "@noble/ciphers/_arx.js"\n' }) diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index 7980ade0..15b12607 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -1,5 +1,5 @@ import { test } from 'node:test' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -17,7 +17,9 @@ import { readRemappingsFile, resolveSolImport, } from '../stasis/src/loaders/solidity.js' +import { diskHost } from '@exodus/stasis-core/host' import { findRemappingsWithContext, foundryProject, foundryTomlRemappings } from '../stasis/src/loaders/foundry.js' +import { readGitmodules, solidityOwnership } from '../stasis/src/loaders/solidity-ownership.js' const fixtures = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'solidity-bundle') @@ -74,12 +76,16 @@ test('extractSolImports finds remapped imports', (t) => { t.assert.deepEqual(extractSolImports(src), ['@openzeppelin/contracts/utils/Math.sol']) }) -test('parseRemappings handles one-per-line entries and ignores invalid lines', (t) => { - const out = parseRemappings('@a/=lib/a/\n @b/=lib/b/\r\ngarbage line\n=empty-prefix\n') +test('parseRemappings handles one-per-line entries and refuses an invalid line, naming it', (t) => { + const out = parseRemappings('@a/=lib/a/\n @b/=lib/b/\r\n\n') t.assert.deepEqual(out, [ { context: null, prefix: '@a/', target: 'lib/a/' }, { context: null, prefix: '@b/', target: 'lib/b/' }, ]) + t.assert.throws(() => parseRemappings('@a/=lib/a/\ngarbage line\n'), { message: 'remappings:2: invalid remapping "garbage line"' }) + // Lines are trimmed as Rust trims them: a byte-order mark isn't whitespace, and stays. + t.assert.deepEqual(parseRemappings('\uFEFFx/=a/\n'), [{ context: null, prefix: '\uFEFFx/', target: 'a/' }]) + t.assert.throws(() => parseRemappings('\n=empty-prefix\n'), { message: 'remappings:2: invalid remapping "=empty-prefix"' }) }) test('parseRemappings reads a `context:` before the prefix', (t) => { @@ -532,7 +538,7 @@ test('foundryProject reads a profile\'s sub-tables however they are spelled: `ex t.assert.throws(() => foundryProject(dir, { env: {} }), { message: /key collision in profile 'default' when extending base\.toml: fuzz$/u }) })) -test('discoverSolidityConfig: --mapping takes exactly that file; no foundry.toml falls back to remappings.txt', withProject({ +test('discoverSolidityConfig: --mapping takes exactly that file\'s remappings; no foundry.toml falls back to remappings.txt', withProject({ 'foundry.toml': '[profile.default]\n', 'mapping.txt': '@m/=lib/m/\nforge-std=lib/forge-std/src\nconsole.sol=lib/forge-std/src/console.sol\n', 'lib/forge-std/src/Test.sol': '', @@ -541,7 +547,8 @@ test('discoverSolidityConfig: --mapping takes exactly that file; no foundry.toml const pinned = await discoverSolidityConfig(dir, { mappingFile: 'mapping.txt', env: {} }) // Slash-terminated as forge reads a remappings file. t.assert.deepEqual(pinned.remappings.map(show), ['@m/=lib/m/', 'forge-std/=lib/forge-std/src/', 'console.sol=lib/forge-std/src/console.sol']) - t.assert.deepEqual(pinned.files, ['mapping.txt']) + // ...and the root foundry.toml, read for its lib dirs. + t.assert.deepEqual(pinned.files, ['mapping.txt', 'foundry.toml']) t.assert.deepEqual((await discoverSolidityConfig(dir, { env: {} })).remappings.map(show), ['forge-std/=lib/forge-std/src/']) const plain = await discoverSolidityConfig(join(dir, 'plain'), { env: {} }) t.assert.deepEqual(plain.remappings.map(show), ['@p/=lib/p/']) @@ -629,13 +636,251 @@ test('resolveSolImport refuses a non-.sol target, one outside the root, and a de }, (t, dir) => { t.assert.equal(resolveSolImport('../../.env', 'lib/dep/src/A.sol', { baseDir: dir }), null) t.assert.equal(resolveSolImport('x/Y.sol', 'src/A.sol', { baseDir: dir, remappings: [{ context: null, prefix: 'x/', target: '/abs/' }] }), null) - const opts = { baseDir: dir, libs: ['lib'], dependencyDirs: ['lib'] } + const opts = { baseDir: dir, libs: ['lib'], ownership: solidityOwnership(dir, { dirs: ['lib'] }) } t.assert.equal(resolveSolImport('secret.sol', 'lib/dep/src/A.sol', opts), null) t.assert.equal(resolveSolImport('secret.sol', 'src/Main.sol', opts), 'secret.sol') t.assert.equal(resolveSolImport('../../other/src/B.sol', 'lib/dep/src/A.sol', opts), 'lib/other/src/B.sol') t.assert.equal(resolveSolImport('../../../node_modules/pkg/C.sol', 'lib/dep/src/A.sol', opts), 'node_modules/pkg/C.sol') })) +test('solidityOwnership decides a path\'s owner from where it really is, and catches a dependency\'s link out of itself', withProject({ + '.env': 'K=1\n', + 'secrets/Keys.sol': '', + 'lib/dep/src/A.sol': '', + 'lib/forge-std/src/Test.sol': '', + 'vendor/linked/src/L.sol': '', + 'packages/ws/W.sol': '', + 'node_modules/.pnpm/foo@1/node_modules/foo/F.sol': '', + 'node_modules/.pnpm/bar@1/node_modules/bar/B.sol': '', +}, (t, dir) => { + const link = (target, at) => { + mkdirSync(dirname(join(dir, at)), { recursive: true }) + symlinkSync(target, join(dir, at)) + } + link('../../../.env', 'lib/dep/src/Evil.sol') // planted by the dependency: out of it + link('../../forge-std/src', 'lib/dep/src/fs') // into another dependency: fine + link('../../../secrets', 'lib/dep/node_modules/x') // a package slot inside the dependency is still its own + link('../lib/dep/src', 'src/vendor') // the project's link into the dependency + link('../vendor/linked', 'lib/linked') // a linked lib entry: the dependency is where it points + link('../../packages/ws', 'node_modules/@org/ws') // a workspace package: the project's own + link('.pnpm/foo@1/node_modules/foo', 'node_modules/foo') + link('../../bar@1/node_modules/bar', 'node_modules/.pnpm/foo@1/node_modules/bar') + const { of } = solidityOwnership(dir, { dirs: ['lib'] }) + const owner = (p) => { + const o = of(p) + return o.escape ? `escape ${o.escape.link} (${o.escape.root})` : o.dependency ? 'dependency' : 'project' + } + t.assert.equal(owner('lib/dep/src/A.sol'), 'dependency') + t.assert.equal(owner('lib/dep/src/Evil.sol'), 'escape lib/dep/src/Evil.sol (lib/dep)') + t.assert.equal(owner('lib/dep/src/fs/Test.sol'), 'dependency') + t.assert.equal(owner('lib/dep/node_modules/x/Keys.sol'), 'escape lib/dep/node_modules/x (lib/dep)') + t.assert.equal(owner('src/vendor/A.sol'), 'dependency') + // Reached through the project's own link, the dependency's link out is still caught. + t.assert.equal(owner('src/vendor/Evil.sol'), 'escape lib/dep/src/Evil.sol (lib/dep)') + t.assert.equal(owner('lib/linked/src/L.sol'), 'dependency') + t.assert.equal(owner('vendor/linked/src/L.sol'), 'dependency') + t.assert.equal(owner('node_modules/@org/ws/W.sol'), 'project') + t.assert.equal(owner('node_modules/foo/F.sol'), 'dependency') + t.assert.equal(owner('node_modules/.pnpm/foo@1/node_modules/bar/B.sol'), 'dependency') + t.assert.equal(owner('secrets/Keys.sol'), 'project') + t.assert.deepEqual(of('lib/dep/src/Nope.sol'), { real: null, outside: false, dependency: false, escape: null, reason: null }) +})) + +test('solidityOwnership: a link from outside the root back into it is untrusted, unless the root was named through it', async (t) => { + const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'stasis-sol-'))) + try { + const proj = join(tmp, 'proj') + mkdirSync(join(proj, 'lib'), { recursive: true }) + mkdirSync(join(tmp, 'shared/evil/src'), { recursive: true }) + writeFileSync(join(proj, '.env'), 'K=1\n') + writeFileSync(join(proj, 'Own.sol'), '') + symlinkSync('../../shared/evil', join(proj, 'lib/evil')) // the project's link to a dependency elsewhere + symlinkSync('../../../proj/.env', join(tmp, 'shared/evil/src/Evil.sol')) // ...which links back in + t.assert.deepEqual(solidityOwnership(proj, { dirs: ['lib'] }).of('lib/evil/src/Evil.sol').escape, { link: '../shared/evil/src/Evil.sol', root: null }) + // Named through a link (a symlinked checkout), an absolute link through that name is fine. + symlinkSync(proj, join(tmp, 'named')) + symlinkSync(join(tmp, 'named/Own.sol'), join(proj, 'Abs.sol')) + t.assert.deepEqual(solidityOwnership(join(tmp, 'named')).of('Abs.sol'), { real: 'Own.sol', outside: false, dependency: false, escape: null, reason: null }) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('solidityOwnership judges the path as the filesystem spells it (a case-insensitive one)', async (t) => { + // Emulate a case-insensitive filesystem under `tmp`, whose names are lowercase on disk: a host that + // reads every path there lowercased, and whose realpath gives the filesystem's spelling. + const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'stasis-sol-'))) + const lower = (p) => (p.startsWith(tmp) ? tmp + p.slice(tmp.length).toLowerCase() : p) + const host = { ...diskHost, realpath: (p) => realpathSync.native(lower(p)) } + for (const name of ['stat', 'readFile', 'readdir', 'readlink']) host[name] = (p) => diskHost[name](lower(p)) + try { + mkdirSync(join(tmp, 'lib/evil/src'), { recursive: true }) + writeFileSync(join(tmp, '.env'), 'K=1\n') + symlinkSync('../../../.env', join(tmp, 'lib/evil/src/test.sol')) + const { of } = solidityOwnership(tmp, { dirs: ['lib'], host }) + // A dependency's remapping to `../../LIB/evil/src/` names the same link. + for (const p of ['lib/evil/src/test.sol', 'LIB/evil/src/Test.sol', 'Lib/Evil/SRC/TEST.sol']) t.assert.equal(of(p).escape?.root, 'lib/evil', p) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('readGitmodules reads .gitmodules as git does: quotes, escapes, comments, key case, continuations', withProject({ + '.gitmodules': [ + '[submodule "a"]', + '\tpath = "vendor/a" ; a comment', + '\tURL = https://github.com/o/a', + '\tbranch = "v1"', + '[submodule "b"]', + '\tpath = lib/b\\', + 'x', + '\turl = "git@github.com:o/b.git" # comment', + // A key may follow its section header on the line. + '[submodule "d"] path = vendor/d', + '\turl = https://github.com/o/d', + // A url relative to the superproject's remote, and none: git reads both, and so does stasis. + '[submodule "e"]', + '\tpath = lib/e', + '\turl = ../e.git', + '[submodule "f"]', + '\tpath = lib/f', + '', + ].join('\n'), +}, (t, dir) => { + t.assert.deepEqual(readGitmodules(dir), [ + { path: 'vendor/a', url: 'https://github.com/o/a', branch: 'v1' }, + { path: 'lib/bx', url: 'git@github.com:o/b.git', branch: undefined }, + { path: 'vendor/d', url: 'https://github.com/o/d', branch: undefined }, + { path: 'lib/e', url: '../e.git', branch: undefined }, + { path: 'lib/f', url: undefined, branch: undefined }, + ]) + t.assert.deepEqual(readGitmodules(join(dir, 'none')), []) +})) + +test('readGitmodules reads what the library refuses submodule by submodule, warning what it drops', withProject({}, (t, dir) => { + const read = (text) => { + writeFileSync(join(dir, '.gitmodules'), text) + const warnings = [] + const warn = console.warn + console.warn = (line) => warnings.push(line.replace('[loader.solidity] .gitmodules: ', '')) + try { + return { submodules: readGitmodules(dir), warnings } + } finally { + console.warn = warn + } + } + const x = '[submodule "x"]\n\tpath = lib/x\n\turl = https://github.com/o/x\n' + const lenient = 'reading it submodule by submodule' + for (const [text, submodules, warnings] of [ + // What git reads but the library doesn't check: kept, bar the keys stasis doesn't use. + [`${x}\tupdate = none\n\tactive = true\n`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], [`x: unsupported field "active"; ${lenient}`]], + [`[core]\n\tbare = false\n[include]\n\tpath = more\n${x}`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], [`a section of [core] where .gitmodules has [submodule "name"] alone, at line 1; ${lenient}`]], + // What git reads two ways: the first, as git's submodule commands read it. + [`${x}\turl = https://github.com/o/y\n[submodule "x"]\n\tbranch = main\n`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: 'main' }], [`x.url: twice, of which git's submodule commands read the first and git config the last, at line 4; ${lenient}`]], + // A branch or url that doesn't read is dropped. A path that doesn't fails closed: its directory, + // inside the repository, is still a dependency (unnamed); one outside it, the submodule is dropped. + [`${x}\tbranch = "v1 x"\n`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], ['x.branch: "v1 x" is not a branch or tag name git takes; ignoring its branch']], + ['[submodule "x"]\n\tpath = lib/x\n\turl = -oProxy=x\n', [{ path: 'lib/x', url: undefined, branch: undefined }], ['x.url: "-oProxy=x" starts with "-", which git ignores the url for; ignoring its url']], + [`[submodule "y"]\n\tpath = "./lib/y/" # vendored\n${x}`, [{ path: 'lib/y', url: undefined, branch: undefined }, { path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], ['y.path: "./lib/y/" is not a relative path in normal form; still taking lib/y as a dependency, unnamed']], + [`[submodule "y"]\n\tpath = ../y\n${x}`, [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], ['y.path: "../y" is outside the repository, where git writes no submodule; skipping the submodule']], + // A [submodule.Y] is read as git reads it: [submodule "y"]. + [`[submodule.Y]\n\tpath = lib/y\n${x}`, [{ path: 'lib/y', url: undefined, branch: undefined }, { path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }], [ + `a section of the form [submodule.name], whose name git lowercases, where .gitmodules has [submodule "name"] alone, at line 1; ${lenient}`, + '[submodule.Y], a section git reads as [submodule "y"]; reading it as that', + ]], + ]) { + t.assert.deepEqual(read(text), { submodules, warnings }, text) + } +})) + +test('readGitmodules refuses a .gitmodules git refuses, rather than read past what git can\'t', withProject({}, (t, dir) => { + const x = '\n\tpath = lib/x\n' + for (const [text, what, line] of [ + // A header git doesn't read: past it, a submodule's keys would be lost, or taken for another's. + [`[submodule.lib/x]${x}`, "a character git doesn't take in a section name", 1], + [`[submodule "x"${x}`, 'a subsection with no "]" right after it', 1], + [`[submodule "x" ]${x}`, 'a subsection with no "]" right after it', 1], + [`[submodule x]${x}`, 'a section name and then no quoted subsection', 1], + [`[submodule "y"]\n\tpath = lib/y\n\tupdate = none\n[submodule x]${x}`, 'a section name and then no quoted subsection', 4], + [`[submodule\n"x"]${x}`, 'a section header that runs past its line', 1], + [`[submodule "x${x}`, 'a subsection with no closing quote', 1], + ['[]\n\tpath = lib/x\n', 'a section with no name', 1], + ['[submodule', 'a section header with no closing "]"', 1], + // A key, value or line git doesn't read. + ['[submodule "x"]\n\tpath # lib/x\n', 'a key and then neither "=" nor the end of its line', 2], + ['[submodule "x"]\n\tpath = "lib/x\n\turl = https://github.com/o/x\n', 'a value with no closing quote', 2], + ['[submodule "x"]\n\tpath = lib\\x\n', "an escape git doesn't read", 2], + ['[submodule "x"]\n\t./path = lib/x\n', 'text where git reads a key, a section or a comment', 2], + ]) { + writeFileSync(join(dir, '.gitmodules'), text) + t.assert.throws(() => readGitmodules(dir), { message: `.gitmodules: ${what} at line ${line}; git refuses such a file` }, text) + } + // What git reads, oddly, the library refuses and stasis reads as git does: sections of one name + // however it's escaped, merged, and a comment's `\` running nothing on. + writeFileSync(join(dir, '.gitmodules'), '[submodule "a\\x"]\n\tpath = lib/x # a comment \\\n[submodule "ax"]\n\turl = https://github.com/o/x\n\tupdate = none\n') + const warn = console.warn + console.warn = () => {} + try { + t.assert.deepEqual(readGitmodules(dir), [{ path: 'lib/x', url: 'https://github.com/o/x', branch: undefined }]) + } finally { + console.warn = warn + } +})) + +test('a remappings.txt taken as written (solc) may map a prefix to nothing', (t) => { + const remappings = parseRemappings('x/=\nctx:y/=\n') + t.assert.deepEqual(remappings, [{ context: null, prefix: 'x/', target: '' }, { context: 'ctx', prefix: 'y/', target: '' }]) + t.assert.equal(resolveSolImport('x/A.sol', 'src/B.sol', { remappings }), 'A.sol') +}) + +test('an invalid remapping in a foundry.toml or remappings variable is an error, naming where it is', withProject({ + 'foundry.toml': '[profile.default]\nremappings = ["a/=b/", "nope"]\n', + 'list/foundry.toml': '[profile.default]\nremappings = "a/=b/"\n', + 'num/foundry.toml': '[profile.default]\nremappings = [1]\n', + 'ok/foundry.toml': '[profile.default]\n', +}, (t, dir) => { + t.assert.throws(() => foundryProject(dir, { env: {} }), { message: 'foundry.toml: `remappings`: invalid remapping "nope"' }) + t.assert.throws(() => foundryProject(join(dir, 'list'), { env: {} }), { message: 'foundry.toml: `remappings` is not an array of strings' }) + t.assert.throws(() => foundryProject(join(dir, 'num'), { env: {} }), { message: 'foundry.toml: `remappings`: invalid remapping 1' }) + t.assert.throws(() => foundryProject(join(dir, 'ok'), { env: { FOUNDRY_REMAPPINGS: 'x/=y/\nbad' } }), { message: 'FOUNDRY_REMAPPINGS:2: invalid remapping "bad"' }) + t.assert.throws(() => foundryTomlRemappings('[profile.default]\nremappings = ["=x/"]\n'), { message: '`remappings`: invalid remapping "=x/"' }) +})) + +test('a legacy [default] table\'s `extends` is ignored, as forge ignores it', withProject({ + 'foundry.toml': '[default]\nextends = "base.toml"\n', + 'base.toml': '[profile.default]\nremappings = ["x/=lib/elsewhere/"]\n', +}, (t, dir) => { + const { remappings, files } = foundryProject(dir, { env: {} }) + t.assert.deepEqual(files, ['foundry.toml']) + t.assert.deepEqual(remappings, []) +})) + +test('discoverSolidityConfig with a mapping file: a foundry.toml forge rejects still gives lib dirs, and FOUNDRY_PROFILE is reported when it picks them', withProject({ + 'foundry.toml': '[profile.default]\nextends = "missing.toml"\n', + 'remappings.txt': 'x/=lib/x/\n', + 'ci/foundry.toml': '[profile.default]\n[profile.ci]\nlibs = ["deps"]\n', + 'ci/remappings.txt': 'x/=deps/x/\n', +}, async (t, dir) => { + const warn = console.warn + const lines = [] + console.warn = (...a) => lines.push(a.join(' ')) + const discover = (sub, env) => discoverSolidityConfig(join(dir, sub), { mappingFile: 'remappings.txt', env }) + try { + const root = await discover('.', {}) + t.assert.deepEqual([root.libs, root.envUsed], [['lib'], []]) + t.assert.ok(lines.some((l) => l.includes('Using the default lib dirs') && l.includes('missing.toml'))) + const ci = await discover('ci', { FOUNDRY_PROFILE: 'ci' }) + t.assert.deepEqual([ci.libs, ci.envUsed], [['deps'], ['FOUNDRY_PROFILE=ci']]) + // A profile foundry.toml doesn't have picks nothing: said, and not reported as shaping the result. + lines.length = 0 + const nope = await discover('ci', { FOUNDRY_PROFILE: 'nope' }) + t.assert.deepEqual([nope.libs, nope.envUsed], [['lib'], []]) + t.assert.deepEqual(lines, ['[loader.solidity] FOUNDRY_PROFILE=nope is not a profile in foundry.toml; using [profile.default]']) + } finally { + console.warn = warn + } +})) + test('resolveSolImport starts a library lookup at the importer directory\'s parent, as foundry-compilers does', withProject({ 'lib/dep/src/utils/C.sol': '', 'lib/dep/src/utils/src/B.sol': '', diff --git a/tests/vfs-bundle-host.test.js b/tests/vfs-bundle-host.test.js index 81159a39..8f1262e8 100644 --- a/tests/vfs-bundle-host.test.js +++ b/tests/vfs-bundle-host.test.js @@ -214,3 +214,27 @@ test('the disk host resolves exactly like require.resolve, including through sym t.assert.equal(diskHost.resolve(join(tmp, 'main.cjs'), './link/z', new Set(['require'])), join(tmp, 'real', 'z.js')) t.assert.equal(createNodeResolver(diskHost).resolve(join(tmp, 'main.cjs'), './link/z', new Set(['require'])), join(tmp, 'real', 'z.js')) })) + +test('a Solidity bundle read through a Vfs host holds a dependency to its own files, as on disk', async (t) => { + const { buildSolidityBundle } = await import('../stasis/src/cmd/bundle.js') + const vfs = write(new Vfs(), { + '/foundry.toml': '[profile.default]\n', + '/.env': 'PRIVATE_KEY=0xabc\n', + '/src/A.sol': 'import "evil/E.sol";\n', + '/lib/evil/src/E.sol': 'import "./Evil.sol";\n', + }) + // A link the dependency planted out of itself, to the project's .env. + vfs.symlink('../../../.env', '/lib/evil/src/Evil.sol') + const host = createVfsHost(vfs) + const build = () => buildSolidityBundle({ cwd: '/', entries: ['src'], env: {}, host }) + const warn = console.warn + console.warn = () => {} + try { + await t.assert.rejects(build, (err) => err.message.includes('refused: lib/evil/src/Evil.sol is a link out of the dependency lib/evil')) + vfs.unlink('/lib/evil/src/Evil.sol') + vfs.writeFile('/lib/evil/src/Evil.sol', 'contract Evil {}\n') + t.assert.deepEqual([...(await build()).sources.keys()].toSorted(), ['lib/evil/src/E.sol', 'lib/evil/src/Evil.sol', 'src/A.sol']) + } finally { + console.warn = warn + } +})