@scopebond/hook 0.4.0 → 0.6.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/README.md +74 -22
- package/dist/cli.js +124 -31
- package/dist/cli.js.map +1 -1
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js +2 -1
- package/dist/cloud.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +2 -2
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +19 -11
- package/dist/init.js.map +1 -1
- package/dist/install.d.ts +21 -2
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +101 -24
- package/dist/install.js.map +1 -1
- package/dist/map.d.ts +10 -3
- package/dist/map.d.ts.map +1 -1
- package/dist/map.js +799 -54
- package/dist/map.js.map +1 -1
- package/dist/minimize.d.ts +6 -3
- package/dist/minimize.d.ts.map +1 -1
- package/dist/minimize.js +24 -4
- package/dist/minimize.js.map +1 -1
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +75 -9
- package/dist/runtime.js.map +1 -1
- package/dist/shell.d.ts +53 -4
- package/dist/shell.d.ts.map +1 -1
- package/dist/shell.js +737 -81
- package/dist/shell.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +6 -5
package/dist/shell.js
CHANGED
|
@@ -6,10 +6,17 @@
|
|
|
6
6
|
// one of them is out of policy.
|
|
7
7
|
//
|
|
8
8
|
// It is best-effort and fail-safe: anything it cannot parse with confidence is
|
|
9
|
-
// returned as a single `opaque` command
|
|
10
|
-
//
|
|
9
|
+
// returned as a single `opaque` command (as is a command whose program is only known
|
|
10
|
+
// at run time), which the mapper records with an empty program so the starter policy
|
|
11
|
+
// denies it rather than trusting it. Every scan is linear in the input
|
|
11
12
|
// length with no backtracking, so a hostile command cannot stall the hot path.
|
|
12
|
-
|
|
13
|
+
/** The canonical program name used for every decision: basename, lower-cased, with
|
|
14
|
+
* a Windows executable suffix removed. `RM.EXE`, `/bin/rm` and `rm` are one program
|
|
15
|
+
* — Windows and macOS resolve them case-insensitively, so matching the literal
|
|
16
|
+
* spelling would let a case or suffix variant through. */
|
|
17
|
+
export const canonProgram = (t) => t.replace(/^.*[\\/]/, "").toLowerCase().replace(/\.(?:exe|cmd|bat|com|ps1)$/, "");
|
|
18
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "fish"]);
|
|
19
|
+
const POWERSHELLS = new Set(["powershell", "pwsh"]);
|
|
13
20
|
const MAX_DEPTH = 6;
|
|
14
21
|
/** Split a command line into top-level simple-command strings at unquoted
|
|
15
22
|
* separators (`;`, newline, `&&`, `||`, `|`, `&`), leaving quotes, `$( )`,
|
|
@@ -76,6 +83,23 @@ function splitTopLevel(src) {
|
|
|
76
83
|
cur += c;
|
|
77
84
|
continue;
|
|
78
85
|
}
|
|
86
|
+
const prev = src[i - 1];
|
|
87
|
+
// An unquoted `#` at the start of a word begins a comment that runs to the end of
|
|
88
|
+
// the line (`rm -rf x # don't` must not read as an unbalanced quote).
|
|
89
|
+
if (c === "#" && (prev === undefined || /[\s;&|()]/.test(prev))) {
|
|
90
|
+
while (i + 1 < src.length && src[i + 1] !== "\n")
|
|
91
|
+
i++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// `&>file`, `2>&1`, `<&3` and `>|file` are redirections, not separators.
|
|
95
|
+
if (c === "&" && (n === ">" || prev === ">" || prev === "<")) {
|
|
96
|
+
cur += c;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (c === "|" && prev === ">") {
|
|
100
|
+
cur += c;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
79
103
|
if (c === ";" || c === "\n") {
|
|
80
104
|
flush();
|
|
81
105
|
continue;
|
|
@@ -99,34 +123,40 @@ function splitTopLevel(src) {
|
|
|
99
123
|
flush();
|
|
100
124
|
return { segments, unbalanced: quote !== null || paren !== 0 || backtick };
|
|
101
125
|
}
|
|
126
|
+
/** What a command substitution leaves behind in the outer command: a parameter
|
|
127
|
+
* expansion, so the word it sat in is treated as unresolved (its value is only known
|
|
128
|
+
* at run time) — as a program name it is dynamic, as a path it is glob-tested. */
|
|
129
|
+
const SUBST = "${__sb_subst}";
|
|
102
130
|
/** Pull the contents of command substitutions and subshell groups out of one
|
|
103
|
-
* segment — `$( … )
|
|
104
|
-
*
|
|
105
|
-
*
|
|
131
|
+
* segment — `$( … )` and `` ` … ` `` (also inside double quotes, where the shell
|
|
132
|
+
* still runs them) and `( … )` groups — for recursion. A substitution is replaced
|
|
133
|
+
* by a placeholder expansion; a group is blanked, except an empty `()` (a function
|
|
134
|
+
* definition), which is kept. */
|
|
106
135
|
function extractSubstitutions(seg) {
|
|
107
136
|
const inner = [];
|
|
108
137
|
let outer = "";
|
|
109
138
|
let quote = null;
|
|
139
|
+
const group = (start) => {
|
|
140
|
+
let depth = 1;
|
|
141
|
+
let j = start;
|
|
142
|
+
for (; j < seg.length && depth > 0; j++) {
|
|
143
|
+
if (seg[j] === "(")
|
|
144
|
+
depth++;
|
|
145
|
+
else if (seg[j] === ")")
|
|
146
|
+
depth--;
|
|
147
|
+
}
|
|
148
|
+
inner.push(seg.slice(start, depth === 0 ? j - 1 : j));
|
|
149
|
+
return j - 1;
|
|
150
|
+
};
|
|
110
151
|
for (let i = 0; i < seg.length; i++) {
|
|
111
152
|
const c = seg[i];
|
|
112
153
|
const n = seg[i + 1];
|
|
113
|
-
if (quote) {
|
|
154
|
+
if (quote === "'") {
|
|
114
155
|
outer += c;
|
|
115
|
-
if (
|
|
116
|
-
if (c === "'")
|
|
117
|
-
quote = null;
|
|
118
|
-
}
|
|
119
|
-
else if (c === "\\" && n !== undefined)
|
|
120
|
-
outer += seg[++i];
|
|
121
|
-
else if (c === '"')
|
|
156
|
+
if (c === "'")
|
|
122
157
|
quote = null;
|
|
123
158
|
continue;
|
|
124
159
|
}
|
|
125
|
-
if (c === "'" || c === '"') {
|
|
126
|
-
quote = c;
|
|
127
|
-
outer += c;
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
160
|
if (c === "`") {
|
|
131
161
|
const end = seg.indexOf("`", i + 1);
|
|
132
162
|
if (end === -1) {
|
|
@@ -134,33 +164,59 @@ function extractSubstitutions(seg) {
|
|
|
134
164
|
continue;
|
|
135
165
|
}
|
|
136
166
|
inner.push(seg.slice(i + 1, end));
|
|
167
|
+
outer += SUBST;
|
|
137
168
|
i = end;
|
|
138
169
|
continue;
|
|
139
170
|
}
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
171
|
+
if (c === "$" && n === "(") {
|
|
172
|
+
i = group(i + 2);
|
|
173
|
+
outer += SUBST;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (quote === '"') {
|
|
177
|
+
outer += c;
|
|
178
|
+
if (c === "\\" && n !== undefined)
|
|
179
|
+
outer += seg[++i];
|
|
180
|
+
else if (c === '"')
|
|
181
|
+
quote = null;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (c === "'" || c === '"') {
|
|
185
|
+
quote = c;
|
|
186
|
+
outer += c;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (c === "\\" && n !== undefined) {
|
|
190
|
+
outer += c + seg[++i];
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (c === "(" && n === ")") {
|
|
194
|
+
outer += "()";
|
|
195
|
+
i++;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (c === "(") {
|
|
199
|
+
i = group(i + 1);
|
|
152
200
|
continue;
|
|
153
201
|
}
|
|
154
202
|
outer += c;
|
|
155
203
|
}
|
|
156
204
|
return { outer, inner };
|
|
157
205
|
}
|
|
158
|
-
/** Tokenize a simple command into words, honoring single and double quotes.
|
|
206
|
+
/** Tokenize a simple command into words, honoring single and double quotes.
|
|
207
|
+
* Unquoted redirection operators (`>`, `>>`, `2>`, `&>`, `>|`, `<`, `<<`, `>&`)
|
|
208
|
+
* become their own operator tokens even when written without spaces (`x>file`),
|
|
209
|
+
* so a redirection target can never hide inside a word. */
|
|
159
210
|
function tokenize(s) {
|
|
160
211
|
const out = [];
|
|
161
212
|
let cur = "";
|
|
162
213
|
let has = false;
|
|
163
214
|
let quote = null;
|
|
215
|
+
const push = () => { if (has) {
|
|
216
|
+
out.push({ t: cur, op: false });
|
|
217
|
+
cur = "";
|
|
218
|
+
has = false;
|
|
219
|
+
} };
|
|
164
220
|
for (let i = 0; i < s.length; i++) {
|
|
165
221
|
const c = s[i];
|
|
166
222
|
const n = s[i + 1];
|
|
@@ -173,9 +229,11 @@ function tokenize(s) {
|
|
|
173
229
|
continue;
|
|
174
230
|
}
|
|
175
231
|
if (quote === '"') {
|
|
232
|
+
// As in bash: inside double quotes a backslash escapes only $ ` " \ and newline;
|
|
233
|
+
// before anything else it is a literal character (so "a\b" keeps its backslash).
|
|
176
234
|
if (c === '"')
|
|
177
235
|
quote = null;
|
|
178
|
-
else if (c === "\\" && n !== undefined)
|
|
236
|
+
else if (c === "\\" && n !== undefined && /[$`"\\\n]/.test(n))
|
|
179
237
|
cur += s[++i];
|
|
180
238
|
else
|
|
181
239
|
cur += c;
|
|
@@ -193,49 +251,262 @@ function tokenize(s) {
|
|
|
193
251
|
continue;
|
|
194
252
|
}
|
|
195
253
|
if (/\s/.test(c)) {
|
|
196
|
-
|
|
197
|
-
|
|
254
|
+
push();
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (c === ">" || c === "<" || (c === "&" && n === ">")) {
|
|
258
|
+
// A pure file-descriptor number (or `&`) directly before the operator is part of it.
|
|
259
|
+
let op = "";
|
|
260
|
+
if (has && /^\d+$/.test(cur)) {
|
|
198
261
|
cur = "";
|
|
199
262
|
has = false;
|
|
200
263
|
}
|
|
264
|
+
else
|
|
265
|
+
push();
|
|
266
|
+
if (c === "&") {
|
|
267
|
+
op = "&";
|
|
268
|
+
i++;
|
|
269
|
+
}
|
|
270
|
+
op += s[i];
|
|
271
|
+
const next = s[i + 1];
|
|
272
|
+
if (next === s[i] && next !== undefined) {
|
|
273
|
+
op += next;
|
|
274
|
+
i++;
|
|
275
|
+
if (op.endsWith("<<") && s[i + 1] === "<") {
|
|
276
|
+
op += "<";
|
|
277
|
+
i++;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else if (next === "|" || next === "&" || (s[i] === "<" && next === ">")) {
|
|
281
|
+
op += next;
|
|
282
|
+
i++;
|
|
283
|
+
}
|
|
284
|
+
out.push({ t: op, op: true });
|
|
201
285
|
continue;
|
|
202
286
|
}
|
|
203
287
|
cur += c;
|
|
204
288
|
has = true;
|
|
205
289
|
}
|
|
206
|
-
|
|
207
|
-
out.push(cur);
|
|
290
|
+
push();
|
|
208
291
|
return out;
|
|
209
292
|
}
|
|
293
|
+
/** Separate redirections from the command's words. `>`/`>>`/`>|`/`&>` targets are
|
|
294
|
+
* writes, `<` a read, `<>` both; here-doc/here-string operands and fd duplications
|
|
295
|
+
* (`2>&1`) are dropped. */
|
|
296
|
+
function splitRedirects(tokens) {
|
|
297
|
+
const words = [];
|
|
298
|
+
const redirects = [];
|
|
299
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
300
|
+
const tok = tokens[i];
|
|
301
|
+
if (!tok.op) {
|
|
302
|
+
words.push(tok.t);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const target = tokens[i + 1] && !tokens[i + 1].op ? tokens[i + 1].t : undefined;
|
|
306
|
+
if (target !== undefined)
|
|
307
|
+
i++;
|
|
308
|
+
const op = tok.t.replace(/^&/, "");
|
|
309
|
+
if (op.startsWith("<<") || target === undefined)
|
|
310
|
+
continue; // here-doc delimiter / here-string text
|
|
311
|
+
if ((op === ">&" || op === "<&") && /^(?:\d+|-)$/.test(target))
|
|
312
|
+
continue; // 2>&1, <&-
|
|
313
|
+
redirects.push({ op: op === ">&" ? ">" : op === "<&" ? "<" : op, target });
|
|
314
|
+
}
|
|
315
|
+
return { words, redirects };
|
|
316
|
+
}
|
|
210
317
|
const basename = (t) => t.replace(/^.*[\\/]/, "");
|
|
211
318
|
const isAssignment = (t) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
319
|
+
const DURATION = /^[\d.]+[smhd]?$/i;
|
|
320
|
+
const WRAPPERS = new Map([
|
|
321
|
+
["sudo", { short: "ugCDhpRrTtU", long: ["--user", "--group", "--close-from", "--chdir", "--prompt", "--chroot", "--role", "--type", "--command-timeout", "--other-user", "--host"] }],
|
|
322
|
+
["doas", { short: "uCa" }],
|
|
323
|
+
["pkexec", { long: ["--user"] }],
|
|
324
|
+
["env", { short: "uCSPa", long: ["--unset", "--chdir", "--split-string", "--argv0"] }],
|
|
325
|
+
["command", {}],
|
|
326
|
+
["builtin", {}],
|
|
327
|
+
["nohup", {}],
|
|
328
|
+
["nice", { short: "n", long: ["--adjustment"] }],
|
|
329
|
+
["time", { short: "fo", long: ["--format", "--output"], write: ["-o", "--output"] }],
|
|
330
|
+
["exec", { short: "a" }],
|
|
331
|
+
["stdbuf", { short: "ioe", long: ["--input", "--output", "--error"] }],
|
|
332
|
+
["setsid", {}],
|
|
333
|
+
["xargs", { short: "ILnPsdEa", optional: "iel", long: ["--arg-file", "--delimiter", "--max-args", "--max-procs", "--max-chars", "--process-slot-var"], read: ["-a", "--arg-file"] }],
|
|
334
|
+
["busybox", {}],
|
|
335
|
+
["timeout", { short: "sk", long: ["--signal", "--kill-after"], lead: DURATION }],
|
|
336
|
+
["gtimeout", { short: "sk", long: ["--signal", "--kill-after"], lead: DURATION }],
|
|
337
|
+
["ionice", { short: "cnp", long: ["--class", "--classdata", "--pid"] }],
|
|
338
|
+
["chrt", { short: "TPD", long: ["--sched-runtime", "--sched-period", "--sched-deadline"], lead: /^\d+$/ }],
|
|
339
|
+
["taskset", { lead: /^(?:0x[0-9a-f]+|[\d,-]+)$/i }],
|
|
340
|
+
["caffeinate", { short: "wt" }],
|
|
341
|
+
["strace", { short: "abeEIoOpPsSuX", long: ["--output", "--trace", "--signal", "--status", "--attach", "--string-limit", "--user", "--env"], write: ["-o", "--output"] }],
|
|
342
|
+
["ltrace", { short: "aADeFlnopsuxw", long: ["--output", "--library"], write: ["-o", "--output"] }],
|
|
343
|
+
["chroot", { long: ["--userspec", "--groups"], lead: /^[^-]/ }],
|
|
344
|
+
["unbuffer", {}],
|
|
345
|
+
["catchsegv", {}],
|
|
346
|
+
["torsocks", { short: "uapP" }],
|
|
347
|
+
["proxychains", { short: "f" }],
|
|
348
|
+
["proxychains4", { short: "f" }],
|
|
349
|
+
]);
|
|
350
|
+
// Shell reserved words and grouping in command position. They run nothing themselves;
|
|
351
|
+
// the command after them is the real one (`if rm …`, `then rm …`, `{ rm …`, `! rm …`).
|
|
352
|
+
const KEYWORDS = new Set(["if", "then", "else", "elif", "fi", "do", "done", "while", "until", "{", "}", "!", "coproc", "esac"]);
|
|
353
|
+
/** Words of a string as the shell would split it (quotes honored, operators dropped). */
|
|
354
|
+
const words = (s) => tokenize(s).filter((t) => !t.op).map((t) => t.t);
|
|
355
|
+
/** Strip the leading shell keywords, `NAME=value` assignments and wrappers (`sudo`,
|
|
356
|
+
* `env`, `time`, `xargs`, `timeout`, `strace` …, each with its own option arity) so
|
|
357
|
+
* the program is the real one about to run. `env -S "…"` splits its string into the
|
|
358
|
+
* command. Returns the remaining words and the wrappers passed on the way. */
|
|
359
|
+
function stripPrefixes(input) {
|
|
360
|
+
let tokens = input;
|
|
361
|
+
const wrappers = [];
|
|
218
362
|
let i = 0;
|
|
219
|
-
|
|
363
|
+
for (let guard = 0; i < tokens.length && guard < 1000; guard++) {
|
|
220
364
|
const t = tokens[i];
|
|
221
365
|
if (isAssignment(t)) {
|
|
222
366
|
i++;
|
|
223
367
|
continue;
|
|
224
368
|
}
|
|
225
|
-
if (
|
|
369
|
+
if (KEYWORDS.has(t)) {
|
|
370
|
+
i++;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
// `for x in …` / `select x in …`: the header runs nothing (its substitutions were
|
|
374
|
+
// already extracted); the body follows `do`.
|
|
375
|
+
if (t === "for" || t === "select")
|
|
376
|
+
return { tokens: [], wrappers };
|
|
377
|
+
// `case WORD in PATTERN) cmd`: skip to the command after the pattern.
|
|
378
|
+
if (t === "case") {
|
|
379
|
+
const at = tokens.indexOf("in", i + 1);
|
|
380
|
+
if (at < 0)
|
|
381
|
+
return { tokens: [], wrappers };
|
|
382
|
+
i = at + 1;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
// A case pattern opening a segment (`b) cmd`), or a function definition
|
|
386
|
+
// (`f() { cmd`, `function f { cmd`).
|
|
387
|
+
if (/^[^()]*\)$/.test(t) && !t.startsWith("$")) {
|
|
388
|
+
i++;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (t.length > 2 && t.endsWith("()")) {
|
|
226
392
|
i++;
|
|
227
|
-
|
|
228
|
-
while (i < tokens.length && tokens[i].startsWith("-")) {
|
|
229
|
-
const opt = tokens[i];
|
|
393
|
+
if (tokens[i] === "{")
|
|
230
394
|
i++;
|
|
231
|
-
|
|
232
|
-
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (t === "function" && i + 1 < tokens.length) {
|
|
398
|
+
i += 2;
|
|
399
|
+
if (tokens[i] === "()")
|
|
400
|
+
i++;
|
|
401
|
+
if (tokens[i] === "{")
|
|
402
|
+
i++;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const w = canonProgram(t);
|
|
406
|
+
const spec = WRAPPERS.get(w);
|
|
407
|
+
if (!spec)
|
|
408
|
+
break;
|
|
409
|
+
// `command -v rm` / `command -V rm` only describes rm; it runs nothing.
|
|
410
|
+
if (w === "command") {
|
|
411
|
+
let query = false;
|
|
412
|
+
for (let j = i + 1; j < tokens.length && tokens[j].startsWith("-"); j++)
|
|
413
|
+
if (/^-[A-Za-z]*[vV]/.test(tokens[j]))
|
|
414
|
+
query = true;
|
|
415
|
+
if (query)
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
const redirects = [];
|
|
419
|
+
wrappers.push({ name: w, raw: t, redirects });
|
|
420
|
+
i++;
|
|
421
|
+
let split;
|
|
422
|
+
const value = (name, attached) => {
|
|
423
|
+
let v = attached;
|
|
424
|
+
if (v === undefined && i < tokens.length)
|
|
425
|
+
v = tokens[i++];
|
|
426
|
+
if (v === undefined)
|
|
427
|
+
return;
|
|
428
|
+
if (spec.write?.includes(name))
|
|
429
|
+
redirects.push({ op: ">", target: v });
|
|
430
|
+
if (spec.read?.includes(name))
|
|
431
|
+
redirects.push({ op: "<", target: v });
|
|
432
|
+
if (w === "env" && (name === "-S" || name === "--split-string"))
|
|
433
|
+
split = v;
|
|
434
|
+
};
|
|
435
|
+
while (i < tokens.length) {
|
|
436
|
+
const o = tokens[i];
|
|
437
|
+
if (o === "--") {
|
|
438
|
+
i++;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
if (!o.startsWith("-") || o === "-")
|
|
442
|
+
break;
|
|
443
|
+
i++;
|
|
444
|
+
if (o.startsWith("--")) {
|
|
445
|
+
const eq = o.indexOf("=");
|
|
446
|
+
if (eq > 0)
|
|
447
|
+
value(o.slice(0, eq), o.slice(eq + 1));
|
|
448
|
+
else if (spec.long?.includes(o))
|
|
449
|
+
value(o, undefined);
|
|
450
|
+
continue;
|
|
233
451
|
}
|
|
452
|
+
for (let k = 1; k < o.length; k++) {
|
|
453
|
+
if (spec.optional?.includes(o[k]))
|
|
454
|
+
break;
|
|
455
|
+
if (spec.short?.includes(o[k])) {
|
|
456
|
+
value("-" + o[k], o.slice(k + 1) || undefined);
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (split !== undefined) {
|
|
462
|
+
tokens = [...words(split), ...tokens.slice(i)];
|
|
463
|
+
i = 0;
|
|
234
464
|
continue;
|
|
235
465
|
}
|
|
236
|
-
|
|
466
|
+
if (spec.lead && i < tokens.length && spec.lead.test(tokens[i]))
|
|
467
|
+
i++;
|
|
468
|
+
}
|
|
469
|
+
return { tokens: tokens.slice(i), wrappers };
|
|
470
|
+
}
|
|
471
|
+
/** A program word whose name is only known at run time: a variable (`$r`), an
|
|
472
|
+
* ANSI-C string (`$'\x72m'`) or a command substitution in its last path segment. */
|
|
473
|
+
const isDynamicProgram = (word) => /[$`]/.test(word.replace(/^.*\//, ""));
|
|
474
|
+
/** The script run by `su -c …`, `script -qc …`, `runuser … -c`, `flock -c …`: the
|
|
475
|
+
* value of `-c`/`--command` (also as the last letter of a short-option cluster). */
|
|
476
|
+
function commandOption(argv) {
|
|
477
|
+
for (let i = 0; i < argv.length; i++) {
|
|
478
|
+
const a = argv[i];
|
|
479
|
+
if (a.startsWith("--command=") || a.startsWith("--session-command="))
|
|
480
|
+
return a.slice(a.indexOf("=") + 1);
|
|
481
|
+
if (a === "-c" || a === "--command" || a === "--session-command" || /^-[A-Za-z]+c$/.test(a))
|
|
482
|
+
return argv[i + 1] ?? null;
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
/** The command words `parallel` runs: everything before the first `:::`/`::::`
|
|
487
|
+
* argument separator, after its own options. */
|
|
488
|
+
function parallelCommand(argv) {
|
|
489
|
+
const valued = new Set(["-j", "-P", "-S", "-a", "-d", "-I", "-E", "-C", "-N", "-n", "-L", "--jobs", "--sshlogin", "--sshloginfile", "--arg-file", "--delimiter", "--colsep", "--results", "--tmpdir", "--workdir", "--wd", "--joblog", "--halt", "--delay", "--timeout", "--retries", "--max-args", "--max-lines", "--env", "--basefile", "--return", "--transferfile"]);
|
|
490
|
+
let i = 0;
|
|
491
|
+
while (i < argv.length && argv[i].startsWith("-") && !argv[i].startsWith(":::")) {
|
|
492
|
+
i += valued.has(argv[i]) ? 2 : 1;
|
|
493
|
+
}
|
|
494
|
+
const cmd = [];
|
|
495
|
+
for (; i < argv.length && !/^::::?\+?$/.test(argv[i]); i++)
|
|
496
|
+
cmd.push(argv[i]);
|
|
497
|
+
return cmd.length ? cmd.join(" ") : null;
|
|
498
|
+
}
|
|
499
|
+
/** The command `watch` re-runs: its operands joined (watch passes them to `sh -c`). */
|
|
500
|
+
function watchCommand(argv) {
|
|
501
|
+
let i = 0;
|
|
502
|
+
while (i < argv.length && argv[i].startsWith("-")) {
|
|
503
|
+
const o = argv[i++];
|
|
504
|
+
if (o === "--")
|
|
505
|
+
break;
|
|
506
|
+
if (o === "-n" || o === "-q" || o === "--interval" || o === "--equexit")
|
|
507
|
+
i++;
|
|
237
508
|
}
|
|
238
|
-
return
|
|
509
|
+
return i < argv.length ? argv.slice(i).join(" ") : null;
|
|
239
510
|
}
|
|
240
511
|
/** Find the script argument of a shell invoked with `-c`, including combined short
|
|
241
512
|
* flags where `c` is last (`-lc`, `-xec`). The script is the next non-option word. */
|
|
@@ -249,48 +520,349 @@ function shellScriptArg(argv) {
|
|
|
249
520
|
}
|
|
250
521
|
return null;
|
|
251
522
|
}
|
|
523
|
+
/** `cmd /c …` / `cmd /k …`: everything after the switch is the script. */
|
|
524
|
+
function cmdScriptArg(argv) {
|
|
525
|
+
const at = argv.findIndex((a) => /^\/[ck]$/i.test(a));
|
|
526
|
+
return at >= 0 && at + 1 < argv.length ? argv.slice(at + 1).join(" ") : null;
|
|
527
|
+
}
|
|
528
|
+
/** `pwsh -Command …` / `-c …` (rest of argv is the script) or `-EncodedCommand <b64>`
|
|
529
|
+
* (base64 UTF-16LE, decoded here so an encoded command cannot hide its program).
|
|
530
|
+
* Returns undefined when there is no inline script, null when it cannot be decoded. */
|
|
531
|
+
function powershellScriptArg(argv) {
|
|
532
|
+
for (let i = 0; i < argv.length; i++) {
|
|
533
|
+
const a = argv[i].toLowerCase();
|
|
534
|
+
if (/^[-/](?:c|command|com|comm|comma|comman)$/.test(a))
|
|
535
|
+
return argv.slice(i + 1).join(" ") || null;
|
|
536
|
+
if (/^[-/](?:e|ec|en|enc|enco|encod|encode|encoded|encodedc\w*)$/.test(a)) {
|
|
537
|
+
const b64 = argv[i + 1];
|
|
538
|
+
if (!b64 || !/^[A-Za-z0-9+/=]+$/.test(b64))
|
|
539
|
+
return null;
|
|
540
|
+
const text = Buffer.from(b64, "base64").toString("utf16le");
|
|
541
|
+
return /[\u0000-\u0008�]/.test(text) ? null : text;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return undefined;
|
|
545
|
+
}
|
|
546
|
+
/** `find … -exec cmd {} ;` / `-execdir` / `-ok`: the embedded command runs per match. */
|
|
547
|
+
function findExecCommands(argv) {
|
|
548
|
+
const out = [];
|
|
549
|
+
for (let i = 0; i < argv.length; i++) {
|
|
550
|
+
if (!/^-(?:exec|execdir|ok|okdir)$/.test(argv[i]))
|
|
551
|
+
continue;
|
|
552
|
+
const cmd = [];
|
|
553
|
+
let j = i + 1;
|
|
554
|
+
for (; j < argv.length && argv[j] !== ";" && argv[j] !== "+" && argv[j] !== "\\;"; j++)
|
|
555
|
+
cmd.push(argv[j]);
|
|
556
|
+
if (cmd.length)
|
|
557
|
+
out.push(cmd.map((w) => (/[\s'"]/.test(w) ? `'${w.replace(/'/g, "")}'` : w)).join(" "));
|
|
558
|
+
i = j;
|
|
559
|
+
}
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
/** Find here-document openers (`<<word`, `<<-word`, `<< 'word'`, `<<\word`) in the
|
|
563
|
+
* whole command, honoring quotes and command-substitution nesting so a `<<` inside
|
|
564
|
+
* `"$(cat <<EOF …)"` is seen but a `<<` inside a quoted string, a here-string
|
|
565
|
+
* (`<<<`) or arithmetic is not. Each opener records its delimiter, the source line it
|
|
566
|
+
* sits on, and whether the command that owns it runs a shell (its body is a script)
|
|
567
|
+
* rather than consuming the body as data. `words`/`stripPrefixes` resolve the owning
|
|
568
|
+
* program past wrappers (`sudo bash <<EOF`). */
|
|
569
|
+
function findHeredocs(src) {
|
|
570
|
+
const found = [];
|
|
571
|
+
const stack = []; // saved quote state at each substitution entry
|
|
572
|
+
let quote = null;
|
|
573
|
+
let cmdStart = 0;
|
|
574
|
+
let line = 0;
|
|
575
|
+
for (let i = 0; i < src.length; i++) {
|
|
576
|
+
const c = src[i];
|
|
577
|
+
const n = src[i + 1];
|
|
578
|
+
if (c === "\n") {
|
|
579
|
+
line++;
|
|
580
|
+
if (!quote)
|
|
581
|
+
cmdStart = i + 1;
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (quote === "'") {
|
|
585
|
+
if (c === "'")
|
|
586
|
+
quote = null;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (quote === '"') {
|
|
590
|
+
if (c === "\\" && n !== undefined) {
|
|
591
|
+
i++;
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (c === '"') {
|
|
595
|
+
quote = null;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
// Command substitutions run inside double quotes; a here-doc can open there.
|
|
599
|
+
if (c === "$" && n === "(") {
|
|
600
|
+
stack.push(quote);
|
|
601
|
+
quote = null;
|
|
602
|
+
cmdStart = i + 2;
|
|
603
|
+
i++;
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (c === "`") {
|
|
607
|
+
stack.push(quote);
|
|
608
|
+
quote = null;
|
|
609
|
+
cmdStart = i + 1;
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
// unquoted
|
|
615
|
+
if (c === "\\" && n !== undefined) {
|
|
616
|
+
i++;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (c === "'" || c === '"') {
|
|
620
|
+
quote = c;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (c === "$" && n === "(") {
|
|
624
|
+
stack.push(null);
|
|
625
|
+
cmdStart = i + 2;
|
|
626
|
+
i++;
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (c === "`") {
|
|
630
|
+
stack.push(null);
|
|
631
|
+
cmdStart = i + 1;
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
if (c === "(") {
|
|
635
|
+
stack.push(null);
|
|
636
|
+
cmdStart = i + 1;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (c === ")") {
|
|
640
|
+
if (stack.length)
|
|
641
|
+
quote = stack.pop() ?? null;
|
|
642
|
+
cmdStart = i + 1;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (c === ";" || c === "&" || c === "|") {
|
|
646
|
+
cmdStart = i + 1;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
if (c === "<" && n === "<") {
|
|
650
|
+
if (src[i + 2] === "<") {
|
|
651
|
+
i += 2;
|
|
652
|
+
continue;
|
|
653
|
+
} // here-string, not a here-doc
|
|
654
|
+
let j = i + 2;
|
|
655
|
+
let dashed = false;
|
|
656
|
+
if (src[j] === "-") {
|
|
657
|
+
dashed = true;
|
|
658
|
+
j++;
|
|
659
|
+
}
|
|
660
|
+
while (src[j] === " " || src[j] === "\t")
|
|
661
|
+
j++;
|
|
662
|
+
let delim = "";
|
|
663
|
+
const q = src[j];
|
|
664
|
+
if (q === "'" || q === '"') {
|
|
665
|
+
const end = src.indexOf(q, j + 1);
|
|
666
|
+
if (end === -1 || src.slice(j + 1, end).includes("\n")) {
|
|
667
|
+
i = j;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
delim = src.slice(j + 1, end);
|
|
671
|
+
j = end + 1;
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
if (src[j] === "\\")
|
|
675
|
+
j++;
|
|
676
|
+
while (j < src.length && /[A-Za-z0-9_./-]/.test(src[j]))
|
|
677
|
+
delim += src[j++];
|
|
678
|
+
}
|
|
679
|
+
if (delim) {
|
|
680
|
+
const cmdText = src.slice(cmdStart, i);
|
|
681
|
+
const { tokens } = stripPrefixes(words(cmdText));
|
|
682
|
+
const prog = tokens.length ? canonProgram(tokens[0]) : "";
|
|
683
|
+
found.push({ line, delim, dashed, ownerIsShell: SHELLS.has(prog) });
|
|
684
|
+
}
|
|
685
|
+
i = j - 1;
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return found;
|
|
690
|
+
}
|
|
691
|
+
/** Remove here-document bodies before the line-splitter runs, so their lines are
|
|
692
|
+
* never parsed as commands. A body is inert data — a commit message, an HTTP
|
|
693
|
+
* payload, a note written with `cat <<EOF > f` (the redirection still records the
|
|
694
|
+
* write) — except a body fed to a shell (`bash <<EOF …`), which runs and is returned
|
|
695
|
+
* for its own decomposition. A `<<` whose delimiter never recurs on a later line is
|
|
696
|
+
* left untouched (it was arithmetic or otherwise not a here-doc). */
|
|
697
|
+
function stripHeredocs(src) {
|
|
698
|
+
if (!src.includes("<<"))
|
|
699
|
+
return { text: src, scripts: [] };
|
|
700
|
+
const openers = findHeredocs(src);
|
|
701
|
+
if (openers.length === 0)
|
|
702
|
+
return { text: src, scripts: [] };
|
|
703
|
+
const strip = (s) => s.replace(/^\t+/, "");
|
|
704
|
+
const byLine = new Map();
|
|
705
|
+
for (const op of openers) {
|
|
706
|
+
const l = byLine.get(op.line) ?? [];
|
|
707
|
+
l.push(op);
|
|
708
|
+
byLine.set(op.line, l);
|
|
709
|
+
}
|
|
710
|
+
const lines = src.split("\n");
|
|
711
|
+
const out = [];
|
|
712
|
+
const scripts = [];
|
|
713
|
+
for (let i = 0; i < lines.length; i++) {
|
|
714
|
+
out.push(lines[i]);
|
|
715
|
+
const ops = byLine.get(i);
|
|
716
|
+
if (!ops)
|
|
717
|
+
continue;
|
|
718
|
+
for (const op of ops) {
|
|
719
|
+
// Only consume a body when the terminator is actually present below.
|
|
720
|
+
let end = -1;
|
|
721
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
722
|
+
if ((op.dashed ? strip(lines[j]) : lines[j]) === op.delim) {
|
|
723
|
+
end = j;
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (end === -1)
|
|
728
|
+
continue;
|
|
729
|
+
const body = lines.slice(i + 1, end).map((l) => (op.dashed ? strip(l) : l));
|
|
730
|
+
if (op.ownerIsShell)
|
|
731
|
+
scripts.push(body.join("\n"));
|
|
732
|
+
i = end; // skip the body and the terminator line
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return { text: out.join("\n"), scripts };
|
|
736
|
+
}
|
|
252
737
|
/** Decompose a command line into the simple commands it will run. Recurses into
|
|
253
738
|
* `-c` scripts and command substitutions up to a bounded depth. */
|
|
254
739
|
export function decomposeShell(command, depth = 0) {
|
|
255
740
|
const src = command.trim();
|
|
256
741
|
if (!src)
|
|
257
742
|
return [];
|
|
258
|
-
const opaque = () => [{ program: "", programRaw: "", argv: [],
|
|
743
|
+
const opaque = (raw = src) => [{ program: "", programRaw: "", argv: [], redirects: [], raw, opaque: true }];
|
|
259
744
|
if (depth > MAX_DEPTH)
|
|
260
745
|
return opaque();
|
|
261
|
-
const {
|
|
746
|
+
const { text, scripts } = stripHeredocs(src);
|
|
747
|
+
const fromHeredocs = scripts.flatMap((s) => decomposeShell(s, depth + 1));
|
|
748
|
+
const { segments, unbalanced } = splitTopLevel(text);
|
|
262
749
|
if (unbalanced)
|
|
263
|
-
return opaque();
|
|
750
|
+
return [...opaque(), ...fromHeredocs];
|
|
264
751
|
const out = [];
|
|
265
752
|
for (const seg of segments) {
|
|
266
753
|
const { outer, inner } = extractSubstitutions(seg);
|
|
267
754
|
for (const sub of inner)
|
|
268
755
|
out.push(...decomposeShell(sub, depth + 1));
|
|
269
|
-
const
|
|
270
|
-
|
|
756
|
+
const split = splitRedirects(tokenize(outer));
|
|
757
|
+
const { tokens, wrappers } = stripPrefixes(split.words);
|
|
758
|
+
const redirects = split.redirects;
|
|
759
|
+
// Each wrapper is recorded as a command of its own (`sudo` is a program a policy
|
|
760
|
+
// may deny), carrying any file its options name (`time -o f`, `xargs -a f`).
|
|
761
|
+
for (const w of wrappers)
|
|
762
|
+
out.push({ program: basename(w.raw), programRaw: w.raw, argv: [], redirects: w.redirects, raw: seg, opaque: false });
|
|
763
|
+
if (tokens.length === 0) {
|
|
764
|
+
// A bare redirection (`> file`) still writes its target.
|
|
765
|
+
if (redirects.length)
|
|
766
|
+
out.push({ program: "", programRaw: "", argv: [], redirects, raw: seg, opaque: false });
|
|
271
767
|
continue; // pure substitution/subshell — inner already handled
|
|
768
|
+
}
|
|
769
|
+
// A program named by a variable, an ANSI-C string or a substitution is only known
|
|
770
|
+
// at run time: it cannot be judged by name, so it is opaque (fails closed).
|
|
771
|
+
if (isDynamicProgram(tokens[0])) {
|
|
772
|
+
out.push(...opaque(seg));
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
272
775
|
const program = basename(tokens[0]);
|
|
273
776
|
const argv = tokens.slice(1);
|
|
274
|
-
out.push({ program, programRaw: tokens[0], argv, raw: seg, opaque: false });
|
|
275
|
-
|
|
276
|
-
|
|
777
|
+
out.push({ program, programRaw: tokens[0], argv, redirects, raw: seg, opaque: false });
|
|
778
|
+
const canon = canonProgram(program);
|
|
779
|
+
const nested = (script) => { if (script)
|
|
780
|
+
out.push(...decomposeShell(script, depth + 1)); };
|
|
781
|
+
if (SHELLS.has(canon)) {
|
|
782
|
+
nested(shellScriptArg(argv));
|
|
783
|
+
}
|
|
784
|
+
else if (canon === "eval") {
|
|
785
|
+
nested(argv.join(" "));
|
|
786
|
+
}
|
|
787
|
+
else if (canon === "trap") {
|
|
788
|
+
if (argv.length >= 2 && !argv[0].startsWith("-"))
|
|
789
|
+
nested(argv[0]);
|
|
790
|
+
}
|
|
791
|
+
else if (canon === "su" || canon === "runuser" || canon === "script" || canon === "flock" || canon === "sg") {
|
|
792
|
+
const script = commandOption(argv);
|
|
793
|
+
if (script)
|
|
794
|
+
nested(script);
|
|
795
|
+
else if (canon === "sg" || canon === "flock") {
|
|
796
|
+
// `sg group cmd …` / `flock [-w n] lockfile cmd …`: the words after the first operand.
|
|
797
|
+
const ops = [];
|
|
798
|
+
for (let k = 0; k < argv.length; k++) {
|
|
799
|
+
if (argv[k].startsWith("-") && ops.length === 0) {
|
|
800
|
+
if (/^-[wEn]$/.test(argv[k]))
|
|
801
|
+
k++;
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
ops.push(argv[k]);
|
|
805
|
+
}
|
|
806
|
+
nested(ops.slice(1).join(" "));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
else if (canon === "watch") {
|
|
810
|
+
nested(watchCommand(argv));
|
|
811
|
+
}
|
|
812
|
+
else if (canon === "parallel") {
|
|
813
|
+
nested(parallelCommand(argv));
|
|
814
|
+
}
|
|
815
|
+
else if (canon === "cmd") {
|
|
816
|
+
const script = cmdScriptArg(argv);
|
|
277
817
|
if (script)
|
|
278
818
|
out.push(...decomposeShell(script, depth + 1));
|
|
279
819
|
}
|
|
820
|
+
else if (POWERSHELLS.has(canon)) {
|
|
821
|
+
const script = powershellScriptArg(argv);
|
|
822
|
+
if (script === null)
|
|
823
|
+
out.push(...opaque(seg));
|
|
824
|
+
else if (script)
|
|
825
|
+
out.push(...decomposeShell(script, depth + 1));
|
|
826
|
+
}
|
|
827
|
+
else if (canon === "find") {
|
|
828
|
+
for (const script of findExecCommands(argv))
|
|
829
|
+
out.push(...decomposeShell(script, depth + 1));
|
|
830
|
+
}
|
|
280
831
|
}
|
|
832
|
+
out.push(...fromHeredocs);
|
|
281
833
|
return out.length ? out : opaque();
|
|
282
834
|
}
|
|
283
|
-
/**
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
export function
|
|
287
|
-
|
|
835
|
+
/** Canonicalize a push destination to the short branch name a policy names:
|
|
836
|
+
* `refs/heads/main` → `main`, `heads/main` → `main`. `HEAD` and `@` mean the
|
|
837
|
+
* current branch and resolve to undefined (the runtime fills the real branch in). */
|
|
838
|
+
export function canonRef(dst) {
|
|
839
|
+
const d = dst.trim();
|
|
840
|
+
if (!d || d === "HEAD" || d === "@")
|
|
841
|
+
return undefined;
|
|
842
|
+
return d.replace(/^refs\/heads\//, "").replace(/^heads\//, "");
|
|
843
|
+
}
|
|
844
|
+
/** A git invocation split into its global `-c` config values, its subcommand and
|
|
845
|
+
* the subcommand's arguments, or null if the program is not git. Global options
|
|
846
|
+
* that take a value (`-C dir`, `-c k=v`, `--git-dir d` …) are skipped with it. */
|
|
847
|
+
export function gitArgs(cmd) {
|
|
848
|
+
if (canonProgram(cmd.program) !== "git")
|
|
288
849
|
return null;
|
|
289
850
|
const a = cmd.argv;
|
|
851
|
+
const configs = [];
|
|
290
852
|
let i = 0;
|
|
291
853
|
while (i < a.length) {
|
|
292
854
|
const t = a[i];
|
|
293
|
-
if (t === "-
|
|
855
|
+
if (t === "-c" || t === "--config-env") {
|
|
856
|
+
configs.push(a[i + 1] ?? "");
|
|
857
|
+
i += 2;
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
if (t.startsWith("--config-env=")) {
|
|
861
|
+
configs.push(t.slice("--config-env=".length));
|
|
862
|
+
i++;
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
if (t === "-C" || t === "--git-dir" || t === "--work-tree" || t === "--namespace" || t === "--super-prefix") {
|
|
294
866
|
i += 2;
|
|
295
867
|
continue;
|
|
296
868
|
}
|
|
@@ -300,32 +872,116 @@ export function parseGitPush(cmd) {
|
|
|
300
872
|
}
|
|
301
873
|
break;
|
|
302
874
|
}
|
|
303
|
-
|
|
875
|
+
return { sub: a[i], args: a.slice(i + 1), configs };
|
|
876
|
+
}
|
|
877
|
+
/** The ref recorded for a push whose destination cannot be read from the command
|
|
878
|
+
* line: an alias (`git -c alias.ship=push ship`), a configured push refspec
|
|
879
|
+
* (`-c remote.origin.push=…`, `-c push.default=matching`) or a lower-level push
|
|
880
|
+
* (`git send-pack`, `git http-push`, `git subtree push`). It starts with `-`, which
|
|
881
|
+
* the starter branch guard refuses, so an unreadable push fails closed. */
|
|
882
|
+
export const UNKNOWN_REF = "--unknown";
|
|
883
|
+
/** Parse a `git … push …` simple command, or null if it is not a push. Handles
|
|
884
|
+
* global options that take an argument (`-C dir`, `-c k=v`), push options that take
|
|
885
|
+
* one (`-o`, `--repo`, `--receive-pack`), every force spelling (`--force`, `-f`,
|
|
886
|
+
* `-uf`, `--force-with-lease`, `--force-if-includes`, a `+` refspec), `src:dst` and
|
|
887
|
+
* fully qualified refspecs, and multiple refspecs — each destination is returned,
|
|
888
|
+
* so `git push origin feature refs/heads/main` cannot hide `main` in second place.
|
|
889
|
+
* `--all`, `--mirror` and `--branches` push every branch: they return the literal
|
|
890
|
+
* flag as the ref, which the starter policy denies; `--tags` alone pushes only tags
|
|
891
|
+
* and returns `--tags`, which it allows. `ref`/`force` mirror the first target for
|
|
892
|
+
* callers that expect a single push. */
|
|
893
|
+
export function parseGitPush(cmd) {
|
|
894
|
+
const g = gitArgs(cmd);
|
|
895
|
+
if (!g)
|
|
304
896
|
return null;
|
|
305
|
-
const
|
|
897
|
+
const aliased = g.configs.some((c) => /^alias\./i.test(c));
|
|
898
|
+
const configuredPush = g.configs.some((c) => /^(?:remote\..*\.push(?:url)?|push\.default|remote\.pushdefault)(?:=|$)/i.test(c));
|
|
899
|
+
const lowLevel = g.sub === "send-pack" || g.sub === "http-push" || (g.sub === "subtree" && g.args.includes("push"));
|
|
900
|
+
if (aliased || lowLevel || (g.sub === "push" && configuredPush)) {
|
|
901
|
+
return { force: false, ref: UNKNOWN_REF, targets: [{ ref: UNKNOWN_REF, force: false }] };
|
|
902
|
+
}
|
|
903
|
+
if (g.sub !== "push")
|
|
904
|
+
return null;
|
|
905
|
+
const rest = g.args;
|
|
306
906
|
let force = false;
|
|
907
|
+
let everything;
|
|
908
|
+
let tags = false;
|
|
909
|
+
let del = false;
|
|
910
|
+
let repo;
|
|
307
911
|
const positional = [];
|
|
308
|
-
for (
|
|
309
|
-
|
|
912
|
+
for (let k = 0; k < rest.length; k++) {
|
|
913
|
+
const t = rest[k];
|
|
914
|
+
if (t === "--force" || t === "--force-with-lease" || t.startsWith("--force-with-lease=") || t === "--force-if-includes") {
|
|
310
915
|
force = true;
|
|
311
916
|
continue;
|
|
312
917
|
}
|
|
918
|
+
if (/^-[A-Za-z]+$/.test(t) && t.includes("f")) {
|
|
919
|
+
force = true;
|
|
920
|
+
continue;
|
|
921
|
+
} // -f, -uf, -fu
|
|
922
|
+
if (t === "--delete" || (/^-[A-Za-z]+$/.test(t) && t.includes("d"))) {
|
|
923
|
+
del = true;
|
|
924
|
+
continue;
|
|
925
|
+
} // --delete, -d, -df
|
|
926
|
+
if (t === "--all" || t === "--mirror" || t === "--branches") {
|
|
927
|
+
everything = t;
|
|
928
|
+
if (t === "--mirror")
|
|
929
|
+
force = true;
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
if (t === "--tags") {
|
|
933
|
+
tags = true;
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
if (t === "--repo") {
|
|
937
|
+
repo = rest[++k];
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
if (t.startsWith("--repo=")) {
|
|
941
|
+
repo = t.slice("--repo=".length);
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
if (t === "-o" || t === "--push-option" || t === "--receive-pack" || t === "--exec") {
|
|
945
|
+
k++;
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
if (t === "--")
|
|
949
|
+
continue;
|
|
313
950
|
if (t.startsWith("-"))
|
|
314
951
|
continue;
|
|
315
952
|
positional.push(t);
|
|
316
953
|
}
|
|
317
|
-
|
|
318
|
-
if
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
954
|
+
// `--repo <r>` names the repository. git itself still reads a first positional as
|
|
955
|
+
// the repository when one is given, but the option's documentation reads as if the
|
|
956
|
+
// positionals were then all refspecs — so with `--repo`, every positional is checked
|
|
957
|
+
// as a refspec AND, when at most one is given, the current branch is too. Either
|
|
958
|
+
// reading of `git push --repo origin HEAD:main` is covered.
|
|
959
|
+
const remote = repo ?? positional[0];
|
|
960
|
+
const specs = repo !== undefined ? positional : positional.slice(1);
|
|
961
|
+
const targets = [];
|
|
962
|
+
// `--all`/`--mirror`/`--branches` reach every branch, so they hit any protected ref;
|
|
963
|
+
// `--mirror` also prunes and force-updates. The pseudo-ref keeps the starter policy's
|
|
964
|
+
// ref allowlist denying it; `all` is the signal a force_push_guard clause reads.
|
|
965
|
+
if (everything)
|
|
966
|
+
targets.push({ ref: everything, force, all: true, ...(everything === "--mirror" ? { del: true } : {}) });
|
|
967
|
+
for (const spec of specs) {
|
|
968
|
+
let s = spec;
|
|
969
|
+
let f = force;
|
|
970
|
+
if (s.startsWith("+")) {
|
|
971
|
+
f = true;
|
|
972
|
+
s = s.slice(1);
|
|
326
973
|
}
|
|
327
|
-
|
|
974
|
+
const colon = s.indexOf(":");
|
|
975
|
+
// `src:dst` pushes to dst; `:dst` deletes dst; `src:` has no destination, so src.
|
|
976
|
+
const isDelete = del || (colon >= 0 && s.slice(0, colon) === "");
|
|
977
|
+
const dst = colon >= 0 ? (s.slice(colon + 1) || s.slice(0, colon)) : s;
|
|
978
|
+
targets.push({ ref: canonRef(dst.replace(/^\+/, "")), force: f || dst.startsWith("+"), ...(isDelete ? { del: true } : {}) });
|
|
328
979
|
}
|
|
329
|
-
|
|
980
|
+
if (repo !== undefined && positional.length <= 1 && !everything && !tags)
|
|
981
|
+
targets.push({ ref: undefined, force, ...(del ? { del: true } : {}) });
|
|
982
|
+
if (targets.length === 0)
|
|
983
|
+
targets.push(tags ? { ref: "--tags", force } : { ref: undefined, force, ...(del ? { del: true } : {}) });
|
|
984
|
+
const first = targets[0];
|
|
985
|
+
return { force: targets.some((t) => t.force), ...(remote !== undefined ? { remote } : {}), ...(first.ref !== undefined ? { ref: first.ref } : {}), targets };
|
|
330
986
|
}
|
|
331
987
|
//# sourceMappingURL=shell.js.map
|