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