redac---n 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +323 -2
- package/THIRD_PARTY_NOTICES.md +59 -0
- package/dist/aho.d.ts +25 -0
- package/dist/aho.js +116 -0
- package/dist/base64.d.ts +10 -0
- package/dist/base64.js +29 -0
- package/dist/bip39-data.d.ts +2 -0
- package/dist/bip39-data.js +2 -0
- package/dist/cache.d.ts +14 -0
- package/dist/cache.js +40 -0
- package/dist/calls.d.ts +64 -0
- package/dist/calls.js +269 -0
- package/dist/charlm-data.d.ts +2 -0
- package/dist/charlm-data.js +3 -0
- package/dist/charlm.d.ts +7 -0
- package/dist/charlm.js +65 -0
- package/dist/checksums.d.ts +10 -0
- package/dist/checksums.js +98 -0
- package/dist/commands.d.ts +72 -0
- package/dist/commands.js +787 -0
- package/dist/context.d.ts +98 -0
- package/dist/context.js +469 -0
- package/dist/encoded.d.ts +11 -0
- package/dist/encoded.js +336 -0
- package/dist/environment.d.ts +10 -0
- package/dist/environment.js +62 -0
- package/dist/formats-data.d.ts +2 -0
- package/dist/formats-data.js +405 -0
- package/dist/formats.d.ts +5 -0
- package/dist/formats.js +470 -0
- package/dist/hmac.d.ts +3 -0
- package/dist/hmac.js +87 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +488 -0
- package/dist/json.d.ts +82 -0
- package/dist/json.js +392 -0
- package/dist/known.d.ts +34 -0
- package/dist/known.js +340 -0
- package/dist/lines.d.ts +28 -0
- package/dist/lines.js +69 -0
- package/dist/lookup.d.ts +5 -0
- package/dist/lookup.js +7 -0
- package/dist/model-data.d.ts +3 -0
- package/dist/model-data.js +4 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.js +19 -0
- package/dist/named.d.ts +20 -0
- package/dist/named.js +3257 -0
- package/dist/names.d.ts +106 -0
- package/dist/names.js +1517 -0
- package/dist/normalize.d.ts +25 -0
- package/dist/normalize.js +529 -0
- package/dist/pem.d.ts +32 -0
- package/dist/pem.js +269 -0
- package/dist/policy.d.ts +3 -0
- package/dist/policy.js +470 -0
- package/dist/public.d.ts +44 -0
- package/dist/public.js +362 -0
- package/dist/publickeys.d.ts +41 -0
- package/dist/publickeys.js +232 -0
- package/dist/random.d.ts +29 -0
- package/dist/random.js +165 -0
- package/dist/repeats.d.ts +21 -0
- package/dist/repeats.js +80 -0
- package/dist/rules.d.ts +42 -0
- package/dist/rules.js +58 -0
- package/dist/runs.d.ts +10 -0
- package/dist/runs.js +36 -0
- package/dist/secretfile.d.ts +52 -0
- package/dist/secretfile.js +722 -0
- package/dist/shell.d.ts +127 -0
- package/dist/shell.js +703 -0
- package/dist/spans.d.ts +61 -0
- package/dist/spans.js +287 -0
- package/dist/sql.d.ts +20 -0
- package/dist/sql.js +66 -0
- package/dist/stream.d.ts +46 -0
- package/dist/stream.js +150 -0
- package/dist/structure.d.ts +24 -0
- package/dist/structure.js +1625 -0
- package/dist/syntax.d.ts +156 -0
- package/dist/syntax.js +446 -0
- package/dist/transcript.d.ts +30 -0
- package/dist/transcript.js +713 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.js +1 -0
- package/dist/unnamed.d.ts +59 -0
- package/dist/unnamed.js +939 -0
- package/dist/urls.d.ts +70 -0
- package/dist/urls.js +303 -0
- package/dist/values.d.ts +178 -0
- package/dist/values.js +1621 -0
- package/dist/yaml.d.ts +40 -0
- package/dist/yaml.js +174 -0
- package/docs/creddata.svg +318 -0
- package/docs/guessing.md +139 -0
- package/docs/holdout.svg +85 -0
- package/docs/layers.md +80 -0
- package/docs/models.svg +81 -0
- package/docs/real-text.svg +112 -0
- package/package.json +57 -5
package/dist/shell.js
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shell command line's commands and words, read by one lexer (lexWord) the way bash reads them: blanks separate words outside
|
|
3
|
+
* quotes; `'…'` quotes everything, `"…"` and a bare backslash escape a character, `$'…'` decodes bash's escapes, and a
|
|
4
|
+
* backslash before a line break removes both; pieces next to each other are one word (`--password='x y'`); expansions
|
|
5
|
+
* (`$(…)`, backticks, `${…}`, `<(…)`) stay in the word as written, and the commands they run are noted; `#` starting a
|
|
6
|
+
* word comments out the rest of the line. `|`, `|&`, `||`, `&&`, `;`, `&` and line breaks end a command; redirections
|
|
7
|
+
* (`>`, `>>`, `2>&1`, `&>`, `<`, `<<EOF`, `<<-`, `<<<`) are read with their targets, a heredoc's body off the lines after
|
|
8
|
+
* its command; and a group (`{ …; }`, `( … )`, a loop, an `if` or a `case`) passes its redirections and its pipe on
|
|
9
|
+
* to the commands inside it. Not read: arithmetic (`$((1 + 2))` stays in its word as written), functions, and a `case`
|
|
10
|
+
* pattern (`x)` is dropped, not run). A quote that doesn't close runs to the end of the text, into the word it opened in:
|
|
11
|
+
* bash rejects such a line, and what follows it is not a command of its own.
|
|
12
|
+
*/
|
|
13
|
+
import { lookup } from "./lookup.js";
|
|
14
|
+
import { lastText, lineEnd, lowerBound } from "./lines.js";
|
|
15
|
+
import { UNQUOTED_READ } from "./syntax.js";
|
|
16
|
+
/**
|
|
17
|
+
* The redirection operators, in the order the lexer tries them (one before the operator it starts with), and what each
|
|
18
|
+
* sends to its target: standard output (`>`), standard output and error (`&>`, and `>&` onto a file), or no output.
|
|
19
|
+
*/
|
|
20
|
+
const REDIRECT_OPERATORS = lookup({
|
|
21
|
+
"<<<": "none",
|
|
22
|
+
"<<-": "none",
|
|
23
|
+
"<<": "none",
|
|
24
|
+
"<>": "none",
|
|
25
|
+
"<&": "none",
|
|
26
|
+
">&": "both",
|
|
27
|
+
">>": "stdout",
|
|
28
|
+
">|": "stdout",
|
|
29
|
+
"&>>": "both",
|
|
30
|
+
"&>": "both",
|
|
31
|
+
"<": "none",
|
|
32
|
+
">": "stdout",
|
|
33
|
+
});
|
|
34
|
+
/** A redirection, at the start of a text, as the lexer reads it: its file descriptor (group 1 is the operator after it). `<(` and `>(` are process substitutions, words. */
|
|
35
|
+
const REDIRECT = new RegExp(String.raw `^\d*(${Object.keys(REDIRECT_OPERATORS)
|
|
36
|
+
.map((op) => op.replace("|", String.raw `\|`) + (op.length === 1 ? String.raw `(?!\()` : ""))
|
|
37
|
+
.join("|")})`);
|
|
38
|
+
/** bash's one-character escapes in `$'…'`. */
|
|
39
|
+
const ANSI_C = {
|
|
40
|
+
n: "\n",
|
|
41
|
+
t: "\t",
|
|
42
|
+
r: "\r",
|
|
43
|
+
a: "\x07",
|
|
44
|
+
b: "\b",
|
|
45
|
+
e: "\x1b",
|
|
46
|
+
E: "\x1b",
|
|
47
|
+
f: "\f",
|
|
48
|
+
v: "\v",
|
|
49
|
+
"\\": "\\",
|
|
50
|
+
"'": "'",
|
|
51
|
+
'"': '"',
|
|
52
|
+
"?": "?",
|
|
53
|
+
};
|
|
54
|
+
/** The escape whose backslash is at `at` in `$'…'`, which ends at `end`: what it decodes to and how long it is (`\n`, `\x41`, `\101`, `\u00e9`, `\cA`). */
|
|
55
|
+
function ansiEscape(text, at, end) {
|
|
56
|
+
const rest = text.slice(at + 1, Math.min(at + 10, end));
|
|
57
|
+
const m = /^(?:x([0-9a-fA-F]{1,2})|u([0-9a-fA-F]{1,4})|U([0-9a-fA-F]{1,8})|([0-7]{1,3}))/.exec(rest);
|
|
58
|
+
if (m) {
|
|
59
|
+
const code = parseInt(m[1] ?? m[2] ?? m[3] ?? m[4], m[4] !== undefined ? 8 : 16);
|
|
60
|
+
return [code <= 0x10ffff ? String.fromCodePoint(code) : "", 1 + m[0].length];
|
|
61
|
+
}
|
|
62
|
+
if (rest[0] === "c" && rest[1] !== undefined)
|
|
63
|
+
return [String.fromCharCode(rest.charCodeAt(1) & 0x1f), 3];
|
|
64
|
+
const simple = ANSI_C[rest[0]];
|
|
65
|
+
return simple === undefined ? [`\\${rest[0] ?? ""}`, 2] : [simple, 2];
|
|
66
|
+
}
|
|
67
|
+
/** Words that open a group, and the word or operator that closes each. */
|
|
68
|
+
const GROUP_CLOSER = lookup({
|
|
69
|
+
"(": ")",
|
|
70
|
+
"{": "}",
|
|
71
|
+
if: "fi",
|
|
72
|
+
for: "done",
|
|
73
|
+
while: "done",
|
|
74
|
+
until: "done",
|
|
75
|
+
select: "done",
|
|
76
|
+
case: "esac",
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* Where each short line of a text starts, by its content (and by its content without leading tabs): a heredoc's
|
|
80
|
+
* terminator is then a lookup, so many heredoc lines in a text cost each body once.
|
|
81
|
+
*/
|
|
82
|
+
export class LineIndex {
|
|
83
|
+
exact = new Map();
|
|
84
|
+
stripped = new Map();
|
|
85
|
+
constructor(text) {
|
|
86
|
+
for (let start = 0; start < text.length;) {
|
|
87
|
+
const nl = text.indexOf("\n", start);
|
|
88
|
+
const end = nl < 0 ? text.length : nl;
|
|
89
|
+
if (end - start <= 200) {
|
|
90
|
+
const line = text.slice(start, text[end - 1] === "\r" ? end - 1 : end);
|
|
91
|
+
for (const [map, key] of [
|
|
92
|
+
[this.exact, line],
|
|
93
|
+
[this.stripped, line.replace(/^\t+/, "")],
|
|
94
|
+
]) {
|
|
95
|
+
const starts = map.get(key);
|
|
96
|
+
if (starts)
|
|
97
|
+
starts.push(start);
|
|
98
|
+
else
|
|
99
|
+
map.set(key, [start]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
start = end + 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** The first line at or after `from` that is `line` (with `strip`, after its leading tabs), or -1. */
|
|
106
|
+
next(line, strip, from) {
|
|
107
|
+
const starts = (strip ? this.stripped : this.exact).get(line);
|
|
108
|
+
return starts?.[lowerBound(starts, from)] ?? -1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The line index of the last text asked about, for a heredoc search whose caller gave none: many lines of one text then
|
|
113
|
+
* cost the text once.
|
|
114
|
+
*/
|
|
115
|
+
const lineIndex = lastText((text) => new LineIndex(text));
|
|
116
|
+
/** Whether an expansion opens at `at`: `$(`, `${` or a backtick. */
|
|
117
|
+
const opensExpansion = (text, at) => text[at] === "`" || (text[at] === "$" && (text[at + 1] === "(" || text[at + 1] === "{"));
|
|
118
|
+
/** Where the expansion opened at `at` (see opensExpansion) closes, or -1. */
|
|
119
|
+
function expansionEnd(lx, at, depth) {
|
|
120
|
+
if (lx.text[at] === "`")
|
|
121
|
+
return closing(lx, at + 1, "`", depth + 1);
|
|
122
|
+
return closing(lx, at + 2, lx.text[at + 1] === "(" ? ")" : "}", depth + 1);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Where the quote of the given kind (`'`, `"` or `$'`) whose content starts at `from` closes: the index of its closing
|
|
126
|
+
* quote, past what the quote reads whole (the escapes in `"…"` and `$'…'`, the expansions in `"…"`); -1 if it doesn't close
|
|
127
|
+
* within the limit. The one place the quotes' grammar is written: lexWord reads a quote's content up to it, and `closing`
|
|
128
|
+
* steps over a quote with it.
|
|
129
|
+
*/
|
|
130
|
+
function quoteEnd(lx, from, quote, depth = 0) {
|
|
131
|
+
const { text, limit } = lx;
|
|
132
|
+
if (quote === "'") {
|
|
133
|
+
const close = text.indexOf("'", from);
|
|
134
|
+
return close < 0 || close >= limit ? -1 : close;
|
|
135
|
+
}
|
|
136
|
+
const closer = quote === '"' ? '"' : "'";
|
|
137
|
+
for (let j = from; j < limit; j++) {
|
|
138
|
+
const ch = text[j];
|
|
139
|
+
if (ch === "\\" && (lx.backslash || quote === "$'"))
|
|
140
|
+
j++;
|
|
141
|
+
else if (ch === closer)
|
|
142
|
+
return j;
|
|
143
|
+
else if (quote === '"' && opensExpansion(text, j)) {
|
|
144
|
+
j = expansionEnd(lx, j, depth);
|
|
145
|
+
if (j < 0)
|
|
146
|
+
return -1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return -1;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* `case` or `esac` where a command starts at `at` (first in the text from `from`, or after a blank run that follows `;`, `&`,
|
|
153
|
+
* `|`, `(` or a line break): `echo case` and `showcase` are not it.
|
|
154
|
+
*/
|
|
155
|
+
function caseWord(text, at, from) {
|
|
156
|
+
const word = text.startsWith("case", at) ? "case" : text.startsWith("esac", at) ? "esac" : undefined;
|
|
157
|
+
if (!word || /[\w.-]/.test(text[at + 4] ?? ""))
|
|
158
|
+
return undefined;
|
|
159
|
+
let k = at;
|
|
160
|
+
while (k > from && (text[k - 1] === " " || text[k - 1] === "\t"))
|
|
161
|
+
k--;
|
|
162
|
+
return k === from || ";&|(\n".includes(text[k - 1]) ? word : undefined;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Where what an expansion opened at `from` (after its opener) closes: the index of `close`, through nested expansions,
|
|
166
|
+
* quotes and escapes, as the shell reads them; -1 if it doesn't within the limit. In a `$(…)`, the `)` that ends a `case`
|
|
167
|
+
* pattern (`$(case x in x) cat .env;; esac)`) is not its end.
|
|
168
|
+
*/
|
|
169
|
+
function closing(lx, from, close, depth = 0) {
|
|
170
|
+
if (depth > 32)
|
|
171
|
+
return -1;
|
|
172
|
+
const { text, limit } = lx;
|
|
173
|
+
let parens = 0;
|
|
174
|
+
let cases = 0;
|
|
175
|
+
// Heredocs opened on the current line: their bodies, from the next line, are text, not code (`$(cat <<'EOF'` with a
|
|
176
|
+
// `don't` in the body).
|
|
177
|
+
let heredocs = [];
|
|
178
|
+
for (let j = from; j < limit; j++) {
|
|
179
|
+
const ch = text[j];
|
|
180
|
+
if (ch === "\\" && lx.backslash)
|
|
181
|
+
j++;
|
|
182
|
+
else if (close === "`") {
|
|
183
|
+
if (ch === "`")
|
|
184
|
+
return j;
|
|
185
|
+
}
|
|
186
|
+
else if (close === ")" && ch === "<" && text[j - 1] !== "<") {
|
|
187
|
+
// (Not a here-string, `<<<`, at its second `<`; at its first, no delimiter follows the `<<`. A delimiter quoted,
|
|
188
|
+
// `<<'EOF'`, `<<"EOF"`, or escaped, `<<\EOF`.)
|
|
189
|
+
const h = /^<<(-?)[ \t]*(?:\\|(["']?))([\w.-]{1,80})\2/.exec(text.slice(j, j + 100));
|
|
190
|
+
if (h) {
|
|
191
|
+
heredocs.push({ delimiter: h[3], strip: h[1] === "-" });
|
|
192
|
+
j += h[0].length - 1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else if (ch === "\n" && heredocs.length) {
|
|
196
|
+
for (const h of heredocs) {
|
|
197
|
+
// (Each body runs to its delimiter's line; one that never ends runs past the limit, and nothing closes.)
|
|
198
|
+
let line = j + 1;
|
|
199
|
+
for (;;) {
|
|
200
|
+
if (line >= limit)
|
|
201
|
+
return -1;
|
|
202
|
+
const nl = text.indexOf("\n", line);
|
|
203
|
+
const body = text.slice(line, nl < 0 ? limit : nl).replace(/\r$/, "");
|
|
204
|
+
line = nl < 0 ? limit : nl + 1;
|
|
205
|
+
if ((h.strip ? body.replace(/^\t+/, "") : body) === h.delimiter)
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
j = line - 1;
|
|
209
|
+
}
|
|
210
|
+
heredocs = [];
|
|
211
|
+
}
|
|
212
|
+
else if (close === ")" && ch === "#" && (j === from || /[ \t\n;&|(]/.test(text[j - 1]))) {
|
|
213
|
+
// A comment runs to the line's end; its quotes are prose.
|
|
214
|
+
const nl = text.indexOf("\n", j);
|
|
215
|
+
if (nl < 0 || nl >= limit)
|
|
216
|
+
return -1;
|
|
217
|
+
j = nl - 1;
|
|
218
|
+
}
|
|
219
|
+
else if (ch === "'" || ch === '"' || (ch === "$" && text[j + 1] === "'")) {
|
|
220
|
+
j = quoteEnd(lx, j + (ch === "$" ? 2 : 1), ch === "$" ? "$'" : ch, depth);
|
|
221
|
+
if (j < 0)
|
|
222
|
+
return -1;
|
|
223
|
+
}
|
|
224
|
+
else if (opensExpansion(text, j)) {
|
|
225
|
+
j = expansionEnd(lx, j, depth);
|
|
226
|
+
if (j < 0)
|
|
227
|
+
return -1;
|
|
228
|
+
}
|
|
229
|
+
else if (close === ")" && (ch === "c" || ch === "e") && caseWord(text, j, from)) {
|
|
230
|
+
cases = Math.max(0, cases + (ch === "c" ? 1 : -1));
|
|
231
|
+
j += 3;
|
|
232
|
+
}
|
|
233
|
+
else if (close === ")" && ch === ")" && parens === 0 && cases > 0)
|
|
234
|
+
continue;
|
|
235
|
+
else if (close === ")" && ch === "(")
|
|
236
|
+
parens++;
|
|
237
|
+
else if (ch === close && parens-- === 0)
|
|
238
|
+
return j;
|
|
239
|
+
}
|
|
240
|
+
return -1;
|
|
241
|
+
}
|
|
242
|
+
/** Whether the `$(` at `at` opens arithmetic, `$((1 + 2))`, and not a subshell in a substitution, `$((cat .env) | head)`: the second `(` closes right before the first. */
|
|
243
|
+
function isArithmetic(lx, at) {
|
|
244
|
+
if (!lx.text.startsWith("$((", at))
|
|
245
|
+
return false;
|
|
246
|
+
const inner = closing(lx, at + 3, ")");
|
|
247
|
+
return inner >= 0 && lx.text[inner + 1] === ")";
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The word at `from`, to a blank, a line break or an operator outside quotes; its `end` is where the next thing starts.
|
|
251
|
+
* Each quote is read to its end (`quoteEnd`), an expansion as written; one that doesn't close runs to the limit.
|
|
252
|
+
*/
|
|
253
|
+
function lexWord(lx, from) {
|
|
254
|
+
const { text, limit, backslash, offset } = lx;
|
|
255
|
+
let i = from;
|
|
256
|
+
let s = "";
|
|
257
|
+
const map = [];
|
|
258
|
+
const ends = [];
|
|
259
|
+
const substitutions = [];
|
|
260
|
+
let quoted = false;
|
|
261
|
+
let literalEnd = false;
|
|
262
|
+
/** A character of the word, written at `[at, to)`. */
|
|
263
|
+
const put = (ch, at, to = at + 1) => {
|
|
264
|
+
s += ch;
|
|
265
|
+
map.push(offset + at);
|
|
266
|
+
ends.push(offset + to);
|
|
267
|
+
};
|
|
268
|
+
// An expansion is copied as written; the command a substitution runs is noted. (Unclosed, it runs to the limit.)
|
|
269
|
+
const expansion = (openLength, close, command) => {
|
|
270
|
+
const begin = i;
|
|
271
|
+
const closed = closing(lx, i + openLength, close);
|
|
272
|
+
const stop = closed < 0 ? limit : closed + 1;
|
|
273
|
+
if (command && !isArithmetic(lx, begin))
|
|
274
|
+
substitutions.push({ start: offset + begin + openLength, end: offset + (closed < 0 ? limit : closed) });
|
|
275
|
+
for (let k = begin; k < stop; k++)
|
|
276
|
+
put(text[k], k);
|
|
277
|
+
i = stop;
|
|
278
|
+
};
|
|
279
|
+
while (i < limit) {
|
|
280
|
+
const ch = text[i];
|
|
281
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r")
|
|
282
|
+
break;
|
|
283
|
+
if ((ch === "<" || ch === ">") && text[i + 1] === "(") {
|
|
284
|
+
expansion(2, ")", true);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (/[|;&()<>]/.test(ch))
|
|
288
|
+
break;
|
|
289
|
+
literalEnd = false;
|
|
290
|
+
if (ch === "\\" && backslash) {
|
|
291
|
+
if (text[i + 1] === "\n")
|
|
292
|
+
i += 2;
|
|
293
|
+
else if (i + 1 < limit) {
|
|
294
|
+
put(text[i + 1], i + 1);
|
|
295
|
+
i += 2;
|
|
296
|
+
}
|
|
297
|
+
else
|
|
298
|
+
i++;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (ch === "'") {
|
|
302
|
+
quoted = literalEnd = true;
|
|
303
|
+
const close = quoteEnd(lx, i + 1, "'");
|
|
304
|
+
const stop = close < 0 ? limit : close;
|
|
305
|
+
for (let k = i + 1; k < stop; k++)
|
|
306
|
+
put(text[k], k);
|
|
307
|
+
i = close < 0 ? stop : stop + 1;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (ch === "$" && text[i + 1] === "'") {
|
|
311
|
+
quoted = literalEnd = true;
|
|
312
|
+
i += 2;
|
|
313
|
+
const close = quoteEnd(lx, i, "$'");
|
|
314
|
+
const stop = close < 0 ? limit : close;
|
|
315
|
+
while (i < stop) {
|
|
316
|
+
if (text[i] === "\\" && i + 1 < stop) {
|
|
317
|
+
// (Every character it decodes to was written as the whole escape.)
|
|
318
|
+
const [decoded, length] = ansiEscape(text, i, stop);
|
|
319
|
+
for (let u = 0; u < decoded.length; u++)
|
|
320
|
+
put(decoded[u], i, i + length);
|
|
321
|
+
i += length;
|
|
322
|
+
}
|
|
323
|
+
else
|
|
324
|
+
put(text[i], i++);
|
|
325
|
+
}
|
|
326
|
+
i = close < 0 ? stop : stop + 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (ch === '"' || (ch === "$" && text[i + 1] === '"')) {
|
|
330
|
+
quoted = true;
|
|
331
|
+
i += ch === "$" ? 2 : 1;
|
|
332
|
+
const close = quoteEnd(lx, i, '"');
|
|
333
|
+
const stop = close < 0 ? limit : close;
|
|
334
|
+
while (i < stop) {
|
|
335
|
+
if (text[i] === "\\" && backslash && text[i + 1] === "\n")
|
|
336
|
+
i += 2;
|
|
337
|
+
else if (text[i] === "\\" && backslash && /["\\$`]/.test(text[i + 1] ?? "")) {
|
|
338
|
+
put(text[i + 1], i + 1);
|
|
339
|
+
i += 2;
|
|
340
|
+
}
|
|
341
|
+
else if (text[i] === "$" && text[i + 1] === "(")
|
|
342
|
+
expansion(2, ")", true);
|
|
343
|
+
else if (text[i] === "$" && text[i + 1] === "{")
|
|
344
|
+
expansion(2, "}", false);
|
|
345
|
+
else if (text[i] === "`")
|
|
346
|
+
expansion(1, "`", true);
|
|
347
|
+
else
|
|
348
|
+
put(text[i], i++);
|
|
349
|
+
}
|
|
350
|
+
i = close < 0 ? stop : stop + 1;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (ch === "$" && text[i + 1] === "(")
|
|
354
|
+
expansion(2, ")", true);
|
|
355
|
+
else if (ch === "$" && text[i + 1] === "{")
|
|
356
|
+
expansion(2, "}", false);
|
|
357
|
+
else if (ch === "`")
|
|
358
|
+
expansion(1, "`", true);
|
|
359
|
+
else
|
|
360
|
+
put(ch, i++);
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
text: s,
|
|
364
|
+
start: offset + from,
|
|
365
|
+
end: offset + i,
|
|
366
|
+
raw: text.slice(from, i),
|
|
367
|
+
quoted,
|
|
368
|
+
literalEnd,
|
|
369
|
+
map,
|
|
370
|
+
ends,
|
|
371
|
+
substitutions,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* The commands of a command line (or several: a line break ends a command), read from `at` to the end of `text`. With
|
|
376
|
+
* `oneLine`, through the logical line at `at` (continued by `\`-newlines, and by a quote that closes within a few lines)
|
|
377
|
+
* and its heredocs' bodies, as out of other text. With `backslash` false a backslash is a character like any other, as
|
|
378
|
+
* on Windows (`.\.env`, `\\srv\share`). A `lines` index of `text` finds heredocs' terminators by lookup.
|
|
379
|
+
*/
|
|
380
|
+
export function shellCommands(text, offset = 0, { backslash = true, oneLine = false, at = 0, lines, } = {}) {
|
|
381
|
+
const out = [];
|
|
382
|
+
let i = at;
|
|
383
|
+
let start = i;
|
|
384
|
+
let words = [];
|
|
385
|
+
let redirects = [];
|
|
386
|
+
let heredocs = [];
|
|
387
|
+
let pending = [];
|
|
388
|
+
/** Open groups, outermost first, each with where its commands start in `out`; a group just closed takes the redirections after it. */
|
|
389
|
+
const groups = [];
|
|
390
|
+
let closed;
|
|
391
|
+
// (One line read out of other text: a quote may run on for up to four more lines, and no further.)
|
|
392
|
+
let limit = text.length;
|
|
393
|
+
if (oneLine) {
|
|
394
|
+
limit = Math.min(text.length, at + UNQUOTED_READ);
|
|
395
|
+
for (let k = 0, nl = at; k < 5 && nl >= 0 && nl < limit; k++) {
|
|
396
|
+
nl = text.indexOf("\n", nl + 1);
|
|
397
|
+
if (k === 4 && nl >= 0)
|
|
398
|
+
limit = Math.min(limit, nl);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const lx = { text, limit, backslash, offset };
|
|
402
|
+
const readWord = () => {
|
|
403
|
+
const word = lexWord(lx, i);
|
|
404
|
+
i = word.end - offset;
|
|
405
|
+
return word;
|
|
406
|
+
};
|
|
407
|
+
const end = (then, to) => {
|
|
408
|
+
if (closed && !words.length) {
|
|
409
|
+
// The redirections and the operator after a group's closer are the group's.
|
|
410
|
+
closed.group.redirects.push(...redirects);
|
|
411
|
+
const last = out.at(-1);
|
|
412
|
+
if (last && out.length > closed.from)
|
|
413
|
+
last.then = then;
|
|
414
|
+
}
|
|
415
|
+
else if (words.length || redirects.length) {
|
|
416
|
+
out.push({
|
|
417
|
+
text: text.slice(start, to),
|
|
418
|
+
start: offset + start,
|
|
419
|
+
end: offset + to,
|
|
420
|
+
words,
|
|
421
|
+
redirects,
|
|
422
|
+
heredocs,
|
|
423
|
+
groups: groups.map((g) => g.group).reverse(),
|
|
424
|
+
then,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
closed = undefined;
|
|
428
|
+
words = [];
|
|
429
|
+
redirects = [];
|
|
430
|
+
heredocs = [];
|
|
431
|
+
};
|
|
432
|
+
const open = (kind) => groups.push({ group: { kind, redirects: [] }, from: out.length });
|
|
433
|
+
const close = (kind) => {
|
|
434
|
+
const k = groups.findLastIndex((g) => GROUP_CLOSER[g.group.kind] === kind);
|
|
435
|
+
if (k < 0)
|
|
436
|
+
return false;
|
|
437
|
+
closed = groups[k];
|
|
438
|
+
groups.length = k;
|
|
439
|
+
return true;
|
|
440
|
+
};
|
|
441
|
+
while (i < limit) {
|
|
442
|
+
const c = text[i];
|
|
443
|
+
if (c === " " || c === "\t" || c === "\r") {
|
|
444
|
+
i++;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (c === "\\" && backslash && text[i + 1] === "\n") {
|
|
448
|
+
i += 2;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
// (Here a word would start: `#` comments out the rest of the line; inside a word, `a#b`, it's a character.)
|
|
452
|
+
if (c === "#") {
|
|
453
|
+
while (i < limit && text[i] !== "\n")
|
|
454
|
+
i++;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (c === "\n") {
|
|
458
|
+
// (A line that ends in `|`, `&&` or `||` goes on to the next: `echo "$PW" |` then ` docker login --password-stdin`.)
|
|
459
|
+
const continued = !words.length && !redirects.length && /^(?:\||\|&|&&|\|\|)$/.test(out.at(-1)?.then ?? "");
|
|
460
|
+
end("\n", i);
|
|
461
|
+
i = heredocBodies(i + 1);
|
|
462
|
+
start = i;
|
|
463
|
+
if (oneLine && !continued)
|
|
464
|
+
break;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
// `<(…)` and `>(…)` are words: process substitutions.
|
|
468
|
+
const op = /^(?:\|\||\|&|&&|[|;&()])/.exec(text.slice(i, i + 2))?.[0];
|
|
469
|
+
if (op && !/^&>/.test(text.slice(i, i + 2))) {
|
|
470
|
+
const inCase = groups.at(-1)?.group.kind === "case" ? groups.at(-1) : undefined;
|
|
471
|
+
if (op === "|" && inCase?.patterns) {
|
|
472
|
+
// (A `case` pattern's alternative, `a|b) …`: no pipe.)
|
|
473
|
+
}
|
|
474
|
+
else if (op === ")" && inCase) {
|
|
475
|
+
// (A `case` pattern's `)`, `x) cat .env;;`: what came before it, the pattern, is no command.)
|
|
476
|
+
words = [];
|
|
477
|
+
redirects = [];
|
|
478
|
+
inCase.patterns = false;
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
if (op === ";" && text[i - 1] === ";" && inCase)
|
|
482
|
+
inCase.patterns = true;
|
|
483
|
+
end(op === "(" || op === ")" ? ";" : op, i);
|
|
484
|
+
if (op === "(")
|
|
485
|
+
open("(");
|
|
486
|
+
else if (op === ")")
|
|
487
|
+
close(")");
|
|
488
|
+
}
|
|
489
|
+
i += op.length;
|
|
490
|
+
start = i;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const redirect = REDIRECT.exec(text.slice(i, i + 12));
|
|
494
|
+
if (redirect) {
|
|
495
|
+
const from = i;
|
|
496
|
+
i += redirect[0].length;
|
|
497
|
+
while (text[i] === " " || text[i] === "\t")
|
|
498
|
+
i++;
|
|
499
|
+
// (A process substitution is a target too, a file its command writes or reads: `tee out.log < <(cat .env)`.)
|
|
500
|
+
const target = i < limit && (!/[\r\n;&|()<>]/.test(text[i]) || /^[<>]\(/.test(text.slice(i, i + 2))) ? readWord() : undefined;
|
|
501
|
+
const fd = /^\d+/.exec(redirect[0])?.[0];
|
|
502
|
+
redirects.push({
|
|
503
|
+
op: redirect[1],
|
|
504
|
+
...(fd ? { fd: Number(fd) } : {}),
|
|
505
|
+
target,
|
|
506
|
+
start: offset + from,
|
|
507
|
+
end: offset + i,
|
|
508
|
+
});
|
|
509
|
+
if (target && (redirect[1] === "<<" || redirect[1] === "<<-")) {
|
|
510
|
+
const heredoc = {
|
|
511
|
+
delimiter: target.text,
|
|
512
|
+
quoted: target.quoted || target.raw.includes("\\"),
|
|
513
|
+
strip: redirect[1] === "<<-",
|
|
514
|
+
start: -1,
|
|
515
|
+
end: -1,
|
|
516
|
+
terminated: false,
|
|
517
|
+
};
|
|
518
|
+
heredocs.push(heredoc);
|
|
519
|
+
pending.push(heredoc);
|
|
520
|
+
}
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const before = i;
|
|
524
|
+
const word = readWord();
|
|
525
|
+
// (A character no branch above reads is skipped: whatever the lexer stops at, every pass of this loop moves on.)
|
|
526
|
+
if (i === before) {
|
|
527
|
+
i++;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
// A reserved word in a command's first place (after `!`, `then`, `else`, `do`) opens or closes a group.
|
|
531
|
+
if (words.every((w) => RESERVED.has(w.text) && !w.quoted) && !word.quoted && !redirects.length) {
|
|
532
|
+
if (word.text === "{" ||
|
|
533
|
+
word.text === "}" ||
|
|
534
|
+
word.text === "fi" ||
|
|
535
|
+
word.text === "done" ||
|
|
536
|
+
word.text === "esac") {
|
|
537
|
+
if (word.text === "{") {
|
|
538
|
+
open("{");
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (close(word.text))
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
else if (Object.hasOwn(GROUP_CLOSER, word.text))
|
|
545
|
+
open(word.text);
|
|
546
|
+
}
|
|
547
|
+
// (`case x in`: the patterns of the first arm follow.)
|
|
548
|
+
if (word.text === "in" && !word.quoted && words.at(-2)?.text === "case" && groups.at(-1)?.group.kind === "case")
|
|
549
|
+
groups.at(-1).patterns = true;
|
|
550
|
+
words.push(word);
|
|
551
|
+
}
|
|
552
|
+
end("", i);
|
|
553
|
+
// A heredoc whose body never came (the text ended on its line) is empty.
|
|
554
|
+
for (const h of pending)
|
|
555
|
+
h.start = h.end = offset + text.length;
|
|
556
|
+
return out;
|
|
557
|
+
/** Reads the bodies of the line's heredocs from `from`, in order; returns where the next line starts. */
|
|
558
|
+
function heredocBodies(from) {
|
|
559
|
+
let at = from;
|
|
560
|
+
for (const h of pending) {
|
|
561
|
+
h.start = offset + at;
|
|
562
|
+
const line = (lines ?? lineIndex(text)).next(h.delimiter, h.strip, at);
|
|
563
|
+
if (line < 0) {
|
|
564
|
+
h.end = offset + text.length;
|
|
565
|
+
at = text.length;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
h.end = offset + line;
|
|
569
|
+
h.terminated = true;
|
|
570
|
+
const nl = text.indexOf("\n", line);
|
|
571
|
+
at = nl < 0 ? text.length : nl + 1;
|
|
572
|
+
}
|
|
573
|
+
pending = [];
|
|
574
|
+
return at;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/** The shell word written at `at`, read to its line's end at most: `'it'\''s'` is one word, `it's`. */
|
|
578
|
+
export function shellWordAt(text, at) {
|
|
579
|
+
return lexWord({ text, limit: lineEnd(text, at, UNQUOTED_READ), backslash: true, offset: 0 }, at);
|
|
580
|
+
}
|
|
581
|
+
/** The words of a command line, redirections aside (see shellCommands). */
|
|
582
|
+
export function shellWords(command, offset = 0, { backslash = true } = {}) {
|
|
583
|
+
return shellCommands(command, offset, { backslash }).flatMap((c) => c.words);
|
|
584
|
+
}
|
|
585
|
+
/** A command's redirections and those of the groups it is inside. */
|
|
586
|
+
export function allRedirects(c) {
|
|
587
|
+
return [...c.redirects, ...c.groups.flatMap((g) => g.redirects)];
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Whether a redirection sends the command's standard output to a file: `>`, `>>`, `>|`, `&>`, `&>>`, and `>&` onto a
|
|
591
|
+
* file (`>& out`; `>&2` and `2>&1` copy a descriptor), with no descriptor written before it or standard output's
|
|
592
|
+
* (`1> out`, `1>& out`; `2> out` sends another's).
|
|
593
|
+
*/
|
|
594
|
+
export function redirectsStdout(r) {
|
|
595
|
+
const sends = REDIRECT_OPERATORS[r.op];
|
|
596
|
+
if (sends === "none" || (r.fd !== undefined && r.fd !== 1))
|
|
597
|
+
return false;
|
|
598
|
+
return sends === "stdout" || !/^(?:\d+|-)$/.test(r.target?.text ?? "");
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Where `text.slice(from)` of a word was written: the characters themselves when they were written together, or,
|
|
602
|
+
* when quotes or escapes sit among them (`'Xk9'"mQ2"`, `'a'\''b'`, `a\ b`), the whole stretch with the quotes around it,
|
|
603
|
+
* so what replaces it leaves the line's quoting whole.
|
|
604
|
+
*/
|
|
605
|
+
export function wordSpan(word, from = 0) {
|
|
606
|
+
if (word.text.length <= from)
|
|
607
|
+
return { start: word.end, end: word.end };
|
|
608
|
+
let start = word.map[from];
|
|
609
|
+
let end = word.ends.at(-1);
|
|
610
|
+
const raw = (k) => word.raw[k - word.start];
|
|
611
|
+
if (word.raw.slice(start - word.start, end - word.start) !== word.text.slice(from)) {
|
|
612
|
+
while (start > word.start && /["'$]/.test(raw(start - 1)))
|
|
613
|
+
start--;
|
|
614
|
+
while (end < word.end && /["']/.test(raw(end)))
|
|
615
|
+
end++;
|
|
616
|
+
}
|
|
617
|
+
return { start, end };
|
|
618
|
+
}
|
|
619
|
+
/** The command line an argument vector stands for, each word quoted as a shell needs it: `["bash", "-lc", "cat .env"]` → `bash -lc 'cat .env'`. */
|
|
620
|
+
export function shellLine(argv) {
|
|
621
|
+
return argv.map((w) => (/^[\w@%+=:,./-]+$/.test(w) ? w : `'${w.replace(/'/g, "'\\''")}'`)).join(" ");
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* A terminal prompt at the start of a line, before a command: a shell's `$ `, `user@host:~$ `, PowerShell's
|
|
625
|
+
* `PS C:\> `, a notebook's `In [3]: !` or a cell's bare `!` before a command. Group 1 is the command.
|
|
626
|
+
*/
|
|
627
|
+
export const PROMPT = /^[ \t]*(?:\$ |In \[\d*\]:[ \t]*!|PS [^>\n]{0,200}> |[\w.-]+@[\w.-]+:[^\n$#]{0,200}[$#] |!(?=[a-z]))[ \t]*(\S[^\n]{0,2000})/;
|
|
628
|
+
/** Words before a simple command's own (`!` negates it); `then`, `do`, `else` open a group's commands. */
|
|
629
|
+
const RESERVED = new Set(["!", "if", "then", "else", "elif", "do", "while", "until"]);
|
|
630
|
+
/**
|
|
631
|
+
* Commands that run the command after them: the options of theirs that take a value (letters, getopt's way:
|
|
632
|
+
* `sudo -iu app` and `-uapp`; and long options), and how many operands come before the command (`timeout`'s duration).
|
|
633
|
+
*/
|
|
634
|
+
const WRAPPERS = new Map([
|
|
635
|
+
[
|
|
636
|
+
"sudo",
|
|
637
|
+
{ values: "ugCDhprtTUR", long: /^--(?:user|group|chdir|host|prompt|role|type|close-from|other-user|chroot)$/ },
|
|
638
|
+
],
|
|
639
|
+
["doas", { values: "uC" }],
|
|
640
|
+
["runuser", { values: "ugl", long: /^--(?:user|group)$/ }],
|
|
641
|
+
["env", { values: "uCS", long: /^--(?:unset|chdir|split-string)$/ }],
|
|
642
|
+
["timeout", { values: "sk", long: /^--(?:signal|kill-after)$/, operands: 1 }],
|
|
643
|
+
["nice", { values: "n", long: /^--adjustment$/ }],
|
|
644
|
+
["ionice", { values: "cnp" }],
|
|
645
|
+
["stdbuf", { values: "ioe" }],
|
|
646
|
+
[
|
|
647
|
+
"xargs",
|
|
648
|
+
{ values: "InPLdEsa", long: /^--(?:replace|max-args|max-procs|max-lines|delimiter|eof|max-chars|arg-file)$/ },
|
|
649
|
+
],
|
|
650
|
+
["exec", { values: "a" }],
|
|
651
|
+
["watch", { values: "n", long: /^--interval$/ }],
|
|
652
|
+
["time", { values: "fo", long: /^--(?:format|output)$/ }],
|
|
653
|
+
["nohup", {}],
|
|
654
|
+
["command", {}],
|
|
655
|
+
["builtin", {}],
|
|
656
|
+
["unbuffer", {}],
|
|
657
|
+
["caffeinate", {}],
|
|
658
|
+
["chronic", {}],
|
|
659
|
+
]);
|
|
660
|
+
/** Whether a word names a wrapper, a command that runs the command after it. */
|
|
661
|
+
export function isWrapper(word) {
|
|
662
|
+
return WRAPPERS.has(word);
|
|
663
|
+
}
|
|
664
|
+
/** A program's name without its directory and Windows' `.exe`: `/usr/bin/grep` → `grep`, `.\\steamcmd.exe` → `steamcmd`. */
|
|
665
|
+
export function programName(word) {
|
|
666
|
+
return (word?.text ?? "").replace(/^.*[/\\]/, "").replace(/\.exe$/i, "");
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* A simple command's words from the program it runs: past assignments (`FOO=1`), reserved words (`!`, `then`, `do`)
|
|
670
|
+
* and wrappers with their options and operands (`sudo -iu app`, `env -i`, `timeout 5`, `nice -n 10`, `xargs -I{}`).
|
|
671
|
+
* A wrapper that runs nothing (`env -0`, `sudo -l`) is the program itself.
|
|
672
|
+
*/
|
|
673
|
+
export function simpleCommand(words) {
|
|
674
|
+
let k = 0;
|
|
675
|
+
for (;;) {
|
|
676
|
+
while (k < words.length &&
|
|
677
|
+
((RESERVED.has(words[k].text) && !words[k].quoted) || /^[A-Za-z_]\w*=/.test(words[k].text)))
|
|
678
|
+
k++;
|
|
679
|
+
const wrapper = WRAPPERS.get(programName(words[k]));
|
|
680
|
+
if (!wrapper)
|
|
681
|
+
return words.slice(k);
|
|
682
|
+
const at = k++;
|
|
683
|
+
for (; k < words.length && words[k].text.startsWith("-"); k++) {
|
|
684
|
+
const w = words[k].text;
|
|
685
|
+
if (w === "--") {
|
|
686
|
+
k++;
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
if (w.startsWith("--")) {
|
|
690
|
+
if (!w.includes("=") && wrapper.long?.test(w))
|
|
691
|
+
k++;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
// (In a cluster, the first letter that takes a value takes the rest of the word, or the next word.)
|
|
695
|
+
const takes = [...w.slice(1)].findIndex((letter) => wrapper.values?.includes(letter));
|
|
696
|
+
if (takes >= 0 && takes === w.length - 2)
|
|
697
|
+
k++;
|
|
698
|
+
}
|
|
699
|
+
k += wrapper.operands ?? 0;
|
|
700
|
+
if (k >= words.length)
|
|
701
|
+
return words.slice(at);
|
|
702
|
+
}
|
|
703
|
+
}
|