@namzu/sdk 44.2.0 → 45.0.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 (124) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/authorization/command-line.d.ts +66 -19
  3. package/dist/authorization/command-line.d.ts.map +1 -1
  4. package/dist/authorization/command-line.js +130 -270
  5. package/dist/authorization/command-line.js.map +1 -1
  6. package/dist/authorization/gate.d.ts +7 -0
  7. package/dist/authorization/gate.d.ts.map +1 -1
  8. package/dist/authorization/gate.js +1 -1
  9. package/dist/authorization/gate.js.map +1 -1
  10. package/dist/authorization/rules.d.ts +10 -1
  11. package/dist/authorization/rules.d.ts.map +1 -1
  12. package/dist/authorization/rules.js +21 -5
  13. package/dist/authorization/rules.js.map +1 -1
  14. package/dist/authorization/shell-lexer.d.ts +138 -0
  15. package/dist/authorization/shell-lexer.d.ts.map +1 -0
  16. package/dist/authorization/shell-lexer.js +2143 -0
  17. package/dist/authorization/shell-lexer.js.map +1 -0
  18. package/dist/authorization/skill-grant.d.ts +182 -0
  19. package/dist/authorization/skill-grant.d.ts.map +1 -0
  20. package/dist/authorization/skill-grant.js +314 -0
  21. package/dist/authorization/skill-grant.js.map +1 -0
  22. package/dist/persona/assembler.d.ts.map +1 -1
  23. package/dist/persona/assembler.js +5 -2
  24. package/dist/persona/assembler.js.map +1 -1
  25. package/dist/prompt/coding-agent-doctrine.d.ts +1 -1
  26. package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
  27. package/dist/prompt/coding-agent-doctrine.js +1 -1
  28. package/dist/prompt/coding-agent-doctrine.js.map +1 -1
  29. package/dist/public-runtime.d.ts +1 -0
  30. package/dist/public-runtime.d.ts.map +1 -1
  31. package/dist/public-runtime.js +4 -0
  32. package/dist/public-runtime.js.map +1 -1
  33. package/dist/public-tools.d.ts.map +1 -1
  34. package/dist/public-tools.js +2 -1
  35. package/dist/public-tools.js.map +1 -1
  36. package/dist/public-types.d.ts +3 -1
  37. package/dist/public-types.d.ts.map +1 -1
  38. package/dist/runtime/jobs/registry.d.ts +2 -2
  39. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  40. package/dist/runtime/jobs/registry.js +6 -2
  41. package/dist/runtime/jobs/registry.js.map +1 -1
  42. package/dist/runtime/query/executor.d.ts +34 -40
  43. package/dist/runtime/query/executor.d.ts.map +1 -1
  44. package/dist/runtime/query/executor.js +81 -51
  45. package/dist/runtime/query/executor.js.map +1 -1
  46. package/dist/runtime/query/index.d.ts.map +1 -1
  47. package/dist/runtime/query/index.js +7 -0
  48. package/dist/runtime/query/index.js.map +1 -1
  49. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  50. package/dist/runtime/query/iteration/index.js +6 -0
  51. package/dist/runtime/query/iteration/index.js.map +1 -1
  52. package/dist/runtime/query/iteration/phases/context.d.ts +7 -0
  53. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  54. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  55. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  56. package/dist/runtime/query/iteration/phases/tool-review.js +48 -0
  57. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  58. package/dist/runtime/query/review-policy.d.ts +11 -0
  59. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  60. package/dist/runtime/query/review-policy.js +32 -0
  61. package/dist/runtime/query/review-policy.js.map +1 -1
  62. package/dist/runtime/query/tooling.d.ts +3 -0
  63. package/dist/runtime/query/tooling.d.ts.map +1 -1
  64. package/dist/runtime/query/tooling.js +1 -0
  65. package/dist/runtime/query/tooling.js.map +1 -1
  66. package/dist/skills/loader.d.ts +8 -0
  67. package/dist/skills/loader.d.ts.map +1 -1
  68. package/dist/skills/loader.js +7 -1
  69. package/dist/skills/loader.js.map +1 -1
  70. package/dist/tools/builtins/bash.d.ts.map +1 -1
  71. package/dist/tools/builtins/bash.js +18 -6
  72. package/dist/tools/builtins/bash.js.map +1 -1
  73. package/dist/tools/builtins/skill.d.ts +2 -9
  74. package/dist/tools/builtins/skill.d.ts.map +1 -1
  75. package/dist/tools/builtins/skill.js +59 -51
  76. package/dist/tools/builtins/skill.js.map +1 -1
  77. package/dist/tools/command-shell.d.ts +90 -0
  78. package/dist/tools/command-shell.d.ts.map +1 -0
  79. package/dist/tools/command-shell.js +129 -0
  80. package/dist/tools/command-shell.js.map +1 -0
  81. package/dist/tools/defineTool.d.ts +11 -0
  82. package/dist/tools/defineTool.d.ts.map +1 -1
  83. package/dist/tools/defineTool.js +29 -1
  84. package/dist/tools/defineTool.js.map +1 -1
  85. package/dist/types/hitl/index.d.ts +23 -0
  86. package/dist/types/hitl/index.d.ts.map +1 -1
  87. package/dist/types/hitl/index.js.map +1 -1
  88. package/dist/types/provider/stream.d.ts +10 -0
  89. package/dist/types/provider/stream.d.ts.map +1 -1
  90. package/dist/types/tool/index.d.ts +67 -5
  91. package/dist/types/tool/index.d.ts.map +1 -1
  92. package/dist/types/tool/index.js.map +1 -1
  93. package/dist/utils/frontmatter.d.ts +17 -1
  94. package/dist/utils/frontmatter.d.ts.map +1 -1
  95. package/dist/utils/frontmatter.js +32 -2
  96. package/dist/utils/frontmatter.js.map +1 -1
  97. package/package.json +1 -1
  98. package/src/authorization/command-line.ts +148 -293
  99. package/src/authorization/gate.ts +8 -0
  100. package/src/authorization/rules.ts +33 -4
  101. package/src/authorization/shell-lexer.ts +2319 -0
  102. package/src/authorization/skill-grant.ts +400 -0
  103. package/src/persona/assembler.ts +5 -2
  104. package/src/prompt/coding-agent-doctrine.ts +1 -1
  105. package/src/public-runtime.ts +9 -0
  106. package/src/public-tools.ts +2 -1
  107. package/src/public-types.ts +7 -0
  108. package/src/runtime/jobs/registry.ts +19 -11
  109. package/src/runtime/query/executor.ts +99 -55
  110. package/src/runtime/query/index.ts +7 -0
  111. package/src/runtime/query/iteration/index.ts +5 -0
  112. package/src/runtime/query/iteration/phases/context.ts +7 -0
  113. package/src/runtime/query/iteration/phases/tool-review.ts +45 -0
  114. package/src/runtime/query/review-policy.ts +53 -0
  115. package/src/runtime/query/tooling.ts +4 -0
  116. package/src/skills/loader.ts +8 -1
  117. package/src/tools/builtins/bash.ts +24 -6
  118. package/src/tools/builtins/skill.ts +74 -53
  119. package/src/tools/command-shell.ts +166 -0
  120. package/src/tools/defineTool.ts +33 -1
  121. package/src/types/hitl/index.ts +21 -0
  122. package/src/types/provider/stream.ts +10 -0
  123. package/src/types/tool/index.ts +67 -5
  124. package/src/utils/frontmatter.ts +52 -2
@@ -0,0 +1,2319 @@
1
+ /**
2
+ * One reading of a bash command line, shared by everything that authorizes
3
+ * one.
4
+ *
5
+ * ## Why there is exactly one
6
+ *
7
+ * Permission rules decide on the commands a line runs, so something has to say
8
+ * what those commands are. That used to be three separate walkers — one cut
9
+ * the line into commands, one split a command into words, one looked for
10
+ * output redirections — and each carried its own copy of bash's quoting rules.
11
+ * Every rule added to one had to be added to the others, and every review
12
+ * found a place where it had not been: `$'…'` first, then `$$'…'`. Each miss
13
+ * was the same defect: two readers disagreeing about where a quote ends, so
14
+ * one of them took a command the shell runs for quoted text. The fix for that
15
+ * class is not a fourth copy. It is a single lexer whose output every caller
16
+ * reads.
17
+ *
18
+ * ## What it reads
19
+ *
20
+ * The bash (5.x, non-POSIX mode) grammar, as far as authorization needs it:
21
+ * quoting in all its forms (`'…'`, `"…"`, `\`, `$'…'` with its escapes
22
+ * decoded, `$"…"`), line continuation, every expansion's extent (`$name`,
23
+ * the special parameters, `${…}` with nested quotes, `$(…)`, `$((…))`,
24
+ * `$[…]`, backticks, `<(…)` and `>(…)`), control operators, redirections
25
+ * including here-documents (whose bodies are consumed, never read as
26
+ * commands), comments, reserved words and compound commands (`( )`, `{ }`,
27
+ * `if`, `while`, `until`, `for`, `select`, `case`, `[[ ]]`), and a nested
28
+ * `bash -c '<payload>'`, whose decoded payload is read the same way.
29
+ *
30
+ * The result is the list of simple commands with each word as bash produces
31
+ * it after quote removal and before expansion, plus a flag on every word whose
32
+ * text is not its runtime value (it contains a parameter, command or
33
+ * arithmetic expansion, a glob, a brace expansion, a tilde, or a
34
+ * locale-dependent escape). A word without that flag is exactly the argument
35
+ * bash passes. `packages/sdk/src/authorization/__tests__/shell-lexer-bash.test.ts`
36
+ * checks that against the bash on the machine running the tests.
37
+ *
38
+ * ## Failing closed
39
+ *
40
+ * {@link ShellLexResult.opaque} is set when the command list is not a
41
+ * complete account of what the line runs: a command or process substitution,
42
+ * an arithmetic context that could evaluate code held in a variable, a
43
+ * syntax error, an unterminated quote, a construct this module does not model
44
+ * (`coproc`, `for ((…))`, a compound array assignment, a function
45
+ * definition), a command that changes how later text is parsed (`shopt`,
46
+ * `enable`, `set -o posix`, …), or a nesting depth past the limit. Nothing is
47
+ * guessed. When the lexer is unsure it says so, and the caller refuses to
48
+ * grant on that line.
49
+ *
50
+ * It is a lexer, not an interpreter. It does not know what `env`, `xargs` or
51
+ * `sudo` do with their arguments, and it does not know what `eval` or `source`
52
+ * will run; callers treat those as they see fit.
53
+ */
54
+
55
+ /** One word of a command, before expansion and after quote removal. */
56
+ export interface ShellWord {
57
+ /** The word as written in the source, quotes and escapes included. */
58
+ readonly text: string
59
+ /**
60
+ * The word after quote removal, with `$'…'` escapes decoded. Expansions
61
+ * that happen at runtime are left as written (`$HOME` stays `$HOME`), and
62
+ * {@link expands} says so.
63
+ */
64
+ readonly value: string
65
+ /**
66
+ * True when {@link value} is not the runtime word: the word contains an
67
+ * expansion, a glob, a brace expansion, a tilde prefix, or a
68
+ * locale-dependent quote or escape.
69
+ */
70
+ readonly expands: boolean
71
+ /** True when any part of the word was quoted or escaped. */
72
+ readonly quoted: boolean
73
+ }
74
+
75
+ /** A redirection: `2>&1`, `> file`, `<<EOF` and the rest. */
76
+ export interface ShellRedirection {
77
+ /**
78
+ * The operator: `<`, `>`, `>>`, `>|`, `<>`, `&>`, `&>>`, `<&`, `>&`, `<<`,
79
+ * `<<-` or `<<<`.
80
+ */
81
+ readonly operator: string
82
+ /** The descriptor it names before the operator (`2`, `{fd}`), if any. */
83
+ readonly fd?: string
84
+ /** The target word. For a here-document, its delimiter. */
85
+ readonly target: ShellWord
86
+ }
87
+
88
+ /** One simple command. */
89
+ export interface ShellCommand {
90
+ /**
91
+ * Every word, leading assignments included. `words[assignments]` is the
92
+ * command name when there is one.
93
+ */
94
+ readonly words: readonly ShellWord[]
95
+ /** How many leading words are variable assignments (`A=1 cmd`). */
96
+ readonly assignments: number
97
+ readonly redirections: readonly ShellRedirection[]
98
+ /**
99
+ * The command's source text, from its first token to its last, taken from
100
+ * the string it was read from (the payload, for a nested shell).
101
+ */
102
+ readonly text: string
103
+ /**
104
+ * Where it was found: the line itself, a command or process substitution
105
+ * (which also makes the line opaque), or the payload of a nested
106
+ * `bash -c`.
107
+ */
108
+ readonly origin: 'line' | 'substitution' | 'shell'
109
+ /** How many `bash -c` payloads enclose it. */
110
+ readonly depth: number
111
+ }
112
+
113
+ export interface ShellLexResult {
114
+ /** Every simple command found, in the order its parse completed. */
115
+ readonly commands: readonly ShellCommand[]
116
+ /**
117
+ * Every redirection in the line, including those on compound commands
118
+ * (`{ a; } > f`) that belong to no single simple command.
119
+ */
120
+ readonly redirections: readonly ShellRedirection[]
121
+ /** True when {@link commands} may not be everything the line runs. */
122
+ readonly opaque: boolean
123
+ /** False when parsing stopped early: a syntax error or an unsupported construct. */
124
+ readonly complete: boolean
125
+ /** Why the line is opaque, for diagnostics. Empty when it is not. */
126
+ readonly reasons: readonly string[]
127
+ }
128
+
129
+ /**
130
+ * Shells whose `-c` argument is another command line, by basename. The
131
+ * payload is decoded as bash reads it; for `dash` and friends the bash reading
132
+ * is a close approximation, and the line is only as exact as that.
133
+ */
134
+ export const NESTED_SHELLS: ReadonlySet<string> = new Set([
135
+ 'sh',
136
+ 'bash',
137
+ 'zsh',
138
+ 'dash',
139
+ 'ksh',
140
+ 'ash',
141
+ 'mksh',
142
+ ])
143
+
144
+ /** How many `bash -c` payloads deep the lexer follows before it gives up. */
145
+ const MAX_SHELL_DEPTH = 4
146
+ /** How deep compound commands and expansions may nest before it gives up. */
147
+ const MAX_NESTING = 100
148
+
149
+ import type { ShellDialect } from '../types/tool/index.js'
150
+
151
+ export type { ShellDialect }
152
+
153
+ export interface ShellLexOptions {
154
+ /** Default `bash`. */
155
+ readonly dialect?: ShellDialect
156
+ }
157
+
158
+ export function lexShellCommandLine(line: string, options: ShellLexOptions = {}): ShellLexResult {
159
+ const context = new Context(line.length)
160
+ context.setDialect(line, options.dialect ?? 'bash')
161
+ try {
162
+ lexInto(line, context, 'line', 0)
163
+ } catch {
164
+ // A defect here, or a stack exhausted by nesting, must not read as a
165
+ // complete account of the line.
166
+ context.complete = false
167
+ context.opaque('internal error')
168
+ }
169
+ return {
170
+ commands: context.commands,
171
+ redirections: context.redirections,
172
+ opaque: context.reasons.size > 0,
173
+ complete: context.complete,
174
+ reasons: [...context.reasons],
175
+ }
176
+ }
177
+
178
+ class Context {
179
+ readonly commands: ShellCommand[] = []
180
+ readonly redirections: ShellRedirection[] = []
181
+ readonly reasons = new Set<string>()
182
+ complete = true
183
+ /**
184
+ * Characters the lexer may re-read. A `((` is read once as arithmetic and,
185
+ * when that fails, again as two subshells; unbounded, nested attempts make
186
+ * the lexer quadratic. Past the budget the line is opaque.
187
+ */
188
+ private rescans: number
189
+
190
+ constructor(length: number) {
191
+ this.rescans = 4 * length + 4096
192
+ }
193
+
194
+ opaque(reason: string): void {
195
+ this.reasons.add(reason)
196
+ }
197
+
198
+ private readonly dialects = new Map<string, ShellDialect>()
199
+
200
+ /** The dialect a string is read in. A string read two ways gets the stricter. */
201
+ setDialect(src: string, dialect: ShellDialect): void {
202
+ if (this.dialects.get(src) !== 'sh') this.dialects.set(src, dialect)
203
+ }
204
+
205
+ dialectFor(src: string): ShellDialect {
206
+ return this.dialects.get(src) ?? 'sh'
207
+ }
208
+
209
+ private readonly sources = new Map<string, SourceState>()
210
+
211
+ /** Per-string state shared by every parser reading that string. */
212
+ source(src: string): SourceState {
213
+ let state = this.sources.get(src)
214
+ if (state === undefined) {
215
+ state = { lastNewline: src.lastIndexOf('\n'), finalLineRaw: false }
216
+ this.sources.set(src, state)
217
+ }
218
+ return state
219
+ }
220
+
221
+ /** Charge `count` re-read characters; stops the parse when spent. */
222
+ rescan(count: number): void {
223
+ this.rescans -= count
224
+ if (this.rescans < 0) throw new Stop('too complex to read')
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Bash appends a newline to each line it reads from a string. For a final line
230
+ * that ends in a backslash it appends a second backslash instead, so the
231
+ * backslash stays literal, but only when that line was read outside a single
232
+ * quote. When the last newline of the string is inside `'…'` or `$'…'`, the
233
+ * final line was read inside the quote, and a trailing backslash becomes a
234
+ * line continuation that disappears (measured, bash 5.3).
235
+ */
236
+ interface SourceState {
237
+ readonly lastNewline: number
238
+ finalLineRaw: boolean
239
+ }
240
+
241
+ /** Stops the parse. Whatever was read so far stays; the line becomes opaque. */
242
+ class Stop extends Error {
243
+ constructor(readonly reason: string) {
244
+ super(reason)
245
+ }
246
+ }
247
+
248
+ function lexInto(
249
+ source: string,
250
+ context: Context,
251
+ origin: ShellCommand['origin'],
252
+ depth: number,
253
+ ): void {
254
+ const parser = new Parser(source, 0, context, origin, depth, 0)
255
+ try {
256
+ parser.program()
257
+ } catch (error) {
258
+ if (!(error instanceof Stop)) throw error
259
+ context.complete = false
260
+ context.opaque(error.reason)
261
+ }
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Tokens
266
+
267
+ type Token =
268
+ | {
269
+ readonly kind: 'word'
270
+ readonly start: number
271
+ readonly end: number
272
+ readonly word: ShellWord
273
+ readonly reservedOk: boolean
274
+ }
275
+ | {
276
+ readonly kind: 'op'
277
+ readonly start: number
278
+ readonly end: number
279
+ readonly op: string
280
+ readonly fd?: string
281
+ }
282
+ | { readonly kind: 'newline'; readonly start: number; readonly end: number }
283
+ | { readonly kind: 'eof'; readonly start: number; readonly end: number }
284
+ /** `(( … ))` at command position. */
285
+ | { readonly kind: 'arith'; readonly start: number; readonly end: number }
286
+
287
+ const REDIRECTIONS = new Set([
288
+ '<',
289
+ '>',
290
+ '>>',
291
+ '>|',
292
+ '<>',
293
+ '&>',
294
+ '&>>',
295
+ '<&',
296
+ '>&',
297
+ '<<',
298
+ '<<-',
299
+ '<<<',
300
+ ])
301
+
302
+ /** Operators after which bash recognises a reserved word. */
303
+ const RESERVED_AFTER_OPS = new Set([';', ';;', ';&', ';;&', '&', '&&', '||', '|', '|&', '(', ')'])
304
+ /** Reserved words after which bash recognises another one. */
305
+ const RESERVED_AFTER_WORDS = new Set([
306
+ '!',
307
+ '{',
308
+ '}',
309
+ 'do',
310
+ 'done',
311
+ 'elif',
312
+ 'else',
313
+ 'esac',
314
+ 'fi',
315
+ 'if',
316
+ 'then',
317
+ 'time',
318
+ 'until',
319
+ 'while',
320
+ ])
321
+ const RESERVED = new Set([
322
+ '!',
323
+ '{',
324
+ '}',
325
+ 'case',
326
+ 'coproc',
327
+ 'do',
328
+ 'done',
329
+ 'elif',
330
+ 'else',
331
+ 'esac',
332
+ 'fi',
333
+ 'for',
334
+ 'function',
335
+ 'if',
336
+ 'select',
337
+ 'then',
338
+ 'time',
339
+ 'until',
340
+ 'while',
341
+ '[[',
342
+ ']]',
343
+ 'in',
344
+ ])
345
+
346
+ /** A compound command starts here (for `coproc NAME compound`). */
347
+ const COMPOUND_AFTER = /^(?:\(|(?:\{|if|while|until|for|select|case|\[\[)(?=[\s;&|()<>]|$))/
348
+ /** Builtins whose arguments may be array assignments. */
349
+ const ASSIGNMENT_BUILTINS = new Set(['declare', 'typeset', 'local', 'export', 'readonly'])
350
+
351
+ const WORD_BREAK = new Set([' ', '\t', '\n', ';', '&', '|', '(', ')', '<', '>'])
352
+
353
+ const NAME_START = /[A-Za-z_]/
354
+ const NAME_CHAR = /[A-Za-z0-9_]/
355
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=/
356
+
357
+ interface PendingHeredoc {
358
+ readonly delimiter: string
359
+ readonly stripTabs: boolean
360
+ readonly quoted: boolean
361
+ }
362
+
363
+ type Last =
364
+ | { readonly kind: 'start' | 'newline' | 'other' }
365
+ | { readonly kind: 'op'; readonly op: string }
366
+ | { readonly kind: 'word'; readonly reserved: string | null }
367
+
368
+ /** A mutable word under construction. */
369
+ class WordBuilder {
370
+ value = ''
371
+ expands = false
372
+ quoted = false
373
+ /** A brace expansion, which POSIX shells do not perform. */
374
+ brace = false
375
+ /** Unquoted `{` seen, and whether a `,` or `..` followed it: brace expansion. */
376
+ private braceOpen = false
377
+ private braceSeparator = false
378
+ /** The previous unquoted character, for tilde and brace detection. */
379
+ private previous = ''
380
+ private empty = true
381
+
382
+ literal(char: string): void {
383
+ this.value += char
384
+ this.empty = false
385
+ }
386
+
387
+ /** An unquoted character, which may be special to expansion. */
388
+ unquoted(char: string): void {
389
+ if (char === '*' || char === '?' || char === '[') this.expands = true
390
+ if (char === '~' && (this.empty || this.previous === '=' || this.previous === ':')) {
391
+ this.expands = true
392
+ }
393
+ if (char === '{') {
394
+ this.braceOpen = true
395
+ } else if (this.braceOpen && (char === ',' || (char === '.' && this.previous === '.'))) {
396
+ this.braceSeparator = true
397
+ } else if (char === '}' && this.braceOpen && this.braceSeparator) {
398
+ this.expands = true
399
+ this.brace = true
400
+ }
401
+ this.previous = char
402
+ this.literal(char)
403
+ }
404
+
405
+ /** Quoted text: never special. */
406
+ quotedText(text: string): void {
407
+ this.quoted = true
408
+ this.previous = ''
409
+ this.value += text
410
+ this.empty = false
411
+ }
412
+
413
+ /** An expansion kept as written. */
414
+ expansion(text: string): void {
415
+ this.expands = true
416
+ this.previous = ''
417
+ this.value += text
418
+ this.empty = false
419
+ }
420
+ }
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // The parser. One instance per string and per nesting of `$(…)`; the token
424
+ // stream is lazy so that a substitution can hand its position back.
425
+
426
+ class Parser {
427
+ private peeked: Token | null = null
428
+ private last: Last = { kind: 'start' }
429
+ private readonly heredocs: PendingHeredoc[] = []
430
+ private readonly source: SourceState
431
+
432
+ constructor(
433
+ private readonly src: string,
434
+ public pos: number,
435
+ private readonly context: Context,
436
+ private readonly origin: ShellCommand['origin'],
437
+ private readonly depth: number,
438
+ private readonly nesting: number,
439
+ ) {
440
+ if (nesting > MAX_NESTING) throw new Stop('nesting too deep')
441
+ this.source = context.source(src)
442
+ }
443
+
444
+ private get dialect(): ShellDialect {
445
+ return this.context.dialectFor(this.src)
446
+ }
447
+
448
+ /**
449
+ * A construct bash reads differently from a POSIX shell. In the `sh`
450
+ * dialect the line is opaque: which of the two runs it is not known.
451
+ */
452
+ private bashOnly(what: string): void {
453
+ if (this.dialect === 'sh') this.context.opaque(`not POSIX sh: ${what}`)
454
+ }
455
+
456
+ /** A string read inside this one (a backtick or here-document body) keeps its dialect. */
457
+ private inherit(body: string): void {
458
+ this.context.setDialect(body, this.dialect)
459
+ }
460
+
461
+ /** Record a single-quoted region; see {@link SourceState}. */
462
+ private singleQuoted(open: number, close: number): void {
463
+ const last = this.source.lastNewline
464
+ if (last > open && last < close) this.source.finalLineRaw = true
465
+ }
466
+
467
+ // --- grammar ----------------------------------------------------------
468
+
469
+ /** The whole string: a list, possibly empty, up to the end. */
470
+ program(): void {
471
+ this.newlines()
472
+ while (this.peek().kind !== 'eof') {
473
+ this.andOr()
474
+ const token = this.peek()
475
+ if (token.kind === 'eof') break
476
+ if (
477
+ token.kind === 'newline' ||
478
+ (token.kind === 'op' && (token.op === ';' || token.op === '&'))
479
+ ) {
480
+ this.take()
481
+ this.newlines()
482
+ continue
483
+ }
484
+ throw this.unexpected(token)
485
+ }
486
+ }
487
+
488
+ /**
489
+ * A `$(…)` body: a list up to the matching `)`. Returns the offset just
490
+ * past it.
491
+ */
492
+ substitution(): number {
493
+ this.newlines()
494
+ const first = this.peek()
495
+ if (!(first.kind === 'op' && first.op === ')')) {
496
+ this.list((token) => token.kind === 'op' && token.op === ')')
497
+ }
498
+ const close = this.take()
499
+ if (close.kind !== 'op' || close.op !== ')') throw this.unexpected(close)
500
+ return close.end
501
+ }
502
+
503
+ /**
504
+ * A `${ …; }` body: a list, possibly empty, up to a `}` where a command
505
+ * could start. That `}` closes the substitution even with more of the
506
+ * word after it: `a${ }b` is the word `ab`.
507
+ */
508
+ braceSubstitution(): number {
509
+ this.closesOnBrace = true
510
+ const closing = (token: Token): boolean => token.kind === 'op' && token.op === '}'
511
+ this.newlines()
512
+ if (!closing(this.peek())) this.list(closing)
513
+ return this.take().end
514
+ }
515
+
516
+ /** Set while reading a `${ …; }` body. */
517
+ private closesOnBrace = false
518
+ /** Open `{ …; }` groups, whose `}` is theirs and not the substitution's. */
519
+ private braceDepth = 0
520
+
521
+ /** After `{`: the list and its closing `}`. */
522
+ private braceGroup(): void {
523
+ this.braceDepth += 1
524
+ try {
525
+ this.list((t) => this.isReserved(t, '}'))
526
+ } finally {
527
+ this.braceDepth -= 1
528
+ }
529
+ this.take()
530
+ }
531
+
532
+ /**
533
+ * A compound list: one or more and-or lists separated by `;`, `&` or
534
+ * newlines, stopping before a token `ends` accepts.
535
+ */
536
+ private list(ends: (token: Token) => boolean): void {
537
+ this.newlines()
538
+ if (ends(this.peek())) throw this.unexpected(this.peek())
539
+ for (;;) {
540
+ this.andOr()
541
+ const token = this.peek()
542
+ if (ends(token)) return
543
+ if (
544
+ token.kind === 'newline' ||
545
+ (token.kind === 'op' && (token.op === ';' || token.op === '&'))
546
+ ) {
547
+ this.take()
548
+ this.newlines()
549
+ if (ends(this.peek())) return
550
+ continue
551
+ }
552
+ throw this.unexpected(token)
553
+ }
554
+ }
555
+
556
+ private andOr(): void {
557
+ this.pipeline()
558
+ for (;;) {
559
+ const token = this.peek()
560
+ if (token.kind === 'op' && (token.op === '&&' || token.op === '||')) {
561
+ this.take()
562
+ this.newlines()
563
+ this.pipeline()
564
+ continue
565
+ }
566
+ return
567
+ }
568
+ }
569
+
570
+ private pipeline(): void {
571
+ // `time`, `time -p` and any number of `!` may lead a pipeline, and a
572
+ // pipeline of just `time` or `!` is valid.
573
+ let prefixed = false
574
+ for (;;) {
575
+ const token = this.peek()
576
+ if (this.isReserved(token, '!')) {
577
+ this.take()
578
+ prefixed = true
579
+ continue
580
+ }
581
+ if (this.isReserved(token, 'time')) {
582
+ this.bashOnly('time')
583
+ this.take()
584
+ prefixed = true
585
+ const option = this.peek()
586
+ if (option.kind === 'word' && option.word.text.startsWith('-')) {
587
+ // In POSIX mode, which is how bash runs as `/bin/sh`, `time`
588
+ // before a word starting with `-` is not a reserved word but
589
+ // the command `time`. The two modes disagree about what runs.
590
+ this.context.opaque('time with an option')
591
+ }
592
+ if (
593
+ option.kind === 'word' &&
594
+ !option.word.quoted &&
595
+ (option.word.value === '-p' || option.word.value === '--')
596
+ ) {
597
+ this.take()
598
+ this.last = { kind: 'word', reserved: 'time' }
599
+ }
600
+ continue
601
+ }
602
+ break
603
+ }
604
+ // `!` or `time` with nothing after it is valid only before a newline,
605
+ // a `;` or the end of the input. (Bash 5.3 also accepts a `&`; 5.2
606
+ // does not, and reading it as an error is the direction that fails
607
+ // closed.)
608
+ if (prefixed) {
609
+ const next = this.peek()
610
+ if (next.kind === 'eof' || next.kind === 'newline' || (next.kind === 'op' && next.op === ';'))
611
+ return
612
+ }
613
+ this.command()
614
+ for (;;) {
615
+ const token = this.peek()
616
+ if (token.kind === 'op' && (token.op === '|' || token.op === '|&')) {
617
+ this.take()
618
+ this.newlines()
619
+ this.afterPipe = true
620
+ this.command()
621
+ continue
622
+ }
623
+ return
624
+ }
625
+ }
626
+
627
+ private command(): void {
628
+ this.level += 1
629
+ try {
630
+ if (this.nestingNow > MAX_NESTING) throw new Stop('nesting too deep')
631
+ this.commandAt()
632
+ } finally {
633
+ this.level -= 1
634
+ }
635
+ }
636
+
637
+ /** Set between a `|` and the command after it, where `time` is a plain word. */
638
+ private afterPipe = false
639
+
640
+ private commandAt(): void {
641
+ const afterPipe = this.afterPipe
642
+ this.afterPipe = false
643
+ const token = this.peek()
644
+ if (token.kind === 'arith') {
645
+ this.take()
646
+ this.redirectionsAfterCompound()
647
+ return
648
+ }
649
+ if (token.kind === 'op' && token.op === '(') {
650
+ this.take()
651
+ this.list((t) => t.kind === 'op' && t.op === ')')
652
+ this.expectOp(')')
653
+ this.redirectionsAfterCompound()
654
+ return
655
+ }
656
+ if (
657
+ token.kind === 'word' &&
658
+ token.reservedOk &&
659
+ token.word.text.replace(/\\\n/g, '') === '[['
660
+ ) {
661
+ this.bashOnly('[[')
662
+ this.conditional()
663
+ this.redirectionsAfterCompound()
664
+ return
665
+ }
666
+ if (
667
+ token.kind === 'word' &&
668
+ token.reservedOk &&
669
+ !token.word.quoted &&
670
+ !token.word.expands &&
671
+ !(afterPipe && token.word.value === 'time')
672
+ ) {
673
+ switch (token.word.value) {
674
+ case '{':
675
+ this.take()
676
+ this.braceGroup()
677
+ this.redirectionsAfterCompound()
678
+ return
679
+ case 'if':
680
+ this.ifCommand()
681
+ this.redirectionsAfterCompound()
682
+ return
683
+ case 'while':
684
+ case 'until':
685
+ this.take()
686
+ this.list((t) => this.isReserved(t, 'do'))
687
+ this.take()
688
+ this.list((t) => this.isReserved(t, 'done'))
689
+ this.take()
690
+ this.redirectionsAfterCompound()
691
+ return
692
+ case 'for':
693
+ case 'select':
694
+ if (token.word.value === 'select') this.bashOnly('select')
695
+ this.forCommand()
696
+ this.redirectionsAfterCompound()
697
+ return
698
+ case 'case':
699
+ this.caseCommand()
700
+ this.redirectionsAfterCompound()
701
+ return
702
+ case 'function':
703
+ this.bashOnly('function')
704
+ this.context.opaque('function definition')
705
+ this.take()
706
+ this.functionBody(true)
707
+ return
708
+ case 'coproc': {
709
+ this.bashOnly('coproc')
710
+ // `coproc [NAME] command`: runs in the background, and its
711
+ // descriptors land in a variable. Read the command; opaque.
712
+ this.context.opaque('coproc')
713
+ this.take()
714
+ const next = this.peek()
715
+ if (
716
+ next.kind === 'word' &&
717
+ COMPOUND_AFTER.test(this.src.slice(this.skipBlanks(next.end)))
718
+ ) {
719
+ this.take()
720
+ const body = this.peek()
721
+ if (body.kind === 'word') this.peeked = { ...body, reservedOk: true }
722
+ }
723
+ this.command()
724
+ return
725
+ }
726
+ case '}':
727
+ case 'then':
728
+ case 'else':
729
+ case 'elif':
730
+ case 'fi':
731
+ case 'do':
732
+ case 'done':
733
+ case 'esac':
734
+ case '!':
735
+ case 'time':
736
+ case 'in':
737
+ case ']]':
738
+ throw this.unexpected(token)
739
+ }
740
+ }
741
+ this.simpleCommand()
742
+ }
743
+
744
+ private ifCommand(): void {
745
+ this.take()
746
+ this.list((t) => this.isReserved(t, 'then'))
747
+ this.take()
748
+ this.list(
749
+ (t) => this.isReserved(t, 'elif') || this.isReserved(t, 'else') || this.isReserved(t, 'fi'),
750
+ )
751
+ for (;;) {
752
+ const token = this.take()
753
+ if (this.isReserved(token, 'fi')) return
754
+ if (this.isReserved(token, 'elif')) {
755
+ this.list((t) => this.isReserved(t, 'then'))
756
+ this.take()
757
+ this.list(
758
+ (t) =>
759
+ this.isReserved(t, 'elif') || this.isReserved(t, 'else') || this.isReserved(t, 'fi'),
760
+ )
761
+ continue
762
+ }
763
+ // else
764
+ this.list((t) => this.isReserved(t, 'fi'))
765
+ this.take()
766
+ return
767
+ }
768
+ }
769
+
770
+ private forCommand(): void {
771
+ this.take()
772
+ const header = this.skipBlanks(this.pos)
773
+ let token: Token
774
+ if (this.peeked === null && this.src.startsWith('((', header)) {
775
+ // `for (( init; test; step ))`: arithmetic throughout.
776
+ const end = this.scanArithmetic(header + 2)
777
+ if (end < 0) throw new Stop('syntax error: arithmetic for loop')
778
+ this.bashOnly('for ((…))')
779
+ this.arithmeticContent(this.src.slice(header + 2, end - 2), header + 2)
780
+ this.pos = end
781
+ this.last = { kind: 'other' }
782
+ this.newlines()
783
+ token = this.peek()
784
+ if (token.kind === 'op' && token.op === ';') {
785
+ this.take()
786
+ this.newlines()
787
+ }
788
+ this.loopBody()
789
+ return
790
+ }
791
+ const name = this.takePlainWord()
792
+ if (name.kind !== 'word') throw this.unexpected(name)
793
+ this.newlines()
794
+ token = this.peek()
795
+ if (token.kind === 'word' && !token.word.quoted && token.word.value === 'in') {
796
+ this.take()
797
+ // Inside a case statement, bash reads `esac` right after `in` as
798
+ // the end of the case.
799
+ const first = this.peekPlainWord()
800
+ if (
801
+ this.caseDepth > 0 &&
802
+ first.kind === 'word' &&
803
+ !first.word.quoted &&
804
+ first.word.value === 'esac'
805
+ ) {
806
+ throw this.unexpected(first)
807
+ }
808
+ for (;;) {
809
+ token = this.takePlainWord()
810
+ if (token.kind === 'word') continue
811
+ if (token.kind === 'newline' || (token.kind === 'op' && token.op === ';')) break
812
+ throw this.unexpected(token)
813
+ }
814
+ this.newlines()
815
+ } else if (token.kind === 'op' && token.op === ';') {
816
+ this.take()
817
+ this.newlines()
818
+ }
819
+ this.loopBody()
820
+ }
821
+
822
+ /** `do list done`, or bash's `{ list }` in its place. */
823
+ private loopBody(): void {
824
+ const token = this.peek()
825
+ if (this.isReservedAnywhere(token, 'do')) {
826
+ this.take()
827
+ this.list((t) => this.isReserved(t, 'done'))
828
+ this.take()
829
+ return
830
+ }
831
+ if (this.isReservedAnywhere(token, '{')) {
832
+ this.bashOnly('a { } loop body')
833
+ this.take()
834
+ this.braceGroup()
835
+ return
836
+ }
837
+ throw this.unexpected(token)
838
+ }
839
+
840
+ private caseDepth = 0
841
+
842
+ private caseCommand(): void {
843
+ this.caseDepth += 1
844
+ try {
845
+ this.caseBody()
846
+ } finally {
847
+ this.caseDepth -= 1
848
+ this.assignmentSyntax = true
849
+ this.allowCompound = true
850
+ }
851
+ }
852
+
853
+ private caseBody(): void {
854
+ this.take()
855
+ const subject = this.takePlainWord()
856
+ if (subject.kind !== 'word') throw this.unexpected(subject)
857
+ this.newlines()
858
+ const keyword = this.take()
859
+ if (keyword.kind !== 'word' || keyword.word.quoted || keyword.word.value !== 'in') {
860
+ throw this.unexpected(keyword)
861
+ }
862
+ // Patterns are never assignments.
863
+ const patterns = (): void => {
864
+ this.assignmentSyntax = false
865
+ this.allowCompound = false
866
+ }
867
+ patterns()
868
+ this.newlines()
869
+ for (;;) {
870
+ patterns()
871
+ let token = this.peek()
872
+ if (
873
+ token.kind === 'word' &&
874
+ !token.word.quoted &&
875
+ !token.word.expands &&
876
+ token.word.value === 'esac'
877
+ ) {
878
+ this.take()
879
+ return
880
+ }
881
+ if (token.kind === 'op' && token.op === '(') {
882
+ this.take()
883
+ token = this.peek()
884
+ }
885
+ // One or more patterns separated by `|`, then `)`.
886
+ for (;;) {
887
+ patterns()
888
+ const pattern = this.take()
889
+ if (pattern.kind !== 'word') throw this.unexpected(pattern)
890
+ const next = this.take()
891
+ if (next.kind === 'op' && next.op === '|') continue
892
+ if (next.kind === 'op' && next.op === ')') break
893
+ throw this.unexpected(next)
894
+ }
895
+ this.assignmentSyntax = true
896
+ this.allowCompound = true
897
+ this.newlines()
898
+ token = this.peek()
899
+ const endsClause = (t: Token): boolean =>
900
+ (t.kind === 'op' && (t.op === ';;' || t.op === ';&' || t.op === ';;&')) ||
901
+ (t.kind === 'word' && !t.word.quoted && !t.word.expands && t.word.value === 'esac')
902
+ if (!endsClause(token)) this.list(endsClause)
903
+ token = this.take()
904
+ if (token.kind === 'word') return // esac
905
+ patterns()
906
+ this.newlines()
907
+ }
908
+ }
909
+
910
+ /**
911
+ * `[[ … ]]`. Nothing in it runs a command except a substitution, which
912
+ * word reading already records. Its operands are arithmetic in places
913
+ * (`-eq`), which can evaluate code held in a variable, so it is opaque.
914
+ */
915
+ private conditional(): void {
916
+ this.take()
917
+ this.context.opaque('conditional expression')
918
+ this.assignmentSyntax = false
919
+ this.allowCompound = false
920
+ for (;;) {
921
+ const token = this.takeConditional()
922
+ if (token.kind === 'eof') throw new Stop('unterminated [[')
923
+ if (token.kind === 'word' && !token.word.quoted && token.word.value === ']]') {
924
+ // Like `((…))`, a finished `[[…]]` accepts a reserved word next.
925
+ this.last = { kind: 'word', reserved: '}' }
926
+ this.assignmentSyntax = true
927
+ this.allowCompound = true
928
+ return
929
+ }
930
+ }
931
+ }
932
+
933
+ /** `name () body` or `function name [()] body`. */
934
+ private functionBody(keyword: boolean): void {
935
+ if (keyword) {
936
+ const name = this.take()
937
+ if (name.kind !== 'word') throw this.unexpected(name)
938
+ }
939
+ const open = this.peek()
940
+ if (open.kind === 'op' && open.op === '(') {
941
+ this.take()
942
+ this.expectOp(')')
943
+ } else if (!keyword) {
944
+ throw this.unexpected(open)
945
+ }
946
+ this.newlines()
947
+ // The body must be a compound command; the reserved word is recognised
948
+ // here whatever came before it.
949
+ const body = this.peek()
950
+ if (
951
+ body.kind === 'word' &&
952
+ !body.word.quoted &&
953
+ ['{', 'if', 'while', 'until', 'for', 'select', 'case', '[['].includes(body.word.value)
954
+ ) {
955
+ this.peeked = { ...body, reservedOk: true }
956
+ this.command()
957
+ return
958
+ }
959
+ if (body.kind === 'op' && body.op === '(') {
960
+ this.command()
961
+ return
962
+ }
963
+ if (body.kind === 'arith') {
964
+ this.command()
965
+ return
966
+ }
967
+ throw this.unexpected(body)
968
+ }
969
+
970
+ private redirectionsAfterCompound(): void {
971
+ for (;;) {
972
+ const token = this.peek()
973
+ if (token.kind === 'op' && REDIRECTIONS.has(token.op)) {
974
+ this.take()
975
+ this.context.redirections.push(this.redirectionTarget(token))
976
+ continue
977
+ }
978
+ if (token.kind === 'word') throw this.unexpected(token)
979
+ return
980
+ }
981
+ }
982
+
983
+ private simpleCommand(): void {
984
+ const words: ShellWord[] = []
985
+ const redirections: ShellRedirection[] = []
986
+ let assignments = 0
987
+ let afterAssignment = false
988
+ let start = -1
989
+ let end = -1
990
+ for (;;) {
991
+ // `NAME=(…)` and `NAME[…]` are read as assignment syntax only where an
992
+ // assignment can stand: first in the command (after any leading
993
+ // redirections), or right after another assignment. An array also
994
+ // counts as an argument of `declare` and its kin.
995
+ this.assignmentSyntax =
996
+ words.length === 0 || (words.length === assignments && afterAssignment)
997
+ this.allowCompound =
998
+ this.assignmentSyntax || ASSIGNMENT_BUILTINS.has(words[assignments]?.value ?? '')
999
+ const token = this.peek()
1000
+ if (token.kind === 'word') {
1001
+ this.take()
1002
+ if (start < 0) start = token.start
1003
+ end = token.end
1004
+ afterAssignment = false
1005
+ if (words.length === assignments && ASSIGNMENT.test(joined(token.word.text))) {
1006
+ assignments += 1
1007
+ if (/^[A-Za-z_][A-Za-z0-9_]*(?:\[|\+=)/.test(joined(token.word.text)))
1008
+ this.bashOnly('array or += assignment')
1009
+ afterAssignment = true
1010
+ const subscript = /^[A-Za-z_][A-Za-z0-9_]*\[([^\]]*)\]/.exec(joined(token.word.text))
1011
+ if (subscript && !/^\d*$/.test(subscript[1] as string))
1012
+ this.context.opaque('arithmetic subscript')
1013
+ }
1014
+ words.push(token.word)
1015
+ continue
1016
+ }
1017
+ if (token.kind === 'op' && REDIRECTIONS.has(token.op)) {
1018
+ this.take()
1019
+ if (start < 0) start = token.start
1020
+ afterAssignment = false
1021
+ // A bash 5 quirk, measured: in a command that so far has only
1022
+ // redirections, the target of a later `&>>` is read as an
1023
+ // assignment word, subscript included, and rejected when it is
1024
+ // shaped like one.
1025
+ const quirk = token.op === '&>>' && words.length === 0 && redirections.length > 0
1026
+ const redirection = this.redirectionTarget(token, quirk)
1027
+ if (quirk && ASSIGNMENT.test(joined(redirection.target.text)))
1028
+ throw new Stop('syntax error')
1029
+ end = this.lastEnd
1030
+ redirections.push(redirection)
1031
+ this.context.redirections.push(redirection)
1032
+ continue
1033
+ }
1034
+ if (
1035
+ token.kind === 'op' &&
1036
+ token.op === '(' &&
1037
+ words.length === 1 &&
1038
+ assignments === 0 &&
1039
+ redirections.length === 0
1040
+ ) {
1041
+ // `name ( ) compound`: a function definition. Its body runs only
1042
+ // when something calls it, under a name no rule sees.
1043
+ this.context.opaque('function definition')
1044
+ this.functionBody(false)
1045
+ return
1046
+ }
1047
+ break
1048
+ }
1049
+ this.allowCompound = true
1050
+ this.assignmentSyntax = true
1051
+ if (start < 0) throw this.unexpected(this.peek())
1052
+ const command: ShellCommand = {
1053
+ words,
1054
+ assignments,
1055
+ redirections,
1056
+ text: this.src.slice(start, end),
1057
+ origin: this.origin,
1058
+ depth: this.depth,
1059
+ }
1060
+ this.context.commands.push(command)
1061
+ this.inspect(command)
1062
+ }
1063
+
1064
+ /**
1065
+ * Take the next token where no assignment syntax applies: a redirection
1066
+ * target, a `for` name or word, a `case` subject.
1067
+ */
1068
+ private takePlainWord(): Token {
1069
+ const assignmentSyntax = this.assignmentSyntax
1070
+ const allowCompound = this.allowCompound
1071
+ this.assignmentSyntax = false
1072
+ this.allowCompound = false
1073
+ try {
1074
+ return this.take()
1075
+ } finally {
1076
+ this.assignmentSyntax = assignmentSyntax
1077
+ this.allowCompound = allowCompound
1078
+ }
1079
+ }
1080
+
1081
+ private peekPlainWord(): Token {
1082
+ const assignmentSyntax = this.assignmentSyntax
1083
+ const allowCompound = this.allowCompound
1084
+ this.assignmentSyntax = false
1085
+ this.allowCompound = false
1086
+ try {
1087
+ return this.peek()
1088
+ } finally {
1089
+ this.assignmentSyntax = assignmentSyntax
1090
+ this.allowCompound = allowCompound
1091
+ }
1092
+ }
1093
+
1094
+ /** The end offset of the last token taken. */
1095
+ private lastEnd = 0
1096
+ /** Whether a word may be a compound array assignment here. */
1097
+ private allowCompound = true
1098
+ /** Whether `NAME[…]` is read as a subscript here, spaces and all. */
1099
+ private assignmentSyntax = true
1100
+
1101
+ private redirectionTarget(
1102
+ operator: Token & { kind: 'op' },
1103
+ assignmentSyntax = false,
1104
+ ): ShellRedirection {
1105
+ const target = assignmentSyntax ? this.take() : this.takePlainWord()
1106
+ if (target.kind !== 'word') throw this.unexpected(target)
1107
+ if (
1108
+ (operator.op === '>&' || operator.op === '<&') &&
1109
+ (target.word.quoted || target.word.expands)
1110
+ ) {
1111
+ // Bash 5.2 expands the target of `>&` twice: `x >&2'$(cmd)'` and
1112
+ // `x >&2${v:-'$(cmd)'}` run `cmd`, which the parse saw quoted
1113
+ // (measured; fixed in 5.3, and 5.2 is what current Debian and
1114
+ // Ubuntu ship).
1115
+ this.context.opaque('quoted or expanding target of >& or <&')
1116
+ }
1117
+ if (operator.op === '<<' || operator.op === '<<-') {
1118
+ if (target.word.value.includes('\n')) {
1119
+ // Bash starts the body at the newline inside the delimiter, so
1120
+ // the rest of the line is body text. Reading on as commands
1121
+ // reports more than runs, which is the safe way to be wrong.
1122
+ this.context.opaque('here-document delimiter spans lines')
1123
+ } else
1124
+ this.heredocs.push({
1125
+ delimiter: target.word.value,
1126
+ stripTabs: operator.op === '<<-',
1127
+ quoted: target.word.quoted,
1128
+ })
1129
+ }
1130
+ return {
1131
+ operator: operator.op,
1132
+ ...(operator.fd !== undefined ? { fd: operator.fd } : {}),
1133
+ target: target.word,
1134
+ }
1135
+ }
1136
+
1137
+ /**
1138
+ * What a finished simple command means for the rest of the line: a nested
1139
+ * shell to read, or a command that changes how bash parses what follows.
1140
+ */
1141
+ private inspect(command: ShellCommand): void {
1142
+ for (const word of command.words) {
1143
+ // Either variable, set in the line, changes how bash parses the rest.
1144
+ if (word.value.includes('POSIXLY_CORRECT=') || word.value.includes('BASH_COMPAT=')) {
1145
+ this.context.opaque('parser setting')
1146
+ }
1147
+ }
1148
+ const head = command.words[command.assignments]
1149
+ if (head === undefined || head.expands) return
1150
+ const name = basename(head.value)
1151
+ if (
1152
+ name !== 'shopt' &&
1153
+ name !== 'enable' &&
1154
+ name !== 'set' &&
1155
+ name !== 'busybox' &&
1156
+ !NESTED_SHELLS.has(name)
1157
+ )
1158
+ return
1159
+ const words = command.words.slice(command.assignments)
1160
+ if (name === 'shopt' || name === 'enable') {
1161
+ this.context.opaque('parser setting')
1162
+ return
1163
+ }
1164
+ if (name === 'set') {
1165
+ for (let i = 1; i < words.length; i += 1) {
1166
+ const word = words[i] as ShellWord
1167
+ if (word.expands) this.context.opaque('parser setting')
1168
+ if (/^[-+][A-Za-z]*k/.test(word.value) || word.value === '--posix')
1169
+ this.context.opaque('parser setting')
1170
+ if (
1171
+ /^[-+][A-Za-z]*o$/.test(word.value) &&
1172
+ /^(?:posix|keyword)$/.test(words[i + 1]?.value ?? '')
1173
+ ) {
1174
+ this.context.opaque('parser setting')
1175
+ }
1176
+ }
1177
+ return
1178
+ }
1179
+ if (name === 'busybox' && words[1] !== undefined && !words[1].expands) {
1180
+ if (NESTED_SHELLS.has(basename(words[1].value))) this.nestedShell(words.slice(1))
1181
+ return
1182
+ }
1183
+ if (!NESTED_SHELLS.has(name)) return
1184
+ this.nestedShell(words)
1185
+ }
1186
+
1187
+ /**
1188
+ * `bash [options] -c payload [name args…]`: the payload is the first
1189
+ * non-option argument once `-c` has been seen among the options.
1190
+ */
1191
+ private nestedShell(words: readonly ShellWord[]): void {
1192
+ const shell = basename(words[0]?.value ?? '')
1193
+ let command = false
1194
+ let payload: ShellWord | undefined
1195
+ for (let i = 1; i < words.length; i += 1) {
1196
+ const word = words[i] as ShellWord
1197
+ if (word.expands) {
1198
+ this.context.opaque('nested shell option')
1199
+ return
1200
+ }
1201
+ const value = word.value
1202
+ if (value === '--' || value === '-') {
1203
+ payload = words[i + 1]
1204
+ break
1205
+ }
1206
+ if (value.startsWith('--')) {
1207
+ if (value === '--rcfile' || value === '--init-file') i += 1
1208
+ continue
1209
+ }
1210
+ if (/^[-+][A-Za-z]+$/.test(value)) {
1211
+ if (value.startsWith('-') && value.includes('c')) command = true
1212
+ // `-o name`, `-O name`: the option takes the next word.
1213
+ if (/[oO]$/.test(value)) i += 1
1214
+ continue
1215
+ }
1216
+ payload = word
1217
+ break
1218
+ }
1219
+ if (!command) return
1220
+ if (payload === undefined) {
1221
+ this.context.opaque('nested shell without a command')
1222
+ return
1223
+ }
1224
+ if (payload.expands) {
1225
+ this.context.opaque('nested shell command is expanded at runtime')
1226
+ return
1227
+ }
1228
+ if (this.depth + 1 >= MAX_SHELL_DEPTH) {
1229
+ this.context.opaque('nested shells too deep')
1230
+ return
1231
+ }
1232
+ const origin = this.origin === 'substitution' ? 'substitution' : 'shell'
1233
+ this.context.rescan(payload.value.length)
1234
+ // `bash -c` is read as bash. Another shell is read in the dialect
1235
+ // that holds for every POSIX shell; zsh and ksh go beyond POSIX in
1236
+ // ways this lexer does not model, so their payloads are opaque, and
1237
+ // still read for what a deny rule can see.
1238
+ this.context.setDialect(payload.value, shell === 'bash' ? 'bash' : 'sh')
1239
+ if (shell === 'zsh' || shell === 'ksh' || shell === 'mksh')
1240
+ this.context.opaque(`nested ${shell} is not modeled`)
1241
+ const inner = new Parser(
1242
+ payload.value,
1243
+ 0,
1244
+ this.context,
1245
+ origin,
1246
+ this.depth + 1,
1247
+ this.nestingNow + 1,
1248
+ )
1249
+ try {
1250
+ inner.program()
1251
+ } catch (error) {
1252
+ if (!(error instanceof Stop)) throw error
1253
+ // The payload does not parse. Bash would refuse it, but this
1254
+ // reading may be wrong about that, so it is opaque rather than empty.
1255
+ this.context.opaque(`nested shell: ${error.reason}`)
1256
+ }
1257
+ }
1258
+
1259
+ /** Compound commands nest on the JS stack; bound how deep. */
1260
+ private level = 0
1261
+ private get nestingNow(): number {
1262
+ return this.nesting + this.level
1263
+ }
1264
+
1265
+ private expectOp(op: string): Token {
1266
+ const token = this.take()
1267
+ if (token.kind !== 'op' || token.op !== op) throw this.unexpected(token)
1268
+ return token
1269
+ }
1270
+
1271
+ private newlines(): void {
1272
+ while (this.peek().kind === 'newline') this.take()
1273
+ }
1274
+
1275
+ private isReserved(token: Token, name: string): boolean {
1276
+ return (
1277
+ token.kind === 'word' &&
1278
+ token.reservedOk &&
1279
+ !token.word.quoted &&
1280
+ !token.word.expands &&
1281
+ token.word.value === name
1282
+ )
1283
+ }
1284
+
1285
+ /** A reserved word recognised by position in a `for` header regardless of what preceded it. */
1286
+ private isReservedAnywhere(token: Token, name: string): boolean {
1287
+ return (
1288
+ token.kind === 'word' &&
1289
+ !token.word.quoted &&
1290
+ !token.word.expands &&
1291
+ token.word.value === name
1292
+ )
1293
+ }
1294
+
1295
+ private unexpected(token: Token): Stop {
1296
+ if (token.kind === 'eof') return new Stop('syntax error: unexpected end of input')
1297
+ return new Stop('syntax error')
1298
+ }
1299
+
1300
+ // --- tokens -----------------------------------------------------------
1301
+
1302
+ private peek(): Token {
1303
+ if (this.peeked === null) this.peeked = this.read()
1304
+ return this.peeked
1305
+ }
1306
+
1307
+ private take(): Token {
1308
+ const token = this.peek()
1309
+ this.peeked = null
1310
+ this.lastEnd = token.end
1311
+ if (token.kind === 'word') {
1312
+ const reserved =
1313
+ token.reservedOk &&
1314
+ !token.word.quoted &&
1315
+ !token.word.expands &&
1316
+ RESERVED.has(token.word.value)
1317
+ ? token.word.value
1318
+ : null
1319
+ this.last = { kind: 'word', reserved }
1320
+ } else if (token.kind === 'op') {
1321
+ this.last = { kind: 'op', op: token.op }
1322
+ } else if (token.kind === 'newline') {
1323
+ this.last = { kind: 'newline' }
1324
+ } else {
1325
+ this.last = { kind: 'other' }
1326
+ }
1327
+ return token
1328
+ }
1329
+
1330
+ private reservedOk(): boolean {
1331
+ const last = this.last
1332
+ switch (last.kind) {
1333
+ case 'start':
1334
+ case 'newline':
1335
+ return true
1336
+ case 'op':
1337
+ return RESERVED_AFTER_OPS.has(last.op)
1338
+ case 'word':
1339
+ return last.reserved !== null && RESERVED_AFTER_WORDS.has(last.reserved)
1340
+ default:
1341
+ return false
1342
+ }
1343
+ }
1344
+
1345
+ /** Skip any `\<newline>` pairs at `at`: bash removes them before tokenizing. */
1346
+ private cont(at: number): number {
1347
+ let i = at
1348
+ for (;;) {
1349
+ if (this.src[i] !== '\\') return i
1350
+ const next = i + 1
1351
+ if (this.src[next] === '\n') i += 2
1352
+ else if (next === this.src.length && this.source.finalLineRaw) {
1353
+ this.bashOnly('a trailing backslash')
1354
+ i += 1
1355
+ } else return i
1356
+ }
1357
+ }
1358
+
1359
+ private skipBlanks(at: number): number {
1360
+ let i = this.cont(at)
1361
+ while (this.src[i] === ' ' || this.src[i] === '\t') i = this.cont(i + 1)
1362
+ return i
1363
+ }
1364
+
1365
+ private read(): Token {
1366
+ const src = this.src
1367
+ for (;;) {
1368
+ const start = this.skipBlanks(this.pos)
1369
+ this.pos = start
1370
+ if (start >= src.length) return { kind: 'eof', start, end: start }
1371
+ const char = src[start] as string
1372
+ if (char === '#') {
1373
+ // A comment runs to the newline; continuations do not extend it.
1374
+ let i = start
1375
+ while (i < src.length && src[i] !== '\n') i += 1
1376
+ this.pos = i
1377
+ continue
1378
+ }
1379
+ if (char === '\n') {
1380
+ this.pos = start + 1
1381
+ this.readHeredocs()
1382
+ return { kind: 'newline', start, end: start + 1 }
1383
+ }
1384
+ if (char === '}' && this.closesOnBrace && this.braceDepth === 0 && this.reservedOk()) {
1385
+ this.pos = start + 1
1386
+ return { kind: 'op', start, end: start + 1, op: '}' }
1387
+ }
1388
+ // After `<&` or `>&`, bash takes a `-` as a token of its own: `3<&-a`
1389
+ // closes descriptor 3 and `a` is the next word.
1390
+ if (
1391
+ char === '-' &&
1392
+ this.last.kind === 'op' &&
1393
+ (this.last.op === '<&' || this.last.op === '>&')
1394
+ ) {
1395
+ const after = src[this.cont(start + 1)]
1396
+ if (after !== undefined && !WORD_BREAK.has(after))
1397
+ this.bashOnly('a word glued to <&- or >&-')
1398
+ this.pos = start + 1
1399
+ return {
1400
+ kind: 'word',
1401
+ start,
1402
+ end: start + 1,
1403
+ word: { text: '-', value: '-', expands: false, quoted: false },
1404
+ reservedOk: false,
1405
+ }
1406
+ }
1407
+ return this.operatorOrWord(start)
1408
+ }
1409
+ }
1410
+
1411
+ /** Read the operator starting at `start`, or a word. */
1412
+ private operatorOrWord(start: number): Token {
1413
+ const src = this.src
1414
+ const char = src[start] as string
1415
+ const n1 = this.cont(start + 1)
1416
+ const c1 = src[n1]
1417
+ const op = (text: string, end: number, fd?: string): Token => {
1418
+ this.pos = end
1419
+ return fd === undefined
1420
+ ? { kind: 'op', start, end, op: text }
1421
+ : { kind: 'op', start, end, op: text, fd }
1422
+ }
1423
+ switch (char) {
1424
+ case ';': {
1425
+ if (c1 === ';') {
1426
+ const n2 = this.cont(n1 + 1)
1427
+ if (src[n2] === '&') {
1428
+ this.bashOnly(';;&')
1429
+ return op(';;&', n2 + 1)
1430
+ }
1431
+ return op(';;', n1 + 1)
1432
+ }
1433
+ if (c1 === '&') {
1434
+ this.bashOnly(';&')
1435
+ return op(';&', n1 + 1)
1436
+ }
1437
+ return op(';', start + 1)
1438
+ }
1439
+ case '&': {
1440
+ if (c1 === '&') return op('&&', n1 + 1)
1441
+ if (c1 === '>') {
1442
+ const n2 = this.cont(n1 + 1)
1443
+ this.bashOnly('&> and &>>')
1444
+ if (src[n2] === '>') return op('&>>', n2 + 1)
1445
+ return op('&>', n1 + 1)
1446
+ }
1447
+ return op('&', start + 1)
1448
+ }
1449
+ case '|': {
1450
+ if (c1 === '|') return op('||', n1 + 1)
1451
+ if (c1 === '&') {
1452
+ this.bashOnly('|&')
1453
+ return op('|&', n1 + 1)
1454
+ }
1455
+ return op('|', start + 1)
1456
+ }
1457
+ case '(': {
1458
+ if (c1 === '(' && this.reservedOk()) {
1459
+ const end = this.arithmeticCommand(n1 + 1)
1460
+ if (end >= 0) {
1461
+ this.pos = end
1462
+ this.bashOnly('((…))')
1463
+ return { kind: 'arith', start, end }
1464
+ }
1465
+ }
1466
+ return op('(', start + 1)
1467
+ }
1468
+ case ')':
1469
+ return op(')', start + 1)
1470
+ case '<':
1471
+ case '>': {
1472
+ if (c1 === '(') return this.word(start)
1473
+ return this.redirectionOperator(start, undefined)
1474
+ }
1475
+ }
1476
+ return this.word(start)
1477
+ }
1478
+
1479
+ private redirectionOperator(start: number, fd: string | undefined): Token {
1480
+ const src = this.src
1481
+ const char = src[start] as string
1482
+ const n1 = this.cont(start + 1)
1483
+ const c1 = src[n1]
1484
+ const done = (text: string, end: number): Token => {
1485
+ this.pos = end
1486
+ return fd === undefined
1487
+ ? { kind: 'op', start, end, op: text }
1488
+ : { kind: 'op', start, end, op: text, fd }
1489
+ }
1490
+ if (char === '<') {
1491
+ if (c1 === '<') {
1492
+ const n2 = this.cont(n1 + 1)
1493
+ if (src[n2] === '<') {
1494
+ this.bashOnly('<<<')
1495
+ return done('<<<', n2 + 1)
1496
+ }
1497
+ if (src[n2] === '-') return done('<<-', n2 + 1)
1498
+ return done('<<', n1 + 1)
1499
+ }
1500
+ if (c1 === '>') return done('<>', n1 + 1)
1501
+ if (c1 === '&') return done('<&', n1 + 1)
1502
+ return done('<', start + 1)
1503
+ }
1504
+ if (c1 === '>') return done('>>', n1 + 1)
1505
+ if (c1 === '|') return done('>|', n1 + 1)
1506
+ if (c1 === '&') return done('>&', n1 + 1)
1507
+ return done('>', start + 1)
1508
+ }
1509
+
1510
+ /** Tokens inside `[[ … ]]`, where `<`, `>` and `(` are operands. */
1511
+ private takeConditional(): Token {
1512
+ this.peeked = null
1513
+ const start = this.skipBlanks(this.pos)
1514
+ this.pos = start
1515
+ const src = this.src
1516
+ if (start >= src.length) return { kind: 'eof', start, end: start }
1517
+ const char = src[start] as string
1518
+ if (char === '\n') {
1519
+ this.pos = start + 1
1520
+ this.readHeredocs()
1521
+ return { kind: 'newline', start, end: start + 1 }
1522
+ }
1523
+ if (char === '<' || char === '>' || char === '(' || char === ')' || char === ';') {
1524
+ this.pos = start + 1
1525
+ return { kind: 'op', start, end: start + 1, op: char }
1526
+ }
1527
+ if (char === '&' || char === '|') {
1528
+ const n1 = this.cont(start + 1)
1529
+ const end = src[n1] === char ? n1 + 1 : start + 1
1530
+ this.pos = end
1531
+ return { kind: 'op', start, end, op: src.slice(start, end) }
1532
+ }
1533
+ const token = this.word(start)
1534
+ return token
1535
+ }
1536
+
1537
+ /**
1538
+ * `(( … ))` at command position. Returns the offset past the closing
1539
+ * `))`, or -1 when the parentheses do not close that way, in which case
1540
+ * bash reads the text as nested subshells instead.
1541
+ */
1542
+ private arithmeticCommand(from: number): number {
1543
+ const scanned = this.scanArithmetic(from)
1544
+ if (scanned < 0) return -1
1545
+ const content = this.src.slice(from, scanned - 2)
1546
+ this.arithmeticContent(content, from)
1547
+ return scanned
1548
+ }
1549
+
1550
+ /**
1551
+ * Scan arithmetic text from `from` to the `))` that closes it at depth
1552
+ * zero. Returns the offset past `))`, or -1.
1553
+ */
1554
+ private scanArithmetic(from: number): number {
1555
+ const src = this.src
1556
+ let depth = 0
1557
+ let i = from
1558
+ while (i < src.length) {
1559
+ i = this.cont(i)
1560
+ const char = src[i]
1561
+ if (char === undefined) return -1
1562
+ if (char === '\\') {
1563
+ i += 2
1564
+ continue
1565
+ }
1566
+ if (char === "'" || char === '"' || char === '`') {
1567
+ const close = src.indexOf(char, i + 1)
1568
+ if (close < 0) {
1569
+ this.context.rescan(src.length - from)
1570
+ return -1
1571
+ }
1572
+ i = close + 1
1573
+ continue
1574
+ }
1575
+ if (char === '(') depth += 1
1576
+ else if (char === ')') {
1577
+ if (depth === 0) {
1578
+ const next = this.cont(i + 1)
1579
+ this.context.rescan(i - from)
1580
+ return src[next] === ')' ? next + 1 : -1
1581
+ }
1582
+ depth -= 1
1583
+ }
1584
+ i += 1
1585
+ }
1586
+ this.context.rescan(i - from)
1587
+ return -1
1588
+ }
1589
+
1590
+ /**
1591
+ * Arithmetic evaluates variables recursively, and a variable holding
1592
+ * `a[$(cmd)]` runs `cmd`. Only arithmetic on literals is transparent.
1593
+ */
1594
+ private arithmeticContent(content: string, at: number): void {
1595
+ const plain = content.replace(/\\\n/g, '')
1596
+ if (/^[0-9\s+\-*/%()<>=!&|^~?:,]*$/.test(plain)) return
1597
+ this.context.opaque('arithmetic')
1598
+ if (/\$\(|`/.test(plain)) {
1599
+ // Read the substitution for the commands it runs.
1600
+ this.scanForSubstitutions(at, at + content.length)
1601
+ }
1602
+ }
1603
+
1604
+ /** Record the commands inside any `$(…)` or backticks in [from, to). */
1605
+ private scanForSubstitutions(from: number, to: number): void {
1606
+ let i = from
1607
+ while (i < to) {
1608
+ const char = this.src[i]
1609
+ if (char === '$' && this.src[i + 1] === '(' && this.src[i + 2] !== '(') {
1610
+ const inner = new Parser(
1611
+ this.src,
1612
+ i + 2,
1613
+ this.context,
1614
+ 'substitution',
1615
+ this.depth,
1616
+ this.nestingNow + 1,
1617
+ )
1618
+ try {
1619
+ i = inner.substitution()
1620
+ } catch (error) {
1621
+ if (!(error instanceof Stop)) throw error
1622
+ return
1623
+ }
1624
+ continue
1625
+ }
1626
+ if (char === '`') {
1627
+ const end = this.backtick(i, new WordBuilder(), false)
1628
+ i = end
1629
+ continue
1630
+ }
1631
+ i += 1
1632
+ }
1633
+ }
1634
+
1635
+ // --- here-documents ---------------------------------------------------
1636
+
1637
+ /** Consume the bodies of here-documents opened on the line just ended. */
1638
+ private readHeredocs(): void {
1639
+ const src = this.src
1640
+ while (this.heredocs.length > 0) {
1641
+ const heredoc = this.heredocs.shift() as PendingHeredoc
1642
+ let i = this.pos
1643
+ const bodyStart = i
1644
+ for (;;) {
1645
+ if (i >= src.length) break
1646
+ let line = ''
1647
+ let j = i
1648
+ for (;;) {
1649
+ const newline = src.indexOf('\n', j)
1650
+ const lineEnd = newline < 0 ? src.length : newline
1651
+ const piece = src.slice(j, lineEnd)
1652
+ // In an unquoted body, a backslash that escapes the newline
1653
+ // joins the lines before the delimiter test.
1654
+ if (!heredoc.quoted && newline >= 0 && trailingBackslashes(piece) % 2 === 1) {
1655
+ this.bashOnly('a line continuation in a here-document')
1656
+ line += piece.slice(0, -1)
1657
+ j = newline + 1
1658
+ continue
1659
+ }
1660
+ line += piece
1661
+ j = newline < 0 ? src.length : newline + 1
1662
+ break
1663
+ }
1664
+ const test = heredoc.stripTabs ? line.replace(/^\t+/, '') : line
1665
+ i = j
1666
+ if (test === heredoc.delimiter) break
1667
+ }
1668
+ if (!heredoc.quoted) {
1669
+ const body = src.slice(bodyStart, i)
1670
+ if (/\$[({[]|`/.test(body)) this.heredocBody(body)
1671
+ }
1672
+ this.pos = i
1673
+ }
1674
+ }
1675
+
1676
+ /** An unquoted here-document body expands; read it for substitutions. */
1677
+ private heredocBody(body: string): void {
1678
+ // Parameter expansion in a body runs nothing; substitution and
1679
+ // arithmetic might. Read with double-quote rules, where `"` is plain.
1680
+ this.context.rescan(body.length)
1681
+ this.inherit(body)
1682
+ const reader = new Parser(body, 0, this.context, this.origin, this.depth, this.nestingNow + 1)
1683
+ try {
1684
+ let i = 0
1685
+ const builder = new WordBuilder()
1686
+ while (i < body.length) {
1687
+ const char = body[i]
1688
+ if (char === '\\') {
1689
+ i += 2
1690
+ continue
1691
+ }
1692
+ if (char === '$') {
1693
+ i = reader.dollar(i, builder, true)
1694
+ continue
1695
+ }
1696
+ if (char === '`') {
1697
+ i = reader.backtick(i, builder, true)
1698
+ continue
1699
+ }
1700
+ i += 1
1701
+ }
1702
+ } catch (error) {
1703
+ if (!(error instanceof Stop)) throw error
1704
+ this.context.opaque(`here-document: ${error.reason}`)
1705
+ }
1706
+ }
1707
+
1708
+ // --- words ------------------------------------------------------------
1709
+
1710
+ private word(start: number): Token {
1711
+ const reservedOk = this.reservedOk()
1712
+ const src = this.src
1713
+ const builder = new WordBuilder()
1714
+ let i = start
1715
+ for (;;) {
1716
+ i = this.cont(i)
1717
+ if (i >= src.length) break
1718
+ const char = src[i] as string
1719
+ if (WORD_BREAK.has(char)) {
1720
+ // `<(…)` and `>(…)` are words of their own.
1721
+ // `<(…)` and `>(…)` are read as part of the word, even mid-word.
1722
+ if ((char === '<' || char === '>') && src[this.cont(i + 1)] === '(') {
1723
+ i = this.processSubstitution(i, builder)
1724
+ continue
1725
+ }
1726
+ if (
1727
+ char === '(' &&
1728
+ this.allowCompound &&
1729
+ /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=$/.test(joined(src.slice(start, i)))
1730
+ ) {
1731
+ this.bashOnly('array assignment')
1732
+ i = this.compoundArray(i + 1, builder)
1733
+ continue
1734
+ }
1735
+ break
1736
+ }
1737
+ if (
1738
+ char === '[' &&
1739
+ this.assignmentSyntax &&
1740
+ /^[A-Za-z_][A-Za-z0-9_]*$/.test(joined(src.slice(start, i)))
1741
+ ) {
1742
+ // `NAME[…]` where an assignment may stand: bash reads the
1743
+ // subscript as one unit, blanks and quotes included.
1744
+ this.bashOnly('subscript')
1745
+ const close = this.subscript(i + 1)
1746
+ builder.expansion(src.slice(i, close))
1747
+ i = close
1748
+ continue
1749
+ }
1750
+ if (char === '\\') {
1751
+ if (i + 1 >= src.length) {
1752
+ // A trailing backslash is a literal backslash.
1753
+ this.bashOnly('a trailing backslash')
1754
+ builder.literal('\\')
1755
+ i += 1
1756
+ continue
1757
+ }
1758
+ builder.quotedText(src[i + 1] as string)
1759
+ i += 2
1760
+ continue
1761
+ }
1762
+ if (char === "'") {
1763
+ const close = src.indexOf("'", i + 1)
1764
+ if (close < 0) throw new Stop('unterminated quote')
1765
+ this.singleQuoted(i, close)
1766
+ builder.quotedText(src.slice(i + 1, close))
1767
+ i = close + 1
1768
+ continue
1769
+ }
1770
+ if (char === '"') {
1771
+ i = this.doubleQuoted(i + 1, builder)
1772
+ continue
1773
+ }
1774
+ if (char === '`') {
1775
+ i = this.backtick(i, builder, false)
1776
+ continue
1777
+ }
1778
+ if (char === '$') {
1779
+ i = this.dollar(i, builder, false)
1780
+ continue
1781
+ }
1782
+ builder.unquoted(char)
1783
+ i += 1
1784
+ }
1785
+ this.pos = i
1786
+ if (builder.brace) this.bashOnly('brace expansion')
1787
+ const word: ShellWord = {
1788
+ text: src.slice(start, i),
1789
+ value: builder.value,
1790
+ expands: builder.expands,
1791
+ quoted: builder.quoted,
1792
+ }
1793
+ // A descriptor before a redirection operator: `2>`, `{fd}>`.
1794
+ const next = src[i]
1795
+ if ((next === '<' || next === '>') && src[this.cont(i + 1)] !== '(') {
1796
+ const fd = joined(word.text)
1797
+ if (/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(fd)) this.bashOnly('{name} redirection')
1798
+ if (/^\d+$/.test(fd) || /^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(fd)) {
1799
+ const operator = this.redirectionOperator(i, fd)
1800
+ return { ...operator, start }
1801
+ }
1802
+ }
1803
+ return { kind: 'word', start, end: i, word, reservedOk }
1804
+ }
1805
+
1806
+ /**
1807
+ * After the `(` of `NAME=(…)`: read the element words to the closing `)`.
1808
+ * Returns the offset past it. The whole word is marked as expanding: an
1809
+ * array is not one argument.
1810
+ */
1811
+ private compoundArray(from: number, builder: WordBuilder): number {
1812
+ const src = this.src
1813
+ let i = from
1814
+ for (;;) {
1815
+ i = this.cont(i)
1816
+ if (i >= src.length) throw new Stop('syntax error: unterminated array')
1817
+ const char = src[i] as string
1818
+ if (char === ' ' || char === '\t' || char === '\n') {
1819
+ i += 1
1820
+ continue
1821
+ }
1822
+ if (char === '#') {
1823
+ while (i < src.length && src[i] !== '\n') i += 1
1824
+ continue
1825
+ }
1826
+ if (char === ')') {
1827
+ builder.expansion(src.slice(from - 1, i + 1))
1828
+ return i + 1
1829
+ }
1830
+ if (
1831
+ WORD_BREAK.has(char) &&
1832
+ !((char === '<' || char === '>') && src[this.cont(i + 1)] === '(')
1833
+ ) {
1834
+ throw new Stop('syntax error in array')
1835
+ }
1836
+ const element = this.word(i)
1837
+ if (element.kind !== 'word') throw new Stop('syntax error in array')
1838
+ const subscript = /^\[([^\]]*)\]\+?=/.exec(element.word.text)
1839
+ if (subscript && !/^\d*$/.test(subscript[1] as string))
1840
+ this.context.opaque('arithmetic subscript')
1841
+ i = element.end
1842
+ }
1843
+ }
1844
+
1845
+ /** After the `[` of `NAME[`: returns the offset past the matching `]`. */
1846
+ private subscript(from: number): number {
1847
+ const src = this.src
1848
+ const scratch = new WordBuilder()
1849
+ let depth = 0
1850
+ let i = from
1851
+ for (;;) {
1852
+ i = this.cont(i)
1853
+ if (i >= src.length) throw new Stop('syntax error: unterminated subscript')
1854
+ const char = src[i] as string
1855
+ if (char === '\\') {
1856
+ i += 2
1857
+ continue
1858
+ }
1859
+ if (char === "'") {
1860
+ const close = src.indexOf("'", i + 1)
1861
+ if (close < 0) throw new Stop('unterminated quote')
1862
+ this.singleQuoted(i, close)
1863
+ i = close + 1
1864
+ continue
1865
+ }
1866
+ if (char === '"') {
1867
+ i = this.doubleQuoted(i + 1, scratch)
1868
+ continue
1869
+ }
1870
+ if (char === '`') {
1871
+ i = this.backtick(i, scratch, false)
1872
+ continue
1873
+ }
1874
+ if (char === '$') {
1875
+ i = this.dollar(i, scratch, false)
1876
+ continue
1877
+ }
1878
+ if ((char === '<' || char === '>') && src[this.cont(i + 1)] === '(') {
1879
+ // Measured: `b[<(cmd)]` runs `cmd`.
1880
+ i = this.processSubstitution(i, scratch)
1881
+ continue
1882
+ }
1883
+ if (char === '[') depth += 1
1884
+ else if (char === ']') {
1885
+ if (depth === 0) return i + 1
1886
+ depth -= 1
1887
+ }
1888
+ i += 1
1889
+ }
1890
+ }
1891
+
1892
+ /** After an opening `"`: returns the offset past the closing one. */
1893
+ private doubleQuoted(from: number, builder: WordBuilder): number {
1894
+ const src = this.src
1895
+ let i = from
1896
+ builder.quotedText('')
1897
+ for (;;) {
1898
+ i = this.cont(i)
1899
+ if (i >= src.length) throw new Stop('unterminated quote')
1900
+ const char = src[i] as string
1901
+ if (char === '"') return i + 1
1902
+ if (char === '\\') {
1903
+ const next = src[i + 1]
1904
+ if (next === '$' || next === '`' || next === '"' || next === '\\') {
1905
+ builder.quotedText(next)
1906
+ i += 2
1907
+ continue
1908
+ }
1909
+ builder.quotedText('\\')
1910
+ i += 1
1911
+ continue
1912
+ }
1913
+ if (char === '$') {
1914
+ i = this.dollar(i, builder, true)
1915
+ continue
1916
+ }
1917
+ if (char === '`') {
1918
+ i = this.backtick(i, builder, true)
1919
+ continue
1920
+ }
1921
+ builder.quotedText(char)
1922
+ i += 1
1923
+ }
1924
+ }
1925
+
1926
+ /** A `$` at `at`. Returns the offset past whatever it introduces. */
1927
+ dollar(at: number, builder: WordBuilder, inDouble: boolean): number {
1928
+ const src = this.src
1929
+ const n = this.cont(at + 1)
1930
+ const next = src[n]
1931
+ if (next === "'" && !inDouble) {
1932
+ this.bashOnly("$'…'")
1933
+ return this.ansiC(n + 1, builder)
1934
+ }
1935
+ if (next === '"' && !inDouble) {
1936
+ this.bashOnly('$"…"')
1937
+ // `$"…"` is translated through the message catalogue at runtime.
1938
+ const inner = new WordBuilder()
1939
+ const end = this.doubleQuoted(n + 1, inner)
1940
+ builder.expansion(src.slice(at, end))
1941
+ builder.quoted = true
1942
+ if (inner.expands) builder.expands = true
1943
+ return end
1944
+ }
1945
+ if (next === '{') {
1946
+ const n2 = this.cont(n + 1)
1947
+ const inner = src[n2]
1948
+ if (inner === ' ' || inner === '\t' || inner === '\n' || inner === '|') {
1949
+ // Bash 5.3's `${ list; }` and `${| list; }`: a command
1950
+ // substitution that runs in the current shell.
1951
+ this.context.opaque('command substitution')
1952
+ const parser = new Parser(
1953
+ src,
1954
+ inner === '|' ? n2 + 1 : n2,
1955
+ this.context,
1956
+ 'substitution',
1957
+ this.depth,
1958
+ this.nestingNow + 1,
1959
+ )
1960
+ const end = parser.braceSubstitution()
1961
+ builder.expansion(src.slice(at, end))
1962
+ return end
1963
+ }
1964
+ const end = this.parameterBraces(n + 1, inDouble)
1965
+ builder.expansion(src.slice(at, end))
1966
+ return end
1967
+ }
1968
+ if (next === '(') {
1969
+ const n2 = this.cont(n + 1)
1970
+ if (src[n2] === '(') {
1971
+ const end = this.scanArithmetic(n2 + 1)
1972
+ if (end >= 0) {
1973
+ this.arithmeticContent(src.slice(n2 + 1, end - 2), n2 + 1)
1974
+ builder.expansion(src.slice(at, end))
1975
+ return end
1976
+ }
1977
+ }
1978
+ this.context.opaque('command substitution')
1979
+ const inner = new Parser(
1980
+ src,
1981
+ n + 1,
1982
+ this.context,
1983
+ 'substitution',
1984
+ this.depth,
1985
+ this.nestingNow + 1,
1986
+ )
1987
+ const end = inner.substitution()
1988
+ builder.expansion(src.slice(at, end))
1989
+ return end
1990
+ }
1991
+ if (next === '[') {
1992
+ this.bashOnly('$[…]')
1993
+ const end = this.matchBracket(n + 1)
1994
+ this.arithmeticContent(src.slice(n + 1, end - 1), n + 1)
1995
+ builder.expansion(src.slice(at, end))
1996
+ return end
1997
+ }
1998
+ if (next !== undefined && NAME_START.test(next)) {
1999
+ let i = n + 1
2000
+ for (;;) {
2001
+ i = this.cont(i)
2002
+ if (i < src.length && NAME_CHAR.test(src[i] as string)) i += 1
2003
+ else break
2004
+ }
2005
+ builder.expansion(src.slice(at, i))
2006
+ return i
2007
+ }
2008
+ if (next === '$' && inDouble) {
2009
+ // Bash's parser pairs `$$` inside double quotes, so a `(` or `{`
2010
+ // after it is literal text as far as the extent of the string goes.
2011
+ // Its expander does not: it re-scans the string and reads the
2012
+ // second `$` as the start of `$(…)` or `${…}`, and so
2013
+ // `"$${x:-"'$(cmd)'"}"` runs `cmd`, which the parse had seen as
2014
+ // single-quoted. Nothing read from the source can say what that
2015
+ // runs, so the line is opaque.
2016
+ const after = src[this.cont(n + 1)]
2017
+ if (after === '(' || after === '{') this.context.opaque('$$ before ( or { in double quotes')
2018
+ }
2019
+ if (next !== undefined && /[0-9$?!#@*-]/.test(next)) {
2020
+ builder.expansion(src.slice(at, n + 1))
2021
+ return n + 1
2022
+ }
2023
+ // A lone `$` is literal.
2024
+ if (inDouble) builder.quotedText('$')
2025
+ else builder.unquoted('$')
2026
+ return at + 1
2027
+ }
2028
+
2029
+ /** `$[…]`: returns the offset past the matching `]`. */
2030
+ private matchBracket(from: number): number {
2031
+ const src = this.src
2032
+ let depth = 0
2033
+ let i = from
2034
+ for (;;) {
2035
+ i = this.cont(i)
2036
+ if (i >= src.length) throw new Stop('unterminated $[')
2037
+ const char = src[i] as string
2038
+ if (char === '\\') {
2039
+ i += 2
2040
+ continue
2041
+ }
2042
+ if (char === '[') depth += 1
2043
+ else if (char === ']') {
2044
+ if (depth === 0) return i + 1
2045
+ depth -= 1
2046
+ }
2047
+ i += 1
2048
+ }
2049
+ }
2050
+
2051
+ /**
2052
+ * After `${`: returns the offset past the matching `}`. The content is
2053
+ * checked against the forms whose evaluation runs nothing; anything else
2054
+ * (indirection, a subscript or offset evaluated as arithmetic, a
2055
+ * transformation) is opaque.
2056
+ */
2057
+ private parameterBraces(from: number, inDouble: boolean): number {
2058
+ const src = this.src
2059
+ let i = from
2060
+ const scratch = new WordBuilder()
2061
+ for (;;) {
2062
+ i = this.cont(i)
2063
+ if (i >= src.length) throw new Stop('unterminated ${')
2064
+ const char = src[i] as string
2065
+ if (char === '}') break
2066
+ if (char === '\\') {
2067
+ i += 2
2068
+ continue
2069
+ }
2070
+ if (char === "'") {
2071
+ if (inDouble) {
2072
+ // Whether a single quote inside `${…}` inside double quotes
2073
+ // quotes depends on the operator and the shell's mode.
2074
+ this.context.opaque('single quote in parameter expansion')
2075
+ i += 1
2076
+ continue
2077
+ }
2078
+ const close = src.indexOf("'", i + 1)
2079
+ if (close < 0) throw new Stop('unterminated quote')
2080
+ this.singleQuoted(i, close)
2081
+ i = close + 1
2082
+ continue
2083
+ }
2084
+ if (char === '"') {
2085
+ i = this.doubleQuoted(i + 1, scratch)
2086
+ continue
2087
+ }
2088
+ if (char === '`') {
2089
+ i = this.backtick(i, scratch, inDouble)
2090
+ continue
2091
+ }
2092
+ if (char === '$') {
2093
+ i = this.dollar(i, scratch, inDouble)
2094
+ continue
2095
+ }
2096
+ if ((char === '<' || char === '>') && src[this.cont(i + 1)] === '(') {
2097
+ // Measured: unquoted, `${x:-<(cmd)}` runs `cmd`. Inside double
2098
+ // quotes bash still parses it, and rejects a malformed one.
2099
+ i = this.processSubstitution(i, scratch)
2100
+ continue
2101
+ }
2102
+ i += 1
2103
+ }
2104
+ const content = src.slice(from, i).replace(/\\\n/g, '')
2105
+ if (!SAFE_PARAMETER.test(content)) this.context.opaque('parameter expansion')
2106
+ else if (!POSIX_PARAMETER.test(content)) this.bashOnly('parameter expansion')
2107
+ return i + 1
2108
+ }
2109
+
2110
+ /** After `$'`: decode to the closing quote. */
2111
+ private ansiC(from: number, builder: WordBuilder): number {
2112
+ const src = this.src
2113
+ let i = from
2114
+ let decoded = ''
2115
+ let exact = true
2116
+ for (;;) {
2117
+ if (i >= src.length) throw new Stop('unterminated quote')
2118
+ const char = src[i] as string
2119
+ if (char === "'") break
2120
+ if (char !== '\\') {
2121
+ decoded += char
2122
+ i += 1
2123
+ continue
2124
+ }
2125
+ const letter = src[i + 1]
2126
+ if (letter === undefined) throw new Stop('unterminated quote')
2127
+ i += 2
2128
+ const simple = ANSI_C_SIMPLE[letter]
2129
+ if (simple !== undefined) {
2130
+ decoded += simple
2131
+ continue
2132
+ }
2133
+ if (letter >= '0' && letter <= '7') {
2134
+ let digits = letter
2135
+ while (digits.length < 3 && /[0-7]/.test(src[i] ?? '')) {
2136
+ digits += src[i]
2137
+ i += 1
2138
+ }
2139
+ const code = Number.parseInt(digits, 8) & 0xff
2140
+ if (code === 0 || code > 0x7f) exact = false
2141
+ decoded += String.fromCharCode(code)
2142
+ continue
2143
+ }
2144
+ if (letter === 'x' && src[i] === '{') {
2145
+ // `\x{HHH…}`: any number of digits, the closing brace optional,
2146
+ // the value truncated to a byte.
2147
+ i += 1
2148
+ let digits = ''
2149
+ while (/[0-9A-Fa-f]/.test(src[i] ?? '')) {
2150
+ digits += src[i]
2151
+ i += 1
2152
+ }
2153
+ if (src[i] === '}') i += 1
2154
+ const code = digits === '' ? 0 : Number.parseInt(digits.slice(-2), 16)
2155
+ if (code === 0 || code > 0x7f) exact = false
2156
+ decoded += String.fromCharCode(code)
2157
+ continue
2158
+ }
2159
+ if (letter === 'x') {
2160
+ let digits = ''
2161
+ while (digits.length < 2 && /[0-9A-Fa-f]/.test(src[i] ?? '')) {
2162
+ digits += src[i]
2163
+ i += 1
2164
+ }
2165
+ if (digits === '') {
2166
+ decoded += '\\x'
2167
+ continue
2168
+ }
2169
+ const code = Number.parseInt(digits, 16)
2170
+ if (code === 0 || code > 0x7f) exact = false
2171
+ decoded += String.fromCharCode(code)
2172
+ continue
2173
+ }
2174
+ if (letter === 'u' || letter === 'U') {
2175
+ const max = letter === 'u' ? 4 : 8
2176
+ let digits = ''
2177
+ while (digits.length < max && /[0-9A-Fa-f]/.test(src[i] ?? '')) {
2178
+ digits += src[i]
2179
+ i += 1
2180
+ }
2181
+ if (digits === '') {
2182
+ decoded += `\\${letter}`
2183
+ continue
2184
+ }
2185
+ const code = Number.parseInt(digits, 16)
2186
+ // Beyond ASCII the result depends on the locale's encoding.
2187
+ if (code === 0 || code > 0x7f) exact = false
2188
+ decoded += code <= 0x10ffff ? String.fromCodePoint(code) : ''
2189
+ continue
2190
+ }
2191
+ if (letter === 'c') {
2192
+ const target = src[i]
2193
+ if (target === undefined || target === "'") {
2194
+ // `\c` with nothing to control: bash keeps it.
2195
+ decoded += '\\c'
2196
+ continue
2197
+ }
2198
+ i += 1
2199
+ if (target === '\\' && src[i] === '\\') i += 1
2200
+ const code = target === '?' ? 0x7f : target.toUpperCase().charCodeAt(0) & 0x1f
2201
+ if (code === 0 || target.charCodeAt(0) > 0x7f) exact = false
2202
+ decoded += String.fromCharCode(code)
2203
+ continue
2204
+ }
2205
+ // Unknown escapes stay as written.
2206
+ decoded += `\\${letter}`
2207
+ }
2208
+ this.singleQuoted(from - 1, i)
2209
+ builder.quotedText(decoded)
2210
+ if (!exact) builder.expands = true
2211
+ return i + 1
2212
+ }
2213
+
2214
+ /**
2215
+ * A backtick substitution starting at `at`. The body is unescaped the way
2216
+ * bash does it and read as a command line of its own. Returns the offset
2217
+ * past the closing backtick.
2218
+ */
2219
+ backtick(at: number, builder: WordBuilder, inDouble: boolean): number {
2220
+ const src = this.src
2221
+ let i = at + 1
2222
+ let body = ''
2223
+ for (;;) {
2224
+ if (i >= src.length) throw new Stop('unterminated backtick')
2225
+ const char = src[i] as string
2226
+ if (char === '`') break
2227
+ if (char === '\\') {
2228
+ const next = src[i + 1]
2229
+ if (next === '$' || next === '`' || next === '\\' || (inDouble && next === '"')) {
2230
+ body += next
2231
+ i += 2
2232
+ continue
2233
+ }
2234
+ body += char
2235
+ i += 1
2236
+ continue
2237
+ }
2238
+ body += char
2239
+ i += 1
2240
+ }
2241
+ this.context.opaque('command substitution')
2242
+ builder.expansion(src.slice(at, i + 1))
2243
+ this.context.rescan(body.length)
2244
+ this.inherit(body)
2245
+ const inner = new Parser(body, 0, this.context, 'substitution', this.depth, this.nestingNow + 1)
2246
+ try {
2247
+ inner.program()
2248
+ } catch (error) {
2249
+ if (!(error instanceof Stop)) throw error
2250
+ }
2251
+ return i + 1
2252
+ }
2253
+
2254
+ /** `<(…)` or `>(…)` at `at`. */
2255
+ private processSubstitution(at: number, builder: WordBuilder): number {
2256
+ const open = this.cont(at + 1)
2257
+ this.context.opaque('process substitution')
2258
+ const inner = new Parser(
2259
+ this.src,
2260
+ open + 1,
2261
+ this.context,
2262
+ 'substitution',
2263
+ this.depth,
2264
+ this.nestingNow + 1,
2265
+ )
2266
+ const end = inner.substitution()
2267
+ builder.expansion(this.src.slice(at, end))
2268
+ return end
2269
+ }
2270
+ }
2271
+
2272
+ /**
2273
+ * `${…}` contents that evaluate nothing but a parameter: a name, a positional
2274
+ * or special parameter, optionally its length, a literal subscript, a literal
2275
+ * substring offset, or one of the default/assign/error/alternative, pattern
2276
+ * removal, substitution and case operators followed by any word (the word
2277
+ * was already read for substitutions).
2278
+ */
2279
+ const SAFE_PARAMETER =
2280
+ /^(?:#?(?:[A-Za-z_][A-Za-z0-9_]*(?:\[(?:\d+|@|\*)\])?|\d+|[@*#?$!-])(?:(?::?[-=+?]|##?|%%?|\/[/#%]?|\^\^?|,,?)[\s\S]*|:\s*-?\d+\s*(?::\s*-?\d+\s*)?)?)$/
2281
+
2282
+ /**
2283
+ * `${…}` forms POSIX defines, with a word free of quotes and escapes, whose
2284
+ * handling inside `${…}` differs between shells.
2285
+ */
2286
+ const POSIX_PARAMETER =
2287
+ /^(?:#?(?:[A-Za-z_][A-Za-z0-9_]*|\d+|[@*#?$!-])|(?:[A-Za-z_][A-Za-z0-9_]*|\d+|[@*#?$!-])(?::?[-=+?]|##?|%%?)[^'"\\`]*)$/
2288
+
2289
+ const ANSI_C_SIMPLE: Readonly<Record<string, string>> = {
2290
+ a: '\x07',
2291
+ b: '\b',
2292
+ e: '\x1b',
2293
+ E: '\x1b',
2294
+ f: '\f',
2295
+ n: '\n',
2296
+ r: '\r',
2297
+ t: '\t',
2298
+ v: '\v',
2299
+ '\\': '\\',
2300
+ "'": "'",
2301
+ '"': '"',
2302
+ '?': '?',
2303
+ }
2304
+
2305
+ /** Source text with its line continuations removed, as bash reads it. */
2306
+ function joined(text: string): string {
2307
+ return text.includes('\\\n') ? text.replace(/\\\n/g, '') : text
2308
+ }
2309
+
2310
+ function trailingBackslashes(text: string): number {
2311
+ let count = 0
2312
+ for (let i = text.length - 1; i >= 0 && text[i] === '\\'; i -= 1) count += 1
2313
+ return count
2314
+ }
2315
+
2316
+ export function basename(word: string): string {
2317
+ const cut = word.lastIndexOf('/')
2318
+ return cut < 0 ? word : word.slice(cut + 1)
2319
+ }