@exodus/stasis 1.0.0-beta.3 → 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 +7 -1
- package/bin/stasis.js +236 -17
- package/package.json +36 -9
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +153 -108
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +3 -5
- package/src/cmd/bundle.js +571 -376
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +3 -11
- package/src/diff.js +6 -6
- package/src/json-stream.js +283 -0
- package/src/loaders/bash.js +17 -36
- 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 +208 -124
- package/src/loaders/rust.js +3234 -166
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -137
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +8 -23
- package/src/mock.js +3 -5
- 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 +32 -18
- package/src/scan.js +79 -43
- 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 -42
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
5
|
+
import { buildGitHubBundle } from '../vfs-bundle/github.js'
|
|
6
|
+
import { bundledSummary, writeBundle, writeFile } from './output.js'
|
|
7
|
+
|
|
8
|
+
// The longest file name ext4, APFS and NTFS take: 255 bytes, or characters, ours being ASCII.
|
|
9
|
+
const NAME_MAX = 255
|
|
10
|
+
// Each character outside the portable filename set [A-Za-z0-9._-] made `_`.
|
|
11
|
+
const portable = (s) => s.replaceAll(/[^\w.-]/gu, '_')
|
|
12
|
+
|
|
13
|
+
// The default output of `stasis github-bundle`: `owner-name.<commit's first 7>.stasis.code.br` of
|
|
14
|
+
// the repo, or `owner-name.<directory, its / made ->.<commit's first 7>.stasis.code.br` of a
|
|
15
|
+
// directory in it, portable. A directory too deep for that to fit in NAME_MAX keeps what fits of
|
|
16
|
+
// its start, and `_` and the first 8 of its sha256 after it; a valid `github` (at most 140
|
|
17
|
+
// characters) leaves at least 91 for it.
|
|
18
|
+
export function githubBundleFile({ github, directory, commit }) {
|
|
19
|
+
const repo = portable(github.replace('/', '-'))
|
|
20
|
+
const tail = `.${portable(commit.slice(0, 7))}.stasis.code.br`
|
|
21
|
+
if (!directory) return `${repo}${tail}`
|
|
22
|
+
const room = NAME_MAX - `${repo}.${tail}`.length
|
|
23
|
+
let dir = portable(directory.replaceAll('/', '-'))
|
|
24
|
+
if (dir.length > room) dir = `${dir.slice(0, room - 9)}_${createHash('sha256').update(directory).digest('hex').slice(0, 8)}`
|
|
25
|
+
return `${repo}.${dir}${tail}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Run `stasis github-bundle`: the bundle of a GitHub repo at a commit, or the one a tag names (the
|
|
29
|
+
// default branch's head without either), as buildGitHubBundle builds it from `options`, written
|
|
30
|
+
// brotli-compressed to `output` (githubBundleFile's name for it by default, `-` for stdout), and a
|
|
31
|
+
// JS bundle's lockfile to `lockfile` where given.
|
|
32
|
+
// The tree is fetched with GITHUB_TOKEN from `env` where it is set; nothing else of `env` is read.
|
|
33
|
+
export async function githubBundleCommand({ cwd = process.cwd(), env = process.env, output, lockfile, brotliQuality, client, ...options } = {}) {
|
|
34
|
+
const built = await buildGitHubBundle({ ...options, lockfile, client: client ?? createClient({ token: env.GITHUB_TOKEN || null }) })
|
|
35
|
+
const dest = writeBundle(cwd, output ?? githubBundleFile({ github: options.github, directory: options.directory, commit: built.bundle.repo.commit }), built.bundle.serialize(), brotliQuality)
|
|
36
|
+
if (lockfile !== undefined) writeFile(resolve(cwd, lockfile), built.lockfile.serialize())
|
|
37
|
+
const from = `${options.github}@${built.bundle.repo.commit}${options.directory ? `/${options.directory}` : ''}`
|
|
38
|
+
console.warn(bundledSummary(built.bundle.sources.size, built.bundle.modules, from, dest))
|
|
39
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
import { brotliCompressSync } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
import { brotliOptions } from '@exodus/stasis-core/brotli'
|
|
6
|
+
|
|
7
|
+
// Default bundle output: stasis.code.br, the same name `stasis run --bundle=load` discovers, so the two round-trip with no flags.
|
|
8
|
+
export const DEFAULT_BUNDLE_FILE = 'stasis.code.br'
|
|
9
|
+
|
|
10
|
+
// Write `data` to the absolute path `abs`, creating its directory.
|
|
11
|
+
export function writeFile(abs, data) {
|
|
12
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
13
|
+
writeFileSync(abs, data)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Write `data` to `target` (resolved against `cwd`), or to stdout for `-`. -> where it went, for the summary
|
|
17
|
+
export function writeOutput(cwd, target, data) {
|
|
18
|
+
if (target === '-') {
|
|
19
|
+
process.stdout.write(data)
|
|
20
|
+
return '<stdout>'
|
|
21
|
+
}
|
|
22
|
+
writeFile(resolve(cwd, target), data)
|
|
23
|
+
return target
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Write a bundle's `serialized` text brotli-compressed (at `brotliQuality`) as writeOutput does. -> where it went
|
|
27
|
+
export const writeBundle = (cwd, target, serialized, brotliQuality) => writeOutput(cwd, target, brotliCompressSync(serialized, brotliOptions(brotliQuality)))
|
|
28
|
+
|
|
29
|
+
// `N package(s)`: every non-empty bucket of a bundle's `modules` (the "." workspace bucket counts as one).
|
|
30
|
+
export function packagesLabel(modules) {
|
|
31
|
+
const packages = [...modules.values()].filter((m) => Object.keys(m.files).length > 0).length
|
|
32
|
+
return `${packages} package${packages === 1 ? '' : 's'}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The summary of a bundle of `count` files in `modules` written from `from` to `dest`.
|
|
36
|
+
export const bundledSummary = (count, modules, from, dest) => `[stasis] Bundled ${count} files in ${packagesLabel(modules)} from ${from} to ${dest}`
|
package/src/cmd/sbom.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname, resolve } from 'node:path'
|
|
1
|
+
import { resolve } from 'node:path'
|
|
3
2
|
|
|
4
3
|
import { parseFile } from '../parse.js'
|
|
5
4
|
import { collectComponents, generateSbom } from '../sbom.js'
|
|
5
|
+
import { writeOutput } from './output.js'
|
|
6
6
|
|
|
7
7
|
// The one-line summary goes to stderr so it never corrupts an SBOM streamed to stdout.
|
|
8
8
|
export function sbomCommand({ cwd = process.cwd(), files, format, output, now, uuid } = {}) {
|
|
@@ -14,15 +14,7 @@ export function sbomCommand({ cwd = process.cwd(), files, format, output, now, u
|
|
|
14
14
|
const doc = generateSbom(format, components, { now, uuid })
|
|
15
15
|
const text = JSON.stringify(doc, undefined, 2) + '\n'
|
|
16
16
|
|
|
17
|
-
const
|
|
18
|
-
if (target === '-') {
|
|
19
|
-
process.stdout.write(text)
|
|
20
|
-
} else {
|
|
21
|
-
const outAbs = resolve(cwd, target)
|
|
22
|
-
mkdirSync(dirname(outAbs), { recursive: true })
|
|
23
|
-
writeFileSync(outAbs, text)
|
|
24
|
-
}
|
|
25
|
-
const dest = target === '-' ? '<stdout>' : target
|
|
17
|
+
const dest = writeOutput(cwd, output ?? '-', text)
|
|
26
18
|
const n = components.length
|
|
27
19
|
console.warn(`[stasis] Wrote ${format} SBOM with ${n} component${n === 1 ? '' : 's'} to ${dest}`)
|
|
28
20
|
return { components, doc }
|
package/src/diff.js
CHANGED
|
@@ -19,6 +19,8 @@ function digestOf(kind, format, value, hash) {
|
|
|
19
19
|
return hash(value)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
const projectPath = (dir, rel) => (dir === '.' ? rel : `${dir}/${rel}`)
|
|
23
|
+
|
|
22
24
|
// Project an artifact onto a uniform shape: scope + Map<dir, { name, version, ecosystem,
|
|
23
25
|
// files: Map<rel, digest> }>. `input` is { artifact, kind }; `hash` re-hashes bundle bytes only.
|
|
24
26
|
export function normalizeArtifact(input, { hash } = {}) {
|
|
@@ -31,8 +33,7 @@ export function normalizeArtifact(input, { hash } = {}) {
|
|
|
31
33
|
for (const [dir, { name, version, ecosystem, files }] of artifact.modules) {
|
|
32
34
|
const digests = new Map()
|
|
33
35
|
for (const [rel, value] of Object.entries(files)) {
|
|
34
|
-
|
|
35
|
-
digests.set(rel, digestOf(kind, formats.get(full), value, hash))
|
|
36
|
+
digests.set(rel, digestOf(kind, formats.get(projectPath(dir, rel)), value, hash))
|
|
36
37
|
}
|
|
37
38
|
// v0 bundles record no name/version; normalize undefined -> null for a single "unknown" sentinel.
|
|
38
39
|
modules.set(dir, { name: name ?? null, version: version ?? null, ecosystem: ecosystem ?? null, files: digests })
|
|
@@ -42,8 +43,6 @@ export function normalizeArtifact(input, { hash } = {}) {
|
|
|
42
43
|
return { scope: artifact.config?.scope ?? 'full', modules, executable: artifact.executable ?? new Set() }
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
const projectPath = (dir, rel) => (dir === '.' ? rel : `${dir}/${rel}`)
|
|
46
|
-
|
|
47
46
|
// Diff two `{ artifact, kind }` operands (`left` = baseline/"from", `right` = "to"). Modules are
|
|
48
47
|
// compared whole-package; files only within packages present on BOTH sides (a one-sided package is
|
|
49
48
|
// reported once as an added/removed module, not per-file). A version/name change is reported only
|
|
@@ -60,16 +59,17 @@ export function diffArtifacts(left, right, { imports = false, hash } = {}) {
|
|
|
60
59
|
const filesRemoved = []
|
|
61
60
|
const filesDiffering = []
|
|
62
61
|
|
|
62
|
+
const summary = (dir, m) => ({ dir, name: m.name, version: m.version, ecosystem: m.ecosystem, files: m.files.size })
|
|
63
63
|
const dirs = new Set([...L.modules.keys(), ...R.modules.keys()])
|
|
64
64
|
for (const dir of dirs) {
|
|
65
65
|
const l = L.modules.get(dir)
|
|
66
66
|
const r = R.modules.get(dir)
|
|
67
67
|
if (l && !r) {
|
|
68
|
-
modulesRemoved.push(
|
|
68
|
+
modulesRemoved.push(summary(dir, l))
|
|
69
69
|
continue
|
|
70
70
|
}
|
|
71
71
|
if (!l && r) {
|
|
72
|
-
modulesAdded.push(
|
|
72
|
+
modulesAdded.push(summary(dir, r))
|
|
73
73
|
continue
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Incremental JSON parser over UTF-8 bytes, holding only the value being built and one token in flight.
|
|
2
|
+
// Exactly JSON.parse(Buffer.concat(chunks).toString('utf8')) for any chunking (json-stream.test.js checks it),
|
|
3
|
+
// so a bundle read in a stream can't parse differently from one read whole. Pure: no Node builtins.
|
|
4
|
+
// Not mirrored: Node 24+'s JSON.parse can return a stale escaped key cached by an earlier call (a V8 bug).
|
|
5
|
+
|
|
6
|
+
const QUOTE = 0x22
|
|
7
|
+
const BACKSLASH = 0x5c
|
|
8
|
+
|
|
9
|
+
// What the next significant byte may be.
|
|
10
|
+
const VALUE = 0 // any value: the top level, after ':' or after an array's ','
|
|
11
|
+
const ARRAY_FIRST = 1 // after '[': a value or ']'
|
|
12
|
+
const OBJECT_FIRST = 2 // after '{': a key or '}'
|
|
13
|
+
const KEY = 3 // after an object's ',': a key
|
|
14
|
+
const COLON = 4
|
|
15
|
+
const AFTER = 5 // after a member or element: ',' or the container's closer
|
|
16
|
+
const DONE = 6 // the top-level value is complete: whitespace only
|
|
17
|
+
// Inside a token that can straddle chunks.
|
|
18
|
+
const STRING = 7
|
|
19
|
+
const NUMBER = 8
|
|
20
|
+
const LITERAL = 9
|
|
21
|
+
|
|
22
|
+
const NUMBER_RE = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u
|
|
23
|
+
const LITERALS = new Map([['true', true], ['false', false], ['null', null]])
|
|
24
|
+
|
|
25
|
+
const isWhitespace = (b) => b === 0x20 || b === 0x0a || b === 0x0d || b === 0x09
|
|
26
|
+
// The bytes a number can span; NUMBER_RE checks its grammar once it ends.
|
|
27
|
+
const isNumberByte = (b) => (b >= 0x30 && b <= 0x39) || b === 0x2d || b === 0x2b || b === 0x2e || b === 0x65 || b === 0x45
|
|
28
|
+
const isLetter = (b) => b >= 0x61 && b <= 0x7a
|
|
29
|
+
|
|
30
|
+
const hasControlByte = (bytes) => {
|
|
31
|
+
for (let i = 0; i < bytes.length; i++) if (bytes[i] < 0x20) return true
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const indexOrEnd = (bytes, byte, from) => {
|
|
36
|
+
const at = bytes.indexOf(byte, from)
|
|
37
|
+
return at === -1 ? bytes.length : at
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const concat = (parts) => {
|
|
41
|
+
let length = 0
|
|
42
|
+
for (const part of parts) length += part.length
|
|
43
|
+
const out = new Uint8Array(length)
|
|
44
|
+
let offset = 0
|
|
45
|
+
for (const part of parts) {
|
|
46
|
+
out.set(part, offset)
|
|
47
|
+
offset += part.length
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class JsonStreamParser {
|
|
53
|
+
// ignoreBOM keeps a leading U+FEFF, as Buffer#toString does; no UTF-8 sequence spans a quote, so per-string decoding is exact.
|
|
54
|
+
#decoder = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
55
|
+
#onString
|
|
56
|
+
#state = VALUE
|
|
57
|
+
#stack = [] // open containers, innermost last
|
|
58
|
+
#path = [] // #path[d]: the key or index #stack[d]'s in-flight member lands at
|
|
59
|
+
#result
|
|
60
|
+
#offset = 0 // bytes consumed by earlier writes, for error positions
|
|
61
|
+
#closed = false
|
|
62
|
+
// Cached indexOf results in the current chunk (-1: not searched yet; its length: none left).
|
|
63
|
+
#nextQuote = -1
|
|
64
|
+
#nextBackslash = -1
|
|
65
|
+
#tokenStart = 0
|
|
66
|
+
#isKey = false
|
|
67
|
+
#parts = [] // copied bytes of a string straddling chunks
|
|
68
|
+
#escaped = false
|
|
69
|
+
#pendingEscape = false // a chunk ended on a string's backslash
|
|
70
|
+
#word = '' // a number or literal straddling chunks
|
|
71
|
+
|
|
72
|
+
// onString(value, path) sees each string value (never a key) and its return is stored instead.
|
|
73
|
+
// `path` is the live key/index path: read it, never keep or mutate it.
|
|
74
|
+
constructor({ onString } = {}) {
|
|
75
|
+
this.#onString = onString
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A chunk may be reused once write() returns: the bytes of a token left open are copied.
|
|
79
|
+
write(bytes) {
|
|
80
|
+
if (this.#closed) throw new Error('JsonStreamParser: write after end or failure')
|
|
81
|
+
if (!ArrayBuffer.isView(bytes)) throw new TypeError('JsonStreamParser: write() takes a Uint8Array')
|
|
82
|
+
// A plain Uint8Array view: a Buffer's slice() doesn't copy, and its indexOf/subarray are slower.
|
|
83
|
+
const view = bytes.constructor === Uint8Array ? bytes : new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
84
|
+
try {
|
|
85
|
+
this.#consume(view)
|
|
86
|
+
} catch (error) {
|
|
87
|
+
this.#closed = true
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
this.#offset += view.length
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
end() {
|
|
94
|
+
if (this.#closed) throw new Error('JsonStreamParser: end after end or failure')
|
|
95
|
+
this.#closed = true
|
|
96
|
+
if (this.#state === NUMBER || this.#state === LITERAL) this.#finishWord()
|
|
97
|
+
if (this.#state !== DONE) throw new SyntaxError(`Unexpected end of JSON input at byte ${this.#offset}`)
|
|
98
|
+
const result = this.#result
|
|
99
|
+
this.#result = undefined
|
|
100
|
+
return result
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#fail(message, i) {
|
|
104
|
+
throw new SyntaxError(`${message} at byte ${this.#offset + i}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#consume(bytes) {
|
|
108
|
+
const n = bytes.length
|
|
109
|
+
this.#nextQuote = -1
|
|
110
|
+
this.#nextBackslash = -1
|
|
111
|
+
let i = 0
|
|
112
|
+
while (i < n) {
|
|
113
|
+
const state = this.#state
|
|
114
|
+
if (state === STRING) {
|
|
115
|
+
i = this.#string(bytes, i)
|
|
116
|
+
} else if (state === NUMBER || state === LITERAL) {
|
|
117
|
+
i = this.#scanWord(bytes, i)
|
|
118
|
+
} else if (isWhitespace(bytes[i])) {
|
|
119
|
+
i += 1
|
|
120
|
+
} else {
|
|
121
|
+
i = this.#structural(bytes[i], i)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#structural(b, i) {
|
|
127
|
+
const state = this.#state
|
|
128
|
+
const isArray = Array.isArray(this.#stack.at(-1))
|
|
129
|
+
if ((state === AFTER || state === ARRAY_FIRST || state === OBJECT_FIRST) && b === (isArray ? 0x5d : 0x7d)) {
|
|
130
|
+
this.#close()
|
|
131
|
+
return i + 1
|
|
132
|
+
}
|
|
133
|
+
if (state === AFTER) {
|
|
134
|
+
if (b !== 0x2c) this.#fail(`Unexpected byte 0x${b.toString(16)} after a value`, i)
|
|
135
|
+
this.#state = isArray ? VALUE : KEY
|
|
136
|
+
return i + 1
|
|
137
|
+
}
|
|
138
|
+
if (state === COLON) {
|
|
139
|
+
if (b !== 0x3a) this.#fail(`Expected ':' after a key, got byte 0x${b.toString(16)}`, i)
|
|
140
|
+
this.#state = VALUE
|
|
141
|
+
return i + 1
|
|
142
|
+
}
|
|
143
|
+
if (state === OBJECT_FIRST || state === KEY) {
|
|
144
|
+
if (b !== QUOTE) this.#fail(`Expected a string key, got byte 0x${b.toString(16)}`, i)
|
|
145
|
+
this.#startToken(STRING, i)
|
|
146
|
+
this.#isKey = true
|
|
147
|
+
return i + 1
|
|
148
|
+
}
|
|
149
|
+
if (state === DONE) this.#fail(`Unexpected byte 0x${b.toString(16)} after the JSON value`, i)
|
|
150
|
+
// VALUE or ARRAY_FIRST
|
|
151
|
+
if (isArray) this.#path[this.#stack.length - 1] = this.#stack.at(-1).length
|
|
152
|
+
if (b === QUOTE) {
|
|
153
|
+
this.#startToken(STRING, i)
|
|
154
|
+
this.#isKey = false
|
|
155
|
+
return i + 1
|
|
156
|
+
}
|
|
157
|
+
if (b === 0x7b || b === 0x5b) {
|
|
158
|
+
this.#stack.push(b === 0x7b ? {} : [])
|
|
159
|
+
this.#path.push(undefined)
|
|
160
|
+
this.#state = b === 0x7b ? OBJECT_FIRST : ARRAY_FIRST
|
|
161
|
+
return i + 1
|
|
162
|
+
}
|
|
163
|
+
if (b === 0x2d || (b >= 0x30 && b <= 0x39)) {
|
|
164
|
+
this.#startToken(NUMBER, i)
|
|
165
|
+
return i
|
|
166
|
+
}
|
|
167
|
+
if (b === 0x74 || b === 0x66 || b === 0x6e) {
|
|
168
|
+
this.#startToken(LITERAL, i)
|
|
169
|
+
return i
|
|
170
|
+
}
|
|
171
|
+
return this.#fail(`Unexpected byte 0x${b.toString(16)} where a value was expected`, i)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#startToken(state, i) {
|
|
175
|
+
this.#state = state
|
|
176
|
+
this.#tokenStart = this.#offset + i
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#string(bytes, start) {
|
|
180
|
+
const n = bytes.length
|
|
181
|
+
let i = start
|
|
182
|
+
if (this.#pendingEscape) {
|
|
183
|
+
this.#pendingEscape = false
|
|
184
|
+
i += 1
|
|
185
|
+
}
|
|
186
|
+
for (;;) {
|
|
187
|
+
if (this.#nextQuote < i) this.#nextQuote = indexOrEnd(bytes, QUOTE, i)
|
|
188
|
+
if (this.#nextBackslash < i) this.#nextBackslash = indexOrEnd(bytes, BACKSLASH, i)
|
|
189
|
+
const quote = this.#nextQuote
|
|
190
|
+
const backslash = this.#nextBackslash
|
|
191
|
+
if (backslash < quote) {
|
|
192
|
+
this.#escaped = true
|
|
193
|
+
if (backslash + 1 === n) {
|
|
194
|
+
this.#pendingEscape = true
|
|
195
|
+
break
|
|
196
|
+
}
|
|
197
|
+
i = backslash + 2
|
|
198
|
+
} else if (quote === n) {
|
|
199
|
+
break
|
|
200
|
+
} else {
|
|
201
|
+
this.#finishString(bytes.subarray(start, quote))
|
|
202
|
+
return quote + 1
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
this.#parts.push(bytes.slice(start))
|
|
206
|
+
return n
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#finishString(tail) {
|
|
210
|
+
let bytes = tail
|
|
211
|
+
if (this.#parts.length > 0) {
|
|
212
|
+
this.#parts.push(tail)
|
|
213
|
+
bytes = concat(this.#parts)
|
|
214
|
+
this.#parts = []
|
|
215
|
+
}
|
|
216
|
+
let value = this.#decoder.decode(bytes)
|
|
217
|
+
if (this.#escaped) {
|
|
218
|
+
// JSON.parse handles the escapes, and rejects raw control bytes.
|
|
219
|
+
this.#escaped = false
|
|
220
|
+
try {
|
|
221
|
+
value = JSON.parse(`"${value}"`)
|
|
222
|
+
} catch (cause) {
|
|
223
|
+
throw new SyntaxError(`Bad string at byte ${this.#tokenStart}`, { cause })
|
|
224
|
+
}
|
|
225
|
+
} else if (hasControlByte(bytes)) {
|
|
226
|
+
throw new SyntaxError(`Bad control character in string at byte ${this.#tokenStart}`)
|
|
227
|
+
}
|
|
228
|
+
if (this.#isKey) {
|
|
229
|
+
this.#path[this.#path.length - 1] = value
|
|
230
|
+
this.#state = COLON
|
|
231
|
+
} else {
|
|
232
|
+
this.#value(this.#onString === undefined ? value : this.#onString(value, this.#path))
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#scanWord(bytes, i) {
|
|
237
|
+
const n = bytes.length
|
|
238
|
+
const isNumber = this.#state === NUMBER
|
|
239
|
+
let j = i
|
|
240
|
+
while (j < n && (isNumber ? isNumberByte(bytes[j]) : isLetter(bytes[j]))) j += 1
|
|
241
|
+
this.#word += this.#decoder.decode(bytes.subarray(i, j))
|
|
242
|
+
if (j === n) return n
|
|
243
|
+
this.#finishWord()
|
|
244
|
+
return j
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#finishWord() {
|
|
248
|
+
const word = this.#word
|
|
249
|
+
this.#word = ''
|
|
250
|
+
if (this.#state === NUMBER) {
|
|
251
|
+
if (!NUMBER_RE.test(word)) throw new SyntaxError(`Bad number at byte ${this.#tokenStart}`)
|
|
252
|
+
this.#value(Number(word))
|
|
253
|
+
} else {
|
|
254
|
+
if (!LITERALS.has(word)) throw new SyntaxError(`Bad literal at byte ${this.#tokenStart}`)
|
|
255
|
+
this.#value(LITERALS.get(word))
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#close() {
|
|
260
|
+
this.#path.pop()
|
|
261
|
+
this.#value(this.#stack.pop())
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#value(value) {
|
|
265
|
+
const depth = this.#stack.length
|
|
266
|
+
if (depth === 0) {
|
|
267
|
+
this.#result = value
|
|
268
|
+
this.#state = DONE
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
const container = this.#stack[depth - 1]
|
|
272
|
+
const key = this.#path[depth - 1]
|
|
273
|
+
if (Array.isArray(container)) {
|
|
274
|
+
container.push(value)
|
|
275
|
+
} else if (key === '__proto__') {
|
|
276
|
+
// JSON.parse defines an own data property; assignment would hit Object.prototype's setter.
|
|
277
|
+
Object.defineProperty(container, key, { value, writable: true, enumerable: true, configurable: true })
|
|
278
|
+
} else {
|
|
279
|
+
container[key] = value
|
|
280
|
+
}
|
|
281
|
+
this.#state = AFTER
|
|
282
|
+
}
|
|
283
|
+
}
|
package/src/loaders/bash.js
CHANGED
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
// paths, absolute/escaping paths) is best-effort and dropped. A dynamically
|
|
5
5
|
// sourced path can be pinned by a `# shellcheck source=...` directive.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { isUtf8 } from 'node:buffer'
|
|
8
|
+
import { realpathSync } from 'node:fs'
|
|
8
9
|
import { readFile } from 'node:fs/promises'
|
|
9
|
-
import { dirname, extname,
|
|
10
|
+
import { dirname, extname, join, resolve } from 'node:path'
|
|
10
11
|
|
|
11
12
|
import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
|
|
13
|
+
import { isFile } from '../resolve-typescript.js'
|
|
14
|
+
import { applyToDir, assertWithinBase } from './paths.js'
|
|
12
15
|
|
|
13
16
|
// The capture class excludes backtick/`(`/`)` so a ref inside a command
|
|
14
17
|
// substitution (`` `source x.sh` ``, `$(bash x.sh)`) doesn't swallow the closing delimiter.
|
|
@@ -51,31 +54,12 @@ export function resolveBashCall(ref, fromFile, { knownFiles, baseDir } = {}) {
|
|
|
51
54
|
const present = (p) => {
|
|
52
55
|
if (!p) return false
|
|
53
56
|
if (knownFiles) return knownFiles.has(p)
|
|
54
|
-
if (baseDir)
|
|
55
|
-
try {
|
|
56
|
-
return statSync(join(baseDir, p)).isFile()
|
|
57
|
-
} catch {
|
|
58
|
-
return false
|
|
59
|
-
}
|
|
60
|
-
}
|
|
57
|
+
if (baseDir) return isFile(join(baseDir, p))
|
|
61
58
|
return false
|
|
62
59
|
}
|
|
63
60
|
|
|
64
|
-
|
|
65
|
-
const resolveRel = (r) =>
|
|
66
|
-
const parts = [...(fromDir ? fromDir.split('/') : []), ...r.split('/')]
|
|
67
|
-
const out = []
|
|
68
|
-
for (const part of parts) {
|
|
69
|
-
if (part === '.' || part === '') continue
|
|
70
|
-
if (part === '..') {
|
|
71
|
-
if (out.length === 0) return null // escapes baseDir
|
|
72
|
-
out.pop()
|
|
73
|
-
} else {
|
|
74
|
-
out.push(part)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return out.join('/') || null
|
|
78
|
-
}
|
|
61
|
+
// Null when it escapes baseDir or names baseDir itself.
|
|
62
|
+
const resolveRel = (r) => applyToDir(fromFile, r.split('/')) || null
|
|
79
63
|
|
|
80
64
|
// Absolute → project-relative, accepted only if it exists (so `/etc/...` is dropped).
|
|
81
65
|
if (ref.startsWith('/')) {
|
|
@@ -128,6 +112,13 @@ export function buildBashTree(sources) {
|
|
|
128
112
|
return { sources, resolutions, missing }
|
|
129
113
|
}
|
|
130
114
|
|
|
115
|
+
// A script's text. One that isn't UTF-8 is refused rather than carried with U+FFFD in it.
|
|
116
|
+
async function readScript(baseDir, relPath) {
|
|
117
|
+
const bytes = await readFile(join(baseDir, relPath))
|
|
118
|
+
if (!isUtf8(bytes)) throw new Error(`Shell script is not valid UTF-8: ${relPath}`)
|
|
119
|
+
return bytes.toString('utf8')
|
|
120
|
+
}
|
|
121
|
+
|
|
131
122
|
// Walk from `entries` following resolved refs, reading each script once; a ref
|
|
132
123
|
// missing on disk warns and is skipped without aborting the walk.
|
|
133
124
|
export async function collectBashFilesFromDisk(baseDir, entries) {
|
|
@@ -141,7 +132,7 @@ export async function collectBashFilesFromDisk(baseDir, entries) {
|
|
|
141
132
|
toLoad.map(async (relPath) => {
|
|
142
133
|
try {
|
|
143
134
|
assertRealPathWithinBase(realBase, baseDir, relPath)
|
|
144
|
-
return [relPath, await
|
|
135
|
+
return [relPath, await readScript(baseDir, relPath)]
|
|
145
136
|
} catch (err) {
|
|
146
137
|
if (err.code === 'ENOENT') {
|
|
147
138
|
console.warn(`[loader.bash] Missing file: ${relPath}`)
|
|
@@ -173,16 +164,6 @@ export async function collectBashFilesFromDisk(baseDir, entries) {
|
|
|
173
164
|
return sources
|
|
174
165
|
}
|
|
175
166
|
|
|
176
|
-
// Reject absolute and `..`-escaping paths in a `.sh.txt` listing so it can't
|
|
177
|
-
// read files outside the listing's own directory.
|
|
178
|
-
function assertWithinBase(baseDir, candidate, label) {
|
|
179
|
-
if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
|
|
180
|
-
const rel = relative(baseDir, resolve(baseDir, candidate)).split(/[\\/]/u).join('/')
|
|
181
|
-
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
182
|
-
throw new Error(`${label} escapes baseDir: ${candidate}`)
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
167
|
// High-level entry: reads the scripts named in a `.sh.txt` listing (relative to
|
|
187
168
|
// it). Unlike loadRust, the listing is authoritative — bash sourcing is too
|
|
188
169
|
// dynamic to walk — so exactly the listed files are read, resolutions computed among them.
|
|
@@ -202,7 +183,7 @@ export async function loadBash(shTxtFile) {
|
|
|
202
183
|
const reads = await Promise.all(
|
|
203
184
|
lines.map(async (relPath) => {
|
|
204
185
|
assertRealPathWithinBase(realBase, baseDir, relPath)
|
|
205
|
-
return [relPath, await
|
|
186
|
+
return [relPath, await readScript(baseDir, relPath)]
|
|
206
187
|
}),
|
|
207
188
|
)
|
|
208
189
|
return buildBashTree(new Map(reads))
|