@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.
@@ -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 { mkdirSync, writeFileSync } from 'node:fs'
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 target = output ?? '-'
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
- const full = dir === '.' ? rel : `${dir}/${rel}`
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({ dir, name: l.name, version: l.version, ecosystem: l.ecosystem, files: l.files.size })
68
+ modulesRemoved.push(summary(dir, l))
69
69
  continue
70
70
  }
71
71
  if (!l && r) {
72
- modulesAdded.push({ dir, name: r.name, version: r.version, ecosystem: r.ecosystem, files: r.files.size })
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
+ }
@@ -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 { realpathSync, statSync } from 'node:fs'
7
+ import { isUtf8 } from 'node:buffer'
8
+ import { realpathSync } from 'node:fs'
8
9
  import { readFile } from 'node:fs/promises'
9
- import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
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
- const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
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 readFile(join(baseDir, relPath), 'utf8')]
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 readFile(join(baseDir, relPath), 'utf8')]
186
+ return [relPath, await readScript(baseDir, relPath)]
206
187
  }),
207
188
  )
208
189
  return buildBashTree(new Map(reads))