Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cmd/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin
const normalized = normalizeEntries(entries, cwd)

const sources = await collectSolidityFilesFromDisk(baseDir, normalized, remappings)
const { resolutions, missing } = buildSolidityTree(sources, { remappings })
const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir })

// Bundles must be self-contained. Refuse to write one when an entry
// can't be loaded from disk, or when any in-bundle file has an import
Expand Down
94 changes: 45 additions & 49 deletions src/loaders/solidity.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// but is itself not included in `sources`.

import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'

const SOL_IMPORT_RE = /import\s[^"']*["']([^"']+)["']/gu
Expand All @@ -28,16 +29,16 @@ export function parseRemappings(content) {
}).filter(Boolean)
}

// Returns a candidate resolved path, or null if the specifier doesn't match
// any remapping and isn't a relative import. The caller is responsible for
// checking whether the resolved path actually exists in `sources` (the
// etherscan bundle's cnames are exact-match keys — `@openzeppelin/…` lives
// alongside `contracts/Foo.sol` — so a non-relative, non-remapped specifier
// may still be a hit when matched verbatim against the bundle). Logging
// "Missing import" also lives at the callsite because only the callsite
// knows whether that verbatim fallback matched.
export function resolveSolImport(specifier, fromFile, remappings) {
// Try remappings (longest prefix match)
// Resolve a Solidity import specifier to a baseDir-relative POSIX path,
// trying three strategies in order:
// 1. user remappings (longest prefix wins)
// 2. relative path (`./...` or `../...`); traversal above the project
// root returns null instead of clamping
// 3. Node's CJS resolver via createRequire, anchored at the importing
// source — only when `baseDir` is provided and the specifier looks
// like a scoped npm package (`@scope/pkg/sub/path`)
// Returns null when no strategy resolves the import.
export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir } = {}) {
let best = null
for (const { prefix, target } of remappings) {
if (specifier.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) {
Expand All @@ -47,13 +48,9 @@ export function resolveSolImport(specifier, fromFile, remappings) {
if (best) {
const target = best.target.replace(/\/$/u, '')
const remaining = specifier.slice(best.prefix.length).replace(/^\//u, '')
const result = remaining ? `${target}/${remaining}` : target
return result.replace(/^\.\//u, '')
return (remaining ? `${target}/${remaining}` : target).replace(/^\.\//u, '')
}

// Relative import. `..` segments that would traverse above the project
// root return null instead of silently clamping to root — clamping would
// change the import target into something the source never asked for.
if (specifier.startsWith('.')) {
const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
const parts = [...(fromDir ? fromDir.split('/') : []), ...specifier.split('/')]
Expand All @@ -70,30 +67,41 @@ export function resolveSolImport(specifier, fromFile, remappings) {
return resolved.join('/')
}

// Node-style `@scope/pkg/sub/path`. createRequire is anchored at the
// importing source so nested node_modules resolve like Node would
// (a file under `node_modules/foo/` finds its deps in
// `foo/node_modules/` before walking up). `..` segments in the
// subpath are rejected so a crafted specifier can't escape the
// resolved package via require.resolve's own path normalization.
if (baseDir && specifier.startsWith('@')) {
const parts = specifier.split('/')
if (parts.length < 3) return null
if (parts.slice(2).includes('..')) return null
try {
const abs = createRequire(resolve(baseDir, fromFile)).resolve(specifier)
const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
if (!rel.startsWith('..') && !isAbsolute(rel)) return rel
} catch { /* package missing, exports map blocks the subpath, … */ }
}

return null
}

// Build the { sources, resolutions, missing } triple from a Map of
// already-loaded Solidity sources (any key format — relative paths for
// disk-loaded projects, `${address}/${cname}` for etherscan, etc.) plus
// optional remappings. Imports that resolve via remappings/relative paths
// win; as a fallback we also accept an import specifier that matches a
// stored key verbatim (etherscan bundles imports like
// `@openzeppelin/contracts/.../IERC20.sol` which is itself a stored
// cname). `missing` lists every (spec, from) pair that could not be
// resolved or that resolved to a file that wasn't loaded into `sources`.
export function buildSolidityTree(sources, { remappings = [] } = {}) {
// already-loaded Solidity sources plus optional remappings. Imports that
// resolve via remappings/relative/Node win; as a final fallback an
// import specifier that matches a stored key verbatim (etherscan
// bundles, hardhat flat layouts) is accepted. `missing` lists every
// (spec, from) pair that could not be resolved or that resolved to a
// file not loaded into `sources`.
export function buildSolidityTree(sources, { remappings = [], baseDir } = {}) {
const resolutions = new Map()
const missing = []
for (const [path, content] of sources) {
const specMap = new Map()
for (const spec of extractSolImports(content)) {
let resolved = resolveSolImport(spec, path, remappings)
let resolved = resolveSolImport(spec, path, { remappings, baseDir })
if (resolved && !sources.has(resolved)) resolved = null
// Verbatim fallback: many Solidity ecosystems (etherscan bundles,
// hardhat flat layouts) ship `@openzeppelin/...` imports as literal
// keys in the source map. If the remap/relative resolution didn't
// hit but the bundle has the specifier itself as a key, use it.
if (!resolved && sources.has(spec)) resolved = spec
if (resolved) {
specMap.set(spec, resolved)
Expand All @@ -107,31 +115,19 @@ export function buildSolidityTree(sources, { remappings = [] } = {}) {
return { sources, resolutions, missing }
}

// Walk the filesystem starting from `entries`, following relative/remapped
// imports and reading each file exactly once. Returns a Map<relPath, content>
// suitable for passing into buildSolidityTree. Files in the same wave are
// read in parallel; subsequent waves depend on the imports discovered in
// the previous one.
//
// Specifiers that exactly match one of the explicit entries (the file
// list passed by the caller) are accepted as-is, even when they don't
// match any remapping. Solidity projects routinely write
// `import "src/A.sol"` and rely on solc's include-path / Foundry's
// remap-from-root behavior; here we don't have a true include path,
// but if the caller listed `src/A.sol` themselves we know the path is
// intentional and resolves to that file. Mirrors the `sources.has(spec)`
// fallback in `buildSolidityTree`, but applied during the walk so the
// file actually gets loaded into `sources`.
// Walk the filesystem starting from `entries`, following resolved
// imports and reading each file exactly once. Files in the same wave
// are read in parallel; the next wave depends on the imports
// discovered in the previous one. Caller-listed entries are also
// accepted as verbatim non-relative import targets (Foundry-style
// `import "src/A.sol"`).
export async function collectSolidityFilesFromDisk(baseDir, entries, remappings) {
const sources = new Map()
const knownEntries = new Set(entries)

const processWave = async (wave) => {
const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
if (toLoad.length === 0) return
// A resolved path that doesn't exist on disk is treated like an
// unresolved import: warn and skip rather than aborting the entire
// walk. Other read errors (EACCES, EIO, …) still propagate.
const reads = await Promise.all(
toLoad.map(async (relPath) => {
try {
Expand All @@ -151,7 +147,7 @@ export async function collectSolidityFilesFromDisk(baseDir, entries, remappings)
const [relPath, content] = entry
sources.set(relPath, content)
for (const spec of extractSolImports(content)) {
let resolved = resolveSolImport(spec, relPath, remappings)
let resolved = resolveSolImport(spec, relPath, { remappings, baseDir })
if (!resolved && knownEntries.has(spec)) resolved = spec
if (resolved) {
if (!sources.has(resolved)) next.push(resolved)
Expand Down Expand Up @@ -210,5 +206,5 @@ export async function loadSolidity(solTxtFile) {
const entries = lines.map((l) => l.replace(/^\.\//u, ''))
for (const e of entries) assertWithinBase(baseDir, e, 'Entry path')
const sources = await collectSolidityFilesFromDisk(baseDir, entries, remappings)
return buildSolidityTree(sources, { remappings })
return buildSolidityTree(sources, { remappings, baseDir })
}
36 changes: 36 additions & 0 deletions tests/bundle-cmd.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,42 @@ test('buildSolidityBundle places node_modules files in per-package modules bucke
)
})

test('buildSolidityBundle resolves @-scoped imports via node_modules with no mapping file', async (t) => {
// No --mapping passed: @oz/contracts/utils/Math.sol must fall back to
// node_modules/@oz/contracts/utils/Math.sol on disk.
const cwd = join(fixtures, 'nm-fallback')
const bundle = await buildSolidityBundle({ cwd, entries: ['src/A.sol'] })
t.assert.deepEqual(
[...bundle.modules.keys()].toSorted(),
['.', 'node_modules/@oz/contracts'],
)
t.assert.equal(bundle.modules.get('node_modules/@oz/contracts').name, '@oz/contracts')
t.assert.equal(bundle.modules.get('node_modules/@oz/contracts').version, '5.0.0')
t.assert.equal(
bundle.imports.get('solidity').get('src/A.sol').get('@oz/contracts/utils/Math.sol'),
'node_modules/@oz/contracts/utils/Math.sol',
)
})

test('buildSolidityBundle resolves nested node_modules from the importing source (Node-style walk)', async (t) => {
// src/A.sol imports @dep/x/Y.sol → resolves to node_modules/@dep/x/Y.sol.
// Y.sol then imports @inner/z/Z.sol — resolution anchored at Y.sol must
// find the nested node_modules/@dep/x/node_modules/@inner/z/Z.sol, not
// walk back to a sibling at node_modules/@inner/z (which doesn't exist).
const cwd = join(fixtures, 'nm-nested')
const bundle = await buildSolidityBundle({ cwd, entries: ['src/A.sol'] })
t.assert.deepEqual(
[...bundle.modules.keys()].toSorted(),
['.', 'node_modules/@dep/x', 'node_modules/@dep/x/node_modules/@inner/z'],
)
t.assert.equal(bundle.modules.get('node_modules/@dep/x/node_modules/@inner/z').name, '@inner/z')
t.assert.equal(bundle.modules.get('node_modules/@dep/x/node_modules/@inner/z').version, '2.0.0')
t.assert.equal(
bundle.imports.get('solidity').get('node_modules/@dep/x/Y.sol').get('@inner/z/Z.sol'),
'node_modules/@dep/x/node_modules/@inner/z/Z.sol',
)
})

test('buildSolidityBundle throws when a node_modules file has no resolvable package.json', async (t) => {
await t.assert.rejects(
() => buildSolidityBundle({
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions tests/fixtures/solidity-bundle/nm-fallback/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "my-app",
"version": "0.1.0"
}
10 changes: 10 additions & 0 deletions tests/fixtures/solidity-bundle/nm-fallback/src/A.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@oz/contracts/utils/Math.sol";

contract A {
function foo() public pure returns (uint256) {
return Math.max(1, 2);
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions tests/fixtures/solidity-bundle/nm-nested/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "my-app",
"version": "0.1.0"
}
6 changes: 6 additions & 0 deletions tests/fixtures/solidity-bundle/nm-nested/src/A.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@dep/x/Y.sol";

contract A {}
69 changes: 61 additions & 8 deletions tests/solidity-loader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ test('parseRemappingsFromToml returns [] when no remappings key present', (t) =>
})

test('resolveSolImport resolves relative imports against the source file', (t) => {
t.assert.equal(resolveSolImport('./B.sol', 'src/A.sol', []), 'src/B.sol')
t.assert.equal(resolveSolImport('../lib/C.sol', 'src/sub/A.sol', []), 'src/lib/C.sol')
t.assert.equal(resolveSolImport('./B.sol', 'src/A.sol'), 'src/B.sol')
t.assert.equal(resolveSolImport('../lib/C.sol', 'src/sub/A.sol'), 'src/lib/C.sol')
})

test('resolveSolImport picks the longest remapping prefix', (t) => {
Expand All @@ -80,21 +80,74 @@ test('resolveSolImport picks the longest remapping prefix', (t) => {
{ prefix: '@oz/contracts/', target: 'lib/oz-c/' },
]
t.assert.equal(
resolveSolImport('@oz/contracts/utils/Math.sol', 'src/A.sol', remappings),
resolveSolImport('@oz/contracts/utils/Math.sol', 'src/A.sol', { remappings }),
'lib/oz-c/utils/Math.sol',
)
t.assert.equal(resolveSolImport('@oz/other.sol', 'src/A.sol', remappings), 'lib/oz/other.sol')
t.assert.equal(
resolveSolImport('@oz/other.sol', 'src/A.sol', { remappings }),
'lib/oz/other.sol',
)
})

test('resolveSolImport returns null for non-relative, non-remapped imports', (t) => {
t.assert.equal(resolveSolImport('@unknown/Foo.sol', 'src/A.sol', []), null)
test('resolveSolImport returns null for non-relative, non-remapped imports without baseDir', (t) => {
// Without baseDir the Node-style strategy is disabled, so anything
// not covered by remappings or relative paths is null.
t.assert.equal(resolveSolImport('@unknown/Foo.sol', 'src/A.sol'), null)
t.assert.equal(resolveSolImport('foo/X.sol', 'src/A.sol'), null)
})

test('resolveSolImport returns null when relative traversal escapes the root', (t) => {
// Going above the project root must not silently clamp to root — that
// would change the import target into a different file altogether.
t.assert.equal(resolveSolImport('../X.sol', 'A.sol', []), null)
t.assert.equal(resolveSolImport('../../X.sol', 'src/A.sol', []), null)
t.assert.equal(resolveSolImport('../X.sol', 'A.sol'), null)
t.assert.equal(resolveSolImport('../../X.sol', 'src/A.sol'), null)
})

test('resolveSolImport uses Node-style resolution for @-scoped imports when baseDir is given', (t) => {
const baseDir = join(fixtures, 'nm-fallback')
t.assert.equal(
resolveSolImport('@oz/contracts/utils/Math.sol', 'src/A.sol', { baseDir }),
'node_modules/@oz/contracts/utils/Math.sol',
)
})

test('resolveSolImport does not invoke Node-style resolution for unscoped specifiers', (t) => {
const baseDir = join(fixtures, 'nm-fallback')
t.assert.equal(resolveSolImport('foo/X.sol', 'src/A.sol', { baseDir }), null)
t.assert.equal(resolveSolImport('X.sol', 'src/A.sol', { baseDir }), null)
})

test('resolveSolImport returns null for `@scope/pkg` with no file subpath', (t) => {
const baseDir = join(fixtures, 'nm-fallback')
t.assert.equal(resolveSolImport('@oz/contracts', 'src/A.sol', { baseDir }), null)
})

test('resolveSolImport rejects `..` in a node-resolved subpath (path-traversal guard)', (t) => {
const baseDir = join(fixtures, 'nm-fallback')
t.assert.equal(
resolveSolImport('@oz/contracts/../../etc/passwd', 'src/A.sol', { baseDir }),
null,
)
t.assert.equal(
resolveSolImport('@oz/contracts/utils/../Math.sol', 'src/A.sol', { baseDir }),
null,
)
})

test('resolveSolImport returns null when the node-resolved package is not installed', (t) => {
const baseDir = join(fixtures, 'nm-fallback')
t.assert.equal(resolveSolImport('@absent/nope/X.sol', 'src/A.sol', { baseDir }), null)
})

test('resolveSolImport prefers a matching remapping over the Node-style fallback', (t) => {
const baseDir = join(fixtures, 'nm-fallback')
t.assert.equal(
resolveSolImport('@oz/contracts/utils/Math.sol', 'src/A.sol', {
baseDir,
remappings: [{ prefix: '@oz/', target: 'lib/oz/' }],
}),
'lib/oz/contracts/utils/Math.sol',
)
})

test('collectSolidityFilesFromDisk walks imports starting from entries', async (t) => {
Expand Down