@marcomigozzi/trailhead 0.12.1 → 0.14.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.it.md +38 -13
- package/README.md +48 -13
- package/bin/lib/codex-projection.js +18 -11
- package/bin/lib/codex-projection.test.js +39 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/trailhead.js +37 -25
- package/bin/trailhead.test.js +162 -5
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
- package/plugins/trailhead/hooks/mod/README.md +187 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
- package/plugins/trailhead/hooks/mod/register.tsx +280 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +79 -7
- package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +3 -1
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -4
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +15 -16
- package/plugins/trailhead/skills/_shared/techniques.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
- package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
- package/plugins/trailhead/types/index.d.ts +69 -0
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// body-sources.js: static analysis of where a `gh` issue/PR write takes its body
|
|
3
|
+
// from, shared by trailhead-secret-guard.js (#210). Nothing is ever executed: shell
|
|
4
|
+
// words are expanded the way bash would for a plain argument ($NAME, ${NAME}, a
|
|
5
|
+
// leading ~, variables set earlier in the command), redirects and flags are parsed,
|
|
6
|
+
// and a write's body is traced to files to read, text to scan, or a reason it cannot
|
|
7
|
+
// be known (the caller then fails closed). Best-effort, not a full shell parser.
|
|
8
|
+
// Self-contained: requires only node built-ins and the sibling gh-subcommand.js (dequote).
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { dequote, dequoteSpans } = require('./gh-subcommand.js');
|
|
13
|
+
|
|
14
|
+
const ASSIGN_TOKEN_RE = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/;
|
|
15
|
+
|
|
16
|
+
// --- static shell expansion (just enough to find a --body-file path) -----------
|
|
17
|
+
|
|
18
|
+
// Look a variable up: variables set earlier in the command first, then the hook's
|
|
19
|
+
// environment. null = unknown or recorded as unresolvable.
|
|
20
|
+
function lookupVar(name, vars, env) {
|
|
21
|
+
if (Object.prototype.hasOwnProperty.call(vars, name)) return vars[name];
|
|
22
|
+
const v = env[name];
|
|
23
|
+
return typeof v === 'string' ? v : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Expand the `$...` at text[i]. Only $NAME and ${NAME} are understood; command
|
|
27
|
+
// substitution, parameter operators and positional/special params are unresolvable.
|
|
28
|
+
// Returns { value, next } or { unresolved }.
|
|
29
|
+
function expandDollar(text, i, vars, env) {
|
|
30
|
+
const rest = text.slice(i + 1);
|
|
31
|
+
let name;
|
|
32
|
+
let len;
|
|
33
|
+
const braced = /^\{([A-Za-z_][A-Za-z0-9_]*)\}/.exec(rest);
|
|
34
|
+
if (braced) {
|
|
35
|
+
name = braced[1];
|
|
36
|
+
len = braced[0].length;
|
|
37
|
+
} else if (rest[0] === '{') {
|
|
38
|
+
return { unresolved: 'parameter expansion operator' };
|
|
39
|
+
} else if (rest[0] === '(') {
|
|
40
|
+
return { unresolved: 'command substitution' };
|
|
41
|
+
} else if (/^[0-9@*#?$!-]/.test(rest)) {
|
|
42
|
+
return { unresolved: `special parameter $${rest[0]}` };
|
|
43
|
+
} else {
|
|
44
|
+
const bare = /^[A-Za-z_][A-Za-z0-9_]*/.exec(rest);
|
|
45
|
+
if (!bare) return { value: '$', next: i + 1 }; // a lone $ is literal
|
|
46
|
+
name = bare[0];
|
|
47
|
+
len = name.length;
|
|
48
|
+
}
|
|
49
|
+
const v = lookupVar(name, vars, env);
|
|
50
|
+
if (v === null) return { unresolved: `unknown variable ${name}` };
|
|
51
|
+
return { value: v, next: i + 1 + len };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ANSI_C_SIMPLE = { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', f: '\f', v: '\v', e: '\x1b' };
|
|
55
|
+
|
|
56
|
+
// Decode the body of an ANSI-C `$'...'` span starting at word[i] (just past the
|
|
57
|
+
// opening quote). Common escapes are decoded (\n \t \\ \' \" \xHH \uHHHH \NNN); any
|
|
58
|
+
// other backslash pair keeps the character after it, so nothing is ever lost.
|
|
59
|
+
// Returns { value, next } or { unresolved }.
|
|
60
|
+
function expandAnsiC(word, i) {
|
|
61
|
+
let out = '';
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (i >= word.length) return { unresolved: 'unterminated quote' };
|
|
64
|
+
const d = word[i];
|
|
65
|
+
if (d === "'") return { value: out, next: i + 1 };
|
|
66
|
+
if (d !== '\\' || i + 1 >= word.length) { out += d; i++; continue; }
|
|
67
|
+
const rest = word.slice(i + 1);
|
|
68
|
+
const n = rest[0];
|
|
69
|
+
let m;
|
|
70
|
+
if (Object.prototype.hasOwnProperty.call(ANSI_C_SIMPLE, n)) {
|
|
71
|
+
out += ANSI_C_SIMPLE[n]; i += 2;
|
|
72
|
+
} else if ((m = /^x([0-9a-fA-F]{1,2})/.exec(rest)) || (m = /^u([0-9a-fA-F]{1,4})/.exec(rest)) || (m = /^U([0-9a-fA-F]{1,8})/.exec(rest))) {
|
|
73
|
+
const cp = parseInt(m[1], 16);
|
|
74
|
+
out += cp <= 0x10ffff ? String.fromCodePoint(cp) : m[0];
|
|
75
|
+
i += 1 + m[0].length;
|
|
76
|
+
} else if ((m = /^[0-7]{1,3}/.exec(rest))) {
|
|
77
|
+
out += String.fromCharCode(parseInt(m[0], 8) & 0xff);
|
|
78
|
+
i += 1 + m[0].length;
|
|
79
|
+
} else {
|
|
80
|
+
out += n; i += 2; // \\ \' \" and any other pair: keep the character
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Expand one shell word the way bash would for a plain argument: single quotes are
|
|
86
|
+
// literal, double quotes expand $NAME/${NAME} only, a bare word also expands a
|
|
87
|
+
// leading `~`/`~/` to HOME. Returns { value } or { unresolved: reason }.
|
|
88
|
+
function expandWord(word, vars, env) {
|
|
89
|
+
let out = '';
|
|
90
|
+
let i = 0;
|
|
91
|
+
if (word[0] === '~') {
|
|
92
|
+
if (word.length > 1 && word[1] !== '/') return { unresolved: 'user home (~user)' };
|
|
93
|
+
const home = lookupVar('HOME', vars, env);
|
|
94
|
+
if (home === null) return { unresolved: 'unknown variable HOME' };
|
|
95
|
+
out = home;
|
|
96
|
+
i = 1;
|
|
97
|
+
}
|
|
98
|
+
while (i < word.length) {
|
|
99
|
+
const c = word[i];
|
|
100
|
+
if (c === "'") {
|
|
101
|
+
const close = word.indexOf("'", i + 1);
|
|
102
|
+
if (close === -1) return { unresolved: 'unterminated quote' };
|
|
103
|
+
out += word.slice(i + 1, close);
|
|
104
|
+
i = close + 1;
|
|
105
|
+
} else if (c === '"') {
|
|
106
|
+
i++;
|
|
107
|
+
for (;;) {
|
|
108
|
+
if (i >= word.length) return { unresolved: 'unterminated quote' };
|
|
109
|
+
const d = word[i];
|
|
110
|
+
if (d === '"') { i++; break; }
|
|
111
|
+
if (d === '`') return { unresolved: 'command substitution' };
|
|
112
|
+
if (d === '\\' && i + 1 < word.length) {
|
|
113
|
+
const n = word[i + 1];
|
|
114
|
+
out += '$`"\\'.includes(n) ? n : d + n;
|
|
115
|
+
i += 2;
|
|
116
|
+
} else if (d === '$') {
|
|
117
|
+
const r = expandDollar(word, i, vars, env);
|
|
118
|
+
if (r.unresolved) return r;
|
|
119
|
+
out += r.value;
|
|
120
|
+
i = r.next;
|
|
121
|
+
} else {
|
|
122
|
+
out += d;
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} else if (c === '\\' && i + 1 < word.length) {
|
|
127
|
+
out += word[i + 1];
|
|
128
|
+
i += 2;
|
|
129
|
+
} else if (c === '$' && word[i + 1] === "'") {
|
|
130
|
+
// ANSI-C quoting: bash drops the `$` and decodes the span.
|
|
131
|
+
const r = expandAnsiC(word, i + 2);
|
|
132
|
+
if (r.unresolved) return r;
|
|
133
|
+
out += r.value;
|
|
134
|
+
i = r.next;
|
|
135
|
+
} else if (c === '$' && word[i + 1] === '"') {
|
|
136
|
+
i++; // locale quoting: drop the `$`, the double-quoted span follows
|
|
137
|
+
} else if (c === '$') {
|
|
138
|
+
const r = expandDollar(word, i, vars, env);
|
|
139
|
+
if (r.unresolved) return r;
|
|
140
|
+
out += r.value;
|
|
141
|
+
i = r.next;
|
|
142
|
+
} else if (c === '`') {
|
|
143
|
+
return { unresolved: 'command substitution' };
|
|
144
|
+
} else {
|
|
145
|
+
out += c;
|
|
146
|
+
i++;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { value: out };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- brace expansion (bash expands {a,b} and {1..3} before quote removal) ---------
|
|
153
|
+
|
|
154
|
+
const BRACE_LIMIT = 256;
|
|
155
|
+
const BRACE_MAX_DEPTH = 64; // nesting of groups / expansions inside a word
|
|
156
|
+
const BRACE_MAX_STEPS = 2_000_000; // scan work per word, so a hostile word cannot stall the hook
|
|
157
|
+
const BRACE_OVERFLOW = Symbol('brace overflow');
|
|
158
|
+
|
|
159
|
+
// Count one unit of scan work and one level of nesting; past either cap the word is "too large".
|
|
160
|
+
const braceTick = (st) => { if (++st.steps > BRACE_MAX_STEPS) throw BRACE_OVERFLOW; };
|
|
161
|
+
const braceDeeper = (st) => { if (++st.depth > BRACE_MAX_DEPTH) throw BRACE_OVERFLOW; };
|
|
162
|
+
|
|
163
|
+
// Index just past the balanced close of a `(...)`/`${...}` opened before s[from], quote aware.
|
|
164
|
+
function skipBalanced(s, from, open, close, st) {
|
|
165
|
+
braceDeeper(st);
|
|
166
|
+
let depth = 1;
|
|
167
|
+
let j = from;
|
|
168
|
+
while (j < s.length && depth > 0) {
|
|
169
|
+
braceTick(st);
|
|
170
|
+
const k = skipProtected(s, j, st);
|
|
171
|
+
if (k >= 0) { j = k; continue; }
|
|
172
|
+
if (s[j] === open) depth++;
|
|
173
|
+
else if (s[j] === close) depth--;
|
|
174
|
+
j++;
|
|
175
|
+
}
|
|
176
|
+
st.depth--;
|
|
177
|
+
return j;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// If a quoted span, an escape, a `${...}`, a `$(...)`/`<(...)`/`>(...)` or a backtick span
|
|
181
|
+
// starts at s[i], the index just past it; otherwise -1. Braces and commas inside these
|
|
182
|
+
// are never part of a brace group.
|
|
183
|
+
function skipProtected(s, i, st) {
|
|
184
|
+
const c = s[i];
|
|
185
|
+
if (c === '\\') return Math.min(i + 2, s.length);
|
|
186
|
+
if (c === "'") {
|
|
187
|
+
const close = s.indexOf("'", i + 1);
|
|
188
|
+
return close === -1 ? s.length : close + 1;
|
|
189
|
+
}
|
|
190
|
+
if (c === '"') {
|
|
191
|
+
for (let j = i + 1; j < s.length; j++) {
|
|
192
|
+
if (s[j] === '\\') j++;
|
|
193
|
+
else if (s[j] === '"') return j + 1;
|
|
194
|
+
}
|
|
195
|
+
return s.length;
|
|
196
|
+
}
|
|
197
|
+
if (c === '`') {
|
|
198
|
+
for (let j = i + 1; j < s.length; j++) {
|
|
199
|
+
if (s[j] === '\\') j++;
|
|
200
|
+
else if (s[j] === '`') return j + 1;
|
|
201
|
+
}
|
|
202
|
+
return s.length;
|
|
203
|
+
}
|
|
204
|
+
if (c === '$' && s[i + 1] === "'") {
|
|
205
|
+
for (let j = i + 2; j < s.length; j++) {
|
|
206
|
+
if (s[j] === '\\') j++;
|
|
207
|
+
else if (s[j] === "'") return j + 1;
|
|
208
|
+
}
|
|
209
|
+
return s.length;
|
|
210
|
+
}
|
|
211
|
+
if (c === '$' && s[i + 1] === '{') return skipBalanced(s, i + 2, '{', '}', st);
|
|
212
|
+
if ((c === '$' || c === '<' || c === '>') && s[i + 1] === '(') return skipBalanced(s, i + 2, '(', ')', st);
|
|
213
|
+
return -1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const SEQ_INT = '[-+]?\\d+';
|
|
217
|
+
|
|
218
|
+
// The values of a `{x..y}` / `{x..y..step}` body, or null when it is not a sequence.
|
|
219
|
+
function sequenceValues(body, limit) {
|
|
220
|
+
let m = new RegExp(`^(${SEQ_INT})\\.\\.(${SEQ_INT})(?:\\.\\.(${SEQ_INT}))?$`).exec(body);
|
|
221
|
+
const letters = !m;
|
|
222
|
+
if (!m) m = /^([A-Za-z])\.\.([A-Za-z])(?:\.\.([-+]?\d+))?$/.exec(body);
|
|
223
|
+
if (!m) return null;
|
|
224
|
+
const from = letters ? m[1].charCodeAt(0) : parseInt(m[1], 10);
|
|
225
|
+
const to = letters ? m[2].charCodeAt(0) : parseInt(m[2], 10);
|
|
226
|
+
const step = Math.abs(m[3] ? parseInt(m[3], 10) : 1) || 1;
|
|
227
|
+
const count = Math.floor(Math.abs(to - from) / step) + 1;
|
|
228
|
+
if (count > limit) throw BRACE_OVERFLOW;
|
|
229
|
+
// Bash pads to the longest endpoint (sign included) when one has a leading zero, like
|
|
230
|
+
// printf %0Nd: `{-01..2}` is `-01 000 001 002`.
|
|
231
|
+
const zeroLed = (e) => (e[0] === '0' && e.length > 1) || (e[0] === '-' && e[1] === '0' && e.length > 2);
|
|
232
|
+
const width = !letters && (zeroLed(m[1]) || zeroLed(m[2])) ? Math.max(m[1].length, m[2].length) : 0;
|
|
233
|
+
const fmt = (v) => (v < 0 ? '-' + String(-v).padStart(Math.max(width - 1, 0), '0') : String(v).padStart(width, '0'));
|
|
234
|
+
const out = [];
|
|
235
|
+
const dir = to >= from ? step : -step;
|
|
236
|
+
for (let n = 0, v = from; n < count; n++, v += dir) out.push(letters ? String.fromCharCode(v) : fmt(v));
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Expand the first expandable group of `s` (and, recursively, the rest). Throws
|
|
241
|
+
// BRACE_OVERFLOW past `limit` results, the depth cap or the scan budget.
|
|
242
|
+
function braceWords(s, limit, st) {
|
|
243
|
+
for (let i = 0; i < s.length;) {
|
|
244
|
+
braceTick(st);
|
|
245
|
+
const k = skipProtected(s, i, st);
|
|
246
|
+
if (k >= 0) { i = k; continue; }
|
|
247
|
+
if (s[i] !== '{') { i++; continue; }
|
|
248
|
+
let depth = 1;
|
|
249
|
+
let j = i + 1;
|
|
250
|
+
const commas = [];
|
|
251
|
+
while (j < s.length) {
|
|
252
|
+
braceTick(st);
|
|
253
|
+
const q = skipProtected(s, j, st);
|
|
254
|
+
if (q >= 0) { j = q; continue; }
|
|
255
|
+
if (s[j] === '{') depth++;
|
|
256
|
+
else if (s[j] === '}' && --depth === 0) break;
|
|
257
|
+
else if (s[j] === ',' && depth === 1) commas.push(j);
|
|
258
|
+
j++;
|
|
259
|
+
}
|
|
260
|
+
if (j >= s.length) { i++; continue; } // unmatched: literal
|
|
261
|
+
let alts;
|
|
262
|
+
if (commas.length) {
|
|
263
|
+
alts = [];
|
|
264
|
+
let from = i + 1;
|
|
265
|
+
for (const c of [...commas, j]) { alts.push(s.slice(from, c)); from = c + 1; }
|
|
266
|
+
braceDeeper(st);
|
|
267
|
+
alts = alts.flatMap((a) => braceWords(a, limit, st));
|
|
268
|
+
st.depth--;
|
|
269
|
+
if (alts.length > limit) throw BRACE_OVERFLOW;
|
|
270
|
+
} else {
|
|
271
|
+
alts = sequenceValues(s.slice(i + 1, j), limit);
|
|
272
|
+
}
|
|
273
|
+
if (!alts) { i++; continue; } // {} / {x}: literal, keep scanning inside
|
|
274
|
+
braceDeeper(st);
|
|
275
|
+
const post = braceWords(s.slice(j + 1), limit, st);
|
|
276
|
+
st.depth--;
|
|
277
|
+
if (alts.length * post.length > limit) throw BRACE_OVERFLOW;
|
|
278
|
+
const pre = s.slice(0, i);
|
|
279
|
+
return alts.flatMap((a) => post.map((p) => pre + a + p));
|
|
280
|
+
}
|
|
281
|
+
return [s];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Bash brace expansion of a RAW shell word (quotes still in place, so they stay
|
|
285
|
+
// literal): { words } or { overflow: true } past `limit` results, the nesting cap, or
|
|
286
|
+
// on any internal fault (#220): a word it cannot expand is never passed through.
|
|
287
|
+
function braceExpand(word, limit = BRACE_LIMIT) {
|
|
288
|
+
try {
|
|
289
|
+
const out = braceWords(String(word), limit, { steps: 0, depth: 0 });
|
|
290
|
+
return out.length > limit ? { overflow: true } : { words: out };
|
|
291
|
+
} catch (e) {
|
|
292
|
+
// A size/depth overflow is expected; anything else is a fault. Both fail closed.
|
|
293
|
+
return e === BRACE_OVERFLOW ? { overflow: true } : { overflow: true, fault: true };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// What bash passes as argv once each word's brace groups are expanded in place
|
|
298
|
+
// (`-{f,f} body=x` is `-f -f body=x`): { toks, overflow, fault }. A word that cannot
|
|
299
|
+
// be expanded stays as written and sets `overflow` (the caller fails closed).
|
|
300
|
+
function braceFlatten(toks) {
|
|
301
|
+
let overflow = false;
|
|
302
|
+
let fault = false;
|
|
303
|
+
const out = toks.flatMap((t) => {
|
|
304
|
+
const b = braceExpand(t);
|
|
305
|
+
if (!b.overflow) return b.words;
|
|
306
|
+
overflow = true;
|
|
307
|
+
fault = fault || b.fault === true;
|
|
308
|
+
return [t];
|
|
309
|
+
});
|
|
310
|
+
return { toks: out, overflow, fault };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Quote-aware word splitter. Like shell-scan's tokenize, but a `$(...)`, `<(...)`,
|
|
314
|
+
// `>(...)` or backtick span stays inside ONE word even when it holds spaces.
|
|
315
|
+
function words(stmt) {
|
|
316
|
+
const s = String(stmt).trim();
|
|
317
|
+
const out = [];
|
|
318
|
+
let cur = '';
|
|
319
|
+
let quote = null; // ' or "
|
|
320
|
+
let ansi = false; // inside a `$'...'` span
|
|
321
|
+
let bare = false; // the previous char is a bare `$` (not escaped, not the 2nd of `$$`)
|
|
322
|
+
for (let i = 0; i < s.length; i++) {
|
|
323
|
+
const c = s[i];
|
|
324
|
+
const afterBare = bare;
|
|
325
|
+
bare = false;
|
|
326
|
+
if (quote) {
|
|
327
|
+
// An ANSI-C `$'...'` span honours backslash escapes (`$'a\'b'`).
|
|
328
|
+
if (quote === "'" && ansi && c === '\\' && i + 1 < s.length) { cur += c + s[++i]; continue; }
|
|
329
|
+
// Inside double quotes bash removes a backslash-newline continuation.
|
|
330
|
+
if (quote === '"' && c === '\\' && s[i + 1] === '\n') { i++; bare = afterBare; continue; }
|
|
331
|
+
cur += c;
|
|
332
|
+
if (quote === '"' && c === '\\' && i + 1 < s.length) cur += s[++i];
|
|
333
|
+
else if (c === quote) { quote = null; ansi = false; }
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (c === "'" || c === '"') { ansi = c === "'" && afterBare; quote = c; cur += c; continue; }
|
|
337
|
+
// A backslash-newline continuation vanishes (`a\<nl>b` is `ab`, `a \<nl>b` is two words).
|
|
338
|
+
if (c === '\\' && s[i + 1] === '\n') { i++; bare = afterBare; continue; } // the bare-dollar state carries over
|
|
339
|
+
if (c === '\\' && i + 1 < s.length) { cur += c + s[++i]; continue; }
|
|
340
|
+
if ((c === '$' || c === '<' || c === '>') && s[i + 1] === '(') {
|
|
341
|
+
let depth = 0;
|
|
342
|
+
let j = i + 1;
|
|
343
|
+
for (; j < s.length; j++) {
|
|
344
|
+
if (s[j] === '(') depth++;
|
|
345
|
+
else if (s[j] === ')' && --depth === 0) break;
|
|
346
|
+
}
|
|
347
|
+
cur += s.slice(i, j + 1);
|
|
348
|
+
i = j;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (c === '`') {
|
|
352
|
+
const close = s.indexOf('`', i + 1);
|
|
353
|
+
const end = close === -1 ? s.length - 1 : close;
|
|
354
|
+
cur += s.slice(i, end + 1);
|
|
355
|
+
i = end;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (/\s/.test(c)) {
|
|
359
|
+
if (cur) { out.push(cur); cur = ''; }
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
// A redirect operator glued to its neighbours (`cat >f<<'EOF'`, `echo x>f`)
|
|
363
|
+
// starts a new word; a file-descriptor digit before it stays with the operator.
|
|
364
|
+
if (c === '<' || c === '>' || (c === '&' && s[i + 1] === '>')) {
|
|
365
|
+
if (cur && !/^\d+$/.test(cur)) { out.push(cur); cur = ''; }
|
|
366
|
+
const op = /^(?:&>>|&>|<<<|<<-|<<|>>|>&|<&|>\||<>|>|<)/.exec(s.slice(i))[0];
|
|
367
|
+
cur += op;
|
|
368
|
+
i += op.length - 1;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (c === '$') bare = !afterBare; // `$$` is the PID: its 2nd `$` opens nothing
|
|
372
|
+
cur += c;
|
|
373
|
+
}
|
|
374
|
+
if (cur) out.push(cur);
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// The command/process substitutions ($(...), `...`, and outside double quotes <(...)
|
|
379
|
+
// and >(...)) in some text: [{ start, end, inner }] (end exclusive). Single-quoted
|
|
380
|
+
// text is skipped, except with `literalQuotes` (heredoc text, where quotes are
|
|
381
|
+
// ordinary characters). An unclosed span runs to the end of the text.
|
|
382
|
+
function substitutionSpans(s, literalQuotes = false) {
|
|
383
|
+
const out = [];
|
|
384
|
+
let inSingle = false;
|
|
385
|
+
let inDouble = false;
|
|
386
|
+
for (let i = 0; i < s.length; i++) {
|
|
387
|
+
const c = s[i];
|
|
388
|
+
if (inSingle) { if (c === "'") inSingle = false; continue; }
|
|
389
|
+
if (c === '\\') { i++; continue; }
|
|
390
|
+
if (!literalQuotes && c === "'") { inSingle = true; continue; }
|
|
391
|
+
if (!literalQuotes && c === '"') { inDouble = !inDouble; continue; }
|
|
392
|
+
if (s[i + 1] === '(' && (c === '$' || (!inDouble && !literalQuotes && (c === '<' || c === '>')))) {
|
|
393
|
+
let depth = 0;
|
|
394
|
+
let j = i + 1;
|
|
395
|
+
for (; j < s.length; j++) {
|
|
396
|
+
if (s[j] === '(') depth++;
|
|
397
|
+
else if (s[j] === ')' && --depth === 0) break;
|
|
398
|
+
}
|
|
399
|
+
out.push({ start: i, end: Math.min(j + 1, s.length), inner: s.slice(i + 2, j) });
|
|
400
|
+
i = j;
|
|
401
|
+
} else if (c === '`') {
|
|
402
|
+
const close = s.indexOf('`', i + 1);
|
|
403
|
+
const end = close === -1 ? s.length : close;
|
|
404
|
+
out.push({ start: i, end: Math.min(end + 1, s.length), inner: s.slice(i + 1, end) });
|
|
405
|
+
i = end;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Expand heredoc text the way bash does for an unquoted delimiter: $NAME and ${NAME}
|
|
412
|
+
// expand, `\$`, `\`` and `\\` are escapes, quotes are ordinary characters.
|
|
413
|
+
function expandPlain(text, vars, env) {
|
|
414
|
+
let out = '';
|
|
415
|
+
let i = 0;
|
|
416
|
+
while (i < text.length) {
|
|
417
|
+
const c = text[i];
|
|
418
|
+
if (c === '\\' && text[i + 1] === '\n') {
|
|
419
|
+
i += 2; // a backslash-newline continuation vanishes
|
|
420
|
+
} else if (c === '\\' && i + 1 < text.length) {
|
|
421
|
+
const n = text[i + 1];
|
|
422
|
+
out += '$`\\'.includes(n) ? n : c + n;
|
|
423
|
+
i += 2;
|
|
424
|
+
} else if (c === '$') {
|
|
425
|
+
const r = expandDollar(text, i, vars, env);
|
|
426
|
+
if (r.unresolved) return r;
|
|
427
|
+
out += r.value;
|
|
428
|
+
i = r.next;
|
|
429
|
+
} else if (c === '`') {
|
|
430
|
+
return { unresolved: 'command substitution' };
|
|
431
|
+
} else {
|
|
432
|
+
out += c;
|
|
433
|
+
i++;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return { value: out };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// --- redirects and flags ----------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
const REDIRECT_RE = /^(\d*)(<<<|<<-?|&>>|&>|>>|>\||>&|<&|<>|>|<)([\s\S]*)$/;
|
|
442
|
+
|
|
443
|
+
// Split a statement's words into plain arguments and redirections.
|
|
444
|
+
// stdin: null | { type: 'heredoc' | 'here' } | { type: 'file', word }
|
|
445
|
+
// outs: [{ word, append }] (stdout redirects; fd duplications like 2>&1 are dropped)
|
|
446
|
+
function parseRedirects(toks) {
|
|
447
|
+
const args = [];
|
|
448
|
+
const outs = [];
|
|
449
|
+
let stdin = null;
|
|
450
|
+
for (let i = 0; i < toks.length; i++) {
|
|
451
|
+
const t = toks[i];
|
|
452
|
+
const m = t.startsWith('<(') || t.startsWith('>(') ? null : REDIRECT_RE.exec(t);
|
|
453
|
+
if (!m) { args.push(t); continue; }
|
|
454
|
+
const [, fd, op, glued] = m;
|
|
455
|
+
if (op === '>&' || op === '<&') { if (!glued) i++; continue; } // 2>&1, >&2: fd duplication
|
|
456
|
+
const word = glued || toks[++i] || '';
|
|
457
|
+
if (op === '<<' || op === '<<-') stdin = { type: 'heredoc' };
|
|
458
|
+
else if (op === '<<<') stdin = { type: 'here', word };
|
|
459
|
+
else if (op === '<' || op === '<>') stdin = { type: 'file', word };
|
|
460
|
+
else if (fd === '' || fd === '1' || op.startsWith('&')) outs.push({ word, append: op.endsWith('>>') });
|
|
461
|
+
}
|
|
462
|
+
return { args, outs, stdin };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// A quoted or split token that is this flag (`'-F'`, `--body-fi""le`, `"--body-file=F"`,
|
|
466
|
+
// `"-F/p"`); free text in the flag's own quoted span (`"-F handling"`) is not (#219).
|
|
467
|
+
function dequotedFlag(raw, long, short) {
|
|
468
|
+
const { text: d, spans } = dequoteSpans(raw);
|
|
469
|
+
const hit = d === long || (short && d === short) || d.startsWith(`${long}=`) || (short && d.startsWith(`${short}=`));
|
|
470
|
+
if (hit) return d;
|
|
471
|
+
if (!short || !d.startsWith(short) || d.startsWith('--') || d.length <= short.length) return null;
|
|
472
|
+
// The attached shorthand (`"-F/p"`, `-"F"/"p a"`, `"-F/my dir/b"`). Only a space DIRECTLY
|
|
473
|
+
// after the flag letter, in the letter's own quoted span, is free text (`"-F handling"`).
|
|
474
|
+
const next = short.length;
|
|
475
|
+
if (/\s/.test(d[next]) && spans[next] !== -1 && spans[next] === spans[next - 1]) return null;
|
|
476
|
+
return d;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Values of a gh flag (long form, optional short form), in order: `--long v`,
|
|
480
|
+
// `--long=v`, `-s v`, `-s=v` and the attached pflag shorthand `-sv`.
|
|
481
|
+
function flagValues(args, long, short) {
|
|
482
|
+
const out = [];
|
|
483
|
+
for (let i = 0; i < args.length; i++) {
|
|
484
|
+
// A quoted or split flag name is the same flag; the raw form is tried first so an
|
|
485
|
+
// unquoted flag keeps its raw value (#219).
|
|
486
|
+
const raw = args[i];
|
|
487
|
+
const rawHit = raw === long || raw.startsWith(`${long}=`) || (short && raw.startsWith(short) && !raw.startsWith('--'));
|
|
488
|
+
const t = rawHit ? raw : dequotedFlag(raw, long, short) || raw;
|
|
489
|
+
if (t === long || (short && t === short)) {
|
|
490
|
+
if (args[i + 1] !== undefined) out.push(args[++i]);
|
|
491
|
+
} else if (t.startsWith(`${long}=`)) out.push(t.slice(long.length + 1));
|
|
492
|
+
else if (short && t.startsWith(`${short}=`)) out.push(t.slice(short.length + 1));
|
|
493
|
+
else if (short && t.startsWith(short) && !t.startsWith('--') && t.length > 2) out.push(t.slice(2));
|
|
494
|
+
}
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Resolve one shell word that names a file to an absolute path, without running
|
|
499
|
+
// anything: { path } or { unresolved }. See resolveBodyFile for the cwd contract.
|
|
500
|
+
function resolveWord(raw, vars, env, cwd) {
|
|
501
|
+
if (raw.startsWith('<(') || raw.startsWith('>(')) return { unresolved: 'process substitution' };
|
|
502
|
+
const r = expandWord(raw, vars, env);
|
|
503
|
+
if (r.unresolved) return { unresolved: r.unresolved };
|
|
504
|
+
return resolveExpanded(r.value, cwd);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Same for an already-expanded value (e.g. the path after `body=@`).
|
|
508
|
+
function resolveExpanded(value, cwd) {
|
|
509
|
+
if (path.isAbsolute(value)) return { path: path.normalize(value) };
|
|
510
|
+
if (cwd === null) return { unresolved: 'relative path after cd (working directory unknown)' };
|
|
511
|
+
return { path: path.resolve(cwd === undefined ? process.cwd() : cwd, value) };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const STDIN_PATHS = new Set(['-', '/dev/stdin', '/dev/fd/0', '/proc/self/fd/0']);
|
|
515
|
+
|
|
516
|
+
// --- where a write's body comes from -----------------------------------------------
|
|
517
|
+
|
|
518
|
+
const basenameOf = (w) => String(w || '').replace(/^.*\//, '');
|
|
519
|
+
const INLINE_PRODUCERS = new Set(['echo', 'printf', 'true', ':']);
|
|
520
|
+
|
|
521
|
+
// Body sources are a list of items:
|
|
522
|
+
// { text } content to scan that is not in the command string (an expanded value)
|
|
523
|
+
// { path } a file to read and scan
|
|
524
|
+
// { unresolved } a source that cannot be known statically: block
|
|
525
|
+
// Content that is already inside the command string (heredoc, herestring, echo/printf
|
|
526
|
+
// arguments) needs no item: the whole command is scanned.
|
|
527
|
+
|
|
528
|
+
const unresolvedItem = (reason) => [{ unresolved: reason }];
|
|
529
|
+
// An unresolvable expansion in inline text (not a file path): the caller words its block differently.
|
|
530
|
+
const inlineUnresolved = (reason) => [{ unresolved: reason, inline: true }];
|
|
531
|
+
|
|
532
|
+
// Items for a file named by a shell word, as seen from statement `rec`.
|
|
533
|
+
function wordItems(word, rec, ctx) {
|
|
534
|
+
const r = resolveWord(word, rec.vars, ctx.env, rec.cwdKnown ? ctx.cwd : null);
|
|
535
|
+
return r.unresolved ? unresolvedItem(r.unresolved) : fileItems(r.path, rec.index, ctx);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Items for reading the file `p` as of statement index `before`. A file an earlier
|
|
539
|
+
// statement of the same command writes (and that is recognised: a redirect fed by
|
|
540
|
+
// inline content or by cat, tee, cp) has the content of its source, not whatever is
|
|
541
|
+
// (or is not) on disk now; unrecognised writers are not counted, so the file must
|
|
542
|
+
// exist and is read.
|
|
543
|
+
function fileItems(p, before, ctx) {
|
|
544
|
+
if (p === '/dev/null') return []; // an empty body: nothing to scan
|
|
545
|
+
const writers = [];
|
|
546
|
+
for (let i = 0; i < before; i++) {
|
|
547
|
+
const rec = ctx.history[i];
|
|
548
|
+
if (rec.sep === '&') continue; // backgrounded: may not have run yet
|
|
549
|
+
for (const t of writerTargets(rec, ctx)) if (t.path === p) writers.push(t);
|
|
550
|
+
}
|
|
551
|
+
if (writers.length === 0) return [{ path: p }];
|
|
552
|
+
const items = writers.flatMap((t) => t.content);
|
|
553
|
+
if (writers.some((t) => t.append)) items.push({ path: p, optional: true }); // keeps what was there
|
|
554
|
+
return items;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Content a `cmd > file` redirect puts in the file, or null when not recognised.
|
|
558
|
+
function redirectContent(cmd0, rest, rec, ctx) {
|
|
559
|
+
if (!cmd0) return [];
|
|
560
|
+
if (INLINE_PRODUCERS.has(cmd0)) return rest.flatMap((a) => valueItems(a, rec, ctx));
|
|
561
|
+
if (cmd0 === 'cat') {
|
|
562
|
+
const files = rest.filter((a) => !a.startsWith('-'));
|
|
563
|
+
return files.length ? files.flatMap((f) => wordItems(f, rec, ctx)) : stdinContent(rec, ctx);
|
|
564
|
+
}
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// The files a statement writes and what it writes into them:
|
|
569
|
+
// [{ path, append, content: items }]. Only the recognised shapes: a stdout
|
|
570
|
+
// redirect of cat/echo/printf (or nothing), `tee [-a] path`, and `cp src dst`.
|
|
571
|
+
function writerTargets(rec, ctx) {
|
|
572
|
+
const red = parseRedirects(words(rec.text));
|
|
573
|
+
let k = 0;
|
|
574
|
+
while (k < red.args.length && ASSIGN_TOKEN_RE.test(red.args[k])) k++;
|
|
575
|
+
const cmd0 = basenameOf(red.args[k]);
|
|
576
|
+
const rest = red.args.slice(k + 1);
|
|
577
|
+
const cwd = rec.cwdKnown ? ctx.cwd : null;
|
|
578
|
+
const target = (word) => {
|
|
579
|
+
const r = resolveWord(word, rec.vars, ctx.env, cwd);
|
|
580
|
+
return r.unresolved ? null : r.path;
|
|
581
|
+
};
|
|
582
|
+
const out = [];
|
|
583
|
+
if (red.outs.length) {
|
|
584
|
+
const content = redirectContent(cmd0, rest, rec, ctx);
|
|
585
|
+
if (content) {
|
|
586
|
+
for (const o of red.outs) {
|
|
587
|
+
const t = target(o.word);
|
|
588
|
+
if (t) out.push({ path: t, append: o.append, content });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (cmd0 === 'tee') {
|
|
593
|
+
const content = stdinContent(rec, ctx);
|
|
594
|
+
if (content) {
|
|
595
|
+
const append = rest.includes('-a') || rest.includes('--append');
|
|
596
|
+
for (const f of rest.filter((a) => !a.startsWith('-'))) {
|
|
597
|
+
const t = target(f);
|
|
598
|
+
if (t) out.push({ path: t, append, content });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (cmd0 === 'cp') {
|
|
603
|
+
const operands = rest.filter((a) => !a.startsWith('-'));
|
|
604
|
+
const dst = operands.length === 2 ? target(operands[1]) : null;
|
|
605
|
+
if (dst) out.push({ path: dst, append: false, content: wordItems(operands[0], rec, ctx) });
|
|
606
|
+
}
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// What a statement feeds to its stdin: items, or null when there is no stdin source.
|
|
611
|
+
function stdinContent(rec, ctx) {
|
|
612
|
+
const red = parseRedirects(words(rec.text));
|
|
613
|
+
if (red.stdin) {
|
|
614
|
+
if (red.stdin.type === 'file') return wordItems(red.stdin.word, rec, ctx);
|
|
615
|
+
if (red.stdin.type === 'here') return valueItems(red.stdin.word, rec, ctx);
|
|
616
|
+
return heredocItems(rec, ctx);
|
|
617
|
+
}
|
|
618
|
+
const prev = ctx.history[rec.index - 1];
|
|
619
|
+
if (prev && prev.sep === '|') return producerItems(prev, ctx);
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// What a pipeline producer writes to stdout.
|
|
624
|
+
function producerItems(rec, ctx) {
|
|
625
|
+
const { args } = parseRedirects(words(rec.text));
|
|
626
|
+
let k = 0;
|
|
627
|
+
while (k < args.length && ASSIGN_TOKEN_RE.test(args[k])) k++;
|
|
628
|
+
const cmd0 = basenameOf(args[k]);
|
|
629
|
+
if (INLINE_PRODUCERS.has(cmd0)) return args.slice(k + 1).flatMap((a) => valueItems(a, rec, ctx));
|
|
630
|
+
if (cmd0 === 'cat') {
|
|
631
|
+
const files = args.slice(k + 1).filter((a) => !a.startsWith('-'));
|
|
632
|
+
if (files.length) return files.flatMap((f) => wordItems(f, rec, ctx));
|
|
633
|
+
return stdinContent(rec, ctx) || unresolvedItem('cat reads a stdin the guard cannot see');
|
|
634
|
+
}
|
|
635
|
+
return unresolvedItem(`the body is piped from ${cmd0 || 'a command'}`);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Items for text whose `$...` and backticks the shell would expand: a --body value,
|
|
639
|
+
// an echo/printf argument, a herestring, an unquoted heredoc body (`literalQuotes`).
|
|
640
|
+
// Substitutions of `cat path`/`<path` are read, `cat <<EOF` follows the heredoc,
|
|
641
|
+
// $VAR is expanded, and anything else is unresolvable. Literal text needs no item:
|
|
642
|
+
// the whole command is scanned.
|
|
643
|
+
function valueItems(raw, rec, ctx, literalQuotes = false) {
|
|
644
|
+
if (literalQuotes) return valueItemsOf(raw, rec, ctx, true); // heredoc bodies are not brace-expanded
|
|
645
|
+
const b = braceExpand(raw);
|
|
646
|
+
if (b.overflow) return inlineUnresolved(b.fault ? 'brace expansion failed' : 'brace expansion too large');
|
|
647
|
+
return b.words.flatMap((w) => valueItemsOf(w, rec, ctx, false));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function valueItemsOf(raw, rec, ctx, literalQuotes) {
|
|
651
|
+
if (!/[$`]/.test(raw) || (!literalQuotes && /^'[\s\S]*'$/.test(raw))) return [];
|
|
652
|
+
const items = [];
|
|
653
|
+
let rest = '';
|
|
654
|
+
let at = 0;
|
|
655
|
+
for (const sp of substitutionSpans(raw, literalQuotes)) {
|
|
656
|
+
rest += raw.slice(at, sp.start);
|
|
657
|
+
at = sp.end;
|
|
658
|
+
items.push(...substitutionItems(sp.inner, rec, ctx));
|
|
659
|
+
}
|
|
660
|
+
rest += raw.slice(at);
|
|
661
|
+
if (/[$`]/.test(rest)) {
|
|
662
|
+
const r = literalQuotes ? expandPlain(rest, rec.vars, ctx.env) : expandWord(rest, rec.vars, ctx.env);
|
|
663
|
+
items.push(...(r.unresolved ? inlineUnresolved(r.unresolved) : [{ text: r.value }]));
|
|
664
|
+
}
|
|
665
|
+
return items;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Items for one `$(inner)` / `inner` substitution.
|
|
669
|
+
function substitutionItems(inner, rec, ctx) {
|
|
670
|
+
const t = inner.trim();
|
|
671
|
+
const h = /^cat\s+<<-?\s*(['"]?)\w+\1$/.exec(t);
|
|
672
|
+
if (h) return h[1] ? [] : heredocItems(rec, ctx); // a quoted delimiter is literal text
|
|
673
|
+
const m = /^cat\s+(\S+)$/.exec(t) || /^<\s*(\S+)$/.exec(t);
|
|
674
|
+
if (m) return wordItems(m[1], rec, ctx);
|
|
675
|
+
return inlineUnresolved('command substitution in the body');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Items for the unquoted heredoc bodies of a statement (the shell expands them).
|
|
679
|
+
function heredocItems(rec, ctx) {
|
|
680
|
+
return (rec.heredocs || []).filter((h) => !h.quoted).flatMap((h) => valueItems(h.body, rec, ctx, true));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Every body source of one write: { items }.
|
|
684
|
+
function bodySources(w, ctx) {
|
|
685
|
+
const rec = w.history[w.index];
|
|
686
|
+
const { args } = parseRedirects(words(w.text));
|
|
687
|
+
const stdinItems = () => stdinContent(rec, ctx) || [];
|
|
688
|
+
const fileArg = (raw) => (STDIN_PATHS.has(raw) ? stdinItems() : wordItems(raw, rec, ctx));
|
|
689
|
+
const items = [];
|
|
690
|
+
if (w.kind === 'api') {
|
|
691
|
+
// Every `body=` field: -F/--field may read a file (`body=@path`, `body=@-`); for
|
|
692
|
+
// -f/--raw-field `@` is literal. Any other value is inline text, expanded and scanned.
|
|
693
|
+
const fields = [
|
|
694
|
+
...flagValues(args, '--field', '-F').map((raw) => ({ raw, fileOk: true })),
|
|
695
|
+
...flagValues(args, '--raw-field', '-f').map((raw) => ({ raw, fileOk: false })),
|
|
696
|
+
];
|
|
697
|
+
for (const { raw, fileOk } of fields) {
|
|
698
|
+
const key = dequote(raw); // `'body'=x`, `bo""dy=x` name the same field (#219)
|
|
699
|
+
if (!key.startsWith('body=')) continue;
|
|
700
|
+
if (!(fileOk && key.startsWith('body=@'))) { items.push(...valueItems(raw, rec, ctx)); continue; }
|
|
701
|
+
const r = expandWord(raw, rec.vars, ctx.env);
|
|
702
|
+
if (r.unresolved) { items.push(...unresolvedItem(r.unresolved)); continue; }
|
|
703
|
+
const file = r.value.slice('body=@'.length);
|
|
704
|
+
if (STDIN_PATHS.has(file)) { items.push(...stdinItems()); continue; }
|
|
705
|
+
const res = resolveExpanded(file, rec.cwdKnown ? ctx.cwd : null);
|
|
706
|
+
items.push(...(res.unresolved ? unresolvedItem(res.unresolved) : fileItems(res.path, rec.index, ctx)));
|
|
707
|
+
}
|
|
708
|
+
for (const raw of flagValues(args, '--input', null)) items.push(...fileArg(raw));
|
|
709
|
+
return items;
|
|
710
|
+
}
|
|
711
|
+
const files = flagValues(args, '--body-file', '-F');
|
|
712
|
+
// gh keeps the last one, but scanning every occurrence is the safe direction.
|
|
713
|
+
for (const raw of files) items.push(...fileArg(raw));
|
|
714
|
+
for (const raw of flagValues(args, '--body', '-b')) items.push(...valueItems(raw, rec, ctx));
|
|
715
|
+
return items;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// What bash sees once `\<newline>` continuations are removed, for the statements of
|
|
719
|
+
// one command: each statement's text (already joined outside single quotes, which
|
|
720
|
+
// stay literal) and its unquoted heredoc bodies. The raw command still has a token
|
|
721
|
+
// broken by a continuation, so the caller scans this too (#215).
|
|
722
|
+
function joinedText(history) {
|
|
723
|
+
const parts = [];
|
|
724
|
+
for (const rec of history) {
|
|
725
|
+
parts.push(rec.text);
|
|
726
|
+
for (const h of rec.heredocs || []) {
|
|
727
|
+
if (!h.quoted && h.body.includes('\\\n')) parts.push(h.body.split('\\\n').join(''));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return parts;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Literal quote removal for a word that cannot be expanded: drop the ' and "
|
|
734
|
+
// characters and keep the rest, so literal halves around an unresolvable piece join.
|
|
735
|
+
const stripQuoteChars = (word) => word.replace(/\$(?=['"])/g, '').replace(/['"]/g, '');
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
// What bash passes as each word once adjacent quoted parts are concatenated
|
|
739
|
+
// (`"a""b"`, `"a"b`, `a''b`), for the statements of one command. An unresolvable
|
|
740
|
+
// word falls back to literal quote removal; blocking on it is left to the body
|
|
741
|
+
// sources. Variables are deliberately not expanded (empty scope): a `$NAME` stays
|
|
742
|
+
// literal here, so an env value is never pulled into a field the guard does not
|
|
743
|
+
// scan (valueItems expands those a body takes). The raw command keeps the quotes
|
|
744
|
+
// between the halves, so the caller scans this too (#217). Unquoted brace groups are
|
|
745
|
+
// expanded first (#220); `overflow` is true when one is too large to enumerate.
|
|
746
|
+
function resolvedWords(history) {
|
|
747
|
+
const out = [];
|
|
748
|
+
for (const rec of history) {
|
|
749
|
+
for (const raw of words(rec.text)) {
|
|
750
|
+
const b = braceExpand(raw);
|
|
751
|
+
if (b.overflow) return { words: out, overflow: true, fault: b.fault === true };
|
|
752
|
+
for (const word of b.words) {
|
|
753
|
+
const r = expandWord(word, {}, {});
|
|
754
|
+
out.push(r.unresolved ? stripQuoteChars(word) : r.value);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return { words: out, overflow: false, fault: false };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Read a body file (regular files only: a FIFO or device could hang the hook).
|
|
762
|
+
// `optional` tolerates a missing file.
|
|
763
|
+
function readBodyFile(p, optional) {
|
|
764
|
+
let st;
|
|
765
|
+
try {
|
|
766
|
+
st = fs.statSync(p);
|
|
767
|
+
} catch (e) {
|
|
768
|
+
if (optional && e.code === 'ENOENT') return '';
|
|
769
|
+
throw e;
|
|
770
|
+
}
|
|
771
|
+
if (!st.isFile()) throw Object.assign(new Error('not a regular file'), { code: 'ENOTFILE' });
|
|
772
|
+
return fs.readFileSync(p, 'utf8').slice(0, 1_000_000);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
module.exports = {
|
|
776
|
+
ASSIGN_TOKEN_RE,
|
|
777
|
+
STDIN_PATHS,
|
|
778
|
+
words,
|
|
779
|
+
expandWord,
|
|
780
|
+
braceExpand,
|
|
781
|
+
braceFlatten,
|
|
782
|
+
BRACE_LIMIT,
|
|
783
|
+
parseRedirects,
|
|
784
|
+
flagValues,
|
|
785
|
+
resolveWord,
|
|
786
|
+
resolveExpanded,
|
|
787
|
+
substitutionSpans,
|
|
788
|
+
expandPlain,
|
|
789
|
+
joinedText,
|
|
790
|
+
resolvedWords,
|
|
791
|
+
stripQuoteChars,
|
|
792
|
+
dequote,
|
|
793
|
+
bodySources,
|
|
794
|
+
readBodyFile,
|
|
795
|
+
};
|