@taliesinb/mash 0.0.0-stage → 0.1.0

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 (203) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -2
  3. package/bin/mash.ts +253 -0
  4. package/dist/bin/mash.d.ts +39 -0
  5. package/dist/bin/mash.d.ts.map +1 -0
  6. package/dist/bin/mash.js +281 -0
  7. package/dist/bin/mash.js.map +1 -0
  8. package/dist/src/analysis/analyze.d.ts +97 -0
  9. package/dist/src/analysis/analyze.d.ts.map +1 -0
  10. package/dist/src/analysis/analyze.js +1406 -0
  11. package/dist/src/analysis/analyze.js.map +1 -0
  12. package/dist/src/analysis/fs.d.ts +39 -0
  13. package/dist/src/analysis/fs.d.ts.map +1 -0
  14. package/dist/src/analysis/fs.js +117 -0
  15. package/dist/src/analysis/fs.js.map +1 -0
  16. package/dist/src/analysis/outputs.d.ts +16 -0
  17. package/dist/src/analysis/outputs.d.ts.map +1 -0
  18. package/dist/src/analysis/outputs.js +277 -0
  19. package/dist/src/analysis/outputs.js.map +1 -0
  20. package/dist/src/analysis/value.d.ts +76 -0
  21. package/dist/src/analysis/value.d.ts.map +1 -0
  22. package/dist/src/analysis/value.js +248 -0
  23. package/dist/src/analysis/value.js.map +1 -0
  24. package/dist/src/behaviors/defs/core.d.ts +15 -0
  25. package/dist/src/behaviors/defs/core.d.ts.map +1 -0
  26. package/dist/src/behaviors/defs/core.js +1084 -0
  27. package/dist/src/behaviors/defs/core.js.map +1 -0
  28. package/dist/src/behaviors/defs/devtools.d.ts +9 -0
  29. package/dist/src/behaviors/defs/devtools.d.ts.map +1 -0
  30. package/dist/src/behaviors/defs/devtools.js +432 -0
  31. package/dist/src/behaviors/defs/devtools.js.map +1 -0
  32. package/dist/src/behaviors/defs/macos.d.ts +12 -0
  33. package/dist/src/behaviors/defs/macos.d.ts.map +1 -0
  34. package/dist/src/behaviors/defs/macos.js +454 -0
  35. package/dist/src/behaviors/defs/macos.js.map +1 -0
  36. package/dist/src/behaviors/defs/tools.d.ts +9 -0
  37. package/dist/src/behaviors/defs/tools.d.ts.map +1 -0
  38. package/dist/src/behaviors/defs/tools.js +639 -0
  39. package/dist/src/behaviors/defs/tools.js.map +1 -0
  40. package/dist/src/behaviors/dsl.d.ts +95 -0
  41. package/dist/src/behaviors/dsl.d.ts.map +1 -0
  42. package/dist/src/behaviors/dsl.js +267 -0
  43. package/dist/src/behaviors/dsl.js.map +1 -0
  44. package/dist/src/behaviors/index.d.ts +12 -0
  45. package/dist/src/behaviors/index.d.ts.map +1 -0
  46. package/dist/src/behaviors/index.js +18 -0
  47. package/dist/src/behaviors/index.js.map +1 -0
  48. package/dist/src/behaviors/registry.d.ts +45 -0
  49. package/dist/src/behaviors/registry.d.ts.map +1 -0
  50. package/dist/src/behaviors/registry.js +266 -0
  51. package/dist/src/behaviors/registry.js.map +1 -0
  52. package/dist/src/behaviors/roles.d.ts +29 -0
  53. package/dist/src/behaviors/roles.d.ts.map +1 -0
  54. package/dist/src/behaviors/roles.js +198 -0
  55. package/dist/src/behaviors/roles.js.map +1 -0
  56. package/dist/src/behaviors/types.d.ts +120 -0
  57. package/dist/src/behaviors/types.d.ts.map +1 -0
  58. package/dist/src/behaviors/types.js +12 -0
  59. package/dist/src/behaviors/types.js.map +1 -0
  60. package/dist/src/index.d.ts +27 -0
  61. package/dist/src/index.d.ts.map +1 -0
  62. package/dist/src/index.js +24 -0
  63. package/dist/src/index.js.map +1 -0
  64. package/dist/src/integrations/deepsea.d.ts +121 -0
  65. package/dist/src/integrations/deepsea.d.ts.map +1 -0
  66. package/dist/src/integrations/deepsea.js +199 -0
  67. package/dist/src/integrations/deepsea.js.map +1 -0
  68. package/dist/src/machine.d.ts +139 -0
  69. package/dist/src/machine.d.ts.map +1 -0
  70. package/dist/src/machine.js +400 -0
  71. package/dist/src/machine.js.map +1 -0
  72. package/dist/src/parser/ast.d.ts +369 -0
  73. package/dist/src/parser/ast.d.ts.map +1 -0
  74. package/dist/src/parser/ast.js +17 -0
  75. package/dist/src/parser/ast.js.map +1 -0
  76. package/dist/src/parser/parser.d.ts +48 -0
  77. package/dist/src/parser/parser.d.ts.map +1 -0
  78. package/dist/src/parser/parser.js +1950 -0
  79. package/dist/src/parser/parser.js.map +1 -0
  80. package/dist/src/policy/effects.d.ts +86 -0
  81. package/dist/src/policy/effects.d.ts.map +1 -0
  82. package/dist/src/policy/effects.js +53 -0
  83. package/dist/src/policy/effects.js.map +1 -0
  84. package/dist/src/policy/policy.d.ts +130 -0
  85. package/dist/src/policy/policy.d.ts.map +1 -0
  86. package/dist/src/policy/policy.js +532 -0
  87. package/dist/src/policy/policy.js.map +1 -0
  88. package/dist/src/printer/doc.d.ts +151 -0
  89. package/dist/src/printer/doc.d.ts.map +1 -0
  90. package/dist/src/printer/doc.js +409 -0
  91. package/dist/src/printer/doc.js.map +1 -0
  92. package/dist/src/printer/highlight.d.ts +53 -0
  93. package/dist/src/printer/highlight.d.ts.map +1 -0
  94. package/dist/src/printer/highlight.js +339 -0
  95. package/dist/src/printer/highlight.js.map +1 -0
  96. package/dist/src/printer/html.d.ts +72 -0
  97. package/dist/src/printer/html.d.ts.map +1 -0
  98. package/dist/src/printer/html.js +423 -0
  99. package/dist/src/printer/html.js.map +1 -0
  100. package/dist/src/printer/print.d.ts +63 -0
  101. package/dist/src/printer/print.d.ts.map +1 -0
  102. package/dist/src/printer/print.js +484 -0
  103. package/dist/src/printer/print.js.map +1 -0
  104. package/dist/src/printer/summary.d.ts +50 -0
  105. package/dist/src/printer/summary.d.ts.map +1 -0
  106. package/dist/src/printer/summary.js +621 -0
  107. package/dist/src/printer/summary.js.map +1 -0
  108. package/dist/src/trace/events.d.ts +183 -0
  109. package/dist/src/trace/events.d.ts.map +1 -0
  110. package/dist/src/trace/events.js +9 -0
  111. package/dist/src/trace/events.js.map +1 -0
  112. package/dist/src/trace/progress.d.ts +58 -0
  113. package/dist/src/trace/progress.d.ts.map +1 -0
  114. package/dist/src/trace/progress.js +83 -0
  115. package/dist/src/trace/progress.js.map +1 -0
  116. package/dist/src/vm/arith.d.ts +29 -0
  117. package/dist/src/vm/arith.d.ts.map +1 -0
  118. package/dist/src/vm/arith.js +258 -0
  119. package/dist/src/vm/arith.js.map +1 -0
  120. package/dist/src/vm/builtins.d.ts +31 -0
  121. package/dist/src/vm/builtins.d.ts.map +1 -0
  122. package/dist/src/vm/builtins.js +1773 -0
  123. package/dist/src/vm/builtins.js.map +1 -0
  124. package/dist/src/vm/emulate.d.ts +75 -0
  125. package/dist/src/vm/emulate.d.ts.map +1 -0
  126. package/dist/src/vm/emulate.js +380 -0
  127. package/dist/src/vm/emulate.js.map +1 -0
  128. package/dist/src/vm/expand.d.ts +68 -0
  129. package/dist/src/vm/expand.d.ts.map +1 -0
  130. package/dist/src/vm/expand.js +744 -0
  131. package/dist/src/vm/expand.js.map +1 -0
  132. package/dist/src/vm/external.d.ts +34 -0
  133. package/dist/src/vm/external.d.ts.map +1 -0
  134. package/dist/src/vm/external.js +228 -0
  135. package/dist/src/vm/external.js.map +1 -0
  136. package/dist/src/vm/fifo.d.ts +66 -0
  137. package/dist/src/vm/fifo.d.ts.map +1 -0
  138. package/dist/src/vm/fifo.js +232 -0
  139. package/dist/src/vm/fifo.js.map +1 -0
  140. package/dist/src/vm/interp.d.ts +209 -0
  141. package/dist/src/vm/interp.d.ts.map +1 -0
  142. package/dist/src/vm/interp.js +1641 -0
  143. package/dist/src/vm/interp.js.map +1 -0
  144. package/dist/src/vm/io.d.ts +180 -0
  145. package/dist/src/vm/io.d.ts.map +1 -0
  146. package/dist/src/vm/io.js +502 -0
  147. package/dist/src/vm/io.js.map +1 -0
  148. package/dist/src/vm/pattern.d.ts +46 -0
  149. package/dist/src/vm/pattern.d.ts.map +1 -0
  150. package/dist/src/vm/pattern.js +498 -0
  151. package/dist/src/vm/pattern.js.map +1 -0
  152. package/dist/src/vm/proc.d.ts +94 -0
  153. package/dist/src/vm/proc.d.ts.map +1 -0
  154. package/dist/src/vm/proc.js +130 -0
  155. package/dist/src/vm/proc.js.map +1 -0
  156. package/dist/src/vm/state.d.ts +104 -0
  157. package/dist/src/vm/state.d.ts.map +1 -0
  158. package/dist/src/vm/state.js +233 -0
  159. package/dist/src/vm/state.js.map +1 -0
  160. package/dist/src/vm/test.d.ts +11 -0
  161. package/dist/src/vm/test.d.ts.map +1 -0
  162. package/dist/src/vm/test.js +277 -0
  163. package/dist/src/vm/test.js.map +1 -0
  164. package/package.json +63 -4
  165. package/src/analysis/analyze.ts +1395 -0
  166. package/src/analysis/fs.ts +121 -0
  167. package/src/analysis/outputs.ts +242 -0
  168. package/src/analysis/value.ts +264 -0
  169. package/src/behaviors/defs/core.ts +970 -0
  170. package/src/behaviors/defs/devtools.ts +400 -0
  171. package/src/behaviors/defs/macos.ts +426 -0
  172. package/src/behaviors/defs/tools.ts +596 -0
  173. package/src/behaviors/dsl.ts +293 -0
  174. package/src/behaviors/index.ts +19 -0
  175. package/src/behaviors/registry.ts +261 -0
  176. package/src/behaviors/roles.ts +187 -0
  177. package/src/behaviors/types.ts +127 -0
  178. package/src/index.ts +27 -0
  179. package/src/integrations/deepsea.ts +259 -0
  180. package/src/machine.ts +483 -0
  181. package/src/parser/ast.ts +472 -0
  182. package/src/parser/parser.ts +1826 -0
  183. package/src/policy/effects.ts +102 -0
  184. package/src/policy/policy.ts +578 -0
  185. package/src/printer/doc.ts +454 -0
  186. package/src/printer/highlight.ts +283 -0
  187. package/src/printer/html.ts +441 -0
  188. package/src/printer/print.ts +528 -0
  189. package/src/printer/summary.ts +601 -0
  190. package/src/trace/events.ts +102 -0
  191. package/src/trace/progress.ts +119 -0
  192. package/src/vm/arith.ts +236 -0
  193. package/src/vm/builtins.ts +1590 -0
  194. package/src/vm/emulate.ts +337 -0
  195. package/src/vm/expand.ts +721 -0
  196. package/src/vm/external.ts +265 -0
  197. package/src/vm/fifo.ts +226 -0
  198. package/src/vm/interp.ts +1623 -0
  199. package/src/vm/io.ts +570 -0
  200. package/src/vm/pattern.ts +477 -0
  201. package/src/vm/proc.ts +147 -0
  202. package/src/vm/state.ts +267 -0
  203. package/src/vm/test.ts +253 -0
package/src/vm/io.ts ADDED
@@ -0,0 +1,570 @@
1
+ /**
2
+ * The VM's I/O model, modelled on POSIX file descriptions.
3
+ *
4
+ * An {@link OpenFile} is an open file description: a pipe end, a real file,
5
+ * a capture buffer (command substitution), /dev/null, or a host stream. It is
6
+ * reference-counted: an {@link FdTable} (one per shell "process") holds
7
+ * references, subshells and pipeline stages clone the table (adding
8
+ * references), and closing the last write reference of a pipe delivers EOF to
9
+ * its reader — exactly the behaviour scripts rely on.
10
+ *
11
+ * Every OpenFile belongs to a {@link Channel}, the identity trace consumers
12
+ * see. Data events are reported once per byte: on write for output channels
13
+ * (pipes, files, captures, stdout/stderr) and on read for input-only channels
14
+ * (input files, here-docs).
15
+ */
16
+
17
+ import * as fs from 'node:fs'
18
+
19
+ export type ChannelKind = 'pipe' | 'file' | 'capture' | 'null' | 'host' | 'heredoc' | 'procsubst'
20
+
21
+ export interface Channel {
22
+ /** Unique id within one machine. */
23
+ id: number
24
+ kind: ChannelKind
25
+ /** Human-readable label, e.g. `pipe(grep foo → wc -l)` or `> out.txt`. */
26
+ label: string
27
+ /** File path for file channels. */
28
+ path?: string
29
+ /** AST node that created the channel (pipeline, redirect, substitution). */
30
+ nodeId?: number
31
+ }
32
+
33
+ export type DataListener = (channel: Channel, chunk: Uint8Array, direction: 'write' | 'read') => void
34
+
35
+ /** Thrown by writes to a pipe whose read end is fully closed (EPIPE / SIGPIPE). */
36
+ export class BrokenPipeError extends Error {
37
+ constructor() {
38
+ super('Broken pipe')
39
+ this.name = 'BrokenPipeError'
40
+ }
41
+ }
42
+
43
+ const encoder = new TextEncoder()
44
+
45
+ export function toBytes(data: string | Uint8Array): Uint8Array {
46
+ return typeof data === 'string' ? encoder.encode(data) : data
47
+ }
48
+
49
+ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
50
+ if (a.length === 0) return b
51
+ if (b.length === 0) return a
52
+ const out = new Uint8Array(a.length + b.length)
53
+ out.set(a, 0)
54
+ out.set(b, a.length)
55
+ return out
56
+ }
57
+
58
+ /**
59
+ * An open file description. Subclasses implement the raw operations; this
60
+ * base class adds reference counting, a shared read-ahead buffer (so `read`
61
+ * can consume one line and leave the rest for the next reader) and tracing.
62
+ */
63
+ export abstract class OpenFile {
64
+ refs = 1
65
+ readonly channel: Channel
66
+ readonly readable: boolean
67
+ readonly writable: boolean
68
+ protected listeners: DataListener[]
69
+ private pushback: Uint8Array = new Uint8Array(0)
70
+ private closedForGood = false
71
+ /** Report reads to listeners (input-only channels). */
72
+ protected traceReads = false
73
+
74
+ constructor(channel: Channel, readable: boolean, writable: boolean, listeners: DataListener[]) {
75
+ this.channel = channel
76
+ this.readable = readable
77
+ this.writable = writable
78
+ this.listeners = listeners
79
+ }
80
+
81
+ /** Add a reference (fd duplication / table clone). */
82
+ retain(): this {
83
+ this.refs++
84
+ return this
85
+ }
86
+
87
+ /** Drop a reference; the last one closes the description. */
88
+ release(): void {
89
+ if (this.refs <= 0) return
90
+ this.refs--
91
+ if (this.refs === 0 && !this.closedForGood) {
92
+ this.closedForGood = true
93
+ this.onLastClose()
94
+ }
95
+ }
96
+
97
+ get closed(): boolean {
98
+ return this.closedForGood
99
+ }
100
+
101
+ protected abstract rawRead(): Promise<Uint8Array | null>
102
+ protected abstract rawWrite(chunk: Uint8Array): Promise<void>
103
+ protected abstract onLastClose(): void
104
+
105
+ async write(data: string | Uint8Array): Promise<void> {
106
+ if (!this.writable) throw new Error(`${this.channel.label}: not open for writing`)
107
+ const bytes = toBytes(data)
108
+ if (bytes.length === 0) return
109
+ await this.rawWrite(bytes)
110
+ for (const l of this.listeners) l(this.channel, bytes, 'write')
111
+ }
112
+
113
+ private inflight: Promise<void> | null = null
114
+ private sawEof = false
115
+ private readError: unknown = null
116
+
117
+ /** Bytes already read from the underlying source but not yet consumed. */
118
+ get buffered(): number {
119
+ return this.pushback.length
120
+ }
121
+
122
+ /**
123
+ * Next chunk, or null at EOF. With `signal`, returns null early when the
124
+ * signal aborts — without consuming anything: data that arrives later stays
125
+ * buffered for the next reader. (This is what lets the VM stop feeding a
126
+ * child process that has exited without losing the rest of a shared stdin.)
127
+ */
128
+ async read(signal?: AbortSignal): Promise<Uint8Array | null> {
129
+ if (!this.readable) throw new Error(`${this.channel.label}: not open for reading`)
130
+ for (;;) {
131
+ if (this.pushback.length > 0) {
132
+ const b = this.pushback
133
+ this.pushback = new Uint8Array(0)
134
+ return b
135
+ }
136
+ if (this.readError !== null) {
137
+ const e = this.readError
138
+ this.readError = null
139
+ throw e
140
+ }
141
+ if (this.sawEof) return null
142
+ if (signal?.aborted) return null
143
+ if (!this.inflight) {
144
+ this.inflight = this.rawRead().then(
145
+ (chunk) => {
146
+ this.inflight = null
147
+ if (chunk === null) this.sawEof = true
148
+ else {
149
+ this.pushback = concat(this.pushback, chunk)
150
+ if (this.traceReads) for (const l of this.listeners) l(this.channel, chunk, 'read')
151
+ }
152
+ },
153
+ (err: unknown) => {
154
+ this.inflight = null
155
+ this.readError = err
156
+ },
157
+ )
158
+ }
159
+ if (signal) {
160
+ await new Promise<void>((resolve) => {
161
+ const onAbort = (): void => resolve()
162
+ signal.addEventListener('abort', onAbort, { once: true })
163
+ this.inflight!.then(() => {
164
+ signal.removeEventListener('abort', onAbort)
165
+ resolve()
166
+ })
167
+ })
168
+ } else {
169
+ await this.inflight
170
+ }
171
+ }
172
+ }
173
+
174
+ /** Return unconsumed bytes to the front of the stream. */
175
+ unread(bytes: Uint8Array): void {
176
+ if (bytes.length > 0) this.pushback = concat(bytes, this.pushback)
177
+ }
178
+
179
+ /**
180
+ * Read up to and including `delim` (a byte), or to EOF. Returns null at EOF
181
+ * with nothing read. Leaves the remainder buffered for the next reader.
182
+ */
183
+ async readUntil(delim: number | null, maxBytes = Infinity): Promise<Uint8Array | null> {
184
+ let acc: Uint8Array = new Uint8Array(0)
185
+ for (;;) {
186
+ const chunk = await this.read()
187
+ if (chunk === null) return acc.length > 0 ? acc : null
188
+ const idx = delim === null ? -1 : chunk.indexOf(delim)
189
+ if (idx >= 0 && acc.length + idx + 1 <= maxBytes) {
190
+ acc = concat(acc, chunk.subarray(0, idx + 1))
191
+ this.unread(chunk.subarray(idx + 1))
192
+ return acc
193
+ }
194
+ if (acc.length + chunk.length >= maxBytes) {
195
+ const take = maxBytes - acc.length
196
+ acc = concat(acc, chunk.subarray(0, take))
197
+ this.unread(chunk.subarray(take))
198
+ return acc
199
+ }
200
+ acc = concat(acc, chunk)
201
+ }
202
+ }
203
+
204
+ /** Read everything to EOF. */
205
+ async readAll(): Promise<Uint8Array> {
206
+ let acc: Uint8Array = new Uint8Array(0)
207
+ for (;;) {
208
+ const chunk = await this.read()
209
+ if (chunk === null) return acc
210
+ acc = concat(acc, chunk)
211
+ }
212
+ }
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Pipes
217
+ // ---------------------------------------------------------------------------
218
+
219
+ /** An in-memory pipe with bounded buffering (backpressure) and POSIX EOF/EPIPE semantics. */
220
+ export class Pipe {
221
+ readonly channel: Channel
222
+ private chunks: Uint8Array[] = []
223
+ private buffered = 0
224
+ private readonly capacity: number
225
+ writersOpen = true
226
+ readersOpen = true
227
+ private readWaiters: (() => void)[] = []
228
+ private writeWaiters: (() => void)[] = []
229
+ readonly readEnd: PipeEnd
230
+ readonly writeEnd: PipeEnd
231
+
232
+ constructor(channel: Channel, listeners: DataListener[], capacity = 64 * 1024) {
233
+ this.channel = channel
234
+ this.capacity = capacity
235
+ this.readEnd = new PipeEnd(this, 'read', listeners)
236
+ this.writeEnd = new PipeEnd(this, 'write', listeners)
237
+ }
238
+
239
+ private wakeReaders(): void {
240
+ const w = this.readWaiters
241
+ this.readWaiters = []
242
+ for (const f of w) f()
243
+ }
244
+
245
+ private wakeWriters(): void {
246
+ const w = this.writeWaiters
247
+ this.writeWaiters = []
248
+ for (const f of w) f()
249
+ }
250
+
251
+ async write(chunk: Uint8Array): Promise<void> {
252
+ while (this.readersOpen && this.buffered >= this.capacity) {
253
+ await new Promise<void>((r) => this.writeWaiters.push(r))
254
+ }
255
+ if (!this.readersOpen) throw new BrokenPipeError()
256
+ this.chunks.push(chunk)
257
+ this.buffered += chunk.length
258
+ this.wakeReaders()
259
+ }
260
+
261
+ async read(): Promise<Uint8Array | null> {
262
+ while (this.chunks.length === 0 && this.writersOpen) {
263
+ await new Promise<void>((r) => this.readWaiters.push(r))
264
+ }
265
+ const chunk = this.chunks.shift()
266
+ if (chunk === undefined) return null
267
+ this.buffered -= chunk.length
268
+ this.wakeWriters()
269
+ return chunk
270
+ }
271
+
272
+ closeWriters(): void {
273
+ this.writersOpen = false
274
+ this.wakeReaders()
275
+ }
276
+
277
+ closeReaders(): void {
278
+ this.readersOpen = false
279
+ this.chunks = []
280
+ this.buffered = 0
281
+ this.wakeWriters()
282
+ }
283
+ }
284
+
285
+ export class PipeEnd extends OpenFile {
286
+ private readonly pipe: Pipe
287
+
288
+ constructor(pipe: Pipe, end: 'read' | 'write', listeners: DataListener[]) {
289
+ super(pipe.channel, end === 'read', end === 'write', listeners)
290
+ this.pipe = pipe
291
+ }
292
+
293
+ protected rawRead(): Promise<Uint8Array | null> {
294
+ return this.pipe.read()
295
+ }
296
+
297
+ protected rawWrite(chunk: Uint8Array): Promise<void> {
298
+ return this.pipe.write(chunk)
299
+ }
300
+
301
+ protected onLastClose(): void {
302
+ if (this.writable) this.pipe.closeWriters()
303
+ else this.pipe.closeReaders()
304
+ }
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Files
309
+ // ---------------------------------------------------------------------------
310
+
311
+ export type FileMode = 'read' | 'write' | 'append' | 'readwrite'
312
+
313
+ /** A real file opened on the host. */
314
+ export class HostFile extends OpenFile {
315
+ readonly fd: number
316
+ private eof = false
317
+
318
+ private constructor(channel: Channel, fd: number, mode: FileMode, listeners: DataListener[]) {
319
+ super(channel, mode === 'read' || mode === 'readwrite', mode !== 'read', listeners)
320
+ this.fd = fd
321
+ this.traceReads = mode === 'read'
322
+ }
323
+
324
+ static async open(channel: Channel, path: string, mode: FileMode, listeners: DataListener[], noclobber = false, umask?: number): Promise<HostFile> {
325
+ const existed = mode !== 'read' && umask !== undefined && fs.existsSync(path)
326
+ const flags = mode === 'read' ? 'r' : mode === 'write' ? (noclobber ? 'wx' : 'w') : mode === 'append' ? 'a' : 'r+'
327
+ let fd: number
328
+ try {
329
+ fd = await new Promise<number>((resolve, reject) => fs.open(path, mode === 'readwrite' && !fs.existsSync(path) ? 'w+' : flags, 0o666, (err, f) => (err ? reject(err) : resolve(f))))
330
+ } catch (e) {
331
+ throw describeFsError(e, path)
332
+ }
333
+ if (mode !== 'read' && umask !== undefined && !existed) {
334
+ try {
335
+ fs.fchmodSync(fd, 0o666 & ~umask)
336
+ } catch {
337
+ // ignore
338
+ }
339
+ }
340
+ return new HostFile(channel, fd, mode, listeners)
341
+ }
342
+
343
+ protected rawRead(): Promise<Uint8Array | null> {
344
+ if (this.eof) return Promise.resolve(null)
345
+ return new Promise((resolve, reject) => {
346
+ const buf = Buffer.allocUnsafe(64 * 1024)
347
+ fs.read(this.fd, buf, 0, buf.length, null, (err, n) => {
348
+ if (err) return reject(err)
349
+ if (n === 0) {
350
+ this.eof = true
351
+ return resolve(null)
352
+ }
353
+ resolve(new Uint8Array(buf.buffer, buf.byteOffset, n).slice())
354
+ })
355
+ })
356
+ }
357
+
358
+ protected rawWrite(chunk: Uint8Array): Promise<void> {
359
+ return new Promise((resolve, reject) => {
360
+ fs.write(this.fd, chunk, 0, chunk.length, null, (err) => (err ? reject(err) : resolve()))
361
+ })
362
+ }
363
+
364
+ protected onLastClose(): void {
365
+ fs.close(this.fd, () => {})
366
+ }
367
+ }
368
+
369
+ export class FsError extends Error {
370
+ readonly code: string
371
+ constructor(message: string, code: string) {
372
+ super(message)
373
+ this.code = code
374
+ }
375
+ }
376
+
377
+ export function describeFsError(e: unknown, path: string): FsError {
378
+ const code = (e as NodeJS.ErrnoException).code ?? 'EIO'
379
+ const text: Record<string, string> = {
380
+ ENOENT: 'No such file or directory',
381
+ EACCES: 'Permission denied',
382
+ EPERM: 'Operation not permitted',
383
+ EISDIR: 'Is a directory',
384
+ ENOTDIR: 'Not a directory',
385
+ EEXIST: 'cannot overwrite existing file',
386
+ EROFS: 'Read-only file system',
387
+ }
388
+ return new FsError(`${path}: ${text[code] ?? code}`, code)
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // Captures, null, constant input, host streams
393
+ // ---------------------------------------------------------------------------
394
+
395
+ /** Collects written bytes (command substitution, `run()` output capture). */
396
+ export class Capture extends OpenFile {
397
+ private parts: Uint8Array[] = []
398
+ size = 0
399
+ /** Bytes beyond this are discarded (but counted). */
400
+ readonly limit: number
401
+ truncated = false
402
+
403
+ constructor(channel: Channel, listeners: DataListener[], limit = 64 * 1024 * 1024) {
404
+ super(channel, false, true, listeners)
405
+ this.limit = limit
406
+ }
407
+
408
+ protected rawRead(): Promise<Uint8Array | null> {
409
+ return Promise.resolve(null)
410
+ }
411
+
412
+ protected async rawWrite(chunk: Uint8Array): Promise<void> {
413
+ if (this.size + chunk.length > this.limit) {
414
+ this.truncated = true
415
+ const room = Math.max(0, this.limit - this.size)
416
+ if (room > 0) this.parts.push(chunk.slice(0, room))
417
+ this.size += chunk.length
418
+ return
419
+ }
420
+ this.parts.push(chunk)
421
+ this.size += chunk.length
422
+ }
423
+
424
+ protected onLastClose(): void {}
425
+
426
+ bytes(): Uint8Array {
427
+ let n = 0
428
+ for (const p of this.parts) n += p.length
429
+ const out = new Uint8Array(n)
430
+ let o = 0
431
+ for (const p of this.parts) {
432
+ out.set(p, o)
433
+ o += p.length
434
+ }
435
+ return out
436
+ }
437
+
438
+ text(): string {
439
+ return new TextDecoder().decode(this.bytes())
440
+ }
441
+ }
442
+
443
+ export class NullFile extends OpenFile {
444
+ constructor(channel: Channel, listeners: DataListener[]) {
445
+ super(channel, true, true, listeners)
446
+ }
447
+
448
+ protected rawRead(): Promise<Uint8Array | null> {
449
+ return Promise.resolve(null)
450
+ }
451
+
452
+ protected rawWrite(): Promise<void> {
453
+ return Promise.resolve()
454
+ }
455
+
456
+ protected onLastClose(): void {}
457
+ }
458
+
459
+ /** Fixed input (here-docs, here-strings). */
460
+ export class ConstInput extends OpenFile {
461
+ private data: Uint8Array | null
462
+
463
+ constructor(channel: Channel, data: string | Uint8Array, listeners: DataListener[]) {
464
+ super(channel, true, false, listeners)
465
+ this.data = toBytes(data)
466
+ this.traceReads = true
467
+ }
468
+
469
+ protected rawRead(): Promise<Uint8Array | null> {
470
+ const d = this.data
471
+ this.data = null
472
+ return Promise.resolve(d && d.length > 0 ? d : null)
473
+ }
474
+
475
+ protected rawWrite(): Promise<void> {
476
+ return Promise.reject(new Error('not writable'))
477
+ }
478
+
479
+ protected onLastClose(): void {}
480
+ }
481
+
482
+ /** Adapts a host Node stream (or callback) as the VM's stdin/stdout/stderr. */
483
+ export class HostStream extends OpenFile {
484
+ private readonly onWrite?: (chunk: Uint8Array) => void | Promise<void>
485
+ private readonly iterator?: AsyncIterator<Uint8Array | string>
486
+
487
+ constructor(
488
+ channel: Channel,
489
+ listeners: DataListener[],
490
+ opts: { write?: (chunk: Uint8Array) => void | Promise<void>; input?: AsyncIterable<Uint8Array | string> },
491
+ ) {
492
+ super(channel, opts.input !== undefined, opts.write !== undefined, listeners)
493
+ this.onWrite = opts.write
494
+ this.iterator = opts.input?.[Symbol.asyncIterator]()
495
+ this.traceReads = opts.input !== undefined
496
+ }
497
+
498
+ protected async rawRead(): Promise<Uint8Array | null> {
499
+ if (!this.iterator) return null
500
+ const r = await this.iterator.next()
501
+ if (r.done) return null
502
+ return toBytes(r.value)
503
+ }
504
+
505
+ protected async rawWrite(chunk: Uint8Array): Promise<void> {
506
+ await this.onWrite?.(chunk)
507
+ }
508
+
509
+ protected onLastClose(): void {}
510
+ }
511
+
512
+ // ---------------------------------------------------------------------------
513
+ // Fd tables
514
+ // ---------------------------------------------------------------------------
515
+
516
+ /** One shell process's file descriptor table. */
517
+ export class FdTable {
518
+ private readonly fds = new Map<number, OpenFile>()
519
+
520
+ get(fd: number): OpenFile | undefined {
521
+ return this.fds.get(fd)
522
+ }
523
+
524
+ /** Install `file` at `fd` (taking ownership of one reference), closing what was there. */
525
+ set(fd: number, file: OpenFile): void {
526
+ const old = this.fds.get(fd)
527
+ this.fds.set(fd, file)
528
+ old?.release()
529
+ }
530
+
531
+ close(fd: number): void {
532
+ const old = this.fds.get(fd)
533
+ if (old) {
534
+ this.fds.delete(fd)
535
+ old.release()
536
+ }
537
+ }
538
+
539
+ /** `n>&m` */
540
+ dup(from: number, to: number): boolean {
541
+ const f = this.fds.get(from)
542
+ if (!f) return false
543
+ if (from === to) return true
544
+ this.set(to, f.retain())
545
+ return true
546
+ }
547
+
548
+ /** A copy sharing every open file (each gains a reference). */
549
+ clone(): FdTable {
550
+ const t = new FdTable()
551
+ for (const [fd, f] of this.fds) t.fds.set(fd, f.retain())
552
+ return t
553
+ }
554
+
555
+ closeAll(): void {
556
+ for (const f of this.fds.values()) f.release()
557
+ this.fds.clear()
558
+ }
559
+
560
+ entries(): [number, OpenFile][] {
561
+ return [...this.fds.entries()]
562
+ }
563
+
564
+ /** Lowest free fd ≥ `min` (for `{var}>file`, bash starts at 10). */
565
+ freeFd(min = 10): number {
566
+ let n = min
567
+ while (this.fds.has(n)) n++
568
+ return n
569
+ }
570
+ }