@exodus/stasis 1.0.0-alpha.1 → 1.0.0-alpha.3
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 +1 -1
- package/bin/stasis.js +25 -3
- package/logo.svg +11 -0
- package/package.json +11 -6
- package/src/audit.js +149 -0
- package/src/cmd/bundle.js +188 -0
- package/src/loaders/solidity.js +210 -0
package/README.md
CHANGED
package/bin/stasis.js
CHANGED
|
@@ -20,8 +20,7 @@ assert(basename(jsname) === 'stasis' || pathsEqual(jsname, fileURLToPath(import.
|
|
|
20
20
|
function usage(prefix = '') {
|
|
21
21
|
console.error(`${prefix}\nUsage:
|
|
22
22
|
stasis run --lock=(add|replace|frozen|ignore) [--bundle=(add|replace|load|ignore)] [--bundle-file=path/to/bundle.br] [--full] path/to/file.js ...
|
|
23
|
-
stasis bundle
|
|
24
|
-
stasis bundle verify path/to/lockfile
|
|
23
|
+
stasis bundle [--mapping=path/to/remappings(.txt|.toml)] [--output=path/to/out.stasis.code.br] path/to/file.sol ...
|
|
25
24
|
stasis prune [path/to/project]
|
|
26
25
|
stasis audit path/to/file ...
|
|
27
26
|
`.trim())
|
|
@@ -77,7 +76,30 @@ if (command === '-v' || command === '--version') {
|
|
|
77
76
|
const [code] = await once(child, 'close')
|
|
78
77
|
process.exitCode = code
|
|
79
78
|
} else if (command === 'bundle') {
|
|
80
|
-
|
|
79
|
+
const flags = []
|
|
80
|
+
const valueFlags = new Set(['--mapping', '--output', '-o'])
|
|
81
|
+
while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
|
|
82
|
+
flags.push(argv.shift())
|
|
83
|
+
}
|
|
84
|
+
const options = {
|
|
85
|
+
mapping: { type: 'string' },
|
|
86
|
+
output: { type: 'string', short: 'o' },
|
|
87
|
+
}
|
|
88
|
+
let values
|
|
89
|
+
try {
|
|
90
|
+
({ values } = parseArgs({ args: flags, options }))
|
|
91
|
+
} catch (cause) {
|
|
92
|
+
usage(`Error: ${cause.message}`)
|
|
93
|
+
}
|
|
94
|
+
if (argv.length === 0) usage('Nothing to bundle: no .sol file given')
|
|
95
|
+
if (!argv.every((f) => f.endsWith('.sol'))) usage('Error: bundle only accepts .sol files')
|
|
96
|
+
const { bundleCommand } = await import('../src/cmd/bundle.js')
|
|
97
|
+
await bundleCommand({
|
|
98
|
+
cwd: process.cwd(),
|
|
99
|
+
entries: argv,
|
|
100
|
+
mappingFile: values.mapping,
|
|
101
|
+
output: values.output,
|
|
102
|
+
})
|
|
81
103
|
} else if (command === 'prune') {
|
|
82
104
|
if (argv.length > 1) usage('Error: prune takes at most one path argument')
|
|
83
105
|
const root = argv[0] ? resolve(argv[0]) : process.cwd()
|
package/logo.svg
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" aria-label="Stasis" viewBox="0 0 128 128">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="a" x1="0" x2="1" y1="0" y2="1">
|
|
4
|
+
<stop offset="0%" stop-color="#1f9cff"/>
|
|
5
|
+
<stop offset="60%" stop-color="#8c5bfe"/>
|
|
6
|
+
<stop offset="100%" stop-color="#b85bfe"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
</defs>
|
|
9
|
+
<rect width="128" height="128" fill="rgba(150,150,150,0.09)" rx="28" ry="28"/>
|
|
10
|
+
<circle cx="64" cy="64" r="28.16" fill="url(#a)"/>
|
|
11
|
+
</svg>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/stasis",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0-alpha.3",
|
|
4
|
+
"description": "File-level source bundles and lockfiles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./esbuild": "./src/esbuild.js",
|
|
@@ -9,16 +9,21 @@
|
|
|
9
9
|
"./loader": "./src/loader.js",
|
|
10
10
|
"./bundle": "./src/bundle.js",
|
|
11
11
|
"./lockfile": "./src/lockfile.js",
|
|
12
|
-
"./prune": "./src/prune.js"
|
|
12
|
+
"./prune": "./src/prune.js",
|
|
13
|
+
"./logo.svg": "./logo.svg"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"bin/stasis.js",
|
|
17
|
+
"logo.svg",
|
|
16
18
|
"src/apis/npm/index.js",
|
|
17
19
|
"src/apis/npm/semver.cjs",
|
|
20
|
+
"src/audit.js",
|
|
18
21
|
"src/bundle.js",
|
|
22
|
+
"src/cmd/bundle.js",
|
|
19
23
|
"src/config.js",
|
|
20
24
|
"src/esbuild.js",
|
|
21
25
|
"src/loader.js",
|
|
26
|
+
"src/loaders/solidity.js",
|
|
22
27
|
"src/lockfile.js",
|
|
23
28
|
"src/prune.js",
|
|
24
29
|
"src/state.js",
|
|
@@ -39,14 +44,14 @@
|
|
|
39
44
|
},
|
|
40
45
|
"repository": {
|
|
41
46
|
"type": "git",
|
|
42
|
-
"url": "git+https://github.com/
|
|
47
|
+
"url": "git+https://github.com/ExodusOSS/stasis.git"
|
|
43
48
|
},
|
|
44
49
|
"author": "Exodus Movement, Inc.",
|
|
45
50
|
"license": "MIT",
|
|
46
51
|
"bugs": {
|
|
47
|
-
"url": "https://github.com/
|
|
52
|
+
"url": "https://github.com/ExodusOSS/stasis/issues"
|
|
48
53
|
},
|
|
49
|
-
"homepage": "https://github.com/
|
|
54
|
+
"homepage": "https://github.com/ExodusOSS/stasis#readme",
|
|
50
55
|
"devDependencies": {
|
|
51
56
|
"@exodus/bytes": "^1.15.0",
|
|
52
57
|
"esbuild": "^0.27.3",
|
package/src/audit.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { brotliDecompressSync } from 'node:zlib'
|
|
3
|
+
|
|
4
|
+
import { advisories } from './apis/npm/index.js'
|
|
5
|
+
import semver from './apis/npm/semver.cjs'
|
|
6
|
+
import { Bundle } from './bundle.js'
|
|
7
|
+
import { Lockfile } from './lockfile.js'
|
|
8
|
+
|
|
9
|
+
function parseFile(file) {
|
|
10
|
+
let buf
|
|
11
|
+
try {
|
|
12
|
+
buf = readFileSync(file)
|
|
13
|
+
} catch (cause) {
|
|
14
|
+
if (cause.code === 'ENOENT') throw new Error(`File not found: ${file}`, { cause })
|
|
15
|
+
throw new Error(`Failed to read ${file}: ${cause.message}`, { cause })
|
|
16
|
+
}
|
|
17
|
+
// Lockfiles are JSON text starting with `{`; code/resource bundles are
|
|
18
|
+
// brotli-compressed JSON. Sniff to route to the right parser so its
|
|
19
|
+
// diagnostic surfaces on failure.
|
|
20
|
+
if (buf[0] === 0x7b /* '{' */) {
|
|
21
|
+
try {
|
|
22
|
+
return Lockfile.parse(buf.toString('utf8'))
|
|
23
|
+
} catch (cause) {
|
|
24
|
+
throw new Error(`Failed to parse stasis lockfile: ${file}`, { cause })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
let text
|
|
28
|
+
try {
|
|
29
|
+
text = brotliDecompressSync(buf).toString('utf8')
|
|
30
|
+
} catch (cause) {
|
|
31
|
+
throw new Error(`Failed to read ${file} as a stasis bundle (not brotli)`, { cause })
|
|
32
|
+
}
|
|
33
|
+
// Code bundles carry `formats`/`imports`; resource bundles don't. Route on shape.
|
|
34
|
+
try {
|
|
35
|
+
const isCode = JSON.parse(text).formats !== undefined
|
|
36
|
+
return isCode ? Bundle.parseCode(text) : Bundle.parseResources(text)
|
|
37
|
+
} catch (cause) {
|
|
38
|
+
throw new Error(`Failed to parse stasis bundle: ${file}`, { cause })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Only audit installed dependencies; workspace/first-party packages live under
|
|
43
|
+
// non-`node_modules` keys in .modules (Lockfile/Bundle merge sources in) and
|
|
44
|
+
// shouldn't be sent to the public registry — they leak names and produce noise.
|
|
45
|
+
export function collectPackagesFromFile(file) {
|
|
46
|
+
const out = []
|
|
47
|
+
for (const [dir, { name, version }] of parseFile(file).modules) {
|
|
48
|
+
if (!dir.includes('node_modules')) continue
|
|
49
|
+
if (name && version) out.push({ name, version })
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function collectPackages(files) {
|
|
55
|
+
const seen = new Set()
|
|
56
|
+
const out = []
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
for (const { name, version } of collectPackagesFromFile(file)) {
|
|
59
|
+
const key = `${name}@${version}`
|
|
60
|
+
if (seen.has(key)) continue
|
|
61
|
+
seen.add(key)
|
|
62
|
+
out.push({ name, version })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out.toSorted((a, b) => a.name.localeCompare(b.name) || semver.compare(a.version, b.version))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SEVERITY_ORDER = { critical: 0, high: 1, moderate: 2, low: 3, info: 4, none: 5 }
|
|
69
|
+
|
|
70
|
+
export function flattenAdvisories(result, packages = []) {
|
|
71
|
+
const installed = new Map()
|
|
72
|
+
for (const { name, version } of packages) {
|
|
73
|
+
if (!installed.has(name)) installed.set(name, [])
|
|
74
|
+
installed.get(name).push(version)
|
|
75
|
+
}
|
|
76
|
+
const rows = []
|
|
77
|
+
for (const [pkg, list] of Object.entries(result)) {
|
|
78
|
+
if (!Array.isArray(list)) continue
|
|
79
|
+
const installedVersions = installed.get(pkg) ?? []
|
|
80
|
+
for (const adv of list) {
|
|
81
|
+
const range = adv.vulnerable_versions ?? ''
|
|
82
|
+
const affected = range
|
|
83
|
+
? installedVersions.filter((v) => semver.satisfies(v, range))
|
|
84
|
+
: installedVersions
|
|
85
|
+
// npm occasionally returns advisories whose vulnerable range matches none
|
|
86
|
+
// of the versions we submitted; drop them so the table (and the CLI exit
|
|
87
|
+
// code) reflect only real hits.
|
|
88
|
+
if (installedVersions.length > 0 && affected.length === 0) continue
|
|
89
|
+
rows.push({
|
|
90
|
+
package: pkg,
|
|
91
|
+
installed: affected.join(', '),
|
|
92
|
+
vulnerable: range,
|
|
93
|
+
severity: adv.severity ?? '',
|
|
94
|
+
title: adv.title ?? '',
|
|
95
|
+
url: adv.url ?? '',
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
rows.sort((a, b) => {
|
|
100
|
+
const sa = SEVERITY_ORDER[a.severity] ?? 99
|
|
101
|
+
const sb = SEVERITY_ORDER[b.severity] ?? 99
|
|
102
|
+
if (sa !== sb) return sa - sb
|
|
103
|
+
if (a.package !== b.package) return a.package < b.package ? -1 : 1
|
|
104
|
+
return a.title < b.title ? -1 : 1
|
|
105
|
+
})
|
|
106
|
+
return rows
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Line breaks in npm advisory titles would otherwise break the box layout.
|
|
110
|
+
// Covers LF, bare CR (and CRLF), and U+2028 / U+2029 line/paragraph separators.
|
|
111
|
+
const cell = (v) => String(v ?? '').replace(/[\r\n\u2028\u2029]+/gu, ' ')
|
|
112
|
+
|
|
113
|
+
export function formatTable(rows, columns) {
|
|
114
|
+
if (rows.length === 0) return ''
|
|
115
|
+
const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => cell(r[c]).length)))
|
|
116
|
+
const pad = (s, w) => s.padEnd(w)
|
|
117
|
+
const line = (l, m, r, fill) => l + widths.map((w) => fill.repeat(w + 2)).join(m) + r
|
|
118
|
+
const row = (vals) => '│ ' + vals.map((v, i) => pad(cell(v), widths[i])).join(' │ ') + ' │'
|
|
119
|
+
return [
|
|
120
|
+
line('┌', '┬', '┐', '─'),
|
|
121
|
+
row(columns),
|
|
122
|
+
line('├', '┼', '┤', '─'),
|
|
123
|
+
...rows.map((r) => row(columns.map((c) => r[c]))),
|
|
124
|
+
line('└', '┴', '┘', '─'),
|
|
125
|
+
].join('\n')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function audit(files) {
|
|
129
|
+
const packages = collectPackages(files)
|
|
130
|
+
if (packages.length === 0) {
|
|
131
|
+
return { packages, advisories: {}, rows: [] }
|
|
132
|
+
}
|
|
133
|
+
const result = await advisories(packages)
|
|
134
|
+
const rows = flattenAdvisories(result, packages)
|
|
135
|
+
return { packages, advisories: result, rows }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function printAuditReport({ packages, rows }, { out = process.stdout, err = process.stderr } = {}) {
|
|
139
|
+
err.write(`Scanned ${packages.length} package${packages.length === 1 ? '' : 's'}\n`)
|
|
140
|
+
if (packages.length === 0) {
|
|
141
|
+
err.write('No node_modules entries found in the input files\n')
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (rows.length === 0) {
|
|
145
|
+
err.write('No advisories found\n')
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
out.write(formatTable(rows, ['severity', 'package', 'installed', 'vulnerable', 'title', 'url']) + '\n')
|
|
149
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path'
|
|
3
|
+
import { brotliCompressSync } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
import { Bundle } from '../bundle.js'
|
|
6
|
+
import { splitNodeModulesPath } from '../util.js'
|
|
7
|
+
import {
|
|
8
|
+
buildSolidityTree,
|
|
9
|
+
collectSolidityFilesFromDisk,
|
|
10
|
+
readRemappingsFile,
|
|
11
|
+
} from '../loaders/solidity.js'
|
|
12
|
+
|
|
13
|
+
// Fallback package identity for the workspace bucket when no package.json
|
|
14
|
+
// with a name+version can be found by walking up from any of the bundled
|
|
15
|
+
// files. Bundle.parseCode requires every workspace/module bucket to have
|
|
16
|
+
// both fields, so we have to attest something.
|
|
17
|
+
const SOLIDITY_WORKSPACE_NAME = 'solidity-bundle'
|
|
18
|
+
const SOLIDITY_WORKSPACE_VERSION = '0.0.0'
|
|
19
|
+
const SOLIDITY_FORMAT = 'solidity'
|
|
20
|
+
|
|
21
|
+
// Walk up from the file's directory looking for the nearest package.json
|
|
22
|
+
// with both `name` and `version`. Returns { pkgDir, name, version }
|
|
23
|
+
// (pkgDir relative to `baseDir`, "." for the project root) or null when no
|
|
24
|
+
// such package.json exists at or above the file.
|
|
25
|
+
function findPackageMetadata(baseDir, fileRelPath) {
|
|
26
|
+
let dir = dirname(fileRelPath)
|
|
27
|
+
while (true) {
|
|
28
|
+
const pkgPath = join(baseDir, dir, 'package.json')
|
|
29
|
+
if (existsSync(pkgPath)) {
|
|
30
|
+
try {
|
|
31
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
32
|
+
if (pkg.name && pkg.version) {
|
|
33
|
+
return { pkgDir: dir, name: pkg.name, version: pkg.version }
|
|
34
|
+
}
|
|
35
|
+
} catch { /* malformed — keep walking */ }
|
|
36
|
+
}
|
|
37
|
+
if (dir === '.' || dir === '/' || dir === '') return null
|
|
38
|
+
const parent = dirname(dir)
|
|
39
|
+
if (parent === dir) return null
|
|
40
|
+
dir = parent
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeEntries(entries, cwd) {
|
|
45
|
+
const baseDir = resolve(cwd)
|
|
46
|
+
return entries.map((e) => {
|
|
47
|
+
const abs = resolve(cwd, e)
|
|
48
|
+
const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
|
|
49
|
+
// On Windows, `path.relative()` returns an absolute path when `abs` is on
|
|
50
|
+
// a different drive than `baseDir` — that wouldn't start with `..` but
|
|
51
|
+
// still escapes, so reject both forms.
|
|
52
|
+
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error(`Entry escapes baseDir: ${e}`)
|
|
53
|
+
return rel.replace(/^\.\//u, '')
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Deepest directory that is a parent of every file in `paths`, expressed
|
|
58
|
+
// relative to `cwd`. `paths` may be POSIX-relative-to-cwd; entries that
|
|
59
|
+
// escape cwd (e.g. `../deps/X.sol` via remapping) push the result above
|
|
60
|
+
// cwd, in which case the returned path starts with `..`. Returns "." when
|
|
61
|
+
// the outermost directory IS cwd itself.
|
|
62
|
+
export function outermostDir(paths, cwd) {
|
|
63
|
+
if (paths.length === 0) return '.'
|
|
64
|
+
const cwdAbs = posix.resolve(cwd.replaceAll(/\\/gu, '/'))
|
|
65
|
+
const absDirs = paths.map((p) => posix.dirname(posix.resolve(cwdAbs, p)))
|
|
66
|
+
const partsList = absDirs.map((d) => d.split('/'))
|
|
67
|
+
const common = []
|
|
68
|
+
for (let i = 0; i < partsList[0].length; i++) {
|
|
69
|
+
const c = partsList[0][i]
|
|
70
|
+
if (!partsList.every((parts) => parts[i] === c)) break
|
|
71
|
+
common.push(c)
|
|
72
|
+
}
|
|
73
|
+
const absCommon = common.join('/') || '/'
|
|
74
|
+
const rel = posix.relative(cwdAbs, absCommon)
|
|
75
|
+
return rel === '' ? '.' : rel
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Build a stasis Bundle (in-memory) from a list of entry .sol files and
|
|
79
|
+
// an optional mapping file. The mapping file (foundry.toml or
|
|
80
|
+
// remappings.txt) is parsed for remappings but NOT included in the
|
|
81
|
+
// bundle's sources. Returns the constructed `Bundle`.
|
|
82
|
+
export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappingFile } = {}) {
|
|
83
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
84
|
+
throw new Error('buildSolidityBundle: at least one entry .sol file is required')
|
|
85
|
+
}
|
|
86
|
+
for (const e of entries) {
|
|
87
|
+
if (!e.endsWith('.sol')) throw new Error(`buildSolidityBundle: not a .sol file: ${e}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const baseDir = resolve(cwd)
|
|
91
|
+
const remappings = mappingFile ? await readRemappingsFile(resolve(cwd, mappingFile)) : []
|
|
92
|
+
const normalized = normalizeEntries(entries, cwd)
|
|
93
|
+
|
|
94
|
+
const sources = await collectSolidityFilesFromDisk(baseDir, normalized, remappings)
|
|
95
|
+
const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir })
|
|
96
|
+
|
|
97
|
+
// Bundles must be self-contained. Refuse to write one when an entry
|
|
98
|
+
// can't be loaded from disk, or when any in-bundle file has an import
|
|
99
|
+
// we couldn't resolve — otherwise downstream consumers would silently
|
|
100
|
+
// operate on a partial set of sources.
|
|
101
|
+
const issues = []
|
|
102
|
+
for (const entry of normalized) {
|
|
103
|
+
if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
|
|
104
|
+
}
|
|
105
|
+
for (const { spec, from } of missing) {
|
|
106
|
+
issues.push(`Unresolved import: ${spec} from ${from}`)
|
|
107
|
+
}
|
|
108
|
+
if (issues.length > 0) {
|
|
109
|
+
throw new Error(`Solidity bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Partition every loaded .sol file into a per-package bucket. The
|
|
113
|
+
// bucket is the directory holding the nearest package.json with a
|
|
114
|
+
// name+version. node_modules files land in `node_modules/<pkg>` buckets
|
|
115
|
+
// (or deeper, when a nested package.json claims the file); workspace
|
|
116
|
+
// files land in their nearest workspace package.json's dir, or in the
|
|
117
|
+
// fallback "." bucket with placeholder metadata when no package.json
|
|
118
|
+
// could be found.
|
|
119
|
+
const modules = new Map()
|
|
120
|
+
const ensureBucket = (dir, name, version) => {
|
|
121
|
+
if (!modules.has(dir)) modules.set(dir, { name, version, files: Object.create(null) })
|
|
122
|
+
return modules.get(dir)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const [path, content] of sources) {
|
|
126
|
+
const meta = findPackageMetadata(baseDir, path)
|
|
127
|
+
const inNodeModules = splitNodeModulesPath(path) !== null
|
|
128
|
+
if (meta) {
|
|
129
|
+
// A file inside node_modules whose nearest package.json is the
|
|
130
|
+
// workspace root would silently land in the workspace bucket and
|
|
131
|
+
// hide the misconfigured dependency — refuse instead.
|
|
132
|
+
if (inNodeModules && !meta.pkgDir.includes('node_modules')) {
|
|
133
|
+
throw new Error(`No package.json with name+version found for ${path}`)
|
|
134
|
+
}
|
|
135
|
+
const rel = meta.pkgDir === '.' ? path : path.slice(meta.pkgDir.length + 1)
|
|
136
|
+
ensureBucket(meta.pkgDir, meta.name, meta.version).files[rel] = content
|
|
137
|
+
} else {
|
|
138
|
+
// Files under node_modules without any discoverable package.json
|
|
139
|
+
// would otherwise leak into the workspace bucket; that's a project
|
|
140
|
+
// misconfiguration, surface it loudly.
|
|
141
|
+
if (inNodeModules) throw new Error(`No package.json with name+version found for ${path}`)
|
|
142
|
+
ensureBucket('.', SOLIDITY_WORKSPACE_NAME, SOLIDITY_WORKSPACE_VERSION).files[path] = content
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const formats = new Map()
|
|
147
|
+
for (const path of sources.keys()) formats.set(path, SOLIDITY_FORMAT)
|
|
148
|
+
|
|
149
|
+
// Solidity imports don't depend on Node conditions; key them under a
|
|
150
|
+
// dedicated "solidity" bucket rather than the wildcard "*" used for
|
|
151
|
+
// JS code bundles.
|
|
152
|
+
const importsForKey = new Map()
|
|
153
|
+
for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
|
|
154
|
+
const imports = new Map([['solidity', importsForKey]])
|
|
155
|
+
|
|
156
|
+
return new Bundle({
|
|
157
|
+
config: { scope: 'full' },
|
|
158
|
+
entries: new Set(normalized),
|
|
159
|
+
modules,
|
|
160
|
+
formats,
|
|
161
|
+
imports,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Run the bundle CLI command end-to-end. Always produces a brotli-compressed
|
|
166
|
+
// stasis bundle (matching the on-disk format of `stasis.code.br`). When
|
|
167
|
+
// `output` is provided, writes to that path; otherwise writes to stdout.
|
|
168
|
+
// Prints a one-line `[stasis] Bundled <n> files from <dir> to <dest>` summary
|
|
169
|
+
// to stderr so it doesn't interleave with the binary output on stdout.
|
|
170
|
+
export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile, output } = {}) {
|
|
171
|
+
const bundle = await buildSolidityBundle({ cwd, entries, mappingFile })
|
|
172
|
+
const data = brotliCompressSync(bundle.serializeCode())
|
|
173
|
+
if (output) {
|
|
174
|
+
const outAbs = resolve(cwd, output)
|
|
175
|
+
mkdirSync(dirname(outAbs), { recursive: true })
|
|
176
|
+
writeFileSync(outAbs, data)
|
|
177
|
+
} else {
|
|
178
|
+
process.stdout.write(data)
|
|
179
|
+
}
|
|
180
|
+
// Bundle.sources flattens every per-package bucket into project-relative
|
|
181
|
+
// paths, so the summary reflects every file we wrote — workspace and
|
|
182
|
+
// node_modules alike — not just the workspace bucket.
|
|
183
|
+
const files = [...bundle.sources.keys()]
|
|
184
|
+
const fromDir = outermostDir(files, resolve(cwd))
|
|
185
|
+
const dest = output ?? '<stdout>'
|
|
186
|
+
console.warn(`[stasis] Bundled ${files.length} files from ${fromDir} to ${dest}`)
|
|
187
|
+
return bundle
|
|
188
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Based on DeepView's Solidity loader.
|
|
2
|
+
// https://github.com/PreventiveMeasures/deepview/blob/main/src/loaders/solidity.js
|
|
3
|
+
//
|
|
4
|
+
// Adapted to produce a `{ sources, resolutions }` pair only. The mapping
|
|
5
|
+
// file (foundry.toml or remappings.txt) is read to extract `remappings`,
|
|
6
|
+
// but is itself not included in `sources`.
|
|
7
|
+
|
|
8
|
+
import { readFile } from 'node:fs/promises'
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
10
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
const SOL_IMPORT_RE = /import\s[^"']*["']([^"']+)["']/gu
|
|
13
|
+
|
|
14
|
+
export function extractSolImports(content) {
|
|
15
|
+
return [...content.matchAll(SOL_IMPORT_RE)].map((m) => m[1])
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseRemappingsFromToml(tomlContent) {
|
|
19
|
+
const match = tomlContent.match(/remappings\s*=\s*\[([\s\S]*?)\]/u)
|
|
20
|
+
if (!match) return []
|
|
21
|
+
return parseRemappings([...match[1].matchAll(/["']([^"']+)["']/gu)].map((m) => m[1]).join('\n'))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseRemappings(content) {
|
|
25
|
+
return content.trim().split('\n').map((entry) => {
|
|
26
|
+
const eqIdx = entry.indexOf('=')
|
|
27
|
+
if (eqIdx === -1) return null
|
|
28
|
+
return { prefix: entry.slice(0, eqIdx), target: entry.slice(eqIdx + 1) }
|
|
29
|
+
}).filter(Boolean)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Resolve a Solidity import specifier to a baseDir-relative POSIX path,
|
|
33
|
+
// trying three strategies in order:
|
|
34
|
+
// 1. user remappings (longest prefix wins)
|
|
35
|
+
// 2. relative path (`./...` or `../...`); traversal above the project
|
|
36
|
+
// root returns null instead of clamping
|
|
37
|
+
// 3. Node's CJS resolver via createRequire, anchored at the importing
|
|
38
|
+
// source — only when `baseDir` is provided and the specifier looks
|
|
39
|
+
// like a scoped npm package (`@scope/pkg/sub/path`)
|
|
40
|
+
// Returns null when no strategy resolves the import.
|
|
41
|
+
export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir } = {}) {
|
|
42
|
+
let best = null
|
|
43
|
+
for (const { prefix, target } of remappings) {
|
|
44
|
+
if (specifier.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) {
|
|
45
|
+
best = { prefix, target }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (best) {
|
|
49
|
+
const target = best.target.replace(/\/$/u, '')
|
|
50
|
+
const remaining = specifier.slice(best.prefix.length).replace(/^\//u, '')
|
|
51
|
+
return (remaining ? `${target}/${remaining}` : target).replace(/^\.\//u, '')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (specifier.startsWith('.')) {
|
|
55
|
+
const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
|
|
56
|
+
const parts = [...(fromDir ? fromDir.split('/') : []), ...specifier.split('/')]
|
|
57
|
+
const resolved = []
|
|
58
|
+
for (const part of parts) {
|
|
59
|
+
if (part === '.' || part === '') continue
|
|
60
|
+
if (part === '..') {
|
|
61
|
+
if (resolved.length === 0) return null
|
|
62
|
+
resolved.pop()
|
|
63
|
+
} else {
|
|
64
|
+
resolved.push(part)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return resolved.join('/')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Node-style `@scope/pkg/sub/path`. createRequire is anchored at the
|
|
71
|
+
// importing source so nested node_modules resolve like Node would
|
|
72
|
+
// (a file under `node_modules/foo/` finds its deps in
|
|
73
|
+
// `foo/node_modules/` before walking up). `..` segments in the
|
|
74
|
+
// subpath are rejected so a crafted specifier can't escape the
|
|
75
|
+
// resolved package via require.resolve's own path normalization.
|
|
76
|
+
if (baseDir && specifier.startsWith('@')) {
|
|
77
|
+
const parts = specifier.split('/')
|
|
78
|
+
if (parts.length < 3) return null
|
|
79
|
+
if (parts.slice(2).includes('..')) return null
|
|
80
|
+
try {
|
|
81
|
+
const abs = createRequire(resolve(baseDir, fromFile)).resolve(specifier)
|
|
82
|
+
const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
|
|
83
|
+
if (!rel.startsWith('..') && !isAbsolute(rel)) return rel
|
|
84
|
+
} catch { /* package missing, exports map blocks the subpath, … */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Build the { sources, resolutions, missing } triple from a Map of
|
|
91
|
+
// already-loaded Solidity sources plus optional remappings. Imports that
|
|
92
|
+
// resolve via remappings/relative/Node win; as a final fallback an
|
|
93
|
+
// import specifier that matches a stored key verbatim (etherscan
|
|
94
|
+
// bundles, hardhat flat layouts) is accepted. `missing` lists every
|
|
95
|
+
// (spec, from) pair that could not be resolved or that resolved to a
|
|
96
|
+
// file not loaded into `sources`.
|
|
97
|
+
export function buildSolidityTree(sources, { remappings = [], baseDir } = {}) {
|
|
98
|
+
const resolutions = new Map()
|
|
99
|
+
const missing = []
|
|
100
|
+
for (const [path, content] of sources) {
|
|
101
|
+
const specMap = new Map()
|
|
102
|
+
for (const spec of extractSolImports(content)) {
|
|
103
|
+
let resolved = resolveSolImport(spec, path, { remappings, baseDir })
|
|
104
|
+
if (resolved && !sources.has(resolved)) resolved = null
|
|
105
|
+
if (!resolved && sources.has(spec)) resolved = spec
|
|
106
|
+
if (resolved) {
|
|
107
|
+
specMap.set(spec, resolved)
|
|
108
|
+
} else {
|
|
109
|
+
console.warn(`[loader.solidity] Missing import: ${spec} from ${path}`)
|
|
110
|
+
missing.push({ spec, from: path })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
resolutions.set(path, specMap)
|
|
114
|
+
}
|
|
115
|
+
return { sources, resolutions, missing }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Walk the filesystem starting from `entries`, following resolved
|
|
119
|
+
// imports and reading each file exactly once. Files in the same wave
|
|
120
|
+
// are read in parallel; the next wave depends on the imports
|
|
121
|
+
// discovered in the previous one. Caller-listed entries are also
|
|
122
|
+
// accepted as verbatim non-relative import targets (Foundry-style
|
|
123
|
+
// `import "src/A.sol"`).
|
|
124
|
+
export async function collectSolidityFilesFromDisk(baseDir, entries, remappings) {
|
|
125
|
+
const sources = new Map()
|
|
126
|
+
const knownEntries = new Set(entries)
|
|
127
|
+
|
|
128
|
+
const processWave = async (wave) => {
|
|
129
|
+
const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
|
|
130
|
+
if (toLoad.length === 0) return
|
|
131
|
+
const reads = await Promise.all(
|
|
132
|
+
toLoad.map(async (relPath) => {
|
|
133
|
+
try {
|
|
134
|
+
return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err.code === 'ENOENT') {
|
|
137
|
+
console.warn(`[loader.solidity] Missing import: ${relPath}`)
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
throw err
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
)
|
|
144
|
+
const next = []
|
|
145
|
+
for (const entry of reads) {
|
|
146
|
+
if (!entry) continue
|
|
147
|
+
const [relPath, content] = entry
|
|
148
|
+
sources.set(relPath, content)
|
|
149
|
+
for (const spec of extractSolImports(content)) {
|
|
150
|
+
let resolved = resolveSolImport(spec, relPath, { remappings, baseDir })
|
|
151
|
+
if (!resolved && knownEntries.has(spec)) resolved = spec
|
|
152
|
+
if (resolved) {
|
|
153
|
+
if (!sources.has(resolved)) next.push(resolved)
|
|
154
|
+
} else {
|
|
155
|
+
console.warn(`[loader.solidity] Missing import: ${spec} from ${relPath}`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
await processWave(next)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await processWave(entries)
|
|
163
|
+
return sources
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Read a foundry.toml or remappings.txt mapping file and return its parsed
|
|
167
|
+
// remappings. The mapping file itself is NOT included in the loaded sources.
|
|
168
|
+
export async function readRemappingsFile(mappingFile) {
|
|
169
|
+
const content = await readFile(mappingFile, 'utf8')
|
|
170
|
+
if (mappingFile.endsWith('.toml')) return parseRemappingsFromToml(content)
|
|
171
|
+
return parseRemappings(content)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Reject absolute paths and `..`-escaping paths in a `.sol.txt` listing so
|
|
175
|
+
// a malicious or sloppy listing can't trick the loader into reading
|
|
176
|
+
// arbitrary files outside the listing's own directory.
|
|
177
|
+
function assertWithinBase(baseDir, candidate, label) {
|
|
178
|
+
if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
|
|
179
|
+
const rel = relative(baseDir, resolve(baseDir, candidate)).split(/[\\/]/u).join('/')
|
|
180
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
181
|
+
throw new Error(`${label} escapes baseDir: ${candidate}`)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// High-level: takes an optional .sol.txt listing file. The first line may
|
|
186
|
+
// be a `*.toml` or `remappings.txt` mapping file (resolved relative to the
|
|
187
|
+
// listing); the remaining lines are `*.sol` files. Mirrors the DeepView
|
|
188
|
+
// loader's interface.
|
|
189
|
+
export async function loadSolidity(solTxtFile) {
|
|
190
|
+
const baseDir = dirname(resolve(solTxtFile))
|
|
191
|
+
const listing = await readFile(solTxtFile, 'utf8')
|
|
192
|
+
const lines = listing.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
193
|
+
if (lines.length === 0) throw new Error(`Empty Solidity listing: ${solTxtFile}`)
|
|
194
|
+
|
|
195
|
+
let remappings = []
|
|
196
|
+
if (lines[0].endsWith('.toml') || lines[0].endsWith('remappings.txt')) {
|
|
197
|
+
const mapping = lines.shift()
|
|
198
|
+
assertWithinBase(baseDir, mapping, 'Mapping path')
|
|
199
|
+
remappings = await readRemappingsFile(join(baseDir, mapping))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!lines.every((line) => line.endsWith('.sol'))) {
|
|
203
|
+
throw new Error(`Solidity listing must only contain .sol files: ${solTxtFile}`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const entries = lines.map((l) => l.replace(/^\.\//u, ''))
|
|
207
|
+
for (const e of entries) assertWithinBase(baseDir, e, 'Entry path')
|
|
208
|
+
const sources = await collectSolidityFilesFromDisk(baseDir, entries, remappings)
|
|
209
|
+
return buildSolidityTree(sources, { remappings, baseDir })
|
|
210
|
+
}
|