@exodus/stasis 1.0.0-beta.2 → 1.0.0-beta.5

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.
Files changed (45) hide show
  1. package/README.md +23 -3
  2. package/bin/stasis.js +375 -170
  3. package/package.json +44 -11
  4. package/src/audit-corrections.js +68 -0
  5. package/src/audit.js +243 -59
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +12 -18
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +95 -128
  11. package/src/cmd/bundle.js +877 -641
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +4 -18
  16. package/src/diff.js +67 -124
  17. package/src/json-stream.js +283 -0
  18. package/src/loader-mock.js +8 -15
  19. package/src/loaders/bash.js +49 -116
  20. package/src/loaders/cargo.js +1699 -0
  21. package/src/loaders/foundry.js +923 -0
  22. package/src/loaders/paths.js +33 -0
  23. package/src/loaders/php.js +339 -394
  24. package/src/loaders/rust.js +3254 -242
  25. package/src/loaders/solidity-ownership.js +531 -0
  26. package/src/loaders/solidity.js +412 -162
  27. package/src/loaders/toml.js +68 -0
  28. package/src/metro-resolver.js +150 -0
  29. package/src/mock.js +57 -165
  30. package/src/parse.js +4 -16
  31. package/src/resolve-fields.js +331 -0
  32. package/src/resolve-node.js +416 -0
  33. package/src/resolve-typescript.js +427 -0
  34. package/src/rollup.js +5 -0
  35. package/src/sbom.js +64 -92
  36. package/src/scan.js +234 -156
  37. package/src/vfs-bundle/entries.js +140 -0
  38. package/src/vfs-bundle/github.js +193 -0
  39. package/src/vfs-bundle/soldeer.js +125 -0
  40. package/src/vfs-bundle/tree.js +606 -0
  41. package/src/vfs-bundle.js +74 -0
  42. package/src/why.js +434 -0
  43. package/src/apis/npm/index.js +0 -36
  44. package/src/apis/npm/semver.cjs +0 -44
  45. package/src/cmd/extract.js +0 -193
@@ -0,0 +1,140 @@
1
+ import { join, normalize, relative } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ import { readJson } from '@exodus/stasis-core/bundle-util'
5
+ import { isAutoExcludedDir, isPlainObject, posixPathEscapes, relativeEscapes } from '@exodus/stasis-core/util'
6
+ import { Vfs } from '@preventive/vfs'
7
+ import { checkVfsOptions, fieldResolverFor } from '../cmd/bundle.js'
8
+ import { foundrySourceDir } from '../loaders/foundry.js'
9
+ import { resolveTypescriptFallback, typescriptExportsTarget } from '../resolve-typescript.js'
10
+ import { checkKind, checkVersion, packageManagerOf, vfsHost } from './tree.js'
11
+
12
+ const JS = /\.[cm]?[jt]s$/u
13
+ // Node's own conditions for require() and for import, which the build's are added to.
14
+ const NODE_CONDITIONS = [['require', 'node', 'node-addons', 'module-sync'], ['import', 'node', 'node-addons', 'module-sync']]
15
+
16
+ // The entry points the package.json in `dir` names, as paths from `dir`, resolved as the JS build
17
+ // with the given options resolves: as Node does, `conditions` added, or with `mainFields` or
18
+ // `metro` through the build's field resolver, once per platform of `platforms` under `metro`; what
19
+ // that misses mapped as tsc maps it under `typescript`, as the build's fallback maps it. In
20
+ // that order: its own entry, as the build resolves `./` there (`main`, or the first of the main
21
+ // fields, else index); each subpath `exports` holds (but a pattern), as the package's name resolves
22
+ // for require() and for import, with the conditions the build adds (the RN ones under `metro`); and
23
+ // each `bin`. Only JS files in `dir` that are there, named from within it; none without a package.json.
24
+ function packageEntries(host, dir, { conditions = [], mainFields, metro = false, platforms = [], jsx = false, typescript = false } = {}) {
25
+ const real = host.realpath(dir)
26
+ const manifest = join(real, 'package.json')
27
+ const pkg = readJson(manifest, host)
28
+ if (!isPlainObject(pkg)) return []
29
+ const found = new Set()
30
+ const add = (file) => {
31
+ const rel = file === undefined ? '' : relative(real, file)
32
+ if (rel !== '' && !relativeEscapes(rel) && JS.test(rel)) found.add(rel)
33
+ }
34
+ const passes = metro || mainFields !== undefined
35
+ ? (metro ? platforms : [null]).map((platform) => fieldResolverFor(platform, { mainFields, metro, conditions, jsx, typescript, host }))
36
+ : [{ extras: conditions, mainFields: ['main'] }]
37
+ // Node's resolution of `specifier`, else under `typescript` the miss `mapped` as tsc maps it; a
38
+ // real path either way, or undefined.
39
+ const viaNode = (specifier, names, extras, mapped) => {
40
+ const set = new Set([...names, ...extras])
41
+ try {
42
+ return host.resolve(manifest, specifier, set)
43
+ } catch {
44
+ const hit = typescript ? mapped(set) : null
45
+ return hit === null ? undefined : host.realpath(hit)
46
+ }
47
+ }
48
+ for (const { extras, mainFields: fields, resolver } of passes) {
49
+ const entry = fields.map((name) => pkg[name]).find((value) => typeof value === 'string' && value !== '')
50
+ if (entry !== undefined && posixPathEscapes(entry)) continue
51
+ if (resolver === undefined) {
52
+ add(viaNode('./', NODE_CONDITIONS[0], extras, (set) => resolveTypescriptFallback(manifest, './', { conditions: set, tsx: jsx, host })))
53
+ } else {
54
+ // A real path, as Node's resolution gives and the scan takes.
55
+ const hit = resolver(manifest, './')
56
+ if (hit?.url !== undefined) add(host.realpath(fileURLToPath(hit.url)))
57
+ }
58
+ }
59
+ const { exports, name } = pkg
60
+ const keyed = isPlainObject(exports) && Object.keys(exports).some((key) => key.startsWith('.'))
61
+ const subpaths = keyed ? Object.keys(exports) : exports === undefined || exports === null ? [] : ['.']
62
+ for (const subpath of typeof name === 'string' ? subpaths : []) {
63
+ if (!subpath.startsWith('.') || subpath.includes('*') || subpath.endsWith('/')) continue
64
+ const specifier = subpath === '.' ? name : `${name}${subpath.slice(1)}`
65
+ for (const { extras } of passes) {
66
+ for (const names of NODE_CONDITIONS) add(viaNode(specifier, names, extras, (set) => typescriptExportsTarget(real, exports, subpath, { conditions: set, tsx: jsx, host })))
67
+ }
68
+ }
69
+ const bins = typeof pkg.bin === 'string' ? [pkg.bin] : pkg.bin !== null && typeof pkg.bin === 'object' ? Object.values(pkg.bin) : []
70
+ for (const bin of bins) {
71
+ if (typeof bin === 'string' && !posixPathEscapes(bin) && host.stat(join(real, bin))?.isFile()) add(host.realpath(join(real, bin)))
72
+ }
73
+ return [...found]
74
+ }
75
+
76
+ // Directories whose .sol files are none of a project's entry points: its tests, scripts and mocks,
77
+ // what it depends on or builds, and what no walk descends into.
78
+ const SKIPPED_DIRS = new Set(['test', 'tests', 'script', 'scripts', 'mock', 'mocks', 'lib', 'node_modules', 'dependencies', 'out', 'cache', 'artifacts', 'build'])
79
+ const isSkippedDir = (name) => SKIPPED_DIRS.has(name.toLowerCase()) || isAutoExcludedDir(name)
80
+ // A .sol file but a test or script by its name.
81
+ const isEntrySol = (entry) => entry.isFile() && entry.name.endsWith('.sol') && !/\.(?:t|s|test|spec)\.sol$/u.test(entry.name)
82
+
83
+ // The .sol files under the directory `rel` of `real` that are entry points by name, but those in a
84
+ // skipped directory; sorted. Links are not followed.
85
+ function solidityFiles(host, real, rel) {
86
+ const out = []
87
+ const walk = (sub) => {
88
+ for (const entry of host.readdir(join(real, sub))) {
89
+ const path = sub === '.' ? entry.name : `${sub}/${entry.name}`
90
+ if (entry.isDirectory()) {
91
+ if (!isSkippedDir(entry.name)) walk(path)
92
+ } else if (isEntrySol(entry)) {
93
+ out.push(path)
94
+ }
95
+ }
96
+ }
97
+ walk(rel)
98
+ return out.toSorted()
99
+ }
100
+
101
+ // The entry points of the Soldeer project in `dir`, by name and layout, as paths from `dir`: its
102
+ // .sol files directly in it, then those under contracts/, then those under its source directory
103
+ // (its foundry.toml's default profile's `src`, else `src`); none of its tests, scripts or mocks,
104
+ // nor any in what it depends on or builds. A link to a directory is none.
105
+ function solidityEntries(host, dir) {
106
+ const real = host.realpath(dir)
107
+ const isDirectory = (rel) => !posixPathEscapes(rel) && host.stat(join(real, rel))?.isDirectory() && host.readlink(join(real, rel)) === null
108
+ const under = (rel) => (isDirectory(rel) ? solidityFiles(host, real, rel) : [])
109
+ const src = normalize(foundrySourceDir(real, { host })).replace(/\/+$/u, '')
110
+ const own = host.readdir(real).filter(isEntrySol).map((entry) => entry.name).toSorted()
111
+ return [...new Set([...own, ...under('contracts'), ...(src === 'contracts' ? [] : under(src))])]
112
+ }
113
+
114
+ // By the kind of bundle a package manager builds: an entry its options are checked over before
115
+ // there are any, the entries the build takes where none are given, and what having none says.
116
+ export const KINDS = {
117
+ js: { standIn: 'index.js', entries: packageEntries, none: 'has no package.json naming a JS entry point there' },
118
+ sol: { standIn: 'index.sol', entries: solidityEntries, none: 'has no .sol entry point directly in it, under contracts/, or under its source directory' },
119
+ }
120
+
121
+ const EMPTY = vfsHost(new Vfs())
122
+
123
+ // The build's checks of `options` before anything is fetched, over an empty tree, which never
124
+ // decides them: for the package manager given, else for any; over the entries given, else over one
125
+ // of the kind the default ones are. -> the PACKAGE_MANAGERS entry of the one given
126
+ export function checkAhead(name, packageManager, options) {
127
+ checkVersion(name, { packageManager, ...options })
128
+ const pm = packageManager === undefined ? undefined : packageManagerOf(name, packageManager)
129
+ const refusal = (entries) => {
130
+ try {
131
+ checkKind(name, checkVfsOptions(name, { ...options, entries, cwd: '/', host: EMPTY, fetched: false }), packageManager === undefined ? undefined : [packageManager])
132
+ return null
133
+ } catch (error) {
134
+ return error
135
+ }
136
+ }
137
+ const refusals = options.entries === undefined ? (pm === undefined ? Object.values(KINDS) : [KINDS[pm.kind]]).map(({ standIn }) => refusal([standIn])) : [refusal(options.entries)]
138
+ if (!refusals.includes(null)) throw refusals[0]
139
+ return pm
140
+ }
@@ -0,0 +1,193 @@
1
+ import { posix } from 'node:path'
2
+
3
+ import { isValidRepoField } from '@exodus/stasis-core/bundle'
4
+ import { posixPathEscapes } from '@exodus/stasis-core/util'
5
+ import { decompress } from '@preventive/archive/compression.js'
6
+ import { ArchiveError, unpack } from '@preventive/archive/tar.js'
7
+ import { createClient } from '@preventive/upstream/github.js'
8
+ import { vfsFromEntries } from '@preventive/vfs'
9
+ import { buildVfsBundle } from '../cmd/bundle.js'
10
+ import { KINDS, checkAhead } from './entries.js'
11
+ import { checkTarget, detectPackageManager, installedAlone, lockfileOf, lockfileRoot, lockfilesListed, noLockfile, packageManagerOf, vfsHost } from './tree.js'
12
+
13
+ // As upstream's tree verification bounds a tarball's unpacked size.
14
+ const MAX_TAR_BYTES = 2 ** 30
15
+ // What a git tree holds (upstream's verification refuses anything else from GitHub).
16
+ const TREE_TYPES = new Set(['file', 'directory', 'symlink'])
17
+ // Lockfile references to a path above the lockfile's directory (conservatively: any importer's):
18
+ // pnpm's and yarn's specs, and package-lock.json's package keys and `resolved` paths, `..` itself
19
+ // (`link:..`, npm's `".."`) or under it.
20
+ const LOCKFILE_ESCAPE = /(?:link:|file:|directory: |["'])\.\.(?![^/"'\s])/u
21
+ const TSCONFIG = /(?:^|\/)[jt]sconfig[^/]*\.json$/u
22
+
23
+ // Whether `target`, a path relative to the file `from`, resolves outside the tree.
24
+ const resolvesOutside = (from, target) => target.startsWith('/') || posixPathEscapes(posix.join(posix.dirname(from), target))
25
+
26
+ // The mode a checkout gives an entry under the usual umask 022. Git keeps only a file's executable
27
+ // bit (upstream verifies that much), and GitHub's tarballs are written with git archive's tar.umask
28
+ // 0002, which makes them 664 and 775 besides.
29
+ const checkoutMode = ({ type, mode }) => (type === 'symlink' ? mode : type === 'directory' || mode & 0o100 ? 0o755 : 0o644)
30
+
31
+ // A GitHub tarball's entries, its one top directory dropped, with the modes a checkout has.
32
+ async function treeEntries(tarball, where) {
33
+ const entries = []
34
+ for (const entry of unpack(await decompress(tarball, 'gzip', { limit: MAX_TAR_BYTES }))) {
35
+ const name = entry.name.slice(entry.name.indexOf('/') + 1)
36
+ if (!TREE_TYPES.has(entry.type)) throw new Error(`${where}: unexpected ${entry.type} ${JSON.stringify(name)} in the tarball`)
37
+ if (entry.name.includes('/')) entries.push({ ...entry, name, mode: checkoutMode(entry) })
38
+ }
39
+ return entries
40
+ }
41
+
42
+ // The first symlink pointing outside the tree, which would otherwise resolve within it.
43
+ const escapingLink = (entries) => entries.find((entry) => entry.type === 'symlink' && resolvesOutside(entry.name, entry.linkname))
44
+
45
+ // Whether a subtree's files refer to a path above it: a lockfile link or a [jt]sconfig path.
46
+ function refersAbove(entries, lockfile) {
47
+ const text = (entry) => new TextDecoder().decode(entry.data)
48
+ return entries.some((entry) => entry.type === 'file' && (entry.name === lockfile
49
+ ? LOCKFILE_ESCAPE.test(text(entry))
50
+ : TSCONFIG.test(entry.name) && [...text(entry).matchAll(/"(\.\.?\/[^"]*)"/gu)].some(([, path]) => resolvesOutside(entry.name, path))))
51
+ }
52
+
53
+ // The directories above `directory`, the repo's root (undefined) last.
54
+ function ancestorsOf(directory) {
55
+ const out = []
56
+ for (let dir = posix.dirname(directory); dir !== '.'; dir = posix.dirname(dir)) out.push(dir)
57
+ return [...out, undefined]
58
+ }
59
+
60
+ // The files of the subtree `tree`, or null where one is a symlink out of it.
61
+ async function treeFiles(client, { github, tree, where }) {
62
+ let files
63
+ try {
64
+ files = await treeEntries(await client.getRepoTreeTarball({ repo: github, tree }), where)
65
+ } catch (error) {
66
+ if (error instanceof ArchiveError) return null // e.g. a symlink out of the subtree
67
+ throw error
68
+ }
69
+ return escapingLink(files) ? null : files
70
+ }
71
+
72
+ // The whole repo's files at `sha`.
73
+ async function repoFiles(client, { github, sha, where }) {
74
+ const files = await treeEntries(await client.getRepoTarball({ repo: github, sha }), where)
75
+ const link = escapingLink(files)
76
+ if (link) throw new Error(`${where}: symlink ${JSON.stringify(link.name)} points outside the repo`)
77
+ return files
78
+ }
79
+
80
+ // Checked as the Bundle checks them, before anything is fetched; a tag's name is upstream's to check.
81
+ function checkRepo(name, { github, sha, tag, directory }) {
82
+ if (github === undefined) throw new Error(`${name}: github is required`)
83
+ if (tag !== undefined && (typeof tag !== 'string' || tag === '')) throw new TypeError(`${name}: tag must be a non-empty string`)
84
+ if (sha !== undefined && tag !== undefined) throw new Error(`${name}: sha and tag both name the commit: give one`)
85
+ for (const [key, value] of Object.entries({ github, commit: sha, directory: directory || undefined })) {
86
+ if (value !== undefined && !isValidRepoField(key, value)) throw new Error(`${name}: invalid ${key}: ${JSON.stringify(value)}`)
87
+ }
88
+ }
89
+
90
+ // listRepoDir's listing of a directory of the repo at `sha`, each listed once.
91
+ function lister(client, { github, sha }) {
92
+ const listings = new Map()
93
+ return (directory) => {
94
+ if (!listings.has(directory)) listings.set(directory, client.listRepoDir({ repo: github, sha, directory }))
95
+ return listings.get(directory)
96
+ }
97
+ }
98
+
99
+ // Of no package manager given, the one whose lockfile alone `directory` (or the repo's root) and
100
+ // the directories above it list, which is then the only one that may install it; refused where
101
+ // none does; undefined where more than one does, or the listings can't tell, for the tree to tell.
102
+ async function listedPackageManager(list, { directory, where }) {
103
+ let listings
104
+ try {
105
+ listings = await Promise.all((directory ? [directory, ...ancestorsOf(directory)] : [undefined]).map(list))
106
+ } catch {
107
+ // No plain directory in git (a symlink, or under one); the root's failure is GitHub's.
108
+ await list(undefined)
109
+ return undefined
110
+ }
111
+ const listed = lockfilesListed(listings)
112
+ if (listed.length === 0) throw noLockfile(where, posix.resolve('/', directory || '.'))
113
+ return listed.length === 1 ? listed[0] : undefined
114
+ }
115
+
116
+ // The files of `directory` alone, when it holds the lockfile, is installed from itself and stands
117
+ // alone; else null.
118
+ async function subtreeEntries(client, list, { github, directory, packageManager, where }) {
119
+ const names = async (path) => (await list(path)).map((entry) => entry.path)
120
+ let listing
121
+ try {
122
+ listing = await list(directory)
123
+ } catch {
124
+ return null // no plain directory in git (a symlink, or under one): the whole repo resolves it
125
+ }
126
+ if (!lockfilesListed([listing]).includes(packageManager)) return null
127
+ const above = ancestorsOf(directory)
128
+ // Installed from a root above it (a workspace's), it is built from there.
129
+ if (!(await installedAlone(packageManager, listing.map((entry) => entry.path), () => Promise.all(above.map(names))))) return null
130
+ // Its tree id, as the listing above it holds it, held to that directory's own.
131
+ const tree = (await list(above[0])).find((entry) => entry.path === posix.basename(directory) && entry.type === 'tree')?.sha
132
+ const files = tree === undefined ? null : await treeFiles(client, { github, tree, where })
133
+ return files === null || refersAbove(files, lockfileOf(packageManager)) ? null : files
134
+ }
135
+
136
+ // The repo as buildGitHubBundle downloads it, for `name`: at `sha`, else the commit `tag` names (an
137
+ // annotated tag followed to it), else the default branch's head; `directory` alone where it stands
138
+ // alone for the package manager installing it, else the whole repo. That package manager is
139
+ // `packageManager` if given, else the one the listings tell, before the tree is fetched, else the
140
+ // tree; `options` are checked for it once it is known.
141
+ // -> { vfs, host, cwd, subtree, sha, where, packageManager, kind }
142
+ async function repoTree(name, { github, sha, tag, directory, client, packageManager }, options) {
143
+ checkAhead(name, packageManager, options)
144
+ checkRepo(name, { github, sha, tag, directory })
145
+ client ??= createClient({ token: null })
146
+ sha ??= tag === undefined ? (await client.getRepoHead({ repo: github })).oid : (await client.getRepoTag({ repo: github, tag })).oid
147
+ const where = `${name}: ${github}@${sha}`
148
+ const list = lister(client, { github, sha })
149
+ let found = packageManager
150
+ if (found === undefined) {
151
+ found = await listedPackageManager(list, { directory, where })
152
+ if (found !== undefined) checkAhead(name, found, options)
153
+ }
154
+ const subtree = directory && found !== undefined ? await subtreeEntries(client, list, { github, directory, packageManager: found, where }) : null
155
+ const vfs = vfsFromEntries(subtree ?? await repoFiles(client, { github, sha, where }))
156
+ const host = vfsHost(vfs)
157
+ const cwd = posix.resolve('/', subtree ? '.' : directory ?? '.')
158
+ if (packageManager === undefined) {
159
+ const detected = detectPackageManager(where, host, cwd, { os: options.os })
160
+ if (detected !== found) checkAhead(name, detected, options)
161
+ found = detected
162
+ }
163
+ return { vfs, host, cwd, subtree, sha, where, packageManager: found, kind: KINDS[packageManagerOf(name, found).kind] }
164
+ }
165
+
166
+ // A GitHub repo at a full commit, or the one `tag` names, the default branch's head without either,
167
+ // as buildVfsBundle builds it: `directory` downloaded alone where it stands alone, else the whole
168
+ // repo, and without a `packageManager`, the one whose lockfile installs it, where only one's does
169
+ // (repoTree). Without `entries`, they are the ones suggestedEntries suggests. `repo` names the
170
+ // commit and where the lockfile is, which the bundle's paths are relative to. Nothing is read from
171
+ // disk: the tree's bytes come from GitHub, or from the cache setCacheDir names, held to the git tree
172
+ // id either way (@preventive/upstream), and are unpacked into a Vfs that buildVfsBundle reads alone.
173
+ export async function buildGitHubBundle({ github, sha, tag, directory, client, packageManager, ...options } = {}) {
174
+ checkTarget('buildGitHubBundle', options)
175
+ const tree = await repoTree('buildGitHubBundle', { github, sha, tag, directory, client, packageManager }, options)
176
+ const root = lockfileRoot(tree.host, tree.packageManager, tree.cwd, options.os)
177
+ const at = tree.subtree ? posix.join(directory, (root ?? '/').slice(1)) : (root ?? '/').slice(1)
178
+ const location = at === '' ? { root: true } : isValidRepoField('directory', at) ? { directory: at } : {}
179
+ let { entries } = options
180
+ if (entries === undefined) {
181
+ // As suggestedEntries suggests them, from the tree at hand.
182
+ entries = tree.kind.entries(tree.host, tree.cwd, options)
183
+ if (entries.length === 0) throw new Error(`${tree.where}: no entries given, and ${directory || 'the repo root'} ${tree.kind.none}`)
184
+ }
185
+ return buildVfsBundle({ ...options, entries, packageManager: tree.packageManager, vfs: tree.vfs, cwd: tree.cwd, repo: { github, commit: tree.sha, ...location } })
186
+ }
187
+
188
+ // suggestedEntries of a GitHub repo, `{ github, sha, tag, directory, client, packageManager }`, as
189
+ // buildGitHubBundle downloads it.
190
+ export async function suggestedRepoEntries(repo, resolution) {
191
+ const tree = await repoTree('suggestedEntries', repo, resolution)
192
+ return tree.kind.entries(tree.host, tree.cwd, resolution)
193
+ }
@@ -0,0 +1,125 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { isTomlTable, readToml } from '../loaders/toml.js'
4
+
5
+ // What `soldeer install` (0.12.0) writes beside the dependencies folder after installing it
6
+ // (soldeer-core's update_config_libs and edit_remappings with RemappingsAction::Update): the
7
+ // remappings.txt it would write is written into the tree, which then serves it in place of the
8
+ // project's; a foundry.toml it would edit is refused, as is what this can't tell it would leave as
9
+ // it is. `view` is the project's root by paths from `/`, `root` its real path, for messages.
10
+
11
+ const SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/u
12
+ // A requirement Soldeer takes as exact: a bare version (its `^` made `=`), or one written `=`.
13
+ const EXACT = /^=?\s*(\S+)$/u
14
+ const withoutBuild = (version) => version.replace(/\+.*$/u, '')
15
+
16
+ // A file's text as Soldeer reads it (a byte order mark kept), or undefined.
17
+ function textOf(view, path) {
18
+ try {
19
+ return view.stat(path).type === 'file' ? view.readFile(path).toString('utf8') : undefined
20
+ } catch {
21
+ return undefined
22
+ }
23
+ }
24
+
25
+ // Rust's `Path::components` of a remapping's target, `.` kept only at the start.
26
+ function components(path) {
27
+ const out = path.startsWith('/') ? ['/'] : []
28
+ for (const [i, part] of path.split('/').entries()) {
29
+ if (part !== '' && (part !== '.' || i === 0)) out.push(part)
30
+ }
31
+ return out
32
+ }
33
+
34
+ // Whether the folder `name` of a remapping's target (its dependencies folder and the one in it) is
35
+ // `dep`'s, as soldeer's path_matches tells, or null where only Rust's semver could tell.
36
+ function folderMatches(dep, name) {
37
+ if (!name.startsWith(`${dep.name}-`)) return false
38
+ const version = name.slice(dep.name.length + 1)
39
+ if (version === dep.version) return true
40
+ if (!SEMVER.test(version)) return version === dep.req
41
+ const exact = EXACT.exec(dep.req.trim())?.[1]
42
+ if (exact !== undefined && SEMVER.test(exact)) return withoutBuild(exact) === withoutBuild(version)
43
+ return null
44
+ }
45
+
46
+ // The remappings Soldeer's update leaves, of `existing` (`[name, target]` pairs) and the config's
47
+ // dependencies, sorted as it sorts them (by their UTF-8 bytes).
48
+ function updatedRemappings(existing, deps, { regenerate, where }) {
49
+ const items = deps.map((dep) => ({ dep, line: `${dep.remapped}=dependencies/${dep.folder}/` }))
50
+ let lines
51
+ if (regenerate || existing.length === 0) {
52
+ lines = items.map(({ line }) => line)
53
+ } else {
54
+ lines = []
55
+ let rest = existing
56
+ for (const { dep, line } of items) {
57
+ let found = false
58
+ rest = rest.filter(([name, target]) => {
59
+ const path = components(target).slice(0, 2)
60
+ const folder = path.at(-1)
61
+ const matches = folder === undefined || ['/', '.', '..'].includes(folder) ? false : folderMatches(dep, folder)
62
+ if (matches === null) throw new Error(`${where}: "${name}=${target}" names ${folder}, which \`soldeer install\` may rewrite to dependencies/${dep.folder}`)
63
+ if (!matches) return true
64
+ lines.push(`${name}=${target.replaceAll(path.join('/').replace(/^\/\//u, '/'), `dependencies/${dep.folder}`)}`)
65
+ found = true
66
+ return false
67
+ })
68
+ if (!found) lines.push(line)
69
+ }
70
+ lines.push(...rest.map(([name, target]) => `${name}=${target}`))
71
+ }
72
+ return lines.toSorted((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))
73
+ }
74
+
75
+ // `[name, target]` of each remapping in `lines`, as Soldeer reads them: split at the first `=`, any
76
+ // line without one passed over.
77
+ const pairs = (lines) => lines.flatMap((line) => {
78
+ const at = line.indexOf('=')
79
+ return at === -1 ? [] : [[line.slice(0, at), line.slice(at + 1)]]
80
+ })
81
+
82
+ export function settleSoldeer(view, vfs, root) {
83
+ const foundryText = textOf(view, '/foundry.toml')
84
+ const foundry = foundryText === undefined ? undefined : readToml(foundryText)
85
+ const configFile = isTomlTable(foundry?.dependencies) ? 'foundry.toml' : 'soldeer.toml'
86
+ const config = configFile === 'foundry.toml' ? foundry : readToml(textOf(view, '/soldeer.toml') ?? '')
87
+ const where = (file) => join(root, file)
88
+
89
+ if (configFile === 'foundry.toml') {
90
+ const libs = foundry.profile?.default?.libs
91
+ if (!Array.isArray(libs) || !libs.includes('dependencies')) {
92
+ throw new Error(`${where('foundry.toml')}: [profile.default] libs holds no "dependencies", which \`soldeer install\` adds`)
93
+ }
94
+ }
95
+
96
+ const settings = { remappings_generate: true, remappings_regenerate: false, remappings_version: true, remappings_prefix: '', remappings_location: 'txt', ...config.soldeer }
97
+ if (!settings.remappings_generate) return
98
+ const locked = new Map(readToml(textOf(view, '/soldeer.lock') ?? '').dependencies?.map((entry) => [entry.name, entry.version]) ?? [])
99
+ const deps = Object.entries(config.dependencies ?? {}).map(([name, value]) => {
100
+ const req = typeof value === 'string' ? value : value.version
101
+ const version = locked.get(name)
102
+ const remapped = `${settings.remappings_prefix}${name}${settings.remappings_version ? `-${req.replaceAll('=', '')}` : ''}/`
103
+ return { name, req, version, folder: `${name}-${version}`, remapped }
104
+ })
105
+
106
+ const remappingsTxt = textOf(view, '/remappings.txt')
107
+ if (configFile === 'foundry.toml' && settings.remappings_location === 'config') {
108
+ if (remappingsTxt !== undefined) throw new Error(`${where('remappings.txt')}: \`soldeer install\` removes it, as remappings_location is "config"`)
109
+ for (const [name, profile] of Object.entries(isTomlTable(foundry.profile) ? foundry.profile : {})) {
110
+ const current = Array.isArray(profile?.remappings) ? profile.remappings : undefined
111
+ if (current === undefined && name !== 'default') continue
112
+ const updated = updatedRemappings(pairs((current ?? []).filter((r) => typeof r === 'string')), deps, { regenerate: settings.remappings_regenerate, where: where('foundry.toml') })
113
+ if (current === undefined || updated.length !== current.length || updated.some((r, i) => r !== current[i])) {
114
+ throw new Error(`${where('foundry.toml')}: [profile.${name}] remappings are not as \`soldeer install\` leaves them`)
115
+ }
116
+ }
117
+ return
118
+ }
119
+
120
+ const lines = (settings.remappings_regenerate ? '' : remappingsTxt ?? '').split('\n')
121
+ if (lines.at(-1) === '') lines.pop()
122
+ const existing = pairs(lines.map((line) => line.replace(/\r$/u, '')))
123
+ const text = updatedRemappings(existing, deps, { regenerate: settings.remappings_regenerate, where: where('remappings.txt') }).map((line) => `${line}\n`).join('')
124
+ if (text !== remappingsTxt) vfs.writeFile('/remappings.txt', text, { mode: remappingsTxt === undefined ? 0o644 : view.lstat('/remappings.txt').mode })
125
+ }