@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
@@ -0,0 +1,1623 @@
1
+ /**
2
+ * The tree-walking interpreter. Every command node emits a `step` event
3
+ * before it runs; simple commands are expanded, resolved, assessed against
4
+ * the policy (through {@link Runtime.assess}, which consults behavior
5
+ * definitions and hooks), and only then have their redirections opened and
6
+ * run. Pipelines run their stages concurrently over in-memory pipes; external
7
+ * commands are real child processes whose stdio is pumped through the VM's
8
+ * channels so it can be observed.
9
+ */
10
+
11
+ import * as fs from 'node:fs'
12
+ import * as path from 'node:path'
13
+ import type * as A from '../parser/ast.ts'
14
+ import { parse, ParseError, literalOf } from '../parser/parser.ts'
15
+ import type { Effect } from '../policy/effects.ts'
16
+ import type { CommandKind, RedirectInfo, SubshellReason, TraceEvent } from '../trace/events.ts'
17
+ import { known } from '../analysis/value.ts'
18
+ import { ArithError, evaluateArith, type ArithEnv } from './arith.ts'
19
+ import { BUILTINS, type BuiltinContext } from './builtins.ts'
20
+ import { Expander, varElements } from './expand.ts'
21
+ import { runExternal, type SpawnFn } from './external.ts'
22
+ import { emulateFind, emulateXargs } from './emulate.ts'
23
+ import type { FdPassing } from './fifo.ts'
24
+ import {
25
+ BrokenPipeError, Capture, ConstInput, FdTable, FsError, HostFile, NullFile, Pipe,
26
+ type Channel, type ChannelKind, type DataListener, type OpenFile,
27
+ } from './io.ts'
28
+ import { matchPattern } from './pattern.ts'
29
+ import { AbortRun, CommandError, DiscardError, ExitSignal, FatalError, LoopSignal, Proc, ReturnSignal, type Job } from './proc.ts'
30
+ import { initShellVars, newVar, ShellState, type Variable } from './state.ts'
31
+ import { evalCond, evalTest } from './test.ts'
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Runtime seam (implemented by Machine)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export interface ExecIntent {
38
+ execId: number
39
+ node: A.SimpleCommand
40
+ argv: string[]
41
+ kind: CommandKind
42
+ path?: string
43
+ cwd: string
44
+ env: Record<string, string>
45
+ redirectEffects: Effect[]
46
+ stdinKind: 'pipe' | 'file' | 'null' | 'terminal' | 'unknown'
47
+ pid: number
48
+ }
49
+
50
+ export type Assessment =
51
+ | { action: 'run'; readsStdin: boolean | 'maybe' }
52
+ | { action: 'deny'; reason: string; status: number }
53
+ | { action: 'abort'; reason: string; status: number }
54
+ | { action: 'simulate'; stdout: string; stderr: string; status: number }
55
+
56
+ export interface Runtime {
57
+ emit(event: TraceEvent): void
58
+ readonly listeners: DataListener[]
59
+ channel(kind: ChannelKind, label: string, extra?: Partial<Channel>): Channel
60
+ nextPid(): number
61
+ nextExecId(): number
62
+ readonly shellPid: number
63
+ assess(intent: ExecIntent): Promise<Assessment>
64
+ /** Policy check for redirections of compound commands (no command to attach them to). */
65
+ assessRedirects(effects: Effect[], node: A.Node, proc: Proc): Promise<Assessment>
66
+ afterCommand(execId: number, status: number): Promise<void>
67
+ spawn: SpawnFn
68
+ readonly signal: AbortSignal
69
+ /** Source text of a node (from whichever parse produced it). */
70
+ text(node: { id: number; span: A.Span }): string
71
+ /** 1-based line number of a node in its source. */
72
+ line(node: { id: number; span: A.Span }): number
73
+ /** Parse source text (eval, source, trap actions), registering it for text()/line(). */
74
+ parse(source: string, origin: string, opts?: { partial?: boolean }): A.Script
75
+ findJob(pid: number): Job | undefined
76
+ /** Pending background work (process substitutions) to settle at the end. */
77
+ track(p: Promise<unknown>): void
78
+ registerJob(job: Job): void
79
+ /** Execute nested bash/sh scripts inside the VM. */
80
+ readonly inlineScripts: boolean
81
+ /** Emulate xargs and find -exec/-delete so each resulting command is traced and checked. */
82
+ readonly emulate: boolean
83
+ /** How `/dev/fd/N` arguments naming VM fds reach external commands (see fifo.ts). */
84
+ readonly fdPassing: FdPassing
85
+ }
86
+
87
+ interface ResolvedRedirect {
88
+ node: A.Redirect
89
+ fd: number
90
+ op: A.RedirectOp
91
+ /** Absolute path for file redirections. */
92
+ path?: string
93
+ /** Special device handling. */
94
+ special?: 'null' | { dup: number }
95
+ dupFd?: number
96
+ close?: boolean
97
+ content?: string
98
+ fdVar?: string
99
+ }
100
+
101
+
102
+ export class Interpreter {
103
+ readonly rt: Runtime
104
+ readonly expander: Expander
105
+
106
+ constructor(rt: Runtime) {
107
+ this.rt = rt
108
+ this.expander = new Expander({
109
+ commandSubst: (node, proc) => this.commandSubst(node, proc),
110
+ processSubst: (node, proc) => this.processSubst(node, proc),
111
+ arith: (expr, proc) => this.arith(expr, proc),
112
+ assign: (proc, name, value) => this.assignScalar(proc, name, value),
113
+ dynamic: (proc, name) => this.dynamicVar(proc, name),
114
+ shellPid: rt.shellPid,
115
+ })
116
+ }
117
+
118
+ // -------------------------------------------------------------------------
119
+ // Entry points
120
+ // -------------------------------------------------------------------------
121
+
122
+ /** Run a parsed script in `proc` (the top-level shell). Returns the exit status. */
123
+ async runTop(script: A.Script, proc: Proc): Promise<number> {
124
+ let status = 0
125
+ try {
126
+ status = await this.execTopLevel(script, proc)
127
+ } catch (e) {
128
+ status = this.handleTermination(e, proc)
129
+ }
130
+ status = await this.runExitTrap(proc, status)
131
+ return status
132
+ }
133
+
134
+ /**
135
+ * Execute a script's top-level statements like bash reads them: an
136
+ * expansion error discards only the current top-level command, and a
137
+ * syntax error further down (partial parse) is reported when reached.
138
+ */
139
+ async execTopLevel(script: A.Script, proc: Proc): Promise<number> {
140
+ let status = proc.state.lastStatus
141
+ let discardedLine = -1
142
+ for (const stmt of script.body) {
143
+ // bash reads one line (a newline-terminated list) at a time; a discard drops the rest of it
144
+ if (this.rt.line(stmt) === discardedLine) continue
145
+ try {
146
+ status = await this.execStmt(stmt, proc)
147
+ } catch (e) {
148
+ if (!(e instanceof DiscardError)) throw e
149
+ this.errorMessage(proc, e.message)
150
+ status = e.status
151
+ proc.state.lastStatus = status
152
+ discardedLine = this.rt.line(stmt)
153
+ }
154
+ }
155
+ if (script.error) {
156
+ this.errorMessage(proc, `syntax error: ${script.error.message}`)
157
+ throw new ExitSignal(2)
158
+ }
159
+ return status
160
+ }
161
+
162
+ private handleTermination(e: unknown, proc: Proc): number {
163
+ if (e instanceof ExitSignal) return e.status
164
+ if (e instanceof FatalError || e instanceof DiscardError) {
165
+ this.errorMessage(proc, e.message)
166
+ return e.status
167
+ }
168
+ if (e instanceof ReturnSignal) return e.status
169
+ if (e instanceof LoopSignal) return 0
170
+ if (e instanceof BrokenPipeError) return 141
171
+ throw e
172
+ }
173
+
174
+ private async runExitTrap(proc: Proc, status: number): Promise<number> {
175
+ const trap = proc.state.traps.get('EXIT')
176
+ if (trap === undefined || trap === '') return status
177
+ proc.state.traps.delete('EXIT')
178
+ proc.state.lastStatus = status
179
+ try {
180
+ const script = this.rt.parse(trap, 'trap EXIT')
181
+ await this.execList(script.body, proc)
182
+ } catch (e) {
183
+ if (e instanceof ExitSignal) return e.status
184
+ if (e instanceof AbortRun) throw e
185
+ if (!(e instanceof ParseError || e instanceof FatalError || e instanceof DiscardError || e instanceof ReturnSignal)) throw e
186
+ if (e instanceof FatalError || e instanceof DiscardError) this.errorMessage(proc, e.message)
187
+ }
188
+ return status
189
+ }
190
+
191
+ // -------------------------------------------------------------------------
192
+ // Output helpers
193
+ // -------------------------------------------------------------------------
194
+
195
+ async write(proc: Proc, fd: number, text: string | Uint8Array): Promise<void> {
196
+ const f = proc.fds.get(fd)
197
+ if (!f || !f.writable) {
198
+ if (fd === 1) throw new CommandError('write error: Bad file descriptor')
199
+ return
200
+ }
201
+ await f.write(text)
202
+ }
203
+
204
+ errorMessage(proc: Proc, message: string): void {
205
+ const f = proc.fds.get(2)
206
+ this.rt.emit({ type: 'error', message, pid: proc.pid })
207
+ if (f?.writable) f.write(`mash: ${message}\n`).catch(() => {})
208
+ }
209
+
210
+ // -------------------------------------------------------------------------
211
+ // Lists and statements
212
+ // -------------------------------------------------------------------------
213
+
214
+ async execList(stmts: A.Stmt[], proc: Proc): Promise<number> {
215
+ let status = proc.state.lastStatus
216
+ for (const stmt of stmts) {
217
+ status = await this.execStmt(stmt, proc)
218
+ }
219
+ return status
220
+ }
221
+
222
+ async execStmt(stmt: A.Stmt, proc: Proc): Promise<number> {
223
+ if (this.rt.signal.aborted) throw new AbortRun('aborted', 130)
224
+ if (proc.state.options.noexec) return 0
225
+ if (stmt.background) {
226
+ this.startBackground(stmt, proc)
227
+ proc.state.lastStatus = 0
228
+ return 0
229
+ }
230
+ const status = await this.execCommand(stmt.command, proc)
231
+ proc.state.lastStatus = status
232
+ return status
233
+ }
234
+
235
+ private startBackground(stmt: A.Stmt, proc: Proc): void {
236
+ const pid = this.rt.nextPid()
237
+ const child = proc.subshell(pid)
238
+ // background jobs do not read the terminal: stdin is /dev/null unless redirected
239
+ const stdin = proc.fds.get(0)
240
+ if (stdin && (stdin.channel.kind === 'host')) child.fds.set(0, new NullFile(this.rt.channel('null', '/dev/null'), this.rt.listeners))
241
+ const ac = new AbortController()
242
+ child.jobSignal = ac.signal
243
+ child.jobChildren = new Set()
244
+ const children = child.jobChildren
245
+ const job: Job = {
246
+ pid,
247
+ nodeId: stmt.id,
248
+ status: null,
249
+ done: Promise.resolve(0),
250
+ kill: (sig = 'SIGTERM') => {
251
+ ac.abort()
252
+ for (const c of children) c.kill(sig)
253
+ },
254
+ }
255
+ this.rt.emit({ type: 'job:start', jobPid: pid, nodeId: stmt.id })
256
+ job.done = this.runSubshell(child, () => this.execCommand(stmt.command, child), 'background', stmt.id).then((s) => {
257
+ job.status = s
258
+ this.rt.emit({ type: 'job:end', jobPid: pid, status: s })
259
+ return s
260
+ })
261
+ proc.jobs.set(pid, job)
262
+ this.rt.registerJob(job)
263
+ proc.state.lastBgPid = pid
264
+ }
265
+
266
+ /** Run `fn` as the body of subshell `child`, handling exit, traps and fd cleanup. */
267
+ async runSubshell(child: Proc, fn: () => Promise<number>, reason: SubshellReason, nodeId: number): Promise<number> {
268
+ this.rt.emit({ type: 'subshell:start', pid: child.pid, parentPid: child.parentPid, nodeId, reason })
269
+ let status: number
270
+ try {
271
+ try {
272
+ status = await fn()
273
+ } catch (e) {
274
+ if (e instanceof AbortRun) throw e
275
+ status = this.handleTermination(e, child)
276
+ // bash 5: an unbound variable or ${x:?} ends a ( … ) subshell with status 1
277
+ // (the top-level shell still exits with 127; bash 3.2 uses 127 for both)
278
+ if (reason === 'subshell' && e instanceof FatalError && e.status === 127) status = 1
279
+ }
280
+ status = await this.runExitTrap(child, status)
281
+ } finally {
282
+ child.fds.closeAll()
283
+ }
284
+ this.rt.emit({ type: 'subshell:end', pid: child.pid, status })
285
+ return status
286
+ }
287
+
288
+ private inErrTrap = false
289
+
290
+ /** After a failing command outside a condition: run the ERR trap, then apply `set -e`. */
291
+ private async checkErrexit(status: number, proc: Proc): Promise<void> {
292
+ if (status === 0 || proc.errexitSuppressed > 0) return
293
+ const trap = proc.state.traps.get('ERR')
294
+ if (trap && !this.inErrTrap) {
295
+ this.inErrTrap = true
296
+ try {
297
+ proc.state.lastStatus = status
298
+ await this.execList(this.rt.parse(trap, 'trap ERR').body, proc.derive({ suppress: true }))
299
+ } finally {
300
+ this.inErrTrap = false
301
+ }
302
+ proc.state.lastStatus = status
303
+ }
304
+ if (proc.state.options.errexit) throw new ExitSignal(status)
305
+ }
306
+
307
+ private step(node: A.Command, proc: Proc): void {
308
+ if (this.rt.signal.aborted) throw new AbortRun('aborted', 130)
309
+ if (proc.jobSignal?.aborted) throw new ExitSignal(143)
310
+ proc.line = this.rt.line(node)
311
+ this.rt.emit({ type: 'step', nodeId: node.id, span: node.span, line: proc.line, node: node.type, pid: proc.pid, depth: proc.depth })
312
+ }
313
+
314
+ private stepCount = 0
315
+
316
+ async execCommand(cmd: A.Command, proc: Proc): Promise<number> {
317
+ // In-process loops only await microtasks while pipes have room; yield to
318
+ // the event loop regularly so child I/O (and the host) is never starved.
319
+ if (++this.stepCount % 64 === 0) await new Promise<void>((r) => setImmediate(r))
320
+ this.step(cmd, proc)
321
+ switch (cmd.type) {
322
+ case 'SimpleCommand': {
323
+ const s = await this.execSimple(cmd, proc)
324
+ // a lone command is a pipeline of one: PIPESTATUS=($?)
325
+ this.setPipestatus(proc, [s])
326
+ await this.checkErrexit(s, proc)
327
+ return s
328
+ }
329
+ case 'Pipeline': {
330
+ const s = await this.execPipeline(cmd, proc)
331
+ if (!cmd.negated) await this.checkErrexit(s, proc)
332
+ return s
333
+ }
334
+ case 'AndOr':
335
+ return this.execAndOr(cmd, proc)
336
+ case 'FunctionDef':
337
+ proc.state.functions.set(cmd.name, { def: cmd })
338
+ return 0
339
+ default:
340
+ return this.withCompoundRedirects(cmd, proc, async (p) => {
341
+ const s = await this.execCompound(cmd, p)
342
+ if (cmd.type === 'Subshell' || cmd.type === 'ArithCommand' || cmd.type === 'CondCommand') {
343
+ this.setPipestatus(p, [s])
344
+ await this.checkErrexit(s, p)
345
+ }
346
+ return s
347
+ })
348
+ }
349
+ }
350
+
351
+ private async execCompound(cmd: A.CompoundCommand, proc: Proc): Promise<number> {
352
+ switch (cmd.type) {
353
+ case 'Group':
354
+ return this.execList(cmd.body, proc)
355
+ case 'Subshell': {
356
+ const child = proc.subshell(this.rt.nextPid())
357
+ return this.runSubshell(child, () => this.execList(cmd.body, child), 'subshell', cmd.id)
358
+ }
359
+ case 'If':
360
+ return this.execIf(cmd, proc)
361
+ case 'Loop':
362
+ return this.execLoop(cmd, proc)
363
+ case 'For':
364
+ return this.execFor(cmd, proc)
365
+ case 'ArithFor':
366
+ return this.execArithFor(cmd, proc)
367
+ case 'Select':
368
+ return this.execSelect(cmd, proc)
369
+ case 'Case':
370
+ return this.execCase(cmd, proc)
371
+ case 'ArithCommand': {
372
+ try {
373
+ const v = await this.arith(cmd.expr, proc)
374
+ return v !== 0n ? 0 : 1
375
+ } catch (e) {
376
+ if (e instanceof ArithError) {
377
+ this.errorMessage(proc, `((: ${e.message}`)
378
+ return 1
379
+ }
380
+ throw e
381
+ }
382
+ }
383
+ case 'CondCommand': {
384
+ try {
385
+ return (await evalCond(cmd.expr, proc, this)) ? 0 : 1
386
+ } catch (e) {
387
+ if (e instanceof CommandError) {
388
+ this.errorMessage(proc, e.message)
389
+ return 2
390
+ }
391
+ throw e
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ private async execAndOr(cmd: A.AndOr, proc: Proc): Promise<number> {
398
+ const suppressed = proc.derive({ suppress: true })
399
+ let status = await this.execCommand(cmd.items[0], cmd.items.length > 1 ? suppressed : proc)
400
+ for (let i = 1; i < cmd.items.length; i++) {
401
+ const op = cmd.ops[i - 1]
402
+ proc.state.lastStatus = status
403
+ if ((op === '&&' && status !== 0) || (op === '||' && status === 0)) continue
404
+ const last = i === cmd.items.length - 1
405
+ status = await this.execCommand(cmd.items[i], last ? proc : suppressed)
406
+ }
407
+ return status
408
+ }
409
+
410
+ private async execIf(cmd: A.If, proc: Proc): Promise<number> {
411
+ const cond = proc.derive({ suppress: true })
412
+ for (let i = 0; i < cmd.clauses.length; i++) {
413
+ const c = cmd.clauses[i]
414
+ const s = await this.execList(c.cond, cond)
415
+ if (s === 0) {
416
+ this.rt.emit({ type: 'branch', nodeId: cmd.id, taken: i, pid: proc.pid })
417
+ return this.execList(c.body, proc)
418
+ }
419
+ }
420
+ if (cmd.elseBody) {
421
+ this.rt.emit({ type: 'branch', nodeId: cmd.id, taken: 'else', pid: proc.pid })
422
+ return this.execList(cmd.elseBody, proc)
423
+ }
424
+ this.rt.emit({ type: 'branch', nodeId: cmd.id, taken: 'none', pid: proc.pid })
425
+ return 0
426
+ }
427
+
428
+ /** Run a loop body; returns 'break' / 'continue' / status. */
429
+ private async loopBody(body: A.Stmt[], proc: Proc): Promise<{ status: number; ctl?: 'break' | 'continue' | 'outer' }> {
430
+ try {
431
+ return { status: await this.execList(body, proc) }
432
+ } catch (e) {
433
+ if (e instanceof LoopSignal) {
434
+ if (e.levels > 1) {
435
+ e.levels--
436
+ throw e
437
+ }
438
+ return { status: 0, ctl: e.kind }
439
+ }
440
+ throw e
441
+ }
442
+ }
443
+
444
+ private async execLoop(cmd: A.Loop, proc: Proc): Promise<number> {
445
+ let status = 0
446
+ let iteration = 0
447
+ const inner = proc.derive({ loop: true })
448
+ const cond = inner.derive({ suppress: true })
449
+ for (;;) {
450
+ let c: number
451
+ try {
452
+ c = await this.execList(cmd.cond, cond)
453
+ } catch (e) {
454
+ if (e instanceof LoopSignal && e.levels <= 1) {
455
+ if (e.kind === 'break') break
456
+ continue
457
+ }
458
+ if (e instanceof LoopSignal) e.levels--
459
+ throw e
460
+ }
461
+ if ((cmd.kind === 'while') !== (c === 0)) break
462
+ iteration++
463
+ this.rt.emit({ type: 'loop:iteration', nodeId: cmd.id, iteration, pid: proc.pid })
464
+ const r = await this.loopBody(cmd.body, inner)
465
+ status = r.status
466
+ if (r.ctl === 'break') break
467
+ }
468
+ return status
469
+ }
470
+
471
+ private async execFor(cmd: A.For, proc: Proc): Promise<number> {
472
+ const items = cmd.items === null ? [...proc.state.positional] : await this.expandFieldsChecked(cmd.items, proc)
473
+ let status = 0
474
+ let iteration = 0
475
+ const inner = proc.derive({ loop: true })
476
+ for (const item of items) {
477
+ iteration++
478
+ this.assignScalar(proc, cmd.variable, item)
479
+ this.rt.emit({ type: 'loop:iteration', nodeId: cmd.id, iteration, variable: cmd.variable, value: item, pid: proc.pid })
480
+ const r = await this.loopBody(cmd.body, inner)
481
+ status = r.status
482
+ if (r.ctl === 'break') break
483
+ }
484
+ return status
485
+ }
486
+
487
+ private async execSelect(cmd: A.Select, proc: Proc): Promise<number> {
488
+ // Non-interactive: print the menu to stderr, read choices from stdin.
489
+ const items = cmd.items === null ? [...proc.state.positional] : await this.expandFieldsChecked(cmd.items, proc)
490
+ let status = 0
491
+ const inner = proc.derive({ loop: true })
492
+ for (;;) {
493
+ await this.write(proc, 2, items.map((it, i) => `${i + 1}) ${it}\n`).join('') + (proc.state.getString('PS3') ?? '#? '))
494
+ const stdin = proc.fds.get(0)
495
+ const line = stdin ? await stdin.readUntil(10) : null
496
+ if (line === null) return status
497
+ const reply = new TextDecoder().decode(line).replace(/\n$/, '')
498
+ this.assignScalar(proc, 'REPLY', reply)
499
+ const n = Number(reply)
500
+ this.assignScalar(proc, cmd.variable, Number.isInteger(n) && n >= 1 && n <= items.length ? items[n - 1] : '')
501
+ const r = await this.loopBody(cmd.body, inner)
502
+ status = r.status
503
+ if (r.ctl === 'break') break
504
+ }
505
+ return status
506
+ }
507
+
508
+ private async execArithFor(cmd: A.ArithFor, proc: Proc): Promise<number> {
509
+ const ev = async (e: A.ArithExpr | null): Promise<bigint> => {
510
+ try {
511
+ return await this.arith(e, proc)
512
+ } catch (err) {
513
+ if (err instanceof ArithError) throw new DiscardError(err.message)
514
+ throw err
515
+ }
516
+ }
517
+ let status = 0
518
+ let iteration = 0
519
+ const inner = proc.derive({ loop: true })
520
+ await ev(cmd.init)
521
+ for (;;) {
522
+ if (cmd.cond && (await ev(cmd.cond)) === 0n) break
523
+ iteration++
524
+ this.rt.emit({ type: 'loop:iteration', nodeId: cmd.id, iteration, pid: proc.pid })
525
+ const r = await this.loopBody(cmd.body, inner)
526
+ status = r.status
527
+ if (r.ctl === 'break') break
528
+ await ev(cmd.step)
529
+ }
530
+ return status
531
+ }
532
+
533
+ private async execCase(cmd: A.Case, proc: Proc): Promise<number> {
534
+ const word = await this.expander.expandString(cmd.word, proc)
535
+ const nocase = proc.state.shopts.has('nocasematch')
536
+ let status = 0
537
+ let fallthrough = false
538
+ for (let i = 0; i < cmd.items.length; i++) {
539
+ const item = cmd.items[i]
540
+ let matched = fallthrough
541
+ if (!matched) {
542
+ for (const p of item.patterns) {
543
+ const pat = await this.expander.expandPattern(p, proc)
544
+ if (matchPattern(word, pat, { nocase })) {
545
+ matched = true
546
+ break
547
+ }
548
+ }
549
+ }
550
+ if (!matched) continue
551
+ this.rt.emit({ type: 'branch', nodeId: cmd.id, taken: i, pid: proc.pid })
552
+ status = await this.execList(item.body, proc)
553
+ if (item.terminator === ';;') return status
554
+ fallthrough = item.terminator === ';&'
555
+ }
556
+ if (!fallthrough) this.rt.emit({ type: 'branch', nodeId: cmd.id, taken: 'none', pid: proc.pid })
557
+ return status
558
+ }
559
+
560
+ // -------------------------------------------------------------------------
561
+ // Pipelines
562
+ // -------------------------------------------------------------------------
563
+
564
+ private async execPipeline(cmd: A.Pipeline, proc: Proc): Promise<number> {
565
+ const start = Date.now()
566
+ const n = cmd.stages.length
567
+ const stageProc = cmd.negated ? proc.derive({ suppress: true }) : proc
568
+ let statuses: number[]
569
+ if (n === 1) {
570
+ statuses = [await this.execCommand(cmd.stages[0], stageProc)]
571
+ } else {
572
+ const pipes: Pipe[] = []
573
+ for (let i = 0; i < n - 1; i++) {
574
+ const label = `${this.rt.text(cmd.stages[i])} | ${this.rt.text(cmd.stages[i + 1])}`
575
+ const ch = this.rt.channel('pipe', label, { nodeId: cmd.id })
576
+ pipes.push(new Pipe(ch, this.rt.listeners))
577
+ }
578
+ const runs = cmd.stages.map((stage, i) => {
579
+ const child = stageProc.subshell(this.rt.nextPid())
580
+ if (i > 0) child.fds.set(0, pipes[i - 1].readEnd.retain())
581
+ if (i < n - 1) {
582
+ child.fds.set(1, pipes[i].writeEnd.retain())
583
+ if (cmd.pipeStderr[i]) child.fds.dup(1, 2)
584
+ }
585
+ return this.runSubshell(child, () => this.execCommand(stage, child), 'pipeline', stage.id)
586
+ })
587
+ for (const p of pipes) {
588
+ p.readEnd.release()
589
+ p.writeEnd.release()
590
+ }
591
+ statuses = await Promise.all(runs)
592
+ }
593
+ this.setPipestatus(proc, statuses)
594
+ let status = statuses[statuses.length - 1]
595
+ if (proc.state.options.pipefail) {
596
+ for (let i = statuses.length - 1; i >= 0; i--) {
597
+ if (statuses[i] !== 0) {
598
+ status = statuses[i]
599
+ break
600
+ }
601
+ }
602
+ }
603
+ if (cmd.timed) {
604
+ const secs = (Date.now() - start) / 1000
605
+ await this.write(proc, 2, `\nreal\t${Math.floor(secs / 60)}m${(secs % 60).toFixed(3)}s\nuser\t0m0.000s\nsys\t0m0.000s\n`).catch(() => {})
606
+ }
607
+ if (cmd.negated) status = status === 0 ? 1 : 0
608
+ return status
609
+ }
610
+
611
+ private setPipestatus(proc: Proc, statuses: number[]): void {
612
+ const values = new Map<number, string>()
613
+ statuses.forEach((s, i) => values.set(i, String(s)))
614
+ proc.state.globals.set('PIPESTATUS', newVar({ kind: 'indexed', values }))
615
+ }
616
+
617
+ // -------------------------------------------------------------------------
618
+ // Simple commands
619
+ // -------------------------------------------------------------------------
620
+
621
+ private async expandFieldsChecked(words: A.Word[], proc: Proc): Promise<string[]> {
622
+ return this.expander.expandFields(words, proc)
623
+ }
624
+
625
+ private async execSimple(cmd: A.SimpleCommand, proc: Proc): Promise<number> {
626
+ const mark = proc.procSubstFds.length
627
+ try {
628
+ return await this.execSimpleInner(cmd, proc)
629
+ } catch (e) {
630
+ if (e instanceof CommandError) {
631
+ this.errorMessage(proc, e.message)
632
+ return e.status
633
+ }
634
+ throw e
635
+ } finally {
636
+ this.closeProcSubsts(proc, mark)
637
+ }
638
+ }
639
+
640
+ private closeProcSubsts(proc: Proc, mark: number): void {
641
+ while (proc.procSubstFds.length > mark) {
642
+ const { fds, fd } = proc.procSubstFds.pop()!
643
+ fds.close(fd)
644
+ }
645
+ }
646
+
647
+ /** `expand_aliases`: re-parse the command with the alias text substituted for its first word. */
648
+ private async expandAlias(cmd: A.SimpleCommand, proc: Proc): Promise<number | null> {
649
+ const st = proc.state
650
+ if (!st.shopts.has('expand_aliases') || st.aliases.size === 0 || st.aliasDepth > 16) return null
651
+ const first = cmd.words[0]
652
+ if (!first || first.type !== 'Word') return null
653
+ const name = literalOf(first)
654
+ if (name === null || !st.aliases.has(name)) return null
655
+ const full = this.rt.text(cmd)
656
+ const offset = cmd.span.start
657
+ const text = full.slice(0, first.span.start - offset) + st.aliases.get(name)! + full.slice(first.span.end - offset)
658
+ const script = this.rt.parse(text, `alias ${name}`)
659
+ st.aliasDepth++
660
+ try {
661
+ return await this.execList(script.body, proc)
662
+ } finally {
663
+ st.aliasDepth--
664
+ }
665
+ }
666
+
667
+ private async execSimpleInner(cmd: A.SimpleCommand, proc: Proc): Promise<number> {
668
+ proc.lastSubstStatus = null
669
+ const aliased = await this.expandAlias(cmd, proc)
670
+ if (aliased !== null) return aliased
671
+ // 1. Expand words (Assign nodes after declaration builtins are evaluated by the builtin).
672
+ const argv: string[] = []
673
+ const declAssigns: { index: number; assign: A.Assign }[] = []
674
+ for (const w of cmd.words) {
675
+ if (w.type === 'Assign') {
676
+ declAssigns.push({ index: argv.length, assign: w })
677
+ argv.push(`\u0000assign:${declAssigns.length - 1}`)
678
+ } else {
679
+ argv.push(...(await this.expander.expandFields([w], proc)))
680
+ }
681
+ }
682
+
683
+ // 2. Assignment-only statement
684
+ if (argv.length === 0) {
685
+ for (const a of cmd.assigns) {
686
+ try {
687
+ await this.performAssign(a, proc, {})
688
+ } catch (e) {
689
+ if (e instanceof CommandError && /readonly variable/.test(e.message)) throw new FatalError(e.message, 1)
690
+ throw e
691
+ }
692
+ }
693
+ const redirStatus = await this.redirectOnly(cmd, proc)
694
+ if (redirStatus !== 0) return redirStatus
695
+ if (proc.state.options.xtrace && cmd.assigns.length > 0) {
696
+ await this.xtrace(proc, cmd.assigns.map((a) => `${a.name}=${this.rt.text(a.value ?? a)}`))
697
+ }
698
+ return proc.lastSubstStatus ?? 0
699
+ }
700
+
701
+ // 3. Prefix assignments (temporary for functions/builtins, environment for externals)
702
+ const prefix: { name: string; value: string }[] = []
703
+ for (const a of cmd.assigns) {
704
+ if (a.value?.type === 'ArrayLiteral') continue
705
+ const v = a.value ? await this.expander.expandString(a.value, proc, { tildeAssign: true }) : ''
706
+ prefix.push({ name: a.name, value: v })
707
+ }
708
+
709
+ // 4. Resolve
710
+ const name = argv[0]
711
+ let kind: CommandKind
712
+ let exePath: string | undefined
713
+ const fn = proc.state.functions.get(name)
714
+ const tempPath = prefix.find((p) => p.name === 'PATH')?.value
715
+ if (fn) kind = 'function'
716
+ else if (BUILTINS.has(name)) kind = 'builtin'
717
+ else {
718
+ exePath = resolveCommand(name, proc.state, tempPath)
719
+ kind = exePath ? 'external' : 'not-found'
720
+ }
721
+
722
+ // 5. Redirect targets
723
+ const redirects = await this.resolveRedirects(cmd.redirs, proc)
724
+ const redirectEffects = redirectEffectsOf(redirects)
725
+
726
+ // 6. Assess (behavior + policy + hooks)
727
+ const execId = this.rt.nextExecId()
728
+ const env = this.childEnv(proc, prefix)
729
+ const realArgv = this.realArgv(argv, declAssigns)
730
+ const stdinFile = redirects.find((r) => r.fd === 0)
731
+ const stdinKind = stdinFile ? (stdinFile.path ? 'file' : 'pipe') : kindOfStdin(proc.fds.get(0))
732
+ const assessment = await this.rt.assess({
733
+ execId, node: cmd, argv: realArgv, kind, path: exePath, cwd: proc.state.cwd, env, redirectEffects, stdinKind, pid: proc.pid,
734
+ })
735
+ if (assessment.action === 'abort') throw new AbortRun(assessment.reason, assessment.status)
736
+ if (assessment.action === 'deny') {
737
+ this.errorMessage(proc, `${name}: ${assessment.reason}`)
738
+ return assessment.status
739
+ }
740
+
741
+ if (proc.state.options.xtrace) await this.xtrace(proc, [...prefix.map((p) => `${p.name}=${p.value}`), ...realArgv])
742
+
743
+ this.rt.emit({
744
+ type: 'command:start', execId, nodeId: cmd.id, span: cmd.span, argv: realArgv, kind, path: exePath, cwd: proc.state.cwd, pid: proc.pid,
745
+ redirects: redirects.map(redirectInfo), line: proc.line,
746
+ })
747
+ const started = Date.now()
748
+ let status: number
749
+ // 7. Open redirections into a command-local fd table
750
+ const fds = proc.fds.clone()
751
+ try {
752
+ try {
753
+ await this.openRedirects(redirects, fds, proc)
754
+ } catch (e) {
755
+ if (e instanceof FsError || e instanceof CommandError) {
756
+ this.errorMessage(proc, e.message)
757
+ status = 1
758
+ this.rt.emit({ type: 'command:end', execId, nodeId: cmd.id, status, durationMs: Date.now() - started })
759
+ return status
760
+ }
761
+ throw e
762
+ }
763
+ const cproc = proc.derive({ fds })
764
+ if (assessment.action === 'simulate') {
765
+ if (assessment.stdout) await this.write(cproc, 1, assessment.stdout).catch(() => {})
766
+ if (assessment.stderr) await this.write(cproc, 2, assessment.stderr).catch(() => {})
767
+ status = assessment.status
768
+ } else {
769
+ status = await this.dispatch(kind, name, argv, declAssigns, prefix, exePath, env, cmd, cproc, proc, assessment.readsStdin, execId)
770
+ }
771
+ } catch (e) {
772
+ if (e instanceof BrokenPipeError) {
773
+ // SIGPIPE: an in-process write to a pipe nobody reads kills this (sub)shell, as in bash
774
+ this.rt.emit({ type: 'command:end', execId, nodeId: cmd.id, status: 141, durationMs: Date.now() - started })
775
+ throw new ExitSignal(141)
776
+ } else {
777
+ this.rt.emit({ type: 'command:end', execId, nodeId: cmd.id, status: e instanceof ExitSignal || e instanceof ReturnSignal ? e.status : 1, durationMs: Date.now() - started })
778
+ throw e
779
+ }
780
+ } finally {
781
+ fds.closeAll()
782
+ }
783
+ this.rt.emit({ type: 'command:end', execId, nodeId: cmd.id, status, durationMs: Date.now() - started })
784
+ await this.rt.afterCommand(execId, status)
785
+ this.setUnderscore(proc, realArgv)
786
+ return status
787
+ }
788
+
789
+ private setUnderscore(proc: Proc, argv: string[]): void {
790
+ const v = proc.state.globals.get('_')
791
+ const last = argv[argv.length - 1] ?? ''
792
+ if (v) v.value = { kind: 'string', value: last }
793
+ else proc.state.globals.set('_', newVar({ kind: 'string', value: last }))
794
+ }
795
+
796
+ private realArgv(argv: string[], declAssigns: { index: number; assign: A.Assign }[]): string[] {
797
+ if (declAssigns.length === 0) return argv
798
+ return argv.map((a) => {
799
+ const m = /^\u0000assign:(\d+)$/.exec(a)
800
+ if (!m) return a
801
+ const as = declAssigns[Number(m[1])].assign
802
+ return this.rt.text(as)
803
+ })
804
+ }
805
+
806
+ private async dispatch(
807
+ kind: CommandKind, name: string, argv: string[], declAssigns: { index: number; assign: A.Assign }[],
808
+ prefix: { name: string; value: string }[], exePath: string | undefined, env: Record<string, string>,
809
+ cmd: A.SimpleCommand, cproc: Proc, base: Proc, readsStdin: boolean | 'maybe', execId: number,
810
+ ): Promise<number> {
811
+ switch (kind) {
812
+ case 'function': {
813
+ const entry = cproc.state.functions.get(name)!
814
+ return this.withTempVars(cproc, prefix, () => this.callFunction(entry.def, argv, cproc))
815
+ }
816
+ case 'builtin': {
817
+ const run = (): Promise<number> => this.runBuiltin(name, argv, declAssigns, cproc, base, cmd)
818
+ if (prefix.length === 0) return run()
819
+ return this.withTempVars(cproc, prefix, run)
820
+ }
821
+ case 'external': {
822
+ if (this.rt.inlineScripts) {
823
+ const plan = inlineShellPlan(exePath!, argv, cproc.state.cwd)
824
+ if (plan) return this.runInlineScript(plan, env, cproc)
825
+ }
826
+ if (this.rt.emulate) {
827
+ const base = path.basename(exePath!)
828
+ const req = { execId, path: exePath!, argv, env, proc: cproc, node: cmd }
829
+ const emulated = base === 'xargs' ? await emulateXargs(this, req) : base === 'find' ? await emulateFind(this, req) : null
830
+ if (emulated !== null) return emulated
831
+ }
832
+ return runExternal(this, {
833
+ execId, path: exePath!, argv, env, cwd: cproc.state.cwd, proc: cproc, readsStdin,
834
+ })
835
+ }
836
+ case 'not-found': {
837
+ const p = name.includes('/') ? path.resolve(cproc.state.cwd, name) : null
838
+ if (p && fs.existsSync(p)) {
839
+ this.errorMessage(cproc, `${name}: ${fs.statSync(p).isDirectory() ? 'Is a directory' : 'Permission denied'}`)
840
+ return 126
841
+ }
842
+ this.errorMessage(cproc, `${name}: ${p ? 'No such file or directory' : 'command not found'}`)
843
+ return 127
844
+ }
845
+ default:
846
+ return 0
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Execute a nested shell script (`bash -c ...`, `bash file`, `./script.sh`)
852
+ * inside the VM as a fresh shell process: only exported variables carry
853
+ * over, functions and options do not. Its commands are traced and
854
+ * policy-checked individually instead of disappearing into a child bash.
855
+ */
856
+ private async runInlineScript(plan: InlinePlan, env: Record<string, string>, proc: Proc): Promise<number> {
857
+ const script = this.rt.parse(plan.source, plan.origin, { partial: true })
858
+ const state = new ShellState(proc.state.cwd)
859
+ for (const [k, v] of Object.entries(env)) {
860
+ const fm = /^BASH_FUNC_(.+)%%$/.exec(k)
861
+ if (fm) {
862
+ const def = proc.state.functions.get(fm[1])
863
+ if (def?.exported) state.functions.set(fm[1], def)
864
+ continue
865
+ }
866
+ state.globals.set(k, newVar({ kind: 'string', value: v }, { exported: true }))
867
+ }
868
+ state.globals.set('SHLVL', newVar({ kind: 'string', value: String(Number(env.SHLVL ?? '1') + 1) }, { exported: true }))
869
+ initShellVars(state)
870
+ for (const [k, v] of Object.entries(plan.options)) (state.options as unknown as Record<string, boolean>)[k] = v
871
+ state.positional = plan.args
872
+ state.arg0 = plan.arg0
873
+ const child = new Proc(state, proc.fds.clone(), this.rt.nextPid(), proc.pid, 0, new Map(), true)
874
+ child.jobSignal = proc.jobSignal
875
+ child.jobChildren = proc.jobChildren
876
+ return this.runSubshell(child, () => this.execTopLevel(script, child), 'script', script.id)
877
+ }
878
+
879
+ /**
880
+ * Run an already-expanded argv (for `command`, `builtin`, `exec`): resolve,
881
+ * assess against the policy, and dispatch.
882
+ */
883
+ async runArgv(argv: string[], proc: Proc, node: A.SimpleCommand, opts: { skipFunctions?: boolean; externalOnly?: boolean; builtinOnly?: boolean }): Promise<number> {
884
+ const name = argv[0]
885
+ let kind: CommandKind
886
+ let exePath: string | undefined
887
+ if (!opts.externalOnly && !opts.skipFunctions && !opts.builtinOnly && proc.state.functions.has(name)) kind = 'function'
888
+ else if (!opts.externalOnly && BUILTINS.has(name)) kind = 'builtin'
889
+ else if (opts.builtinOnly) kind = 'not-found'
890
+ else {
891
+ exePath = resolveCommand(name, proc.state)
892
+ kind = exePath ? 'external' : 'not-found'
893
+ }
894
+ const execId = this.rt.nextExecId()
895
+ const env = this.childEnv(proc, [])
896
+ const assessment = await this.rt.assess({
897
+ execId, node, argv, kind, path: exePath, cwd: proc.state.cwd, env, redirectEffects: [], stdinKind: kindOfStdin(proc.fds.get(0)), pid: proc.pid,
898
+ })
899
+ if (assessment.action === 'abort') throw new AbortRun(assessment.reason, assessment.status)
900
+ if (assessment.action === 'deny') {
901
+ this.errorMessage(proc, `${name}: ${assessment.reason}`)
902
+ return assessment.status
903
+ }
904
+ this.rt.emit({ type: 'command:start', execId, nodeId: node.id, span: node.span, argv, kind, path: exePath, cwd: proc.state.cwd, pid: proc.pid, redirects: [] })
905
+ const started = Date.now()
906
+ let status: number
907
+ if (assessment.action === 'simulate') {
908
+ if (assessment.stdout) await this.write(proc, 1, assessment.stdout).catch(() => {})
909
+ if (assessment.stderr) await this.write(proc, 2, assessment.stderr).catch(() => {})
910
+ status = assessment.status
911
+ } else {
912
+ status = await this.dispatch(kind, name, argv, [], [], exePath, env, node, proc, proc, assessment.readsStdin, execId)
913
+ }
914
+ this.rt.emit({ type: 'command:end', execId, nodeId: node.id, status, durationMs: Date.now() - started })
915
+ await this.rt.afterCommand(execId, status)
916
+ return status
917
+ }
918
+
919
+ private async withTempVars(proc: Proc, vars: { name: string; value: string }[], fn: () => Promise<number>): Promise<number> {
920
+ if (vars.length === 0) return fn()
921
+ const scope = new Map<string, Variable>()
922
+ for (const v of vars) scope.set(v.name, newVar({ kind: 'string', value: v.value }, { exported: true }))
923
+ proc.state.locals.push(scope)
924
+ try {
925
+ return await fn()
926
+ } finally {
927
+ const i = proc.state.locals.lastIndexOf(scope)
928
+ if (i >= 0) proc.state.locals.splice(i, 1)
929
+ }
930
+ }
931
+
932
+ async callFunction(def: A.FunctionDef, argv: string[], proc: Proc): Promise<number> {
933
+ const st = proc.state
934
+ const savedPositional = st.positional
935
+ st.positional = argv.slice(1)
936
+ st.locals.push(new Map())
937
+ st.funcStack.unshift(def.name)
938
+ this.rt.emit({ type: 'function:enter', name: def.name, nodeId: def.id, pid: proc.pid, args: argv.slice(1) })
939
+ let status: number
940
+ try {
941
+ status = await this.execCommand(def.body, proc)
942
+ } catch (e) {
943
+ if (e instanceof ReturnSignal) status = e.status
944
+ else throw e
945
+ } finally {
946
+ const ret = st.traps.get('RETURN')
947
+ if (ret) {
948
+ try {
949
+ await this.execList(this.rt.parse(ret, 'trap RETURN').body, proc)
950
+ } catch {
951
+ // a failing RETURN trap does not change the function's status
952
+ }
953
+ }
954
+ st.locals.pop()
955
+ st.positional = savedPositional
956
+ st.funcStack.shift()
957
+ }
958
+ this.rt.emit({ type: 'function:exit', name: def.name, status, pid: proc.pid })
959
+ return status
960
+ }
961
+
962
+ private async runBuiltin(name: string, argv: string[], declAssigns: { index: number; assign: A.Assign }[], proc: Proc, base: Proc, cmd: A.SimpleCommand): Promise<number> {
963
+ const impl = BUILTINS.get(name)!
964
+ const ctx: BuiltinContext = {
965
+ interp: this,
966
+ proc,
967
+ base,
968
+ argv,
969
+ node: cmd,
970
+ assignArg: (arg: string) => {
971
+ const m = /^\u0000assign:(\d+)$/.exec(arg)
972
+ return m ? declAssigns[Number(m[1])].assign : null
973
+ },
974
+ out: (text) => this.write(proc, 1, text),
975
+ err: (text) => this.write(proc, 2, text),
976
+ error: (message) => this.errorMessage(proc, `${name}: ${message}`),
977
+ }
978
+ try {
979
+ return await impl(ctx)
980
+ } catch (e) {
981
+ if (e instanceof CommandError) {
982
+ this.errorMessage(proc, `${name}: ${e.message}`)
983
+ return e.status
984
+ }
985
+ throw e
986
+ }
987
+ }
988
+
989
+ private async redirectOnly(cmd: A.SimpleCommand, proc: Proc): Promise<number> {
990
+ if (cmd.redirs.length === 0) return 0
991
+ const redirects = await this.resolveRedirects(cmd.redirs, proc)
992
+ const a = await this.rt.assessRedirects(redirectEffectsOf(redirects), cmd, proc)
993
+ if (a.action === 'abort') throw new AbortRun(a.reason, a.status)
994
+ if (a.action === 'deny') {
995
+ this.errorMessage(proc, a.reason)
996
+ return a.status
997
+ }
998
+ const fds = proc.fds.clone()
999
+ try {
1000
+ await this.openRedirects(redirects, fds, proc)
1001
+ } catch (e) {
1002
+ if (e instanceof FsError || e instanceof CommandError) {
1003
+ this.errorMessage(proc, e.message)
1004
+ return 1
1005
+ }
1006
+ throw e
1007
+ } finally {
1008
+ fds.closeAll()
1009
+ }
1010
+ return 0
1011
+ }
1012
+
1013
+ private async xtrace(proc: Proc, words: string[]): Promise<void> {
1014
+ const ps4 = proc.state.getString('PS4') ?? '+ '
1015
+ const { shellQuote } = await import('./expand.ts')
1016
+ await this.write(proc, 2, `${ps4}${words.map((w) => (w === '' ? "''" : shellQuote(w))).join(' ')}\n`).catch(() => {})
1017
+ }
1018
+
1019
+ childEnv(proc: Proc, prefix: { name: string; value: string }[]): Record<string, string> {
1020
+ const env = proc.state.environment()
1021
+ for (const [name, f] of proc.state.functions) {
1022
+ if (f.exported) env[`BASH_FUNC_${name}%%`] = `() ${this.rt.text(f.def.body)}`
1023
+ }
1024
+ for (const p of prefix) env[p.name] = p.value
1025
+ return env
1026
+ }
1027
+
1028
+ // -------------------------------------------------------------------------
1029
+ // Assignments
1030
+ // -------------------------------------------------------------------------
1031
+
1032
+ assignScalar(proc: Proc, name: string, value: string, opts: { local?: boolean } = {}): void {
1033
+ const st = proc.state
1034
+ const v = opts.local ? st.declareLocal(name) : st.ensure(name)
1035
+ if (v.readonly) throw new CommandError(`${name}: readonly variable`)
1036
+ value = this.applyAttrs(v, value, proc)
1037
+ if (v.value?.kind === 'indexed') v.value.values.set(0, value)
1038
+ else if (v.value?.kind === 'assoc') v.value.values.set('0', value)
1039
+ else v.value = { kind: 'string', value }
1040
+ this.rt.emit({ type: 'assign', name, value, pid: proc.pid, nodeId: 0 })
1041
+ if (name === 'PWD' && path.isAbsolute(value)) {
1042
+ // keep the VM cwd in sync only through cd
1043
+ }
1044
+ }
1045
+
1046
+ /** `name[index]=value` from an already-expanded subscript (`printf -v 'a[1]'`). */
1047
+ async assignElement(proc: Proc, name: string, index: string, value: string): Promise<void> {
1048
+ const v = proc.state.ensure(name)
1049
+ if (v.readonly) throw new CommandError(`${name}: readonly variable`)
1050
+ if (v.value?.kind === 'assoc' || v.declaredKind === 'assoc') {
1051
+ if (v.value?.kind !== 'assoc') v.value = { kind: 'assoc', values: new Map() }
1052
+ v.value.values.set(index, this.applyAttrs(v, value, proc))
1053
+ } else {
1054
+ let idx = Number(await this.arithText(index, proc))
1055
+ if (v.value === null || v.value.kind !== 'indexed') {
1056
+ const values = new Map<number, string>()
1057
+ if (v.value?.kind === 'string') values.set(0, v.value.value)
1058
+ v.value = { kind: 'indexed', values }
1059
+ }
1060
+ if (idx < 0) idx = (v.value.values.size === 0 ? 0 : Math.max(...v.value.values.keys()) + 1) + idx
1061
+ v.value.values.set(idx, this.applyAttrs(v, value, proc))
1062
+ }
1063
+ this.rt.emit({ type: 'assign', name: `${name}[…]`, value, pid: proc.pid, nodeId: 0 })
1064
+ }
1065
+
1066
+ private applyAttrs(v: Variable, value: string, proc: Proc): string {
1067
+ if (v.integer) {
1068
+ try {
1069
+ const env = this.arithEnv(proc)
1070
+ // synchronous evaluation of text
1071
+ value = String(evaluateArithTextSync(value, env))
1072
+ } catch {
1073
+ value = '0'
1074
+ }
1075
+ }
1076
+ if (v.upper) value = value.toUpperCase()
1077
+ if (v.lower) value = value.toLowerCase()
1078
+ return value
1079
+ }
1080
+
1081
+ /**
1082
+ * Expand an assignment's value now and return an equivalent assignment with
1083
+ * literal parts (so `local x=1 y=$x` sees the old `$x`, as bash expands
1084
+ * all arguments before the builtin runs).
1085
+ */
1086
+ async freezeAssign(a: A.Assign, proc: Proc): Promise<A.Assign> {
1087
+ const lit = (value: string, like: { id: number; span: A.Span }): A.Word => ({
1088
+ type: 'Word', id: like.id, span: like.span, parts: [{ type: 'SglQuoted', value, ansi: false, id: like.id, span: like.span }],
1089
+ })
1090
+ const index = a.index ? lit(await this.expander.expandString(a.index, proc), a.index) : null
1091
+ if (a.value === null) return { ...a, index }
1092
+ if (a.value.type === 'Word') return { ...a, index, value: lit(await this.expander.expandString(a.value, proc, { tildeAssign: true }), a.value) }
1093
+ const elems: A.ArrayElem[] = []
1094
+ for (const el of a.value.elems) {
1095
+ if (el.index) elems.push({ index: lit(await this.expander.expandString(el.index, proc), el.index), value: lit(await this.expander.expandString(el.value, proc), el.value) })
1096
+ else for (const f of await this.expander.expandFields([el.value], proc)) elems.push({ index: null, value: lit(f, el.value) })
1097
+ }
1098
+ return { ...a, index, value: { ...a.value, elems } }
1099
+ }
1100
+
1101
+ /** Perform an assignment node (prefix, bare, or declaration-builtin argument). */
1102
+ async performAssign(a: A.Assign, proc: Proc, opts: { local?: boolean; exported?: boolean; declareKind?: 'indexed' | 'assoc' }): Promise<void> {
1103
+ const st = proc.state
1104
+ const v = opts.local ? st.declareLocal(a.name) : st.ensure(a.name)
1105
+ if (v.readonly) throw new CommandError(`${a.name}: readonly variable`)
1106
+ if (opts.exported) v.exported = true
1107
+ if (opts.declareKind) v.declaredKind = opts.declareKind
1108
+ if (a.value?.type === 'ArrayLiteral') {
1109
+ const isAssoc = opts.declareKind === 'assoc' || v.declaredKind === 'assoc' || v.value?.kind === 'assoc'
1110
+ if (isAssoc) {
1111
+ const values = a.append && v.value?.kind === 'assoc' ? v.value.values : new Map<string, string>()
1112
+ for (const el of a.value.elems) {
1113
+ if (!el.index) {
1114
+ // bash 5.1+: `declare -A m=(k v k2 v2)` key/value pairs
1115
+ continue
1116
+ }
1117
+ const key = await this.expander.expandString(el.index, proc)
1118
+ values.set(key, this.applyAttrs(v, await this.expander.expandString(el.value, proc), proc))
1119
+ }
1120
+ const pairs = a.value.elems.filter((e) => !e.index)
1121
+ for (let i = 0; i + 1 < pairs.length; i += 2) {
1122
+ values.set(await this.expander.expandString(pairs[i].value, proc), await this.expander.expandString(pairs[i + 1].value, proc))
1123
+ }
1124
+ v.value = { kind: 'assoc', values }
1125
+ } else {
1126
+ const values = a.append && v.value?.kind === 'indexed' ? v.value.values : a.append && v.value?.kind === 'string' ? new Map([[0, v.value.value]]) : new Map<number, string>()
1127
+ let next = values.size === 0 ? 0 : Math.max(...values.keys()) + 1
1128
+ for (const el of a.value.elems) {
1129
+ if (el.index) {
1130
+ const idxText = await this.expander.expandString(el.index, proc)
1131
+ next = Number(await this.arithText(idxText, proc))
1132
+ values.set(next, this.applyAttrs(v, await this.expander.expandString(el.value, proc), proc))
1133
+ next++
1134
+ } else {
1135
+ for (const f of await this.expander.expandFields([el.value], proc)) {
1136
+ values.set(next++, this.applyAttrs(v, f, proc))
1137
+ }
1138
+ }
1139
+ }
1140
+ v.value = { kind: 'indexed', values }
1141
+ }
1142
+ this.rt.emit({ type: 'assign', name: a.name, value: `(${varElements(v).join(' ')})`, pid: proc.pid, nodeId: a.id })
1143
+ return
1144
+ }
1145
+ let value = a.value ? await this.expander.expandString(a.value, proc, { tildeAssign: true }) : ''
1146
+ if (a.index) {
1147
+ const isAssoc = v.value?.kind === 'assoc' || v.declaredKind === 'assoc'
1148
+ if (isAssoc) {
1149
+ const key = await this.expander.expandString(a.index, proc)
1150
+ if (v.value?.kind !== 'assoc') v.value = { kind: 'assoc', values: new Map() }
1151
+ const old = v.value.values.get(key) ?? ''
1152
+ v.value.values.set(key, this.applyAttrs(v, a.append ? old + value : value, proc))
1153
+ } else {
1154
+ const idxText = await this.expander.expandString(a.index, proc)
1155
+ let idx = Number(await this.arithText(idxText, proc))
1156
+ if (v.value === null || v.value.kind !== 'indexed') {
1157
+ const values = new Map<number, string>()
1158
+ if (v.value?.kind === 'string') values.set(0, v.value.value)
1159
+ v.value = { kind: 'indexed', values }
1160
+ }
1161
+ if (idx < 0) idx = (v.value.values.size === 0 ? 0 : Math.max(...v.value.values.keys()) + 1) + idx
1162
+ const old = v.value.values.get(idx) ?? ''
1163
+ v.value.values.set(idx, this.applyAttrs(v, a.append ? old + value : value, proc))
1164
+ }
1165
+ this.rt.emit({ type: 'assign', name: `${a.name}[…]`, value, pid: proc.pid, nodeId: a.id })
1166
+ return
1167
+ }
1168
+ if (a.append) {
1169
+ if (v.integer) {
1170
+ const old = st.getString(a.name) ?? '0'
1171
+ value = String(BigInt(this.applyAttrs(v, old, proc)) + BigInt(this.applyAttrs(v, value, proc)))
1172
+ } else if (v.value?.kind === 'indexed') {
1173
+ const values = v.value.values
1174
+ values.set(values.size === 0 ? 0 : Math.max(...values.keys()) + 1, value)
1175
+ return
1176
+ } else {
1177
+ value = (st.getString(a.name) ?? '') + value
1178
+ }
1179
+ }
1180
+ if (a.value === null && opts.declareKind === undefined && (opts.local || opts.exported)) {
1181
+ // `local x` / `export x` without value: declare only
1182
+ }
1183
+ value = this.applyAttrs(v, value, proc)
1184
+ if (v.value?.kind === 'indexed') v.value.values.set(0, value)
1185
+ else if (v.value?.kind === 'assoc') v.value.values.set('0', value)
1186
+ else v.value = { kind: 'string', value }
1187
+ this.rt.emit({ type: 'assign', name: a.name, value, pid: proc.pid, nodeId: a.id })
1188
+ }
1189
+
1190
+ // -------------------------------------------------------------------------
1191
+ // Arithmetic
1192
+ // -------------------------------------------------------------------------
1193
+
1194
+ arithEnv(proc: Proc): ArithEnv {
1195
+ const st = proc.state
1196
+ return {
1197
+ get: (name, index) => {
1198
+ if (index === null) {
1199
+ if (/^[0-9]+$/.test(name)) return st.positional[Number(name) - 1]
1200
+ return this.dynamicVar(proc, name) ?? st.getString(name)
1201
+ }
1202
+ const v = st.lookup(name)
1203
+ if (!v || v.value === null) return undefined
1204
+ if (v.value.kind === 'indexed') return v.value.values.get(Number(index))
1205
+ if (v.value.kind === 'assoc') return v.value.values.get(index)
1206
+ return index === '0' ? v.value.value : undefined
1207
+ },
1208
+ isAssoc: (name) => {
1209
+ const v = st.lookup(name)
1210
+ return v?.value?.kind === 'assoc' || v?.declaredKind === 'assoc'
1211
+ },
1212
+ set: (name, index, value) => {
1213
+ const v = st.ensure(name)
1214
+ if (v.readonly) throw new ArithError(`${name}: readonly variable`)
1215
+ const s = String(value)
1216
+ if (index === null) {
1217
+ if (v.value?.kind === 'indexed') v.value.values.set(0, s)
1218
+ else v.value = { kind: 'string', value: s }
1219
+ } else if (v.value?.kind === 'assoc' || v.declaredKind === 'assoc') {
1220
+ if (v.value?.kind !== 'assoc') v.value = { kind: 'assoc', values: new Map() }
1221
+ v.value.values.set(index, s)
1222
+ } else {
1223
+ if (v.value?.kind !== 'indexed') {
1224
+ const values = new Map<number, string>()
1225
+ if (v.value?.kind === 'string') values.set(0, v.value.value)
1226
+ v.value = { kind: 'indexed', values }
1227
+ }
1228
+ v.value.values.set(Number(index), s)
1229
+ }
1230
+ this.rt.emit({ type: 'assign', name, value: s, pid: proc.pid, nodeId: 0 })
1231
+ },
1232
+ expandWord: (word) => this.expander.expandString(word, proc),
1233
+ nounset: st.options.nounset,
1234
+ }
1235
+ }
1236
+
1237
+ async arith(expr: A.ArithExpr | null, proc: Proc): Promise<bigint> {
1238
+ return evaluateArith(expr, this.arithEnv(proc))
1239
+ }
1240
+
1241
+ async arithText(text: string, proc: Proc): Promise<bigint> {
1242
+ const { evaluateArithText } = await import('./arith.ts')
1243
+ try {
1244
+ return evaluateArithText(text, this.arithEnv(proc))
1245
+ } catch (e) {
1246
+ if (e instanceof ArithError) throw new DiscardError(e.message)
1247
+ throw e
1248
+ }
1249
+ }
1250
+
1251
+ // -------------------------------------------------------------------------
1252
+ // Dynamic variables
1253
+ // -------------------------------------------------------------------------
1254
+
1255
+ private readonly startTime = Date.now()
1256
+
1257
+ dynamicVar(proc: Proc, name: string): string | undefined {
1258
+ switch (name) {
1259
+ case 'RANDOM': return String(Math.floor(Math.random() * 32768))
1260
+ case 'SECONDS': return String(Math.floor((Date.now() - this.startTime) / 1000))
1261
+ case 'EPOCHSECONDS': return String(Math.floor(Date.now() / 1000))
1262
+ case 'EPOCHREALTIME': return (Date.now() / 1000).toFixed(6)
1263
+ case 'LINENO': return String(proc.line)
1264
+ case 'BASHPID': return String(proc.pid)
1265
+ case 'BASH_SUBSHELL': return String(proc.depth)
1266
+ case 'PPID': return String(process.pid)
1267
+ case 'FUNCNAME': return proc.state.funcStack[0]
1268
+ case 'UID': return String(process.getuid?.() ?? 0)
1269
+ case 'EUID': return String(process.geteuid?.() ?? 0)
1270
+ case 'BASH': return '/bin/bash'
1271
+ default: return undefined
1272
+ }
1273
+ }
1274
+
1275
+ // -------------------------------------------------------------------------
1276
+ // Substitutions
1277
+ // -------------------------------------------------------------------------
1278
+
1279
+ async commandSubst(node: A.CmdSubst, proc: Proc): Promise<string> {
1280
+ // $(< file)
1281
+ if (node.body.length === 1 && node.body[0].command.type === 'SimpleCommand') {
1282
+ const c = node.body[0].command
1283
+ if (c.words.length === 0 && c.assigns.length === 0 && c.redirs.length === 1 && c.redirs[0].op === '<') {
1284
+ const target = await this.expander.expandString(c.redirs[0].target, proc)
1285
+ const abs = path.resolve(proc.state.cwd, target)
1286
+ const a = await this.rt.assessRedirects([{ kind: 'read', path: known(abs), source: '$(<)' }], c, proc)
1287
+ if (a.action === 'abort') throw new AbortRun(a.reason, a.status)
1288
+ if (a.action === 'deny') {
1289
+ this.errorMessage(proc, a.reason)
1290
+ proc.lastSubstStatus = a.status
1291
+ return ''
1292
+ }
1293
+ try {
1294
+ const text = fs.readFileSync(abs, 'utf8')
1295
+ proc.lastSubstStatus = 0
1296
+ return text.replace(/\n+$/, '')
1297
+ } catch (e) {
1298
+ this.errorMessage(proc, `${target}: ${(e as NodeJS.ErrnoException).code === 'ENOENT' ? 'No such file or directory' : String(e)}`)
1299
+ proc.lastSubstStatus = 1
1300
+ return ''
1301
+ }
1302
+ }
1303
+ }
1304
+ const child = proc.subshell(this.rt.nextPid())
1305
+ if (!proc.state.shopts.has('inherit_errexit')) child.state.options.errexit = false
1306
+ const capture = new Capture(this.rt.channel('capture', `$(${this.rt.text(node).replace(/^\$\(|\)$/g, '').replace(/^`|`$/g, '')})`, { nodeId: node.id }), this.rt.listeners)
1307
+ child.fds.set(1, capture)
1308
+ // keep a reference so closeAll does not discard the bytes before we read them
1309
+ capture.retain()
1310
+ const status = await this.runSubshell(child, () => this.execList(node.body, child), 'cmdsubst', node.id)
1311
+ proc.lastSubstStatus = status
1312
+ proc.state.lastStatus = status
1313
+ const text = capture.text()
1314
+ capture.release()
1315
+ return text.replace(/\n+$/, '')
1316
+ }
1317
+
1318
+ async processSubst(node: A.ProcSubst, proc: Proc): Promise<string> {
1319
+ const ch = this.rt.channel('procsubst', `${node.direction}(${this.rt.text(node).slice(2, -1)})`, { nodeId: node.id })
1320
+ const pipe = new Pipe(ch, this.rt.listeners)
1321
+ const child = proc.subshell(this.rt.nextPid())
1322
+ let fd = 63
1323
+ while (proc.fds.get(fd) && fd > 10) fd--
1324
+ if (node.direction === '<') {
1325
+ child.fds.set(1, pipe.writeEnd)
1326
+ proc.fds.set(fd, pipe.readEnd)
1327
+ } else {
1328
+ child.fds.set(0, pipe.readEnd)
1329
+ proc.fds.set(fd, pipe.writeEnd)
1330
+ }
1331
+ proc.procSubstFds.push({ fds: proc.fds, fd })
1332
+ this.rt.track(this.runSubshell(child, () => this.execList(node.body, child), 'procsubst', node.id))
1333
+ return `/dev/fd/${fd}`
1334
+ }
1335
+
1336
+ // -------------------------------------------------------------------------
1337
+ // Redirections
1338
+ // -------------------------------------------------------------------------
1339
+
1340
+ private async withCompoundRedirects(cmd: A.CompoundCommand, proc: Proc, fn: (p: Proc) => Promise<number>): Promise<number> {
1341
+ if (cmd.redirs.length === 0) return fn(proc)
1342
+ const mark = proc.procSubstFds.length
1343
+ const redirects = await this.resolveRedirects(cmd.redirs, proc)
1344
+ const a = await this.rt.assessRedirects(redirectEffectsOf(redirects), cmd, proc)
1345
+ if (a.action === 'abort') throw new AbortRun(a.reason, a.status)
1346
+ if (a.action === 'deny') {
1347
+ this.errorMessage(proc, a.reason)
1348
+ this.closeProcSubsts(proc, mark)
1349
+ return a.status
1350
+ }
1351
+ const fds = proc.fds.clone()
1352
+ try {
1353
+ try {
1354
+ await this.openRedirects(redirects, fds, proc)
1355
+ } catch (e) {
1356
+ if (e instanceof FsError || e instanceof CommandError) {
1357
+ this.errorMessage(proc, e.message)
1358
+ return 1
1359
+ }
1360
+ throw e
1361
+ }
1362
+ this.closeProcSubsts(proc, mark)
1363
+ return await fn(proc.derive({ fds }))
1364
+ } finally {
1365
+ fds.closeAll()
1366
+ this.closeProcSubsts(proc, mark)
1367
+ }
1368
+ }
1369
+
1370
+ async resolveRedirects(redirs: A.Redirect[], proc: Proc): Promise<ResolvedRedirect[]> {
1371
+ const out: ResolvedRedirect[] = []
1372
+ for (const r of redirs) {
1373
+ const defaultFd = r.op.startsWith('<') ? 0 : 1
1374
+ const fd = r.fd ?? defaultFd
1375
+ if (r.op === '<<' || r.op === '<<-') {
1376
+ const body = r.heredoc ? (r.heredoc.quoted ? (r.heredoc.body.parts[0] as A.SglQuoted | undefined)?.value ?? '' : await this.expander.expandHeredoc(r.heredoc.body, proc)) : ''
1377
+ out.push({ node: r, fd, op: r.op, content: body, fdVar: r.fdVar ?? undefined })
1378
+ continue
1379
+ }
1380
+ if (r.op === '<<<') {
1381
+ const s = await this.expander.expandString(r.target, proc)
1382
+ out.push({ node: r, fd, op: r.op, content: s + '\n', fdVar: r.fdVar ?? undefined })
1383
+ continue
1384
+ }
1385
+ const fields = await this.expander.expandFields([r.target], proc)
1386
+ if (fields.length !== 1 && !(r.op === '<&' || r.op === '>&')) throw new CommandError(`${this.rt.text(r.target)}: ambiguous redirect`)
1387
+ const targetText = fields[0] ?? ''
1388
+ if (r.op === '<&' || r.op === '>&') {
1389
+ if (targetText === '-') {
1390
+ out.push({ node: r, fd, op: r.op, close: true, fdVar: r.fdVar ?? undefined })
1391
+ continue
1392
+ }
1393
+ const m = /^(\d+)(-?)$/.exec(targetText)
1394
+ if (m) {
1395
+ out.push({ node: r, fd, op: r.op, dupFd: Number(m[1]), close: m[2] === '-', fdVar: r.fdVar ?? undefined })
1396
+ continue
1397
+ }
1398
+ if (r.op === '>&' && r.fd === null) {
1399
+ // `>& file` == `&> file`
1400
+ out.push({ node: r, fd: 1, op: '&>', path: path.resolve(proc.state.cwd, targetText) })
1401
+ continue
1402
+ }
1403
+ throw new CommandError(`${targetText}: ambiguous redirect`)
1404
+ }
1405
+ const special = specialPath(targetText)
1406
+ out.push({ node: r, fd: r.op === '&>' || r.op === '&>>' ? 1 : fd, op: r.op, path: special ? undefined : path.resolve(proc.state.cwd, targetText), special, fdVar: r.fdVar ?? undefined })
1407
+ }
1408
+ return out
1409
+ }
1410
+
1411
+ async openRedirects(redirects: ResolvedRedirect[], fds: FdTable, proc: Proc): Promise<void> {
1412
+ for (const r of redirects) {
1413
+ let fd = r.fd
1414
+ if (r.fdVar) {
1415
+ if (r.close && r.dupFd === undefined) {
1416
+ const n = Number(proc.state.getString(r.fdVar))
1417
+ if (Number.isInteger(n)) fds.close(n)
1418
+ continue
1419
+ }
1420
+ fd = fds.freeFd(10)
1421
+ this.assignScalar(proc, r.fdVar, String(fd))
1422
+ }
1423
+ if (r.close && r.dupFd === undefined) {
1424
+ fds.close(fd)
1425
+ continue
1426
+ }
1427
+ if (r.dupFd !== undefined) {
1428
+ if (!fds.dup(r.dupFd, fd)) throw new CommandError(`${r.dupFd}: Bad file descriptor`)
1429
+ if (r.close) fds.close(r.dupFd)
1430
+ continue
1431
+ }
1432
+ if (r.content !== undefined) {
1433
+ fds.set(fd, new ConstInput(this.rt.channel('heredoc', r.op === '<<<' ? '<<<' : `<<${r.node.heredoc?.delimiter ?? ''}`, { nodeId: r.node.id }), r.content, this.rt.listeners))
1434
+ continue
1435
+ }
1436
+ let file: OpenFile
1437
+ if (r.special === 'null') file = new NullFile(this.rt.channel('null', '/dev/null'), this.rt.listeners)
1438
+ else if (r.special && typeof r.special === 'object') {
1439
+ const src = fds.get(r.special.dup) ?? proc.fds.get(r.special.dup)
1440
+ if (!src) throw new CommandError(`/dev/fd/${r.special.dup}: Bad file descriptor`)
1441
+ file = src.retain()
1442
+ } else {
1443
+ const mode = r.op === '<' ? 'read' : r.op === '>>' || r.op === '&>>' ? 'append' : r.op === '<>' ? 'readwrite' : 'write'
1444
+ const noclobber = proc.state.options.noclobber && (r.op === '>' || r.op === '&>') && !isNonRegular(r.path!)
1445
+ file = await HostFile.open(this.rt.channel('file', `${r.op} ${r.path}`, { path: r.path, nodeId: r.node.id }), r.path!, mode, this.rt.listeners, noclobber, proc.state.umask)
1446
+ }
1447
+ fds.set(fd, file)
1448
+ if (r.op === '&>' || r.op === '&>>') fds.dup(1, 2)
1449
+ }
1450
+ }
1451
+ }
1452
+
1453
+ // ---------------------------------------------------------------------------
1454
+ // Helpers
1455
+ // ---------------------------------------------------------------------------
1456
+
1457
+ interface InlinePlan {
1458
+ source: string
1459
+ origin: string
1460
+ arg0: string
1461
+ args: string[]
1462
+ options: Record<string, boolean>
1463
+ }
1464
+
1465
+ const INLINE_SHELLS = new Set(['bash', 'sh'])
1466
+
1467
+ /**
1468
+ * Decide whether an external invocation is a shell script mash can run
1469
+ * itself: `bash [-euxo opt] -c SCRIPT [arg0 args]`, `bash [opts] FILE args`,
1470
+ * or an executable file whose shebang names bash/sh.
1471
+ */
1472
+ function inlineShellPlan(exePath: string, argv: string[], cwd: string): InlinePlan | null {
1473
+ const base = path.basename(exePath)
1474
+ let args = argv.slice(1)
1475
+ if (!INLINE_SHELLS.has(base)) {
1476
+ // `./script.sh` with a bash/sh shebang
1477
+ let head: string
1478
+ try {
1479
+ const fd = fs.openSync(exePath, 'r')
1480
+ const buf = Buffer.alloc(128)
1481
+ const n = fs.readSync(fd, buf, 0, 128, 0)
1482
+ fs.closeSync(fd)
1483
+ head = buf.subarray(0, n).toString('utf8')
1484
+ } catch {
1485
+ return null
1486
+ }
1487
+ const m = /^#![ \t]*(\S+)(?:[ \t]+(\S+))?/.exec(head.split("\n")[0])
1488
+ if (!m) {
1489
+ // no shebang: the kernel returns ENOEXEC and bash runs the file as a shell script
1490
+ if (head.startsWith('#!') || head.includes('\0') || /[\x00-\x08\x0e-\x1f]/.test(head.slice(0, 64)) || head.startsWith('\x7fELF') || head.startsWith('\xca\xfe') || head.startsWith('\xcf\xfa')) return null
1491
+ args = [exePath, ...args]
1492
+ return inlineFromArgs(args, base, cwd)
1493
+ }
1494
+ const interp = path.basename(m[1]) === 'env' ? m[2] : path.basename(m[1])
1495
+ if (!interp || !INLINE_SHELLS.has(interp)) return null
1496
+ if (m[2] && path.basename(m[1]) !== 'env' && m[2] !== '-e') return null
1497
+ args = [exePath, ...args]
1498
+ if (m[2] === '-e') args.unshift('-e')
1499
+ }
1500
+ return inlineFromArgs(args, base, cwd)
1501
+ }
1502
+
1503
+ function inlineFromArgs(args: string[], base: string, cwd: string): InlinePlan | null {
1504
+ const options: Record<string, boolean> = {}
1505
+ const flagMap: Record<string, string> = { e: 'errexit', u: 'nounset', x: 'xtrace', f: 'noglob', v: 'verbose', C: 'noclobber', a: 'allexport' }
1506
+ let i = 0
1507
+ let command: string | undefined
1508
+ for (; i < args.length; i++) {
1509
+ const a = args[i]
1510
+ if (a === '-c') {
1511
+ command = args[i + 1]
1512
+ i += 2
1513
+ break
1514
+ }
1515
+ if (a === '-o' || a === '+o') {
1516
+ const name = args[++i]
1517
+ if (name === 'pipefail' || name === 'errexit' || name === 'nounset' || name === 'xtrace' || name === 'noglob') options[name] = a === '-o'
1518
+ else return null
1519
+ continue
1520
+ }
1521
+ if (a === '--') {
1522
+ i++
1523
+ break
1524
+ }
1525
+ if (/^[-+][euxfvCa]+$/.test(a)) {
1526
+ for (const f of a.slice(1)) options[flagMap[f]] = a[0] === '-'
1527
+ continue
1528
+ }
1529
+ if (a.startsWith('-')) return null // interactive, login, --rcfile, ... : spawn a real shell
1530
+ break
1531
+ }
1532
+ if (command !== undefined) {
1533
+ return { source: command, origin: 'bash -c', arg0: args[i] ?? base, args: args.slice(i + 1), options }
1534
+ }
1535
+ const file = args[i]
1536
+ if (file === undefined) return null // reads the script from stdin
1537
+ const abs = path.resolve(cwd, file)
1538
+ let source: string
1539
+ try {
1540
+ source = fs.readFileSync(abs, 'utf8')
1541
+ } catch {
1542
+ return null
1543
+ }
1544
+ return { source, origin: abs, arg0: file, args: args.slice(i + 1), options }
1545
+ }
1546
+
1547
+ import { evaluateArithText as evaluateArithTextSync } from './arith.ts'
1548
+
1549
+ function isNonRegular(p: string): boolean {
1550
+ try {
1551
+ return !fs.statSync(p).isFile()
1552
+ } catch {
1553
+ return false
1554
+ }
1555
+ }
1556
+
1557
+ function specialPath(p: string): ResolvedRedirect['special'] {
1558
+ if (p === '/dev/null') return 'null'
1559
+ if (p === '/dev/stdin') return { dup: 0 }
1560
+ if (p === '/dev/stdout') return { dup: 1 }
1561
+ if (p === '/dev/stderr') return { dup: 2 }
1562
+ const m = /^\/dev\/fd\/(\d+)$/.exec(p)
1563
+ if (m) return { dup: Number(m[1]) }
1564
+ return undefined
1565
+ }
1566
+
1567
+ function redirectEffectsOf(redirects: ResolvedRedirect[]): Effect[] {
1568
+ const effects: Effect[] = []
1569
+ for (const r of redirects) {
1570
+ if (!r.path) continue
1571
+ if (r.op === '<') effects.push({ kind: 'read', path: known(r.path), source: 'redirect' })
1572
+ else if (r.op === '<>') {
1573
+ effects.push({ kind: 'read', path: known(r.path), source: 'redirect' })
1574
+ effects.push({ kind: 'write', path: known(r.path), source: 'redirect' })
1575
+ } else effects.push({ kind: 'write', path: known(r.path), append: r.op === '>>' || r.op === '&>>', source: 'redirect' })
1576
+ }
1577
+ return effects
1578
+ }
1579
+
1580
+ function redirectInfo(r: ResolvedRedirect): RedirectInfo {
1581
+ return { fd: r.fd, op: r.op, path: r.path ?? (r.special === 'null' ? '/dev/null' : undefined), dupFd: r.dupFd }
1582
+ }
1583
+
1584
+ function kindOfStdin(f: OpenFile | undefined): ExecIntent['stdinKind'] {
1585
+ if (!f) return 'null'
1586
+ switch (f.channel.kind) {
1587
+ case 'null': return 'null'
1588
+ case 'file': case 'heredoc': return 'file'
1589
+ case 'host': return 'terminal'
1590
+ default: return 'pipe'
1591
+ }
1592
+ }
1593
+
1594
+ /** PATH lookup (with the shell's hash table). */
1595
+ export function resolveCommand(name: string, state: ShellState, pathOverride?: string): string | undefined {
1596
+ const isExec = (p: string): boolean => {
1597
+ try {
1598
+ const st = fs.statSync(p)
1599
+ if (!st.isFile()) return false
1600
+ fs.accessSync(p, fs.constants.X_OK)
1601
+ return true
1602
+ } catch {
1603
+ return false
1604
+ }
1605
+ }
1606
+ if (name.includes('/')) {
1607
+ const p = path.resolve(state.cwd, name)
1608
+ return isExec(p) ? p : undefined
1609
+ }
1610
+ const cached = pathOverride === undefined ? state.hash.get(name) : undefined
1611
+ if (cached && isExec(cached)) return cached
1612
+ const PATH = pathOverride ?? state.getString('PATH') ?? '/usr/bin:/bin'
1613
+ for (const dir of PATH.split(':')) {
1614
+ const p = path.resolve(state.cwd, dir === '' ? '.' : dir, name)
1615
+ if (isExec(p)) {
1616
+ if (pathOverride === undefined) state.hash.set(name, p)
1617
+ return p
1618
+ }
1619
+ }
1620
+ return undefined
1621
+ }
1622
+
1623
+ export { literalOf, evalTest }