@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.
- package/README.md +23 -3
- package/bin/stasis.js +375 -170
- package/package.json +44 -11
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +243 -59
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +95 -128
- package/src/cmd/bundle.js +877 -641
- package/src/cmd/diff.js +2 -19
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +4 -18
- package/src/diff.js +67 -124
- package/src/json-stream.js +283 -0
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +49 -116
- package/src/loaders/cargo.js +1699 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +339 -394
- package/src/loaders/rust.js +3254 -242
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -162
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +150 -0
- package/src/mock.js +57 -165
- package/src/parse.js +4 -16
- package/src/resolve-fields.js +331 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +427 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +64 -92
- package/src/scan.js +234 -156
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +193 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +606 -0
- package/src/vfs-bundle.js +74 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -44
- package/src/cmd/extract.js +0 -193
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
// Who owns each file of a Solidity project -- the project or one of its dependencies -- decided by
|
|
2
|
+
// where the file really is, for the import resolution (solidity.js), forge's config discovery
|
|
3
|
+
// (foundry.js) and the bundler's --manifests. Dependencies are untrusted input: a link one plants
|
|
4
|
+
// out of itself is never followed.
|
|
5
|
+
|
|
6
|
+
import { realpathSync } from 'node:fs'
|
|
7
|
+
import { isAbsolute, join, parse, posix, relative, resolve, sep } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { utf8toString } from '@exodus/bytes/utf8.js'
|
|
10
|
+
import { LockfileError, parseGitmodules } from '@preventive/lockfile/foundry.js'
|
|
11
|
+
import { NO_ENTRY, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util'
|
|
12
|
+
import { diskHost } from '@exodus/stasis-core/host'
|
|
13
|
+
import { hasNodeModulesSegment } from '@exodus/stasis-core/util'
|
|
14
|
+
import { isDir } from '../resolve-typescript.js'
|
|
15
|
+
|
|
16
|
+
// `/`-separated, as the loader's paths are: only Windows' separator is converted (on POSIX a `\\` is
|
|
17
|
+
// part of a name, and must not read as a directory boundary).
|
|
18
|
+
const toSlashes = (p) => (sep === '\\' ? p.replaceAll('\\', '/') : p)
|
|
19
|
+
|
|
20
|
+
// --- Reading --------------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
// `p`'s real path as `host` resolves it, which throws when it can't: on disk, as the OS does
|
|
23
|
+
// (realpath(3): the filesystem's own spelling, which Node's realpathSync doesn't give).
|
|
24
|
+
const realpathIn = (host, p) => (host === diskHost ? realpathSync.native(p) : host.realpath(p))
|
|
25
|
+
|
|
26
|
+
// `p`'s real path (realpathIn), or null.
|
|
27
|
+
export function realpathOrNull(p, host = diskHost) {
|
|
28
|
+
try {
|
|
29
|
+
return realpathIn(host, p)
|
|
30
|
+
} catch {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A path relative to a dir (slashes) that stays inside it.
|
|
36
|
+
const inRoot = (rel) => rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel)
|
|
37
|
+
|
|
38
|
+
// `abs`, a file the resolution read, relative to the project `root` (slashes): as spelled when that
|
|
39
|
+
// lies inside it with no `..` to resolve (a linked lib's files keep the lib's path), else by real
|
|
40
|
+
// paths -- where the read went (an absolute or `/proc/self/cwd` lib; a `..` after a symlink) --
|
|
41
|
+
// `../` when outside the project. One whose real path the OS can't give (past PATH_MAX) is never
|
|
42
|
+
// normalized, which could name another file: it keeps the path it was read by, `..` and all, from
|
|
43
|
+
// the root however that's spelled (as given or by its real path), for solidityOwnership to refuse,
|
|
44
|
+
// or else stays absolute, a name --manifests refuses as unresolvable. Real paths are `host`'s.
|
|
45
|
+
export function projectRelative(root, abs, host = diskHost) {
|
|
46
|
+
const rel = toSlashes(relative(root, abs))
|
|
47
|
+
if (inRoot(rel) && !toSlashes(abs).split('/').includes('..')) return rel
|
|
48
|
+
const real = realpathOrNull(abs, host)
|
|
49
|
+
const realRoot = realpathOrNull(root, host)
|
|
50
|
+
if (real !== null) return toSlashes(relative(realRoot ?? root, real))
|
|
51
|
+
return below(resolve(root), abs) ?? (realRoot === null ? null : below(realRoot, abs)) ?? toSlashes(abs)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// `abs` from `dir`, component by component as spelled (empty and `.` ones dropped, `..` kept), or
|
|
55
|
+
// null when it doesn't start with `dir`'s components.
|
|
56
|
+
function below(dir, abs) {
|
|
57
|
+
const parts = (p) => toSlashes(p).split('/').filter((c) => c !== '' && c !== '.')
|
|
58
|
+
const d = parts(dir)
|
|
59
|
+
const a = parts(abs)
|
|
60
|
+
return d.length < a.length && d.every((c, i) => a[i] === c) ? a.slice(d.length).join('/') : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// realpathIn `host` of `p`: `{ real }`, or `{ real: null, missing }`, `missing` only when nothing
|
|
64
|
+
// is there at all. The OS may fail to resolve what is there -- a real path past PATH_MAX, a loop, a
|
|
65
|
+
// link whose end it can't name (`/proc/self/fd/0` on a pipe), a dir it may not search -- and a
|
|
66
|
+
// read may still get through.
|
|
67
|
+
function osRealpath(p, host) {
|
|
68
|
+
try {
|
|
69
|
+
return { real: realpathIn(host, p), missing: false }
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { real: null, missing: NO_ENTRY.has(err.code) && !lexists(p, host) }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Whether anything is at `p` itself, a link not followed (`host.readlink` throws when nothing is).
|
|
76
|
+
function lexists(p, host) {
|
|
77
|
+
try {
|
|
78
|
+
host.readlink(p)
|
|
79
|
+
return true
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (NO_ENTRY.has(err.code)) return false
|
|
82
|
+
throw err
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// `bytes` as UTF-8 text, a byte-order mark kept. Bytes that aren't UTF-8 throw, naming them
|
|
87
|
+
// `label`, rather than read with U+FFFD in their place: forge, solc and git refuse such a file, and
|
|
88
|
+
// the text bundled or read must be the file's own.
|
|
89
|
+
export function decodeUtf8(bytes, label) {
|
|
90
|
+
try {
|
|
91
|
+
return utf8toString(bytes)
|
|
92
|
+
} catch (err) {
|
|
93
|
+
throw new Error(`${label}: not valid UTF-8`, { cause: err })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A config file's text (decodeUtf8), or null when there's no file (readRegularFileOrNull: a regular
|
|
98
|
+
// file only, read through `host`). Errors name it `label`.
|
|
99
|
+
export function readUtf8OrNull(file, label, host = diskHost) {
|
|
100
|
+
const buf = readRegularFileOrNull(file, label, host)
|
|
101
|
+
return buf === null ? null : decodeUtf8(buf, label)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- .gitmodules ------------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
// The submodules of the project at `baseDir`, `{ path, url, branch }` (`url` and `branch` when set),
|
|
107
|
+
// from its `.gitmodules` as @preventive/lockfile reads it (as git does). A url is taken as written
|
|
108
|
+
// (`checkUrls: false`): relative to the superproject's remote, a path or none, as it only names a
|
|
109
|
+
// GitHub submodule's bucket. A file the library refuses -- something git reads two ways, or that it
|
|
110
|
+
// doesn't check (`update = none`, `active`, a `[core]` section) -- never fails the bundle: it's
|
|
111
|
+
// warned about and read submodule by submodule (gitmodulesLeniently). One git itself refuses is an
|
|
112
|
+
// error: read past what git can't, a submodule's section would be lost, and its directory with it.
|
|
113
|
+
export function readGitmodules(baseDir, host = diskHost) {
|
|
114
|
+
const text = readUtf8OrNull(join(baseDir, '.gitmodules'), '.gitmodules', host)
|
|
115
|
+
if (text === null) return []
|
|
116
|
+
try {
|
|
117
|
+
return Object.values(parseGitmodules(text, { checkUrls: false }))
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (!(err instanceof LockfileError)) throw err
|
|
120
|
+
const { submodules, notes } = gitmodulesLeniently(text)
|
|
121
|
+
if (!notes.some((note) => note.startsWith(`${err.message};`))) notes.unshift(`${err.message}; reading it submodule by submodule`)
|
|
122
|
+
for (const note of notes) console.warn(`[loader.solidity] .gitmodules: ${note}`)
|
|
123
|
+
return submodules
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The keys a submodule is read for; past `path`, they're dropped in this order to read the rest.
|
|
128
|
+
const SUBMODULE_KEYS = new Set(['path', 'url', 'branch'])
|
|
129
|
+
const DROPPABLE = ['branch', 'url']
|
|
130
|
+
|
|
131
|
+
// `.gitmodules` text the library refused as a whole, read as git reads it (readGitConfig) a
|
|
132
|
+
// submodule at a time: a key of `submodule.<name>.<key>`, from `[submodule "name"]` or
|
|
133
|
+
// `[submodule.name]`, its last `path`, `url` and `branch` (the sections of one name merged), as git
|
|
134
|
+
// reads the checkout's .gitmodules -- a value replaces an earlier one, but for a path or url git
|
|
135
|
+
// ignores, one starting with `-` -- each submodule then read by the library alone. One that still
|
|
136
|
+
// doesn't read loses its branch, then its url; one whose path doesn't read fails closed: its
|
|
137
|
+
// directory, when the path names one inside the repository (`./lib/x`, `lib/x/`), is still a
|
|
138
|
+
// dependency, just unnamed, and else the submodule is skipped. Every other path given it is a
|
|
139
|
+
// dependency too: git reads the first of two where it reads .gitmodules from a commit, so no path a
|
|
140
|
+
// submodule is given may name the project's own code. `notes` say what was dropped.
|
|
141
|
+
function gitmodulesLeniently(text) {
|
|
142
|
+
const sections = new Map() // a submodule's name -> Map<key, its entry>
|
|
143
|
+
const paths = new Map() // a submodule's name -> every `path` given it
|
|
144
|
+
const notes = []
|
|
145
|
+
for (const { section, subsection, header, keys } of readGitConfig(text)) {
|
|
146
|
+
const variable = subsection === undefined ? section : `${section}.${subsection}`
|
|
147
|
+
if (!variable?.startsWith('submodule.')) continue
|
|
148
|
+
const name = variable.slice('submodule.'.length)
|
|
149
|
+
if (section !== 'submodule') notes.push(`${header}, a section git reads as [submodule "${name}"]; reading it as that`)
|
|
150
|
+
const kept = sections.get(name) ?? sections.set(name, new Map()).get(name)
|
|
151
|
+
const given = paths.get(name) ?? paths.set(name, []).get(name)
|
|
152
|
+
for (const entry of keys) {
|
|
153
|
+
if (!SUBMODULE_KEYS.has(entry.key)) continue
|
|
154
|
+
if (entry.key === 'path') given.push(entry.value)
|
|
155
|
+
if (entry.key !== 'branch' && kept.has(entry.key) && entry.value?.startsWith('-')) continue
|
|
156
|
+
kept.set(entry.key, entry)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const submodules = []
|
|
160
|
+
for (const [name, kept] of sections) {
|
|
161
|
+
const before = submodules.length
|
|
162
|
+
const header = `[submodule "${name.replaceAll(/["\\]/gu, '\\$&')}"]`
|
|
163
|
+
const read = () => Object.values(parseGitmodules([header, ...[...kept.values()].map((entry) => entry.text), ''].join('\n'), { checkUrls: false }))
|
|
164
|
+
let first = null
|
|
165
|
+
const dropped = []
|
|
166
|
+
for (;;) {
|
|
167
|
+
try {
|
|
168
|
+
submodules.push(...read())
|
|
169
|
+
if (first !== null) notes.push(`${first.message}; ignoring its ${dropped.join(' and ')}`)
|
|
170
|
+
break
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (!(err instanceof LockfileError)) throw err
|
|
173
|
+
first ??= err
|
|
174
|
+
const next = DROPPABLE.find((key) => kept.has(key))
|
|
175
|
+
if (next !== undefined) {
|
|
176
|
+
kept.delete(next)
|
|
177
|
+
dropped.push(next)
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
const path = normalSubmodulePath(kept.get('path')?.value)
|
|
181
|
+
if (path === null) {
|
|
182
|
+
notes.push(`${first.message}; skipping the submodule`)
|
|
183
|
+
} else {
|
|
184
|
+
submodules.push({ path, url: undefined, branch: undefined })
|
|
185
|
+
notes.push(`${first.message}; still taking ${path} as a dependency, unnamed`)
|
|
186
|
+
}
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const taken = new Set(submodules.slice(before).map((s) => s.path))
|
|
191
|
+
for (const path of paths.get(name).map(normalSubmodulePath)) {
|
|
192
|
+
if (path === null || taken.has(path)) continue
|
|
193
|
+
taken.add(path)
|
|
194
|
+
submodules.push({ path, url: undefined, branch: undefined })
|
|
195
|
+
notes.push(`${header}: path ${path} too, which git reads where it reads .gitmodules from a commit; still taking it as a dependency, unnamed`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { submodules, notes }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// A `path`'s value -> the directory it names, normalized, when that lies inside the repository (else
|
|
202
|
+
// null): `./lib/x` and `lib/x/` are lib/x.
|
|
203
|
+
function normalSubmodulePath(value) {
|
|
204
|
+
if (typeof value !== 'string') return null
|
|
205
|
+
const path = posix.normalize(value).replace(/\/+$/u, '')
|
|
206
|
+
return path !== '' && path !== '.' && inRoot(path) ? path : null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// git's ctype, ASCII alone: only these are space, and a key is letters, digits and `-`, starting with
|
|
210
|
+
// a letter.
|
|
211
|
+
const GIT_SPACE = new Set([' ', '\t', '\n', '\r'])
|
|
212
|
+
const isGitAlpha = (char) => /^[A-Za-z]$/u.test(char)
|
|
213
|
+
const isGitKeyChar = (char) => /^[\dA-Za-z-]$/u.test(char)
|
|
214
|
+
const GIT_ESCAPES = { __proto__: null, t: '\t', b: '\b', n: '\n', '\\': '\\', '"': '"' }
|
|
215
|
+
|
|
216
|
+
// `.gitmodules` text as git's config.c reads a config file, to the character, refusing what git
|
|
217
|
+
// refuses ("bad config line") and nothing more: its sections in order, each `{ section, subsection,
|
|
218
|
+
// header, keys }` -- the name lowercased, the subsection with a backslash's character for it, the
|
|
219
|
+
// header as written -- and each key `{ key, value, text }`: its name lowercased, its value (null for
|
|
220
|
+
// a key alone) and its text, from the key to the end of its value. Keys before any header are in a
|
|
221
|
+
// first section with no name.
|
|
222
|
+
function readGitConfig(text) {
|
|
223
|
+
const src = text.startsWith('\uFEFF') ? text.slice(1) : text // a byte-order mark git skips
|
|
224
|
+
let pos = 0
|
|
225
|
+
let last = 0 // where the character last read starts
|
|
226
|
+
let line = 1
|
|
227
|
+
let at = 1 // its line
|
|
228
|
+
let eof = false
|
|
229
|
+
// git's get_next_char: CRLF is a line end, a lone CR is space, and the end of the file a line end,
|
|
230
|
+
// read again at every call after.
|
|
231
|
+
const next = () => {
|
|
232
|
+
last = pos
|
|
233
|
+
at = line
|
|
234
|
+
if (pos >= src.length) {
|
|
235
|
+
eof = true
|
|
236
|
+
return '\n'
|
|
237
|
+
}
|
|
238
|
+
let char = src[pos++]
|
|
239
|
+
if (char === '\r' && src[pos] === '\n') char = src[pos++]
|
|
240
|
+
if (char === '\n') line++
|
|
241
|
+
return char
|
|
242
|
+
}
|
|
243
|
+
const refuse = (what) => new Error(`.gitmodules: ${what} at line ${at}; git refuses such a file`)
|
|
244
|
+
|
|
245
|
+
// git's get_base_var and get_extended_base_var: `[name]` or `[name "subsection"]`.
|
|
246
|
+
const readHeader = () => {
|
|
247
|
+
let section = ''
|
|
248
|
+
for (;;) {
|
|
249
|
+
const char = next()
|
|
250
|
+
if (eof) throw refuse('a section header with no closing "]"')
|
|
251
|
+
if (char === ']') break
|
|
252
|
+
if (GIT_SPACE.has(char)) return { section, subsection: readSubsection(char) }
|
|
253
|
+
if (!isGitKeyChar(char) && char !== '.') throw refuse("a character git doesn't take in a section name")
|
|
254
|
+
section += char.toLowerCase()
|
|
255
|
+
}
|
|
256
|
+
if (section === '') throw refuse('a section with no name')
|
|
257
|
+
return { section, subsection: undefined }
|
|
258
|
+
}
|
|
259
|
+
const readSubsection = (first) => {
|
|
260
|
+
let char = first
|
|
261
|
+
do {
|
|
262
|
+
if (char === '\n') throw refuse('a section header that runs past its line')
|
|
263
|
+
char = next()
|
|
264
|
+
} while (GIT_SPACE.has(char))
|
|
265
|
+
if (char !== '"') throw refuse('a section name and then no quoted subsection')
|
|
266
|
+
let subsection = ''
|
|
267
|
+
for (char = next(); char !== '"'; char = next()) {
|
|
268
|
+
if (char === '\\') char = next()
|
|
269
|
+
if (char === '\n') throw refuse('a subsection with no closing quote')
|
|
270
|
+
subsection += char
|
|
271
|
+
}
|
|
272
|
+
if (next() !== ']') throw refuse('a subsection with no "]" right after it')
|
|
273
|
+
return subsection
|
|
274
|
+
}
|
|
275
|
+
// git's parse_value: quotes, escapes, a `\` that runs the value on, a comment outside quotes, and
|
|
276
|
+
// space outside quotes trimmed at both ends.
|
|
277
|
+
const readValue = () => {
|
|
278
|
+
let value = ''
|
|
279
|
+
let quoted = false
|
|
280
|
+
let comment = false
|
|
281
|
+
let trim = -1 // where the space at the end begins, outside quotes
|
|
282
|
+
for (;;) {
|
|
283
|
+
let char = next()
|
|
284
|
+
if (char === '\n') {
|
|
285
|
+
if (quoted) throw refuse('a value with no closing quote')
|
|
286
|
+
return trim === -1 ? value : value.slice(0, trim)
|
|
287
|
+
}
|
|
288
|
+
if (comment) continue
|
|
289
|
+
if (GIT_SPACE.has(char) && !quoted) {
|
|
290
|
+
if (value !== '') {
|
|
291
|
+
if (trim === -1) trim = value.length
|
|
292
|
+
value += char
|
|
293
|
+
}
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
if (!quoted && (char === '#' || char === ';')) {
|
|
297
|
+
comment = true
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
trim = -1
|
|
301
|
+
if (char === '\\') {
|
|
302
|
+
char = next()
|
|
303
|
+
if (char === '\n') continue
|
|
304
|
+
if (!(char in GIT_ESCAPES)) throw refuse("an escape git doesn't read")
|
|
305
|
+
value += GIT_ESCAPES[char]
|
|
306
|
+
} else if (char === '"') {
|
|
307
|
+
quoted = !quoted
|
|
308
|
+
} else {
|
|
309
|
+
value += char
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// git's get_value: a key, and `=` and its value or nothing.
|
|
314
|
+
const readKey = (first, start) => {
|
|
315
|
+
let key = first.toLowerCase()
|
|
316
|
+
let char = next()
|
|
317
|
+
for (; isGitKeyChar(char); char = next()) key += char.toLowerCase() // the end reads as a line end
|
|
318
|
+
while (char === ' ' || char === '\t') char = next()
|
|
319
|
+
let value = null
|
|
320
|
+
if (char !== '\n') {
|
|
321
|
+
if (char !== '=') throw refuse('a key and then neither "=" nor the end of its line')
|
|
322
|
+
value = readValue()
|
|
323
|
+
}
|
|
324
|
+
return { key, value, text: src.slice(start, last) }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const sections = [{ section: undefined, subsection: undefined, header: undefined, keys: [] }]
|
|
328
|
+
let comment = false
|
|
329
|
+
for (;;) {
|
|
330
|
+
const char = next()
|
|
331
|
+
const start = last
|
|
332
|
+
if (char === '\n') {
|
|
333
|
+
if (eof) return sections
|
|
334
|
+
comment = false
|
|
335
|
+
} else if (comment || GIT_SPACE.has(char)) {
|
|
336
|
+
continue
|
|
337
|
+
} else if (char === '#' || char === ';') {
|
|
338
|
+
comment = true
|
|
339
|
+
} else if (char === '[') {
|
|
340
|
+
const { section, subsection } = readHeader()
|
|
341
|
+
sections.push({ section, subsection, header: src.slice(start, pos), keys: [] })
|
|
342
|
+
} else if (isGitAlpha(char)) {
|
|
343
|
+
sections.at(-1).keys.push(readKey(char, start))
|
|
344
|
+
} else {
|
|
345
|
+
throw refuse('text where git reads a key, a section or a comment')
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// --- Ownership --------------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
const readdirOrEmpty = (dir, host) => {
|
|
353
|
+
try {
|
|
354
|
+
return host.readdir(dir)
|
|
355
|
+
} catch {
|
|
356
|
+
return []
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const NOTHING = { abs: null, escape: null }
|
|
361
|
+
// A link target's separators, as the OS reads them (a `\\` is part of a name on POSIX).
|
|
362
|
+
const TARGET_SEPARATORS = sep === '\\' ? /[\\/]/u : /\//u
|
|
363
|
+
|
|
364
|
+
// Who owns each project-relative path, decided from how it resolves on disk. The dependencies are
|
|
365
|
+
// every `node_modules/<pkg>` (`@scope/<pkg>`), each entry of the `dirs` (forge's libs, Soldeer's
|
|
366
|
+
// `dependencies/`; a linked entry is the dependency where it points, as a symlinked
|
|
367
|
+
// `lib/forge-std`), and the `packages` (git submodules). `assert(path)` throws for a path `of`
|
|
368
|
+
// refuses; `of(path)` gives `{ real, outside, dependency, escape }`:
|
|
369
|
+
// - `real`: the real path, spelled as the filesystem spells it (project-relative; null when
|
|
370
|
+
// nothing is there), `outside` when it's out of the root;
|
|
371
|
+
// - `dependency`: the real path lies in a dependency, however the path got there (a project's
|
|
372
|
+
// `src/vendor -> ../lib/dep/src` holds the dependency's code);
|
|
373
|
+
// - `escape`: `{ link, root, why }` (and `reason`, saying so) when the path may not be read: it
|
|
374
|
+
// crosses a symlink that no one trusted placed -- one planted inside the dependency `root` that
|
|
375
|
+
// leads out of it to anything but another dependency (`lib/evil/src/Evil.sol -> ../../../.env`),
|
|
376
|
+
// or one outside the project (`root` null) that leads back into it (a dependency linked from
|
|
377
|
+
// elsewhere: `lib/evil -> ../../shared/evil` holding `Evil.sol -> ../../proj/.env`) -- or
|
|
378
|
+
// (`why: 'unresolved'`) the walk below can't vouch for it: it resolves the path link by link, and
|
|
379
|
+
// where that doesn't land where the OS's realpath does (a link target it can't read as the OS
|
|
380
|
+
// does, one that isn't UTF-8), or the OS can't resolve it at all (a real path past PATH_MAX), the
|
|
381
|
+
// path is refused rather than trusted. `real` null with no `escape` means nothing is there. A
|
|
382
|
+
// link the project placed (a workspace package in node_modules, a linked `lib/` entry) may lead
|
|
383
|
+
// anywhere in the root, and so may one on the path the project was named by (a symlinked
|
|
384
|
+
// checkout, macOS's `/tmp`). The project is read through `host`.
|
|
385
|
+
export function solidityOwnership(baseDir, { dirs = [], packages = [], host = diskHost } = {}) {
|
|
386
|
+
const realBase = realpathIn(host, baseDir)
|
|
387
|
+
const realOf = (p) => realpathOrNull(p, host)
|
|
388
|
+
const named = resolve(baseDir)
|
|
389
|
+
const onNamedPath = (abs) => named === abs || named.startsWith(abs.endsWith(sep) ? abs : `${abs}${sep}`)
|
|
390
|
+
const toRel = (abs) => toSlashes(relative(realBase, abs)) || '.'
|
|
391
|
+
const inside = (rel) => rel !== '.' && inRoot(rel)
|
|
392
|
+
const under = (rel, dir) => rel === dir || rel.startsWith(`${dir}/`)
|
|
393
|
+
const clean = (d) => posix.normalize(toSlashes(d)).replace(/\/+$/u, '')
|
|
394
|
+
|
|
395
|
+
// Dirs whose entries are dependencies, and dependency dirs themselves; each by its real path too.
|
|
396
|
+
const holders = new Set()
|
|
397
|
+
const roots = new Set()
|
|
398
|
+
const addReal = (set, rel) => {
|
|
399
|
+
const real = realOf(join(baseDir, rel))
|
|
400
|
+
if (real !== null && inside(toRel(real))) set.add(toRel(real))
|
|
401
|
+
}
|
|
402
|
+
// A dir as the project names it: relative to the root, or (an absolute lib) by its real path.
|
|
403
|
+
const projectDir = (d) => {
|
|
404
|
+
if (!isAbsolute(d)) return clean(d)
|
|
405
|
+
const real = realOf(d)
|
|
406
|
+
return real === null ? null : toRel(real)
|
|
407
|
+
}
|
|
408
|
+
for (const d of dirs.map(projectDir).filter((rel) => rel !== null && inside(rel))) {
|
|
409
|
+
if (posix.basename(d) === 'node_modules') continue // a package's own rule, below
|
|
410
|
+
holders.add(d)
|
|
411
|
+
addReal(holders, d)
|
|
412
|
+
for (const e of readdirOrEmpty(join(baseDir, d), host)) if (e.isSymbolicLink() && isDir(join(baseDir, d, e.name), host)) addReal(roots, `${d}/${e.name}`)
|
|
413
|
+
}
|
|
414
|
+
for (const p of packages.map(clean).filter(inside)) {
|
|
415
|
+
roots.add(p)
|
|
416
|
+
addReal(roots, p)
|
|
417
|
+
}
|
|
418
|
+
const dependencyDirs = [...holders, ...roots]
|
|
419
|
+
const inDependency = (rel) => inside(rel) && (hasNodeModulesSegment(rel) || dependencyDirs.some((d) => under(rel, d)))
|
|
420
|
+
// The innermost dependency holding `rel`, a real path.
|
|
421
|
+
const rootOf = (rel) => {
|
|
422
|
+
if (!inside(rel)) return null
|
|
423
|
+
const parts = rel.split('/')
|
|
424
|
+
let best = null
|
|
425
|
+
const take = (r) => {
|
|
426
|
+
if (best === null || r.length > best.length) best = r
|
|
427
|
+
}
|
|
428
|
+
for (let i = 0; i < parts.length; i++) {
|
|
429
|
+
const end = i + (parts[i + 1]?.startsWith('@') ? 3 : 2)
|
|
430
|
+
if (parts[i] === 'node_modules' && end <= parts.length) take(parts.slice(0, end).join('/'))
|
|
431
|
+
}
|
|
432
|
+
for (const d of holders) if (rel.startsWith(`${d}/`)) take(`${d}/${rel.slice(d.length + 1).split('/')[0]}`)
|
|
433
|
+
for (const r of roots) if (under(rel, r)) take(r)
|
|
434
|
+
return best
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Resolve `parts` from the real dir `start` as realpath does, checking each symlink crossed
|
|
438
|
+
// (and those its target crosses): `{ abs, escape }`, `abs` null when nothing is there, and
|
|
439
|
+
// spelled as given past the last link. A link's dir and target take the filesystem's spelling (a
|
|
440
|
+
// case-insensitive one finds `lib` for `LIB`) before their owners are judged.
|
|
441
|
+
const walk = (start, parts, depth) => {
|
|
442
|
+
let cur = start
|
|
443
|
+
for (const part of parts) {
|
|
444
|
+
if (part === '' || part === '.') continue
|
|
445
|
+
if (part === '..') {
|
|
446
|
+
cur = parse(cur).root === cur ? cur : join(cur, '..')
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
const next = join(cur, part)
|
|
450
|
+
let target
|
|
451
|
+
try {
|
|
452
|
+
target = host.readlink(next)
|
|
453
|
+
} catch {
|
|
454
|
+
return NOTHING
|
|
455
|
+
}
|
|
456
|
+
if (target === null) {
|
|
457
|
+
cur = next
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
// A target that isn't UTF-8 reads with U+FFFD in it: not a name a string path can spell.
|
|
461
|
+
if (target.includes('\uFFFD')) return NOTHING
|
|
462
|
+
if (depth >= 40) return NOTHING // ELOOP
|
|
463
|
+
const r = walk(isAbsolute(target) ? parse(target).root : cur, target.split(TARGET_SEPARATORS), depth + 1)
|
|
464
|
+
if (r.abs === null || r.escape !== null) return r
|
|
465
|
+
const dir = realOf(cur) ?? cur
|
|
466
|
+
const abs = realOf(r.abs)
|
|
467
|
+
if (abs === null) return NOTHING
|
|
468
|
+
const at = toRel(join(dir, part))
|
|
469
|
+
const to = toRel(abs)
|
|
470
|
+
if (inside(at)) {
|
|
471
|
+
const root = rootOf(toRel(dir))
|
|
472
|
+
if (root !== null && !under(to, root) && !inDependency(to)) return { abs, escape: { link: at, root } }
|
|
473
|
+
} else if (inRoot(to) && !onNamedPath(next)) {
|
|
474
|
+
return { abs, escape: { link: at, root: null } }
|
|
475
|
+
}
|
|
476
|
+
cur = abs
|
|
477
|
+
}
|
|
478
|
+
return { abs: cur, escape: null }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const owners = new Map()
|
|
482
|
+
const of = (rel) => {
|
|
483
|
+
let owner = owners.get(rel)
|
|
484
|
+
if (owner === undefined) {
|
|
485
|
+
// As given, not normalized: the OS resolves a `..` after a link from where the link leads.
|
|
486
|
+
const path = rel === '' ? realBase : `${realBase}${sep}${rel}`
|
|
487
|
+
let { abs, escape } = walk(realBase, rel.split('/'), 0)
|
|
488
|
+
if (escape === null) {
|
|
489
|
+
// The OS's answer is the one a read gets: the walk must agree with it, or the path is
|
|
490
|
+
// refused, as it is when the OS can't resolve it at all, though a read may still get through.
|
|
491
|
+
// (Past its last link the walk's path is spelled as given; with none, it's `path` itself.)
|
|
492
|
+
const { real: os, missing } = osRealpath(path, host)
|
|
493
|
+
const walked = abs === null ? null : abs === path ? os : realOf(abs)
|
|
494
|
+
if (walked !== os || (os === null && !missing)) escape = { link: rel, root: null, why: 'unresolved' }
|
|
495
|
+
abs = os
|
|
496
|
+
}
|
|
497
|
+
const real = abs === null ? null : toRel(abs)
|
|
498
|
+
owner = {
|
|
499
|
+
real,
|
|
500
|
+
outside: real !== null && !inRoot(real),
|
|
501
|
+
dependency: real !== null && inDependency(real),
|
|
502
|
+
escape,
|
|
503
|
+
reason: escape && escapeReason(rel, escape),
|
|
504
|
+
}
|
|
505
|
+
owners.set(rel, owner)
|
|
506
|
+
}
|
|
507
|
+
return owner
|
|
508
|
+
}
|
|
509
|
+
// Throws, saying why, for a path `of` refuses; `what` names it (`'entry '`).
|
|
510
|
+
const assert = (rel, what = '') => {
|
|
511
|
+
const { reason } = of(rel)
|
|
512
|
+
if (reason) throw new Error(`Refusing ${what}${rel}: ${reason}`)
|
|
513
|
+
}
|
|
514
|
+
return { of, assert }
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// The ownership of the project at `baseDir` given its lib dirs (`soldeer`: forge's `dependencies/`
|
|
518
|
+
// holds dependencies too), with its git submodules, which it keeps as `submodules` (readGitmodules),
|
|
519
|
+
// read through `host`.
|
|
520
|
+
export function projectOwnership(baseDir, libs, { soldeer = false, host = diskHost } = {}) {
|
|
521
|
+
const submodules = readGitmodules(baseDir, host)
|
|
522
|
+
const dirs = [...libs, ...(soldeer ? ['dependencies'] : [])]
|
|
523
|
+
return { ...solidityOwnership(baseDir, { dirs, packages: submodules.map((s) => s.path), host }), submodules }
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Why a path is refused (see solidityOwnership).
|
|
527
|
+
function escapeReason(path, { link, root, why }) {
|
|
528
|
+
if (why === 'unresolved') return `${path} crosses a link stasis can't follow the way the filesystem does`
|
|
529
|
+
const what = root === null ? 'a link from outside the project root back into it' : `a link out of the dependency ${root}`
|
|
530
|
+
return link === path ? `${path} is ${what}` : `it resolves to ${path} through ${link}, ${what}`
|
|
531
|
+
}
|