@exodus/stasis 1.0.0-beta.3 → 1.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/bin/stasis.js +253 -22
- package/package.json +42 -10
- package/src/audit-corrections.js +72 -0
- package/src/audit.js +181 -112
- package/src/bundle-from-lockfile.js +3 -5
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +3 -5
- package/src/cmd/bundle.js +677 -402
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +3 -11
- package/src/diff.js +22 -14
- package/src/json-stream.js +283 -0
- package/src/loaders/bash.js +17 -36
- package/src/loaders/cargo.js +1716 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +212 -127
- package/src/loaders/rust.js +3234 -166
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -137
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +9 -24
- package/src/mock.js +3 -5
- package/src/resolve-fields.js +338 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +541 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +35 -19
- package/src/scan.js +211 -90
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +199 -0
- package/src/vfs-bundle/prisma.js +439 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +613 -0
- package/src/vfs-bundle.js +75 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -42
package/src/loaders/solidity.js
CHANGED
|
@@ -1,116 +1,354 @@
|
|
|
1
1
|
// Based on DeepView's Solidity loader.
|
|
2
2
|
// https://github.com/PreventiveMeasures/deepview/blob/main/src/loaders/solidity.js
|
|
3
|
-
// Produces a `{ sources, resolutions }` pair
|
|
4
|
-
// remappings
|
|
3
|
+
// Produces a `{ sources, resolutions }` pair. Imports resolve the way solc does under the project's
|
|
4
|
+
// build tool: remappings (discovered the way `forge build` does for a Foundry project, see
|
|
5
|
+
// foundry.js), then a Foundry library's include path, solc's base path (the project root), and
|
|
6
|
+
// Hardhat's/Node's node_modules lookup. The mapping/config files are read, not added to `sources`.
|
|
7
|
+
// Dependencies are untrusted input: an import only ever reaches a `.sol` file inside the project,
|
|
8
|
+
// a dependency's imports only its own and other dependencies' files (by real path), and nothing
|
|
9
|
+
// is read through a link a dependency planted out of itself (solidityOwnership). The project is read
|
|
10
|
+
// through a `host` (@exodus/stasis-core/host), the disk's by default.
|
|
5
11
|
|
|
6
|
-
import { realpathSync, statSync } from 'node:fs'
|
|
7
12
|
import { readFile } from 'node:fs/promises'
|
|
8
|
-
import {
|
|
9
|
-
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
13
|
+
import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path'
|
|
10
14
|
|
|
11
|
-
import {
|
|
15
|
+
import { diskHost } from '@exodus/stasis-core/host'
|
|
16
|
+
import { assertRealPathWithinBase, relativeEscapes, toPosix } from '@exodus/stasis-core/util'
|
|
17
|
+
import { isDir, isFile } from '../resolve-typescript.js'
|
|
18
|
+
import {
|
|
19
|
+
FOUNDRY_TOML,
|
|
20
|
+
REMAPPINGS_TXT,
|
|
21
|
+
foundryLibs,
|
|
22
|
+
foundryProfile,
|
|
23
|
+
foundryProject,
|
|
24
|
+
foundryTomlRemappings,
|
|
25
|
+
parseRemappingLines,
|
|
26
|
+
readFoundryTomlRemappings,
|
|
27
|
+
shownFrom,
|
|
28
|
+
toSolcRemapping,
|
|
29
|
+
} from './foundry.js'
|
|
30
|
+
import { decodeUtf8, projectOwnership, projectRelative, readUtf8OrNull, realpathOrNull, solidityOwnership } from './solidity-ownership.js'
|
|
31
|
+
import { assertWithinBase } from './paths.js'
|
|
12
32
|
|
|
13
|
-
|
|
33
|
+
// --- Import scan ------------------------------------------------------------------------------
|
|
14
34
|
|
|
15
|
-
|
|
16
|
-
|
|
35
|
+
// The scan's ASCII classes, by char code: identifier start [A-Za-z_$], identifier part [\w$], digit,
|
|
36
|
+
// and a number literal's [\w.].
|
|
37
|
+
const isIdentStart = (c) => (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || c === 95 || c === 36
|
|
38
|
+
const isDigit = (c) => c >= 48 && c <= 57
|
|
39
|
+
const isIdentPart = (c) => isIdentStart(c) || isDigit(c)
|
|
40
|
+
const isNumberPart = (c) => (isIdentPart(c) && c !== 36) || c === 46
|
|
41
|
+
const STRING_ESCAPES = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', 0: '\0' }
|
|
42
|
+
|
|
43
|
+
const utf8 = (s) => [...Buffer.from(s, 'utf8')]
|
|
44
|
+
|
|
45
|
+
// A string literal starting at `text[i]` (a quote): `{ value, end }`, `value` null when it's
|
|
46
|
+
// unterminated on its line (solc rejects those). A literal is bytes, as in solc: `\xNN` is one
|
|
47
|
+
// byte, `\uNNNN` and plain text their UTF-8, and the path is those bytes read as UTF-8.
|
|
48
|
+
function readStringLiteral(text, i) {
|
|
49
|
+
const quote = text.charCodeAt(i)
|
|
50
|
+
let bytes = null // set at the first escape; until then the literal is a plain slice
|
|
51
|
+
let start = i + 1
|
|
52
|
+
let j = start
|
|
53
|
+
while (j < text.length) {
|
|
54
|
+
const c = text.charCodeAt(j)
|
|
55
|
+
if (c === quote) {
|
|
56
|
+
if (bytes === null) return { value: text.slice(start, j), end: j + 1 }
|
|
57
|
+
bytes.push(...utf8(text.slice(start, j)))
|
|
58
|
+
return { value: Buffer.from(bytes).toString('utf8'), end: j + 1 }
|
|
59
|
+
}
|
|
60
|
+
if (c === 10 || c === 13) return { value: null, end: j }
|
|
61
|
+
if (c !== 92) {
|
|
62
|
+
j++
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
bytes ??= []
|
|
66
|
+
bytes.push(...utf8(text.slice(start, j)))
|
|
67
|
+
const next = text[j + 1]
|
|
68
|
+
if (next === 'x' && /^[\da-f]{2}$/iu.test(text.slice(j + 2, j + 4))) {
|
|
69
|
+
bytes.push(Number.parseInt(text.slice(j + 2, j + 4), 16))
|
|
70
|
+
j += 4
|
|
71
|
+
} else if (next === 'u' && /^[\da-f]{4}$/iu.test(text.slice(j + 2, j + 6))) {
|
|
72
|
+
bytes.push(...utf8(String.fromCodePoint(Number.parseInt(text.slice(j + 2, j + 6), 16))))
|
|
73
|
+
j += 6
|
|
74
|
+
} else if (next === '\r' && text[j + 2] === '\n') {
|
|
75
|
+
j += 3 // line continuation
|
|
76
|
+
} else if (next === '\n' || next === '\r') {
|
|
77
|
+
j += 2
|
|
78
|
+
} else if (next === undefined) {
|
|
79
|
+
return { value: null, end: j + 1 }
|
|
80
|
+
} else {
|
|
81
|
+
bytes.push(...utf8(STRING_ESCAPES[next] ?? next))
|
|
82
|
+
j += 2
|
|
83
|
+
}
|
|
84
|
+
start = j
|
|
85
|
+
}
|
|
86
|
+
return { value: null, end: j }
|
|
17
87
|
}
|
|
18
88
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
89
|
+
// The path of every import directive, in source order. The text is tokenized far enough to skip
|
|
90
|
+
// comments and string literals, so a commented-out `// import "./Old.sol";` or a string holding
|
|
91
|
+
// the word `import` is never taken for one. An import is the `import` keyword followed, before its
|
|
92
|
+
// `;`, by the path literal: `import "p";`, `import "p" as X;`, `import * as X from "p";`,
|
|
93
|
+
// `import {A, B as C} from "p";` -- over any number of lines.
|
|
94
|
+
export function extractSolImports(content) {
|
|
95
|
+
const specs = []
|
|
96
|
+
const n = content.length
|
|
97
|
+
let inImport = false
|
|
98
|
+
let i = 0
|
|
99
|
+
while (i < n) {
|
|
100
|
+
const c = content.charCodeAt(i)
|
|
101
|
+
const next = content.charCodeAt(i + 1)
|
|
102
|
+
if (c === 47 && next === 47) { // `//`, to the end of the line (`\n` or `\r`, as solc ends it)
|
|
103
|
+
let eol = i + 2
|
|
104
|
+
while (eol < n && content.charCodeAt(eol) !== 10 && content.charCodeAt(eol) !== 13) eol++
|
|
105
|
+
i = eol
|
|
106
|
+
} else if (c === 47 && next === 42) { // `/*`
|
|
107
|
+
const close = content.indexOf('*/', i + 2)
|
|
108
|
+
i = close === -1 ? n : close + 2
|
|
109
|
+
} else if (c === 34 || c === 39) { // `"` or `'`
|
|
110
|
+
const { value, end } = readStringLiteral(content, i)
|
|
111
|
+
// An unterminated literal ends the import too (solc rejects the file).
|
|
112
|
+
if (inImport && value !== null) specs.push(value)
|
|
113
|
+
inImport = false
|
|
114
|
+
i = end
|
|
115
|
+
} else if (isIdentStart(c)) {
|
|
116
|
+
let j = i + 1
|
|
117
|
+
while (j < n && isIdentPart(content.charCodeAt(j))) j++
|
|
118
|
+
if (j - i === 6 && content.startsWith('import', i)) inImport = true
|
|
119
|
+
i = j
|
|
120
|
+
} else if (isDigit(c)) {
|
|
121
|
+
// A number literal (`0x1f`, `1e18`, `1_000`): its letters aren't identifiers.
|
|
122
|
+
let j = i + 1
|
|
123
|
+
while (j < n && isNumberPart(content.charCodeAt(j))) j++
|
|
124
|
+
i = j
|
|
125
|
+
} else {
|
|
126
|
+
if (c === 59) inImport = false // `;`
|
|
127
|
+
i++
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return specs
|
|
23
131
|
}
|
|
24
132
|
|
|
133
|
+
// --- Remappings ---------------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
// Loader-side shape: `{ context, prefix, target }` (context null = global).
|
|
136
|
+
const toLoaderRemapping = ({ context, name, path }) => ({ context, prefix: name, target: path })
|
|
137
|
+
|
|
138
|
+
// remappings.txt text -> remappings as written, one `[context:]prefix=target` per line (lines
|
|
139
|
+
// trimmed, blank ones skipped; an empty target is solc's, valid). A line that isn't one throws.
|
|
25
140
|
export function parseRemappings(content) {
|
|
26
|
-
return content
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
// (
|
|
37
|
-
//
|
|
38
|
-
|
|
141
|
+
return parseRemappingLines(content, { emptyPath: true }).map(toLoaderRemapping)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// foundry.toml text -> the `remappings` of `[profile.default]`, overlaid by the selected profile's
|
|
145
|
+
// (FOUNDRY_PROFILE in `env`) when it sets them.
|
|
146
|
+
export function parseRemappingsFromToml(tomlContent, { env = process.env } = {}) {
|
|
147
|
+
return foundryTomlRemappings(tomlContent, foundryProfile(env)).map(toLoaderRemapping)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Read a mapping file -> its remappings as listed (no discovery around it) and the files read. A
|
|
151
|
+
// foundry.toml (its selected profile, with its `extends` base) is forge's, and so is a
|
|
152
|
+
// remappings.txt when `forge` says forge reads it: slash-terminated the way forge reads them.
|
|
153
|
+
// Otherwise (solc, Hardhat) a remappings.txt applies as written. Messages name files `show(file)`;
|
|
154
|
+
// files are read through `host`.
|
|
155
|
+
function readMapping(mappingFile, { env, forge, host, show = (f) => f }) {
|
|
156
|
+
if (mappingFile.endsWith('.toml')) {
|
|
157
|
+
const { remappings, files, profiled } = readFoundryTomlRemappings(mappingFile, foundryProfile(env), { show, host })
|
|
158
|
+
return { remappings: remappings.map(toSolcRemapping), files, profiled }
|
|
159
|
+
}
|
|
160
|
+
const name = show(mappingFile)
|
|
161
|
+
const text = readUtf8OrNull(mappingFile, name, host)
|
|
162
|
+
if (text === null) throw new Error(`${name}: no such file`)
|
|
163
|
+
const listed = parseRemappingLines(text, { label: name, emptyPath: !forge })
|
|
164
|
+
return { remappings: listed.map(forge ? toSolcRemapping : toLoaderRemapping), files: [mappingFile] }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Read a foundry.toml/remappings.txt mapping file -> its remappings (see readMapping; `forge`
|
|
168
|
+
// defaults to a remappings.txt applying as written). The file itself is not added to sources.
|
|
169
|
+
export function readRemappingsFile(mappingFile, { env = process.env, forge = false, host = diskHost } = {}) {
|
|
170
|
+
return readMapping(mappingFile, { env, forge, host }).remappings
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- Resolution ---------------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
// What resolves the imports of the project at `baseDir`:
|
|
176
|
+
// `{ remappings, libs, ownership, files, envUsed }`.
|
|
177
|
+
// - `mappingFile` (foundry.toml / remappings.txt): exactly the remappings it lists (see readMapping).
|
|
178
|
+
// - else, with a foundry.toml at the root: what `forge build` uses (foundry.js) -- remappings.txt,
|
|
179
|
+
// the profile's remappings, dependencies' own configs, auto-detected `lib/` remappings and their
|
|
180
|
+
// contexts.
|
|
181
|
+
// - else a remappings.txt at the root (solc / Hardhat 3), taken as written.
|
|
182
|
+
// `libs` are forge's lib dirs whenever the root has a foundry.toml (an absolute import inside a
|
|
183
|
+
// library resolves against it); `ownership` tells the dependencies' files from the project's
|
|
184
|
+
// (solidityOwnership: forge's libs, Soldeer's `dependencies/`, git submodules, node_modules);
|
|
185
|
+
// `files` the project-relative config files read (`../` for one outside the project); `envUsed`
|
|
186
|
+
// the environment variables that shaped the result. The project is read through `host`.
|
|
187
|
+
export function discoverSolidityConfig(baseDir, { mappingFile, env = process.env, host = diskHost } = {}) {
|
|
188
|
+
const forge = isFile(join(baseDir, FOUNDRY_TOML), host)
|
|
189
|
+
if (forge && !mappingFile) return foundryProject(baseDir, { env, host })
|
|
190
|
+
const { libs, profiled, files: libsFiles } = forge ? foundryLibs(baseDir, { env, host }) : { libs: [], profiled: false, files: [] }
|
|
191
|
+
const ownership = projectOwnership(baseDir, libs, { soldeer: forge, host })
|
|
192
|
+
const show = shownFrom(toPosix(resolve(baseDir)), host)
|
|
193
|
+
if (mappingFile) {
|
|
194
|
+
const abs = resolve(baseDir, mappingFile)
|
|
195
|
+
const { remappings, files, profiled: mappingProfiled } = readMapping(abs, { env, forge, host, show })
|
|
196
|
+
// The profile picks the mapping file's remappings (a .toml) or the root foundry.toml's libs: the
|
|
197
|
+
// files read are both's.
|
|
198
|
+
const envUsed = profiled || mappingProfiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : []
|
|
199
|
+
return { remappings, libs, ownership, files: [...new Set([...files, ...libsFiles].map((f) => projectRelative(baseDir, f, host)))], envUsed }
|
|
200
|
+
}
|
|
201
|
+
const txt = join(baseDir, REMAPPINGS_TXT)
|
|
202
|
+
if (!isFile(txt, host)) return { remappings: [], libs, ownership, files: [], envUsed: [] }
|
|
203
|
+
return { remappings: readMapping(txt, { env, forge, host, show }).remappings, libs, ownership, files: [REMAPPINGS_TXT], envUsed: [] }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Solc's remapping choice for the source unit `name` imported from `fromFile`: among the
|
|
207
|
+
// remappings whose context is a prefix of `fromFile` and whose prefix is a prefix of `name`, the
|
|
208
|
+
// longest context wins, then the longest prefix, then the one listed last. The target replaces the
|
|
209
|
+
// prefix verbatim (`//` collapsed). Null when none applies.
|
|
210
|
+
export function applyRemappings(name, fromFile, remappings) {
|
|
39
211
|
let best = null
|
|
40
|
-
for (const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
212
|
+
for (const r of remappings) {
|
|
213
|
+
const context = r.context ?? ''
|
|
214
|
+
if (!fromFile.startsWith(context) || !name.startsWith(r.prefix)) continue
|
|
215
|
+
if (best && (context.length < best.context.length || (context.length === best.context.length && r.prefix.length < best.prefix.length))) continue
|
|
216
|
+
best = { context, prefix: r.prefix, target: r.target }
|
|
44
217
|
}
|
|
45
|
-
if (best)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
resolved.push(part)
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return resolved.join('/')
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Node-style `@scope/pkg/sub/path`. createRequire is anchored at the importing
|
|
68
|
-
// source so nested node_modules resolve like Node. `..` segments in the subpath
|
|
69
|
-
// are rejected so a crafted specifier can't escape the resolved package.
|
|
70
|
-
if (baseDir && specifier.startsWith('@')) {
|
|
71
|
-
const parts = specifier.split('/')
|
|
72
|
-
if (parts.length < 3) return null
|
|
73
|
-
if (parts.slice(2).includes('..')) return null
|
|
74
|
-
try {
|
|
75
|
-
const abs = createRequire(resolve(baseDir, fromFile)).resolve(specifier)
|
|
76
|
-
const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
|
|
77
|
-
if (!rel.startsWith('..') && !isAbsolute(rel)) return rel
|
|
78
|
-
} catch { /* package missing, exports map blocks the subpath, … */ }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Project-relative fallback: accept the spec as-is if a real file sits at
|
|
82
|
-
// `<baseDir>/<spec>`. The isFile() guard keeps directories out of the read
|
|
83
|
-
// queue (they'd surface as EISDIR mid-walk). Only bare specs reach here.
|
|
84
|
-
if (baseDir && !isAbsolute(specifier)) {
|
|
85
|
-
const rel = relative(baseDir, resolve(baseDir, specifier)).split(/[\\/]/u).join('/')
|
|
86
|
-
if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) {
|
|
87
|
-
try {
|
|
88
|
-
if (statSync(join(baseDir, rel)).isFile()) return rel
|
|
89
|
-
} catch { /* missing or not statable */ }
|
|
218
|
+
if (!best) return null
|
|
219
|
+
return posix.normalize(best.target + name.slice(best.prefix.length)).replace(/^\.\//u, '')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// A relative import (first segment `.` or `..`) as solc resolves it: against the importing file's
|
|
223
|
+
// directory. Null when it climbs above the root: solc clamps there (`../../B.sol` from `src/A.sol`
|
|
224
|
+
// is `B.sol`), but an import reaching out of the project is refused rather than redirected.
|
|
225
|
+
function resolveRelativeImport(specifier, fromFile) {
|
|
226
|
+
const resolved = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')).split('/') : []
|
|
227
|
+
for (const part of specifier.split('/')) {
|
|
228
|
+
if (part === '.' || part === '') continue
|
|
229
|
+
if (part === '..') {
|
|
230
|
+
if (resolved.length === 0) return null
|
|
231
|
+
resolved.pop()
|
|
232
|
+
} else {
|
|
233
|
+
resolved.push(part)
|
|
90
234
|
}
|
|
91
235
|
}
|
|
236
|
+
return resolved.join('/')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const isRelativeImport = (specifier) => {
|
|
240
|
+
const first = specifier.split('/')[0]
|
|
241
|
+
return first === '.' || first === '..'
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// `<baseDir>/<spec>` when a real file sits there, as a clean project-relative path.
|
|
245
|
+
function projectFile(baseDir, spec, host) {
|
|
246
|
+
if (isAbsolute(spec)) return null
|
|
247
|
+
const rel = toPosix(relative(baseDir, resolve(baseDir, spec)))
|
|
248
|
+
if (rel === '' || relativeEscapes(rel)) return null
|
|
249
|
+
return isFile(join(baseDir, rel), host) ? rel : null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// No `.`/`..`/empty segment: a bare spec can't wander out of the directory it's looked up in.
|
|
253
|
+
const isPlainSpec = (spec) => spec.split('/').every((p) => p !== '' && p !== '.' && p !== '..')
|
|
92
254
|
|
|
255
|
+
// Forge's absolute import inside a library (`lib/dep/src/A.sol` importing `src/B.sol`): tried
|
|
256
|
+
// against each directory from the parent of the importer's up to (not including) its lib dir, as
|
|
257
|
+
// foundry-compilers' `resolve_absolute_library` does; forge passes the matching one to solc as an
|
|
258
|
+
// include path.
|
|
259
|
+
function libraryFile(baseDir, spec, fromFile, libs, host) {
|
|
260
|
+
const lib = libs.map((l) => posix.normalize(toPosix(l)).replace(/\/$/u, '')).find((l) => fromFile.startsWith(`${l}/`))
|
|
261
|
+
if (!lib) return null
|
|
262
|
+
for (let dir = posix.dirname(posix.dirname(fromFile)); dir !== lib && dir.startsWith(`${lib}/`); dir = posix.dirname(dir)) {
|
|
263
|
+
const hit = projectFile(baseDir, `${dir}/${spec}`, host)
|
|
264
|
+
if (hit) return hit
|
|
265
|
+
}
|
|
93
266
|
return null
|
|
94
267
|
}
|
|
95
268
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
269
|
+
// A package import (`pkg/path.sol`, `@scope/pkg/path.sol`) by file path through node_modules, from
|
|
270
|
+
// the importing file's directory up to the root (Hardhat and Node; a package's `exports` map
|
|
271
|
+
// doesn't apply to Solidity sources).
|
|
272
|
+
function nodeModulesFile(baseDir, spec, fromFile, host) {
|
|
273
|
+
const parts = spec.split('/')
|
|
274
|
+
if (parts.length < (spec.startsWith('@') ? 3 : 2)) return null
|
|
275
|
+
for (let dir = posix.dirname(fromFile); ; dir = posix.dirname(dir)) {
|
|
276
|
+
if (posix.basename(dir) !== 'node_modules') {
|
|
277
|
+
const hit = projectFile(baseDir, dir === '.' ? `node_modules/${spec}` : `${dir}/node_modules/${spec}`, host)
|
|
278
|
+
if (hit) return hit
|
|
279
|
+
}
|
|
280
|
+
if (dir === '.' || dir === '/' || dir === '') return null
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Where an import resolves, as `{ path }`, or `{ reason }` when it may not be read (`reason: null`:
|
|
285
|
+
// it names no file). See resolveSolImport.
|
|
286
|
+
function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [], ownership, host = diskHost } = {}) {
|
|
287
|
+
const relativeImport = isRelativeImport(specifier)
|
|
288
|
+
const name = relativeImport ? resolveRelativeImport(specifier, fromFile) : specifier
|
|
289
|
+
if (name === null) return { reason: 'it climbs above the project root' }
|
|
290
|
+
let path = applyRemappings(name, fromFile, remappings)
|
|
291
|
+
if (path === null && relativeImport) path = name
|
|
292
|
+
if (path === null && baseDir) {
|
|
293
|
+
const plain = isPlainSpec(name)
|
|
294
|
+
path = (plain ? libraryFile(baseDir, name, fromFile, libs, host) : null) ?? projectFile(baseDir, name, host) ?? (plain ? nodeModulesFile(baseDir, name, fromFile, host) : null)
|
|
295
|
+
}
|
|
296
|
+
if (path === null) return { reason: null }
|
|
297
|
+
if (isAbsolute(path) || posix.isAbsolute(path) || path === '..' || path.startsWith('../')) return { reason: `it resolves to ${path}, outside the project root` }
|
|
298
|
+
if (!path.endsWith('.sol')) return { reason: `it resolves to ${path}, which is not a .sol file` }
|
|
299
|
+
if (!baseDir) return { path }
|
|
300
|
+
const own = ownership ?? solidityOwnership(baseDir, { host })
|
|
301
|
+
const target = own.of(path)
|
|
302
|
+
if (target.reason) return { reason: target.reason }
|
|
303
|
+
// (A link out of the root is refused when the file is read.)
|
|
304
|
+
if (target.real !== null && !target.outside && !target.dependency && own.of(fromFile).dependency) return { reason: `a dependency may not import the project's own ${path}` }
|
|
305
|
+
return { path }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Resolve a Solidity import to a baseDir-relative POSIX path, the way solc does: a relative import
|
|
309
|
+
// (`./`, `../`) is taken against the importing file (root escape -> null), then remappings apply
|
|
310
|
+
// (longest context, then longest prefix; see applyRemappings). An unremapped non-relative import
|
|
311
|
+
// is then looked up, when `baseDir` is given, inside the importer's library (forge's include path;
|
|
312
|
+
// `libs` are forge's lib dirs), as a project file (solc's base path), and through node_modules by
|
|
313
|
+
// file path (Hardhat / Node). Returns null when nothing resolves, or when the result isn't a `.sol`
|
|
314
|
+
// file inside the root, crosses a link a dependency planted out of itself, or is the project's own
|
|
315
|
+
// file imported by a dependency's -- by real path, with `ownership` (solidityOwnership's; by default
|
|
316
|
+
// only node_modules holds dependencies). The project is read through `host`.
|
|
317
|
+
export function resolveSolImport(specifier, fromFile, options = {}) {
|
|
318
|
+
return resolveImport(specifier, fromFile, options).path ?? null
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// The files that describe a Solidity build (bundled by `--manifests`) besides the config files
|
|
322
|
+
// discovery read: the root's dependency pins, and each package's manifests. (`hardhat.config.*` is
|
|
323
|
+
// code that may hold keys, so it is never carried.)
|
|
324
|
+
export const SOLIDITY_ROOT_MANIFESTS = [FOUNDRY_TOML, REMAPPINGS_TXT, 'foundry.lock', 'soldeer.lock', '.gitmodules', 'package.json']
|
|
325
|
+
export const SOLIDITY_PACKAGE_MANIFESTS = ['package.json', FOUNDRY_TOML, REMAPPINGS_TXT]
|
|
326
|
+
|
|
327
|
+
// --- The walk -----------------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
const warnUnresolved = (spec, from, reason) =>
|
|
330
|
+
console.warn(`[loader.solidity] ${reason ? 'Refused' : 'Missing'} import: ${spec} from ${from}${reason ? ` (${reason})` : ''}`)
|
|
331
|
+
|
|
332
|
+
// Build `{ sources, resolutions, missing }` from already-loaded Solidity sources plus remappings.
|
|
333
|
+
// Imports resolve as resolveSolImport does (`libs`, `ownership`, `host`: see there); as a final
|
|
334
|
+
// fallback a specifier naming no file but matching a stored key verbatim is accepted. `missing`
|
|
335
|
+
// lists every `{ spec, from }` that didn't resolve or resolved outside `sources`, with the
|
|
336
|
+
// `reason` when it was refused.
|
|
337
|
+
export function buildSolidityTree(sources, { remappings = [], baseDir, libs = [], host = diskHost, ownership = baseDir && solidityOwnership(baseDir, { host }) } = {}) {
|
|
101
338
|
const resolutions = new Map()
|
|
102
339
|
const missing = []
|
|
340
|
+
const options = { remappings, baseDir, libs, ownership, host }
|
|
103
341
|
for (const [path, content] of sources) {
|
|
104
342
|
const specMap = new Map()
|
|
105
343
|
for (const spec of extractSolImports(content)) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (!resolved && sources.has(spec)) resolved = spec
|
|
344
|
+
const r = resolveImport(spec, path, options)
|
|
345
|
+
let resolved = r.path && sources.has(r.path) ? r.path : null
|
|
346
|
+
if (!resolved && !r.reason && sources.has(spec)) resolved = spec
|
|
109
347
|
if (resolved) {
|
|
110
348
|
specMap.set(spec, resolved)
|
|
111
349
|
} else {
|
|
112
|
-
|
|
113
|
-
missing.push({ spec, from: path })
|
|
350
|
+
warnUnresolved(spec, path, r.reason)
|
|
351
|
+
missing.push(r.reason ? { spec, from: path, reason: r.reason } : { spec, from: path })
|
|
114
352
|
}
|
|
115
353
|
}
|
|
116
354
|
resolutions.set(path, specMap)
|
|
@@ -118,85 +356,121 @@ export function buildSolidityTree(sources, { remappings = [], baseDir } = {}) {
|
|
|
118
356
|
return { sources, resolutions, missing }
|
|
119
357
|
}
|
|
120
358
|
|
|
121
|
-
// Walk the
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
359
|
+
// Walk the project from `entries`, following resolved imports and reading each file once, a wave at
|
|
360
|
+
// a time: the files of one, then the imports they name. Caller-listed entries are also accepted as
|
|
361
|
+
// verbatim non-relative import targets naming no file (Foundry-style `import "src/A.sol"`). An entry
|
|
362
|
+
// that crosses a dependency's link out of itself (see solidityOwnership) is refused. The project is
|
|
363
|
+
// read through `host`.
|
|
364
|
+
export function collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs = [], host = diskHost, ownership = solidityOwnership(baseDir, { host }) } = {}) {
|
|
125
365
|
const sources = new Map()
|
|
126
366
|
const knownEntries = new Set(entries)
|
|
127
|
-
const realBase =
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
console.warn(`[loader.solidity] Missing import: ${relPath}`)
|
|
140
|
-
return null
|
|
141
|
-
}
|
|
142
|
-
throw err
|
|
367
|
+
const realBase = host.realpath(baseDir)
|
|
368
|
+
const options = { remappings, baseDir, libs, ownership, host }
|
|
369
|
+
for (const entry of entries) ownership.assert(entry, 'entry ')
|
|
370
|
+
for (let wave = entries; wave.length > 0;) {
|
|
371
|
+
const reads = [...new Set(wave)].filter((p) => !sources.has(p)).map((relPath) => {
|
|
372
|
+
try {
|
|
373
|
+
assertRealPathWithinBase(realBase, baseDir, relPath, host)
|
|
374
|
+
return [relPath, decodeUtf8(host.readFile(join(baseDir, relPath)), relPath)]
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (err.code === 'ENOENT') {
|
|
377
|
+
console.warn(`[loader.solidity] Missing import: ${relPath}`)
|
|
378
|
+
return null
|
|
143
379
|
}
|
|
144
|
-
|
|
145
|
-
|
|
380
|
+
throw err
|
|
381
|
+
}
|
|
382
|
+
})
|
|
146
383
|
const next = []
|
|
147
384
|
for (const entry of reads) {
|
|
148
385
|
if (!entry) continue
|
|
149
386
|
const [relPath, content] = entry
|
|
150
387
|
sources.set(relPath, content)
|
|
151
388
|
for (const spec of extractSolImports(content)) {
|
|
152
|
-
|
|
153
|
-
|
|
389
|
+
const r = resolveImport(spec, relPath, options)
|
|
390
|
+
const resolved = r.path ?? (!r.reason && knownEntries.has(spec) ? spec : null)
|
|
154
391
|
if (resolved) {
|
|
155
392
|
if (!sources.has(resolved)) next.push(resolved)
|
|
156
393
|
} else {
|
|
157
|
-
|
|
394
|
+
warnUnresolved(spec, relPath, r.reason)
|
|
158
395
|
}
|
|
159
396
|
}
|
|
160
397
|
}
|
|
161
|
-
|
|
398
|
+
wave = next
|
|
162
399
|
}
|
|
163
|
-
|
|
164
|
-
await processWave(entries)
|
|
165
400
|
return sources
|
|
166
401
|
}
|
|
167
402
|
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
403
|
+
// Every `.sol` file under the project-relative directory `dir`, sorted, symlinks followed as forge
|
|
404
|
+
// (walkdir, `follow_links`) collects a source dir: a symlinked directory whose real path is one on
|
|
405
|
+
// the current walk (the walk root included) is a loop and skipped; anything else is walked, so two
|
|
406
|
+
// links to one directory are two directories. A dir's real path is its parent's plus its name
|
|
407
|
+
// unless it's a symlink.
|
|
408
|
+
function solidityFilesUnder(baseDir, dir, host) {
|
|
409
|
+
const out = []
|
|
410
|
+
// The real paths of the directories on the current walk; one may repeat (a real directory
|
|
411
|
+
// reached again through a link is walked, as walkdir does, and checked only at links).
|
|
412
|
+
const stack = []
|
|
413
|
+
const walk = (rel, real) => {
|
|
414
|
+
stack.push(real)
|
|
415
|
+
for (const e of host.readdir(join(baseDir, rel))) {
|
|
416
|
+
const child = rel === '.' ? e.name : `${rel}/${e.name}`
|
|
417
|
+
let kind = e
|
|
418
|
+
let childReal = join(real, e.name)
|
|
419
|
+
if (e.isSymbolicLink()) {
|
|
420
|
+
childReal = realpathOrNull(join(baseDir, child), host)
|
|
421
|
+
kind = host.stat(join(baseDir, child))
|
|
422
|
+
if (kind === null) continue
|
|
423
|
+
if (kind.isDirectory() && (childReal === null || stack.includes(childReal))) continue
|
|
424
|
+
}
|
|
425
|
+
if (kind.isDirectory()) walk(child, childReal)
|
|
426
|
+
else if (kind.isFile() && e.name.endsWith('.sol')) out.push(child)
|
|
427
|
+
}
|
|
428
|
+
stack.pop()
|
|
429
|
+
}
|
|
430
|
+
const root = realpathOrNull(join(baseDir, dir), host)
|
|
431
|
+
if (root !== null) walk(dir, root)
|
|
432
|
+
return out.toSorted()
|
|
174
433
|
}
|
|
175
434
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
435
|
+
// Project-relative entries with each directory replaced by the `.sol` files under it (deduped, in
|
|
436
|
+
// order). A `.sol` entry is kept as is (a missing one is reported by the walk); a directory that
|
|
437
|
+
// is missing or holds no `.sol` file is skipped with a warning, as forge skips an absent `script/`,
|
|
438
|
+
// and it's an error only when no entry yields a file (when none exists, a mistyped path). The
|
|
439
|
+
// project is read through `host`.
|
|
440
|
+
export function expandSolidityEntries(baseDir, entries, host = diskHost) {
|
|
441
|
+
const out = new Set()
|
|
442
|
+
const shown = (entry) => (entry === '.' ? './' : `${entry}/`)
|
|
443
|
+
for (const e of entries) {
|
|
444
|
+
const entry = e === '' ? '.' : e
|
|
445
|
+
const dir = isDir(join(baseDir, entry), host)
|
|
446
|
+
if (!dir && entry.endsWith('.sol')) {
|
|
447
|
+
out.add(entry)
|
|
448
|
+
continue
|
|
449
|
+
}
|
|
450
|
+
const files = dir ? solidityFilesUnder(baseDir, entry, host) : []
|
|
451
|
+
if (files.length === 0) console.warn(`[stasis] Skipping ${shown(entry)}: ${dir ? 'no .sol files under it' : 'no such directory'}`)
|
|
452
|
+
for (const f of files) out.add(f)
|
|
453
|
+
}
|
|
454
|
+
if (out.size === 0) {
|
|
455
|
+
if (entries.every((e) => host.stat(join(baseDir, e)) === null)) throw new Error(`No such file or directory: ${entries[0]}`)
|
|
456
|
+
throw new Error(`No .sol files under ${entries.map((e) => shown(e === '' ? '.' : e)).join(', ')} (a directory entry stands for the Solidity sources under it)`)
|
|
183
457
|
}
|
|
458
|
+
return [...out]
|
|
184
459
|
}
|
|
185
460
|
|
|
186
|
-
// High-level entry: a `.sol.txt` listing whose optional first line is a
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
export async function loadSolidity(solTxtFile) {
|
|
461
|
+
// High-level entry: a `.sol.txt` listing whose optional first line is a `*.toml`/`remappings.txt`
|
|
462
|
+
// mapping file (resolved relative to the listing); the remaining lines are `*.sol` files. Without
|
|
463
|
+
// a mapping line, the remappings are discovered as for `stasis bundle` (discoverSolidityConfig).
|
|
464
|
+
export async function loadSolidity(solTxtFile, { env = process.env } = {}) {
|
|
190
465
|
const baseDir = dirname(resolve(solTxtFile))
|
|
191
|
-
const listing = await readFile(solTxtFile,
|
|
466
|
+
const listing = decodeUtf8(await readFile(solTxtFile), solTxtFile)
|
|
192
467
|
const lines = listing.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
193
468
|
if (lines.length === 0) throw new Error(`Empty Solidity listing: ${solTxtFile}`)
|
|
194
469
|
|
|
195
|
-
let
|
|
470
|
+
let mappingFile
|
|
196
471
|
if (lines[0].endsWith('.toml') || lines[0].endsWith('remappings.txt')) {
|
|
197
|
-
|
|
198
|
-
assertWithinBase(baseDir,
|
|
199
|
-
remappings = await readRemappingsFile(join(baseDir, mapping))
|
|
472
|
+
mappingFile = lines.shift()
|
|
473
|
+
assertWithinBase(baseDir, mappingFile, 'Mapping path')
|
|
200
474
|
}
|
|
201
475
|
|
|
202
476
|
if (!lines.every((line) => line.endsWith('.sol'))) {
|
|
@@ -205,6 +479,7 @@ export async function loadSolidity(solTxtFile) {
|
|
|
205
479
|
|
|
206
480
|
const entries = lines.map((l) => l.replace(/^\.\//u, ''))
|
|
207
481
|
for (const e of entries) assertWithinBase(baseDir, e, 'Entry path')
|
|
208
|
-
const
|
|
209
|
-
|
|
482
|
+
const { remappings, libs, ownership } = discoverSolidityConfig(baseDir, { mappingFile, env })
|
|
483
|
+
const sources = collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs, ownership })
|
|
484
|
+
return buildSolidityTree(sources, { remappings, baseDir, libs, ownership })
|
|
210
485
|
}
|