tarsec 0.5.2 → 0.5.3
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.
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a parsed AST back into a bash command.
|
|
3
|
+
*
|
|
4
|
+
* The inverse of `bashParser`, with one guarantee that matters more than
|
|
5
|
+
* exact reproduction: **the output is always safe to hand to bash**. Text
|
|
6
|
+
* that needs quoting gets quoted, whatever the node's tag claims. The
|
|
7
|
+
* parser cannot produce a `LiteralWord` holding `; rm -rf /` — its word
|
|
8
|
+
* charset forbids `;` — but a consumer can construct or mutate one, and a
|
|
9
|
+
* naive emitter would hand that straight to a shell.
|
|
10
|
+
*
|
|
11
|
+
* Where quoting cannot rescue a field — a variable name, an assignment
|
|
12
|
+
* target, a redirect operator — an `AstToBashError` is thrown instead.
|
|
13
|
+
* Quoting those would stop them being a variable, a target or an operator
|
|
14
|
+
* at all, so refusing is the only way to keep the guarantee.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const ast = bashParser("git commit -m 'hi'").result;
|
|
18
|
+
* astToBash(ast); // "git commit -m 'hi'"
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import { BashAST, BashNode } from "./types.js";
|
|
22
|
+
/**
|
|
23
|
+
* Thrown for an AST that cannot be written as bash at all.
|
|
24
|
+
*
|
|
25
|
+
* Quoting rescues word TEXT — `'; rm -rf /'` is one harmless argument.
|
|
26
|
+
* It cannot rescue a variable name, an assignment target or a redirect
|
|
27
|
+
* operator: quoting those stops them being a variable, a target or an
|
|
28
|
+
* operator. Emitting them raw would let a hand-built AST inject a second
|
|
29
|
+
* command, so the only way to keep the safety guarantee is to refuse.
|
|
30
|
+
*/
|
|
31
|
+
export declare class AstToBashError extends Error {
|
|
32
|
+
constructor(message: string);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Turn an AST back into bash source.
|
|
36
|
+
*
|
|
37
|
+
* Accepts a whole script (commands joined with `; `) or any single node,
|
|
38
|
+
* down to a lone word — handy in tests and while debugging.
|
|
39
|
+
*
|
|
40
|
+
* For anything the parser produced, `bashParser(astToBash(ast))` gives an
|
|
41
|
+
* equal AST. That is equality of ASTs, not of strings: whitespace
|
|
42
|
+
* normalizes, and redirects move to the end of their command.
|
|
43
|
+
*
|
|
44
|
+
* For a hand-built AST the guarantee is deliberately weaker. The output is
|
|
45
|
+
* always safe and always produces the words asked for, but a tag can
|
|
46
|
+
* change on the way back: `literal "a b"` emits `'a b'` and returns as a
|
|
47
|
+
* `singleQuoted`. No emission preserves both the tag and the meaning, and
|
|
48
|
+
* meaning is the one worth keeping.
|
|
49
|
+
*/
|
|
50
|
+
export declare function astToBash(node: BashNode | BashAST): string;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
2
|
+
const DIGITS = "0123456789";
|
|
3
|
+
/** The characters the parser reads as a bare word or a path. Text made
|
|
4
|
+
* only of these can be emitted unquoted and read back as the same word.
|
|
5
|
+
* This is `bareWordChars` / `bareWordStartChars` from parsers.ts plus
|
|
6
|
+
* `/`, since `literalWordParser` and `pathWordParser` between them cover
|
|
7
|
+
* both. */
|
|
8
|
+
const BARE_WORD_CHARS = new Set(LETTERS + DIGITS + "_-.=:+/");
|
|
9
|
+
const BARE_WORD_START_CHARS = new Set(LETTERS + DIGITS + "_.=:+/");
|
|
10
|
+
/** Characters that can continue a variable name. A variable part followed
|
|
11
|
+
* by one of these has to be braced. */
|
|
12
|
+
const VAR_NAME_CHARS = new Set(LETTERS + DIGITS + "_");
|
|
13
|
+
/** Characters the parser allows in a flag name or value. */
|
|
14
|
+
const FLAG_CHARS = new Set(LETTERS + DIGITS + "_-./");
|
|
15
|
+
/** The redirect operators the parser recognizes. `&>` is separate: bash
|
|
16
|
+
* has no fd-prefixed form of it, and the parser rejects `2&> f`. */
|
|
17
|
+
const FD_REDIRECT_OPS = new Set([">", ">>", "<"]);
|
|
18
|
+
const BARE_REDIRECT_OPS = new Set(["&>"]);
|
|
19
|
+
/**
|
|
20
|
+
* Thrown for an AST that cannot be written as bash at all.
|
|
21
|
+
*
|
|
22
|
+
* Quoting rescues word TEXT — `'; rm -rf /'` is one harmless argument.
|
|
23
|
+
* It cannot rescue a variable name, an assignment target or a redirect
|
|
24
|
+
* operator: quoting those stops them being a variable, a target or an
|
|
25
|
+
* operator. Emitting them raw would let a hand-built AST inject a second
|
|
26
|
+
* command, so the only way to keep the safety guarantee is to refuse.
|
|
27
|
+
*/
|
|
28
|
+
export class AstToBashError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "AstToBashError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** `[A-Za-z_][A-Za-z0-9_]*`, matching the parser's identifier rule. */
|
|
35
|
+
function isIdentifier(text) {
|
|
36
|
+
if (text.length === 0)
|
|
37
|
+
return false;
|
|
38
|
+
if (!(LETTERS + "_").includes(text[0]))
|
|
39
|
+
return false;
|
|
40
|
+
for (const character of text) {
|
|
41
|
+
if (!VAR_NAME_CHARS.has(character))
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
/** A variable name: an identifier, or a single digit for a positional
|
|
47
|
+
* parameter (`$1`), matching `varNameParser`. */
|
|
48
|
+
function isVariableName(text) {
|
|
49
|
+
return isIdentifier(text) || (text.length === 1 && DIGITS.includes(text));
|
|
50
|
+
}
|
|
51
|
+
function isFlagText(text) {
|
|
52
|
+
for (const character of text) {
|
|
53
|
+
if (!FLAG_CHARS.has(character))
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return text.length > 0;
|
|
57
|
+
}
|
|
58
|
+
/** Can this text be written without quotes and read back unchanged?
|
|
59
|
+
*
|
|
60
|
+
* A leading `-` disqualifies it: bare `-la` reads back as a flag, not a
|
|
61
|
+
* literal. Quoting is the safe answer in that case. */
|
|
62
|
+
function isBareWord(text) {
|
|
63
|
+
if (text.length === 0)
|
|
64
|
+
return false;
|
|
65
|
+
if (!BARE_WORD_START_CHARS.has(text[0]))
|
|
66
|
+
return false;
|
|
67
|
+
for (const character of text) {
|
|
68
|
+
if (!BARE_WORD_CHARS.has(character))
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
/** Wrap text in single quotes. Bash has no escape inside single quotes,
|
|
74
|
+
* so an embedded `'` closes the string, emits an escaped quote, and
|
|
75
|
+
* reopens: `it's` becomes `'it'\''s'`. */
|
|
76
|
+
function singleQuote(text) {
|
|
77
|
+
return `'${text.split("'").join("'\\''")}'`;
|
|
78
|
+
}
|
|
79
|
+
/** Emit bare where that reads back unchanged, quoted otherwise. */
|
|
80
|
+
function quoteIfNeeded(text) {
|
|
81
|
+
return isBareWord(text) ? text : singleQuote(text);
|
|
82
|
+
}
|
|
83
|
+
/** Escape the characters that stay special inside double quotes. */
|
|
84
|
+
function escapeForDoubleQuotes(text) {
|
|
85
|
+
let out = "";
|
|
86
|
+
for (const character of text) {
|
|
87
|
+
if (character === '"' || character === "\\" || character === "$" || character === "`") {
|
|
88
|
+
out += "\\";
|
|
89
|
+
}
|
|
90
|
+
out += character;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function doubleQuotedToBash(word) {
|
|
95
|
+
const inner = word.parts
|
|
96
|
+
.map((part) => part.tag === "literal" ? escapeForDoubleQuotes(part.text) : wordToBash(part))
|
|
97
|
+
.join("");
|
|
98
|
+
return `"${inner}"`;
|
|
99
|
+
}
|
|
100
|
+
/** Would `next` be read as a continuation of the variable name before it? */
|
|
101
|
+
function continuesVariableName(next) {
|
|
102
|
+
if (next === undefined)
|
|
103
|
+
return false;
|
|
104
|
+
const text = next.tag === "literal" ? next.text : "";
|
|
105
|
+
return text.length > 0 && VAR_NAME_CHARS.has(text[0]);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Adjacent parts, concatenated with no separator.
|
|
109
|
+
*
|
|
110
|
+
* The one subtlety: `[variable "A", literal "bc"]` written plainly gives
|
|
111
|
+
* `$Abc`, which reads back as a variable named `Abc` — a different
|
|
112
|
+
* command. A variable is braced whenever the next part could continue its
|
|
113
|
+
* name.
|
|
114
|
+
*/
|
|
115
|
+
function interpolatedToBash(word) {
|
|
116
|
+
return word.parts
|
|
117
|
+
.map((part, index) => {
|
|
118
|
+
if (part.tag === "variable" && continuesVariableName(word.parts[index + 1])) {
|
|
119
|
+
return `\${${part.name}}`;
|
|
120
|
+
}
|
|
121
|
+
return wordToBash(part);
|
|
122
|
+
})
|
|
123
|
+
.join("");
|
|
124
|
+
}
|
|
125
|
+
function wordToBash(word) {
|
|
126
|
+
switch (word.tag) {
|
|
127
|
+
case "literal":
|
|
128
|
+
case "path":
|
|
129
|
+
return quoteIfNeeded(word.text);
|
|
130
|
+
case "singleQuoted":
|
|
131
|
+
return singleQuote(word.text);
|
|
132
|
+
case "doubleQuoted":
|
|
133
|
+
return doubleQuotedToBash(word);
|
|
134
|
+
case "variable":
|
|
135
|
+
if (!isVariableName(word.name)) {
|
|
136
|
+
throw new AstToBashError(`Not a valid variable name: ${JSON.stringify(word.name)}`);
|
|
137
|
+
}
|
|
138
|
+
return `$${word.name}`;
|
|
139
|
+
case "flag":
|
|
140
|
+
return flagToBash(word);
|
|
141
|
+
case "interpolatedVariable":
|
|
142
|
+
return interpolatedToBash(word);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** A flag CAN be rescued by quoting: the whole token becomes a single
|
|
146
|
+
* argument. It reads back as a quoted word rather than a flag, which is
|
|
147
|
+
* the documented trade for hand-built nodes — one argument that means
|
|
148
|
+
* what it says beats a flag that starts a second command. */
|
|
149
|
+
function flagToBash(word) {
|
|
150
|
+
const rendered = word.flagValue === undefined
|
|
151
|
+
? word.flagName
|
|
152
|
+
: `${word.flagName}=${word.flagValue}`;
|
|
153
|
+
const isSafe = word.flagName.startsWith("-") &&
|
|
154
|
+
isFlagText(word.flagName.replace(/^--?/, "")) &&
|
|
155
|
+
(word.flagValue === undefined || isFlagText(word.flagValue));
|
|
156
|
+
return isSafe ? rendered : singleQuote(rendered);
|
|
157
|
+
}
|
|
158
|
+
function assignmentToBash(assignment) {
|
|
159
|
+
if (!isIdentifier(assignment.name)) {
|
|
160
|
+
throw new AstToBashError(`Not a valid assignment name: ${JSON.stringify(assignment.name)}`);
|
|
161
|
+
}
|
|
162
|
+
const value = assignment.value === null ? "" : wordToBash(assignment.value);
|
|
163
|
+
return `${assignment.name}=${value}`;
|
|
164
|
+
}
|
|
165
|
+
function redirectToBash(redirect) {
|
|
166
|
+
const takesFd = FD_REDIRECT_OPS.has(redirect.op);
|
|
167
|
+
if (!takesFd && !BARE_REDIRECT_OPS.has(redirect.op)) {
|
|
168
|
+
throw new AstToBashError(`Not a supported redirect operator: ${JSON.stringify(redirect.op)}`);
|
|
169
|
+
}
|
|
170
|
+
if (redirect.fd !== undefined && !takesFd) {
|
|
171
|
+
throw new AstToBashError(`\`${redirect.op}\` takes no file descriptor.`);
|
|
172
|
+
}
|
|
173
|
+
const fd = redirect.fd === undefined ? "" : String(redirect.fd);
|
|
174
|
+
return `${fd}${redirect.op} ${wordToBash(redirect.target)}`;
|
|
175
|
+
}
|
|
176
|
+
/** Assignments, command, arguments, redirects.
|
|
177
|
+
*
|
|
178
|
+
* Redirects go last because the AST does not record where they sat among
|
|
179
|
+
* the arguments — `cmd > out.txt arg` comes back as `cmd arg > out.txt`,
|
|
180
|
+
* which is the same command. */
|
|
181
|
+
function simpleCommandToBash(command) {
|
|
182
|
+
const pieces = [
|
|
183
|
+
...command.assignments.map(assignmentToBash),
|
|
184
|
+
...(command.command === null ? [] : [wordToBash(command.command)]),
|
|
185
|
+
...command.args.map(wordToBash),
|
|
186
|
+
...command.redirects.map(redirectToBash),
|
|
187
|
+
];
|
|
188
|
+
return pieces.join(" ");
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* A chain operand, parenthesized when it needs to be.
|
|
192
|
+
*
|
|
193
|
+
* `&&` and `||` are one precedence level and associate left, so an
|
|
194
|
+
* `and`/`or` nested on the RIGHT cannot be written flat: `And(a, Or(b, c))`
|
|
195
|
+
* as `a && b || c` reads back as `Or(And(a, b), c)`, a different tree.
|
|
196
|
+
*
|
|
197
|
+
* Bash parens are a subshell, so this is not a pure grouping — but the
|
|
198
|
+
* parser never builds this shape (chains are left-associative, so right
|
|
199
|
+
* children are always primaries), and it is the only way to preserve a
|
|
200
|
+
* hand-built one.
|
|
201
|
+
*/
|
|
202
|
+
function chainOperandToBash(command, isRightOperand) {
|
|
203
|
+
const needsParens = isRightOperand && (command.tag === "and" || command.tag === "or");
|
|
204
|
+
const emitted = commandToBash(command);
|
|
205
|
+
return needsParens ? `(${emitted})` : emitted;
|
|
206
|
+
}
|
|
207
|
+
function commandToBash(command) {
|
|
208
|
+
switch (command.tag) {
|
|
209
|
+
case "simpleCommand":
|
|
210
|
+
return simpleCommandToBash(command);
|
|
211
|
+
case "and":
|
|
212
|
+
case "or": {
|
|
213
|
+
const operator = command.tag === "and" ? "&&" : "||";
|
|
214
|
+
const left = chainOperandToBash(command.left, false);
|
|
215
|
+
const right = chainOperandToBash(command.right, true);
|
|
216
|
+
return `${left} ${operator} ${right}`;
|
|
217
|
+
}
|
|
218
|
+
case "parens":
|
|
219
|
+
return `(${commandToBash(command.command)})`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function isCommand(node) {
|
|
223
|
+
return (node.tag === "simpleCommand" ||
|
|
224
|
+
node.tag === "and" ||
|
|
225
|
+
node.tag === "or" ||
|
|
226
|
+
node.tag === "parens");
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Turn an AST back into bash source.
|
|
230
|
+
*
|
|
231
|
+
* Accepts a whole script (commands joined with `; `) or any single node,
|
|
232
|
+
* down to a lone word — handy in tests and while debugging.
|
|
233
|
+
*
|
|
234
|
+
* For anything the parser produced, `bashParser(astToBash(ast))` gives an
|
|
235
|
+
* equal AST. That is equality of ASTs, not of strings: whitespace
|
|
236
|
+
* normalizes, and redirects move to the end of their command.
|
|
237
|
+
*
|
|
238
|
+
* For a hand-built AST the guarantee is deliberately weaker. The output is
|
|
239
|
+
* always safe and always produces the words asked for, but a tag can
|
|
240
|
+
* change on the way back: `literal "a b"` emits `'a b'` and returns as a
|
|
241
|
+
* `singleQuoted`. No emission preserves both the tag and the meaning, and
|
|
242
|
+
* meaning is the one worth keeping.
|
|
243
|
+
*/
|
|
244
|
+
export function astToBash(node) {
|
|
245
|
+
if (Array.isArray(node)) {
|
|
246
|
+
return node.map(commandToBash).join("; ");
|
|
247
|
+
}
|
|
248
|
+
if (isCommand(node)) {
|
|
249
|
+
return commandToBash(node);
|
|
250
|
+
}
|
|
251
|
+
switch (node.tag) {
|
|
252
|
+
case "assignment":
|
|
253
|
+
return assignmentToBash(node);
|
|
254
|
+
case "redirect":
|
|
255
|
+
return redirectToBash(node);
|
|
256
|
+
default:
|
|
257
|
+
return wordToBash(node);
|
|
258
|
+
}
|
|
259
|
+
}
|