@gotgenes/pi-permission-system 35.0.2 → 35.0.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.
- package/CHANGELOG.md +16 -0
- package/docs/configuration.md +22 -3
- package/package.json +1 -1
- package/src/access-intent/bash/awk-invocation.ts +86 -0
- package/src/access-intent/bash/command-effects.ts +50 -21
- package/src/access-intent/bash/command-enumeration.ts +7 -2
- package/src/access-intent/bash/node-text.ts +76 -0
- package/src/access-intent/bash/sed-invocation.ts +471 -0
- package/src/access-intent/bash/token-collection.ts +13 -5
- package/src/access-intent/bash/wrapper-analysis.ts +11 -5
- package/src/handlers/gates/bash-path-extractor.ts +0 -23
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [35.0.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v35.0.2...pi-permission-system-v35.0.3) (2026-09-29)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** a quoted withdrawing option keeps a wrapped find, fd, or sort floored ([d60653a](https://github.com/gotgenes/pi-packages/commit/d60653a3e53847851e889e8133a7fdab143b8cbb)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
14
|
+
* **pi-permission-system:** sed that only prints resolves on the read surface ([274ba89](https://github.com/gotgenes/pi-packages/commit/274ba8963d899f97306bfcfd2ece10ca2720d29d)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
15
|
+
* **pi-permission-system:** sed substitutions and regex addresses resolve on the read surface ([fbbc940](https://github.com/gotgenes/pi-packages/commit/fbbc94099fd7f0b31155b73c5c46b99359482ab9)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
16
|
+
* **pi-permission-system:** awk that only reads its input resolves on the read surface ([136c739](https://github.com/gotgenes/pi-packages/commit/136c739e2025d3d47bfcfc72929533411e515232)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
17
|
+
* **pi-permission-system:** a brace-expanded argument withdraws sed's and awk's read claim ([5b38dd3](https://github.com/gotgenes/pi-packages/commit/5b38dd3fb6e87ffa011dd25a80df372a955b4696)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* **pi-permission-system:** document sed and awk as presumed readers ([d3d0314](https://github.com/gotgenes/pi-packages/commit/d3d0314bf1a2940db237f384d1f377f727a3ea66)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
22
|
+
* **pi-permission-system:** name brace expansion among the computed spellings ([e4fee09](https://github.com/gotgenes/pi-packages/commit/e4fee09286705c898dd211ed5618ab4c040472d1)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
23
|
+
|
|
8
24
|
## [35.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v35.0.1...pi-permission-system-v35.0.2) (2026-09-28)
|
|
9
25
|
|
|
10
26
|
|
package/docs/configuration.md
CHANGED
|
@@ -879,12 +879,12 @@ A path token owned by one of them consults the `_read` surface alone:
|
|
|
879
879
|
|
|
880
880
|
<!-- BEGIN PURE_READER_CORE -->
|
|
881
881
|
|
|
882
|
-
`basename`, `cat`, `cd`, `diff`, `dirname`, `echo`, `egrep`, `fd`, `fgrep`, `find`, `grep`, `head`, `ls`, `pwd`, `realpath`, `rg`, `sort`, `stat`, `tail`, `wc`, `which`
|
|
882
|
+
`awk`, `basename`, `cat`, `cd`, `diff`, `dirname`, `echo`, `egrep`, `fd`, `fgrep`, `find`, `grep`, `head`, `ls`, `pwd`, `realpath`, `rg`, `sed`, `sort`, `stat`, `tail`, `wc`, `which`
|
|
883
883
|
|
|
884
884
|
<!-- END PURE_READER_CORE -->
|
|
885
885
|
|
|
886
886
|
The bar for admission is structural, not popularity: implementation-independent read-only-ness across GNU and BSD alike, no option that redirects output to a file, and effects that do not depend on argument content.
|
|
887
|
-
`
|
|
887
|
+
`uniq`, `tee`, `dd`, and `split` each have a positional or option that writes a file; `file` is excluded because `-C`/`--compile` writes a `magic.mgc` file; `less` and `more` can escape to a shell; `git`, `pnpm`, and `node` are subcommand-dependent; `gawk` and `nawk` are not admitted.
|
|
888
888
|
|
|
889
889
|
Three members are read-only **until an argument says otherwise**, and naming one of these options withdraws the claim — the token falls back to consulting both surfaces:
|
|
890
890
|
|
|
@@ -902,6 +902,25 @@ A core word counts only as a **bare basename**.
|
|
|
902
902
|
The core cannot be extended or removed from configuration.
|
|
903
903
|
If you do not trust a member of it, deny or ask on the paths themselves — an effect proof only chooses which surface answers, and never overrides the answer.
|
|
904
904
|
|
|
905
|
+
##### `sed` and `awk`
|
|
906
|
+
|
|
907
|
+
`sed` and `awk` can write through their script or program as well as their options (`sed 'w out'`, `awk '{print > FILENAME}'`), so a withdrawing option is not enough to guard them.
|
|
908
|
+
They are read-only only when the whole command line is **proven** read-only, and anything the proof does not recognize withdraws the claim:
|
|
909
|
+
|
|
910
|
+
| Command | Proven read-only when |
|
|
911
|
+
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
912
|
+
| `sed` | every option is one of `-n`, `-E`, `-r`, `-s`, `-u`, `-z` (clustered or not), `-e`/`--expression`, `--quiet`, `--silent`, `--regexp-extended`, `--separate`, `--unbuffered`, `--null-data`, `--posix`, `--debug`, and `--sandbox`, and every script uses only read-only commands |
|
|
913
|
+
| `awk` | the only options are `-F` and `-v`, and the program text holds no `>`, `\|`, `system`, or `@` |
|
|
914
|
+
|
|
915
|
+
So `sed -n '1,80p' file`, `sed 's/a/b/g' file`, and `awk '{print $1}' file` read, while `sed -i …`, `sed -f script.sed …`, a script with a `w`, `r`, `e`, `a`, `i`, or `c` command, `s///w` or `s///e`, `awk -f prog.awk …`, and `awk '{print > "out"}' …` consult both surfaces.
|
|
916
|
+
A long option is matched only when spelled in full, so every abbreviation (`--in`, `--qui`) withdraws the claim.
|
|
917
|
+
`awk`'s check is a character scan rather than a parse, so a comparison such as `NR>=100` withdraws it too.
|
|
918
|
+
|
|
919
|
+
Two more shapes withdraw the claim:
|
|
920
|
+
|
|
921
|
+
- An argument whose value only the shell decides (`"$range"`, `$f`, `$(cmd)`, `-*`, `-\i`, `{-i,-n}`), wherever it sits, because it could arrive as `-i` or as the script itself.
|
|
922
|
+
- A `sed` script where GNU and BSD would read different commands: a delimiter inside a bracket expression (`s/[/]/x/`), or an `-e` after the first positional.
|
|
923
|
+
|
|
905
924
|
#### Wrapper transparency
|
|
906
925
|
|
|
907
926
|
The [indirection-wrapper floor](#fail-closed-behavior) exists because a wrapper hides the command that should be gated.
|
|
@@ -921,7 +940,7 @@ All four of these must hold, and each is a way the floor's reason could still ap
|
|
|
921
940
|
4. The enclosing statement provably writes no file through a redirect.
|
|
922
941
|
A destination the parse cannot resolve — `> $OUT`, `> $(mktemp)` — counts against the exemption rather than for it.
|
|
923
942
|
|
|
924
|
-
So `xargs grep -l foo`, `xargs wc -l`, and `find . -name '*.ts' -exec cat {} +` stop prompting under a matching `bash` allow, while `xargs rm`, `xargs sed -i`, `time pnpm test`, and `find . -exec sh -c '…' \;` still prompt.
|
|
943
|
+
So `xargs grep -l foo`, `xargs wc -l`, `xargs sed -n 1p`, and `find . -name '*.ts' -exec cat {} +` stop prompting under a matching `bash` allow, while `xargs rm`, `xargs sed -i`, `time pnpm test`, and `find . -exec sh -c '…' \;` still prompt.
|
|
925
944
|
|
|
926
945
|
Three things this does **not** change:
|
|
927
946
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ArgWord } from "./node-text";
|
|
2
|
+
|
|
3
|
+
// ── Public surface ─────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether an `awk` invocation's arguments withdraw its read claim.
|
|
7
|
+
*
|
|
8
|
+
* The claim stands only when the command line is **proven** read-only: the
|
|
9
|
+
* only options are `-F` and `-v`, the program arrives as literal text, and
|
|
10
|
+
* that text holds none of the constructs through which a POSIX or GNU awk
|
|
11
|
+
* program writes a file or runs a command.
|
|
12
|
+
*
|
|
13
|
+
* The program check is a character scan, not a parse, so it over-retracts:
|
|
14
|
+
* `NR>=100` withdraws the claim because `>` is also `print >`'s redirect.
|
|
15
|
+
* That costs relief and never safety — a withdrawn claim consults both
|
|
16
|
+
* surfaces, exactly as `awk` did before it joined the core.
|
|
17
|
+
*
|
|
18
|
+
* A computed word withdraws the claim wherever it sits, since it could spell
|
|
19
|
+
* `-f` or the program itself.
|
|
20
|
+
*/
|
|
21
|
+
export function awkWithdrawsReadClaim(argWords: readonly ArgWord[]): boolean {
|
|
22
|
+
if (argWords.some(({ computed }) => computed)) return true;
|
|
23
|
+
const program = selectProgram(argWords.map(({ value }) => value));
|
|
24
|
+
return program === null || !provesReadOnlyProgram(program);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── The option walk ────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** `-F sep` and `-v name=value`: the options every awk shares that cannot write. */
|
|
30
|
+
const VALUE_OPTIONS: ReadonlySet<string> = new Set(["-F", "-v"]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The program text an argument list hands `awk`, or `null` when the list
|
|
34
|
+
* cannot be proven to hand it only that.
|
|
35
|
+
*
|
|
36
|
+
* The program is the first positional. An option-looking word anywhere else
|
|
37
|
+
* withdraws the claim: whether an awk stops reading options at the program
|
|
38
|
+
* varies by implementation, so a trailing `-f` is not assumed to be a file.
|
|
39
|
+
*/
|
|
40
|
+
function selectProgram(values: readonly string[]): string | null {
|
|
41
|
+
let program: string | null = null;
|
|
42
|
+
let optionsEnded = false;
|
|
43
|
+
for (let i = 0; i < values.length; i++) {
|
|
44
|
+
const value = values[i];
|
|
45
|
+
if (optionsEnded || !isOptionWord(value)) {
|
|
46
|
+
program ??= value;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (program !== null) return null;
|
|
50
|
+
if (value === "--") {
|
|
51
|
+
optionsEnded = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (VALUE_OPTIONS.has(value)) {
|
|
55
|
+
if (i + 1 >= values.length) return null;
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!isAttachedValueOption(value)) return null;
|
|
60
|
+
}
|
|
61
|
+
return program;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isOptionWord(value: string): boolean {
|
|
65
|
+
return value.startsWith("-") && value !== "-";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `-F:` or `-vn=3`: an allowed option carrying its value in the same word. */
|
|
69
|
+
function isAttachedValueOption(value: string): boolean {
|
|
70
|
+
return VALUE_OPTIONS.has(value.slice(0, 2)) && value.length > 2;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── The program scan ───────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Text whose presence means the program may write a file or run a command.
|
|
77
|
+
*
|
|
78
|
+
* `>` covers `print >` and `>>`; `|` covers a pipe to or from a command and
|
|
79
|
+
* gawk's `|&` coprocess; `system` covers `system()`; `@` covers gawk's
|
|
80
|
+
* `@include`, `@load`, and indirect function calls.
|
|
81
|
+
*/
|
|
82
|
+
const WRITE_CAPABLE_TEXT: readonly string[] = [">", "|", "system", "@"];
|
|
83
|
+
|
|
84
|
+
function provesReadOnlyProgram(program: string): boolean {
|
|
85
|
+
return !WRITE_CAPABLE_TEXT.some((text) => program.includes(text));
|
|
86
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { type TokenEffect, UNPROVEN_EFFECT } from "#src/access-intent/effect";
|
|
2
|
+
import { awkWithdrawsReadClaim } from "./awk-invocation";
|
|
3
|
+
import type { ArgWord } from "./node-text";
|
|
4
|
+
import { sedWithdrawsReadClaim } from "./sed-invocation";
|
|
2
5
|
|
|
3
6
|
// ── Public surface ─────────────────────────────────────────────────────────
|
|
4
7
|
|
|
@@ -22,18 +25,16 @@ import { type TokenEffect, UNPROVEN_EFFECT } from "#src/access-intent/effect";
|
|
|
22
25
|
*/
|
|
23
26
|
export function proveCommandEffect(
|
|
24
27
|
headWord: string,
|
|
25
|
-
argWords: readonly
|
|
28
|
+
argWords: readonly ArgWord[],
|
|
26
29
|
): TokenEffect {
|
|
27
30
|
if (!isBareCoreWord(headWord)) return UNPROVEN_EFFECT;
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
return RETRACTED_EFFECT;
|
|
31
|
-
}
|
|
31
|
+
const withdrawsClaim = RETRACTION_GUARDS.get(headWord);
|
|
32
|
+
if (withdrawsClaim?.(argWords)) return RETRACTED_EFFECT;
|
|
32
33
|
return CORE_READ_EFFECT;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
|
-
* The
|
|
37
|
+
* The pure-reader core: the command words that are read-only for any
|
|
37
38
|
* arguments, in any implementation.
|
|
38
39
|
*
|
|
39
40
|
* Exported so `docs/configuration.md`'s published roster is held to it by a
|
|
@@ -118,8 +119,7 @@ interface CoreAdmission {
|
|
|
118
119
|
*
|
|
119
120
|
* | Word | Why not |
|
|
120
121
|
* | ----------------------------------------------- | -------------------------------------------------------------- |
|
|
121
|
-
* | `
|
|
122
|
-
* | `sed` | `-i` is in-place, and BSD needs a separate argument where GNU attaches one, so the guard is dialect-variant |
|
|
122
|
+
* | `gawk`, `nawk` | Their own dialects' options are unaudited; `awk` is guarded instead |
|
|
123
123
|
* | `uniq` | `uniq IN OUT` writes its second positional |
|
|
124
124
|
* | `tee`, `dd`, `split`, `csplit`, `xxd`, `tree`, `curl`, `wget` | Each has a positional or option that writes a file |
|
|
125
125
|
* | `less`, `more` | Interactive shell escape (`!cmd`) and `LESSOPEN` preprocessing |
|
|
@@ -160,6 +160,16 @@ function coreAdmissions(): readonly CoreAdmission[] {
|
|
|
160
160
|
reason:
|
|
161
161
|
"Read-only until an argument says otherwise — see RETRACTION_GUARDS",
|
|
162
162
|
},
|
|
163
|
+
{
|
|
164
|
+
words: ["sed"],
|
|
165
|
+
reason:
|
|
166
|
+
"Read-only until the command line says otherwise: an allowlist proof over its options and script — see sed-invocation.ts",
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
words: ["awk"],
|
|
170
|
+
reason:
|
|
171
|
+
"Read-only until the command line says otherwise: an allowlist proof over its options and program text — see awk-invocation.ts",
|
|
172
|
+
},
|
|
163
173
|
];
|
|
164
174
|
}
|
|
165
175
|
|
|
@@ -189,18 +199,35 @@ interface RetractionGuard {
|
|
|
189
199
|
}
|
|
190
200
|
|
|
191
201
|
/**
|
|
192
|
-
*
|
|
202
|
+
* Whether a guarded word's arguments withdraw its read claim.
|
|
193
203
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* guarded. `find`'s options are single-dash long words that never cluster, so
|
|
197
|
-
* they match as exact words; `sort`'s only short option containing `o` is `-o`
|
|
198
|
-
* itself, so the cluster rule cannot over-retract there.
|
|
204
|
+
* Each guarded word owns its own predicate, so a word whose proof needs more
|
|
205
|
+
* than option spellings can supply one without widening the option shape.
|
|
199
206
|
*/
|
|
200
|
-
|
|
207
|
+
type ClaimWithdrawal = (argWords: readonly ArgWord[]) => boolean;
|
|
208
|
+
|
|
209
|
+
/** A withdrawal decided by option spellings alone: any argument naming one. */
|
|
210
|
+
function optionGuard(guard: RetractionGuard): ClaimWithdrawal {
|
|
211
|
+
return (argWords) =>
|
|
212
|
+
argWords.some(({ value }) => retractsClaim(value, guard));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The guarded words and what withdraws each one's claim.
|
|
217
|
+
*
|
|
218
|
+
* `find`, `fd`, and `sort` are guarded by option spellings alone, chosen
|
|
219
|
+
* because their write options spell identically in GNU and BSD. `sed` needs a
|
|
220
|
+
* proof over its script too, since a `w` command writes whatever its options
|
|
221
|
+
* say, and `awk` a proof over its program, since `print >` does the same — so
|
|
222
|
+
* each owns a predicate of its own. `find`'s options are single-dash long
|
|
223
|
+
* words that never cluster, so they match as exact words; `sort`'s only short
|
|
224
|
+
* option containing `o` is `-o` itself, so the cluster rule cannot
|
|
225
|
+
* over-retract there.
|
|
226
|
+
*/
|
|
227
|
+
const RETRACTION_GUARDS: ReadonlyMap<string, ClaimWithdrawal> = new Map([
|
|
201
228
|
[
|
|
202
229
|
"find",
|
|
203
|
-
{
|
|
230
|
+
optionGuard({
|
|
204
231
|
exactWords: new Set([
|
|
205
232
|
"-exec",
|
|
206
233
|
"-execdir",
|
|
@@ -212,22 +239,24 @@ const RETRACTION_GUARDS: ReadonlyMap<string, RetractionGuard> = new Map([
|
|
|
212
239
|
"-fprintf",
|
|
213
240
|
"-fls",
|
|
214
241
|
]),
|
|
215
|
-
},
|
|
242
|
+
}),
|
|
216
243
|
],
|
|
217
244
|
[
|
|
218
245
|
"fd",
|
|
219
|
-
{
|
|
246
|
+
optionGuard({
|
|
220
247
|
longStems: new Set(["--exec", "--exec-batch"]),
|
|
221
248
|
shortLetters: new Set(["x", "X"]),
|
|
222
|
-
},
|
|
249
|
+
}),
|
|
223
250
|
],
|
|
224
251
|
[
|
|
225
252
|
"sort",
|
|
226
|
-
{
|
|
253
|
+
optionGuard({
|
|
227
254
|
longStems: new Set(["--output"]),
|
|
228
255
|
shortLetters: new Set(["o"]),
|
|
229
|
-
},
|
|
256
|
+
}),
|
|
230
257
|
],
|
|
258
|
+
["sed", sedWithdrawsReadClaim],
|
|
259
|
+
["awk", awkWithdrawsReadClaim],
|
|
231
260
|
]);
|
|
232
261
|
|
|
233
262
|
// ── Private helpers ────────────────────────────────────────────────────────
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BashCommandContext, FloorExemption } from "#src/types";
|
|
2
2
|
import { EXECUTION_HOST_TYPES, forEachExecutionIn } from "./nested-execution";
|
|
3
|
+
import { readArgWord } from "./node-text";
|
|
3
4
|
import { parseUnresolvedWithin } from "./parse-health";
|
|
4
5
|
import type { TSNode } from "./parser";
|
|
5
6
|
import { REDIRECT_NODE_TYPES, redirectMayWriteFile } from "./redirect-analysis";
|
|
@@ -284,7 +285,11 @@ export function collectSalvagedCommands(node: TSNode): BashCommand[] {
|
|
|
284
285
|
export function inlineShellPayloadNode(command: TSNode): TSNode | null {
|
|
285
286
|
const nodes = commandWordNodes(command);
|
|
286
287
|
const index = inlineShellPayloadIndex(
|
|
287
|
-
nodes.map((node) => ({
|
|
288
|
+
nodes.map((node) => ({
|
|
289
|
+
...readArgWord(node),
|
|
290
|
+
text: node.text,
|
|
291
|
+
offset: node.startIndex,
|
|
292
|
+
})),
|
|
288
293
|
);
|
|
289
294
|
return index === -1 ? null : (nodes.at(index) ?? null);
|
|
290
295
|
}
|
|
@@ -490,7 +495,7 @@ function readCommandUnit(node: TSNode): {
|
|
|
490
495
|
let previous: TSNode | undefined;
|
|
491
496
|
for (const word of nodes) {
|
|
492
497
|
if (previous) text += gapBetween(node, previous, word, redirects);
|
|
493
|
-
words.push({ text: word.text, offset: text.length });
|
|
498
|
+
words.push({ ...readArgWord(word), text: word.text, offset: text.length });
|
|
494
499
|
text += word.text;
|
|
495
500
|
previous = word;
|
|
496
501
|
}
|
|
@@ -27,6 +27,82 @@ export const ARG_NODE_TYPES = new Set([
|
|
|
27
27
|
"raw_string",
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* One argument as the program receives it, and whether that is knowable.
|
|
32
|
+
*
|
|
33
|
+
* A capability proof must read the value after quote removal, since `'-o'` and
|
|
34
|
+
* `-o` reach `sort` identically, and must know when the value is computed,
|
|
35
|
+
* since a word only the shell decides can spell any option at all.
|
|
36
|
+
*/
|
|
37
|
+
export interface ArgWord {
|
|
38
|
+
/** The string the shell passes after quote removal ({@link resolveNodeText}). */
|
|
39
|
+
readonly value: string;
|
|
40
|
+
/**
|
|
41
|
+
* Whether `value` may differ from what the program receives: a part only
|
|
42
|
+
* running the command decides ({@link hasComputedPart}), or a spelling the
|
|
43
|
+
* shell rewrites before the program sees it — an escape, a glob, a brace
|
|
44
|
+
* expansion, an ANSI-C string — which {@link resolveNodeText} passes through as written.
|
|
45
|
+
*/
|
|
46
|
+
readonly computed: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read an argument node into the word the program receives. */
|
|
50
|
+
export function readArgWord(node: TSNode): ArgWord {
|
|
51
|
+
return {
|
|
52
|
+
value: resolveNodeText(node),
|
|
53
|
+
computed: hasComputedPart(node) || !isSpelledExactly(node),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether {@link resolveNodeText} returns exactly the string the shell passes.
|
|
59
|
+
*
|
|
60
|
+
* Answers `false` for any node type it does not know, which is the
|
|
61
|
+
* fail-closed direction for a caller proving what a word cannot be.
|
|
62
|
+
*/
|
|
63
|
+
function isSpelledExactly(node: TSNode): boolean {
|
|
64
|
+
switch (node.type) {
|
|
65
|
+
case "raw_string":
|
|
66
|
+
return true;
|
|
67
|
+
case "word":
|
|
68
|
+
return !SHELL_REWRITTEN_CHARACTERS.test(node.text);
|
|
69
|
+
case "string_content":
|
|
70
|
+
return !node.text.includes("\\");
|
|
71
|
+
case "simple_expansion":
|
|
72
|
+
case "expansion":
|
|
73
|
+
return resolvePlainVariableExpansion(node) !== null;
|
|
74
|
+
case "string":
|
|
75
|
+
return childrenSpelledExactly(node);
|
|
76
|
+
case "concatenation":
|
|
77
|
+
// The grammar splits `{-i,-n}` into plain words, so the expansion is
|
|
78
|
+
// visible only across the whole concatenation's text.
|
|
79
|
+
return !BRACE_EXPANSION.test(node.text) && childrenSpelledExactly(node);
|
|
80
|
+
default:
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A `"` delimiter is spelled exactly; every named child must be too. */
|
|
86
|
+
function childrenSpelledExactly(node: TSNode): boolean {
|
|
87
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
88
|
+
const child = node.child(i);
|
|
89
|
+
if (!child || child.type === '"') continue;
|
|
90
|
+
if (!isSpelledExactly(child)) return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** An escape, or a glob the shell may expand into other words. */
|
|
96
|
+
const SHELL_REWRITTEN_CHARACTERS = /[\\*?[]/;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A brace the shell expands: one holding a `,` or a `..` range.
|
|
100
|
+
*
|
|
101
|
+
* An empty `{}` is left alone by bash, which is why `find -exec … {} +` keeps
|
|
102
|
+
* its placeholder as written.
|
|
103
|
+
*/
|
|
104
|
+
const BRACE_EXPANSION = /\{[^}]*(,|\.\.)[^}]*\}/;
|
|
105
|
+
|
|
30
106
|
/**
|
|
31
107
|
* Whether an argument node's value is decided at run time: it contains a
|
|
32
108
|
* command or process substitution, an arithmetic expansion, or a variable
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import type { ArgWord } from "./node-text";
|
|
2
|
+
|
|
3
|
+
// ── Public surface ─────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether a `sed` invocation's arguments withdraw its read claim.
|
|
7
|
+
*
|
|
8
|
+
* The claim stands only when the whole command line is **proven** read-only:
|
|
9
|
+
* every option is one the allowlist knows, and every script parses under a
|
|
10
|
+
* grammar that admits no command able to write a file or run one. Anything
|
|
11
|
+
* the walk does not recognize withdraws the claim, so an unknown option or
|
|
12
|
+
* command costs one ask rather than a missed write.
|
|
13
|
+
*
|
|
14
|
+
* A computed word withdraws the claim wherever it sits: GNU `sed` permutes
|
|
15
|
+
* options, so even a trailing `"$f"` can arrive as `-i`.
|
|
16
|
+
*
|
|
17
|
+
* The walk never needs to know which `sed` is installed. Where GNU and BSD
|
|
18
|
+
* would read the same argument list differently — an `-e` after the first
|
|
19
|
+
* positional, which GNU takes as a script and BSD as a file — it withdraws.
|
|
20
|
+
*/
|
|
21
|
+
export function sedWithdrawsReadClaim(argWords: readonly ArgWord[]): boolean {
|
|
22
|
+
if (argWords.some(({ computed }) => computed)) return true;
|
|
23
|
+
const scripts = selectScripts(argWords.map(({ value }) => value));
|
|
24
|
+
if (scripts === null) return true;
|
|
25
|
+
return !scripts.every(provesReadOnlyScript);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── The option walk ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/** Short options that take no argument and cannot write: `-n -E -r -s -u -z`. */
|
|
31
|
+
const FLAG_LETTERS: ReadonlySet<string> = new Set([
|
|
32
|
+
"n",
|
|
33
|
+
"E",
|
|
34
|
+
"r",
|
|
35
|
+
"s",
|
|
36
|
+
"u",
|
|
37
|
+
"z",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Long options that take no argument and cannot write, matched exactly.
|
|
42
|
+
*
|
|
43
|
+
* Abbreviations are deliberately absent: GNU accepts `--qui` for `--quiet`
|
|
44
|
+
* and `--in` for `--in-place` alike, and matching whole words only is what
|
|
45
|
+
* lets every abbreviation withdraw without a prefix rule.
|
|
46
|
+
*/
|
|
47
|
+
const FLAG_WORDS: ReadonlySet<string> = new Set([
|
|
48
|
+
"--quiet",
|
|
49
|
+
"--silent",
|
|
50
|
+
"--regexp-extended",
|
|
51
|
+
"--separate",
|
|
52
|
+
"--unbuffered",
|
|
53
|
+
"--null-data",
|
|
54
|
+
"--posix",
|
|
55
|
+
"--debug",
|
|
56
|
+
"--sandbox",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const EXPRESSION_OPTION = "--expression";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The scripts an argument list hands `sed`, or `null` when the list cannot be
|
|
63
|
+
* proven to hand it only those.
|
|
64
|
+
*
|
|
65
|
+
* Every `-e` value is a script; with none, the first positional is. Options
|
|
66
|
+
* are recognized wherever they sit until a `--`, because GNU permutes them.
|
|
67
|
+
*/
|
|
68
|
+
function selectScripts(values: readonly string[]): string[] | null {
|
|
69
|
+
const scripts: string[] = [];
|
|
70
|
+
let firstPositional: string | undefined;
|
|
71
|
+
let optionsEnded = false;
|
|
72
|
+
for (let i = 0; i < values.length; i++) {
|
|
73
|
+
const value = values[i];
|
|
74
|
+
if (optionsEnded || !isOptionWord(value)) {
|
|
75
|
+
firstPositional ??= value;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (value === "--") {
|
|
79
|
+
optionsEnded = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (FLAG_WORDS.has(value)) continue;
|
|
83
|
+
const expression = expressionOf(value, values[i + 1]);
|
|
84
|
+
if (expression === null) return null;
|
|
85
|
+
// An -e after a positional is a script to GNU and a file to BSD.
|
|
86
|
+
if (expression.script !== undefined && firstPositional !== undefined) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (expression.script !== undefined) scripts.push(expression.script);
|
|
90
|
+
if (expression.consumesNext) i++;
|
|
91
|
+
}
|
|
92
|
+
if (scripts.length > 0) return scripts;
|
|
93
|
+
return firstPositional === undefined ? null : [firstPositional];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isOptionWord(value: string): boolean {
|
|
97
|
+
return value.startsWith("-") && value !== "-";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** What one option word contributes: a script, and whether it took the next word. */
|
|
101
|
+
interface OptionReading {
|
|
102
|
+
readonly script?: string;
|
|
103
|
+
readonly consumesNext: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read one option word, or `null` when it is not one the allowlist proves.
|
|
108
|
+
*
|
|
109
|
+
* A short cluster may hold only flag letters until an `e`, which ends it: the
|
|
110
|
+
* rest of the word is the script, or the next word is when nothing follows.
|
|
111
|
+
*/
|
|
112
|
+
function expressionOf(
|
|
113
|
+
value: string,
|
|
114
|
+
next: string | undefined,
|
|
115
|
+
): OptionReading | null {
|
|
116
|
+
if (value.startsWith("--")) return longExpressionOf(value, next);
|
|
117
|
+
for (let j = 1; j < value.length; j++) {
|
|
118
|
+
const letter = value[j];
|
|
119
|
+
if (letter === "e") {
|
|
120
|
+
const attached = value.slice(j + 1);
|
|
121
|
+
if (attached !== "") return { script: attached, consumesNext: false };
|
|
122
|
+
return next === undefined ? null : { script: next, consumesNext: true };
|
|
123
|
+
}
|
|
124
|
+
if (!FLAG_LETTERS.has(letter)) return null;
|
|
125
|
+
}
|
|
126
|
+
return { consumesNext: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function longExpressionOf(
|
|
130
|
+
value: string,
|
|
131
|
+
next: string | undefined,
|
|
132
|
+
): OptionReading | null {
|
|
133
|
+
if (value.startsWith(`${EXPRESSION_OPTION}=`)) {
|
|
134
|
+
return {
|
|
135
|
+
script: value.slice(EXPRESSION_OPTION.length + 1),
|
|
136
|
+
consumesNext: false,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (value !== EXPRESSION_OPTION || next === undefined) return null;
|
|
140
|
+
return { script: next, consumesNext: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── The script grammar ─────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Commands that take no argument and neither write a file nor run a command.
|
|
147
|
+
*
|
|
148
|
+
* `l`, `q`, and `Q` accept an optional number, read by the scanner.
|
|
149
|
+
*/
|
|
150
|
+
const READ_ONLY_COMMANDS: ReadonlySet<string> = new Set([
|
|
151
|
+
"p",
|
|
152
|
+
"P",
|
|
153
|
+
"d",
|
|
154
|
+
"D",
|
|
155
|
+
"n",
|
|
156
|
+
"N",
|
|
157
|
+
"g",
|
|
158
|
+
"G",
|
|
159
|
+
"h",
|
|
160
|
+
"H",
|
|
161
|
+
"x",
|
|
162
|
+
"=",
|
|
163
|
+
"z",
|
|
164
|
+
"F",
|
|
165
|
+
"l",
|
|
166
|
+
"q",
|
|
167
|
+
"Q",
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
/** The commands among {@link READ_ONLY_COMMANDS} that take an optional number. */
|
|
171
|
+
const NUMBERED_COMMANDS: ReadonlySet<string> = new Set(["l", "q", "Q"]);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether a script is made only of commands that cannot write a file or run
|
|
175
|
+
* a command.
|
|
176
|
+
*
|
|
177
|
+
* An allowlist grammar: `w`, `W`, `r`, `R`, `e`, `v`, `a`, `i`, `c`, an `s`
|
|
178
|
+
* flag outside `g p i I m M` and digits (so `s///w` and `s///e`), and any
|
|
179
|
+
* character the scanner does not recognize end the proof.
|
|
180
|
+
*/
|
|
181
|
+
function provesReadOnlyScript(script: string): boolean {
|
|
182
|
+
return new ScriptScanner(script).provesReadOnly();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Flags an `s` command may carry without writing a file or running one. */
|
|
186
|
+
const SUBSTITUTION_FLAGS = /[gpiImM0-9]/;
|
|
187
|
+
|
|
188
|
+
/** Flags a regex address may carry: GNU's case-insensitive and multiline. */
|
|
189
|
+
const ADDRESS_FLAGS: ReadonlySet<string> = new Set(["I", "M"]);
|
|
190
|
+
|
|
191
|
+
/** Commands whose argument is a label running to the end of the command. */
|
|
192
|
+
const LABEL_COMMANDS: ReadonlySet<string> = new Set(["b", "t", "T", ":"]);
|
|
193
|
+
|
|
194
|
+
/** How an address position read: nothing there, an address, or a malformed one. */
|
|
195
|
+
type AddressReading = "none" | "read" | "invalid";
|
|
196
|
+
|
|
197
|
+
/** A single left-to-right pass over one script. */
|
|
198
|
+
class ScriptScanner {
|
|
199
|
+
private position = 0;
|
|
200
|
+
private openBlocks = 0;
|
|
201
|
+
|
|
202
|
+
constructor(private readonly source: string) {}
|
|
203
|
+
|
|
204
|
+
provesReadOnly(): boolean {
|
|
205
|
+
this.skipSeparators();
|
|
206
|
+
while (!this.atEnd()) {
|
|
207
|
+
if (!this.readCommand()) return false;
|
|
208
|
+
this.skipSeparators();
|
|
209
|
+
}
|
|
210
|
+
return this.openBlocks === 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** One `[address[,address]][!…]command`, and what may follow it. */
|
|
214
|
+
private readCommand(): boolean {
|
|
215
|
+
if (this.peek() === "#") return this.skipComment();
|
|
216
|
+
if (!this.readAddresses()) return false;
|
|
217
|
+
this.skipBlanks();
|
|
218
|
+
while (this.peek() === "!") {
|
|
219
|
+
this.position++;
|
|
220
|
+
this.skipBlanks();
|
|
221
|
+
}
|
|
222
|
+
const command = this.peek();
|
|
223
|
+
if (command === undefined) return false;
|
|
224
|
+
this.position++;
|
|
225
|
+
if (command === "{") {
|
|
226
|
+
this.openBlocks++;
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
if (!this.readCommandBody(command)) return false;
|
|
230
|
+
this.skipBlanks();
|
|
231
|
+
return this.atEnd() || this.atSeparator() || this.closesBlock();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** What follows a command letter, up to where a separator must come. */
|
|
235
|
+
private readCommandBody(command: string): boolean {
|
|
236
|
+
if (command === "}") return this.closeBlock();
|
|
237
|
+
if (command === "s") return this.readSubstitution();
|
|
238
|
+
if (command === "y") return this.readTransliteration();
|
|
239
|
+
if (LABEL_COMMANDS.has(command)) return this.readLabel(command);
|
|
240
|
+
if (!READ_ONLY_COMMANDS.has(command)) return false;
|
|
241
|
+
if (NUMBERED_COMMANDS.has(command)) {
|
|
242
|
+
this.skipBlanks();
|
|
243
|
+
this.skipDigits();
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** An optional address, then an optional `,` and a second one. */
|
|
249
|
+
private readAddresses(): boolean {
|
|
250
|
+
const first = this.readAddress();
|
|
251
|
+
if (first === "invalid") return false;
|
|
252
|
+
if (first === "none") return true;
|
|
253
|
+
this.skipBlanks();
|
|
254
|
+
if (this.peek() !== ",") return true;
|
|
255
|
+
this.position++;
|
|
256
|
+
this.skipBlanks();
|
|
257
|
+
return this.readSecondAddress();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A line number, `first~step`, `$`, `/re/`, or `\cREc`. */
|
|
261
|
+
private readAddress(): AddressReading {
|
|
262
|
+
const next = this.peek();
|
|
263
|
+
if (next === "$") {
|
|
264
|
+
this.position++;
|
|
265
|
+
return "read";
|
|
266
|
+
}
|
|
267
|
+
if (next === "/" || next === "\\") return this.readRegexAddress();
|
|
268
|
+
if (!this.skipDigits()) return "none";
|
|
269
|
+
if (this.peek() === "~") {
|
|
270
|
+
this.position++;
|
|
271
|
+
this.skipDigits();
|
|
272
|
+
}
|
|
273
|
+
return "read";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private readRegexAddress(): AddressReading {
|
|
277
|
+
if (this.peek() === "\\") this.position++;
|
|
278
|
+
const delimiter = this.takeDelimiter();
|
|
279
|
+
if (delimiter === null || !this.readRegex(delimiter)) return "invalid";
|
|
280
|
+
while (ADDRESS_FLAGS.has(this.peek() ?? "")) this.position++;
|
|
281
|
+
return "read";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The end of a range: an address, or GNU's `+N` / `~N`. */
|
|
285
|
+
private readSecondAddress(): boolean {
|
|
286
|
+
const sign = this.peek();
|
|
287
|
+
if (sign === "+" || sign === "~") {
|
|
288
|
+
this.position++;
|
|
289
|
+
return this.skipDigits();
|
|
290
|
+
}
|
|
291
|
+
return this.readAddress() === "read";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** `s/regex/replacement/flags`, whose flags must not write or execute. */
|
|
295
|
+
private readSubstitution(): boolean {
|
|
296
|
+
const delimiter = this.takeDelimiter();
|
|
297
|
+
if (delimiter === null) return false;
|
|
298
|
+
if (!this.readRegex(delimiter)) return false;
|
|
299
|
+
if (!this.readLiteral(delimiter)) return false;
|
|
300
|
+
while (SUBSTITUTION_FLAGS.test(this.peek() ?? "")) this.position++;
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** `y/source/target/`, whose operands are character lists, not regexes. */
|
|
305
|
+
private readTransliteration(): boolean {
|
|
306
|
+
const delimiter = this.takeDelimiter();
|
|
307
|
+
if (delimiter === null) return false;
|
|
308
|
+
return this.readLiteral(delimiter) && this.readLiteral(delimiter);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A label, running to `;` or a newline.
|
|
313
|
+
*
|
|
314
|
+
* GNU ends a label there; BSD runs it to the newline, so it sees fewer
|
|
315
|
+
* commands than this reading does, never more. A `}` stays in the label,
|
|
316
|
+
* which leaves its block open and the proof unfinished — the reading on
|
|
317
|
+
* which the two agree least.
|
|
318
|
+
*/
|
|
319
|
+
private readLabel(command: string): boolean {
|
|
320
|
+
this.skipBlanks();
|
|
321
|
+
const start = this.position;
|
|
322
|
+
while (!this.atEnd() && !this.atSeparator()) this.position++;
|
|
323
|
+
return command !== ":" || this.position > start;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private closeBlock(): boolean {
|
|
327
|
+
this.openBlocks--;
|
|
328
|
+
return this.openBlocks >= 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** A `}` directly after a command ends it, as a separator would. */
|
|
332
|
+
private closesBlock(): boolean {
|
|
333
|
+
return this.peek() === "}";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private skipComment(): boolean {
|
|
337
|
+
while (!this.atEnd() && this.peek() !== "\n") this.position++;
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The delimiter a regex or `s`/`y` command opens with, or `null` when it is
|
|
343
|
+
* one this grammar does not accept.
|
|
344
|
+
*
|
|
345
|
+
* A letter, digit, backslash, blank, or newline delimiter is legal to GNU in
|
|
346
|
+
* places and a trap everywhere: `\n` and `\d` stop meaning what they say.
|
|
347
|
+
*/
|
|
348
|
+
private takeDelimiter(): string | null {
|
|
349
|
+
const delimiter = this.peek();
|
|
350
|
+
if (delimiter === undefined || !/[!-/:-@[-`{-~]/.test(delimiter)) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
if (delimiter === "\\") return null;
|
|
354
|
+
this.position++;
|
|
355
|
+
return delimiter;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* A regex up to its closing delimiter, bracket expressions included.
|
|
360
|
+
*
|
|
361
|
+
* A delimiter inside a bracket expression ends the proof: BSD reads `[/]`
|
|
362
|
+
* as a bracket and GNU ends the regex at its `/`, so the two disagree about
|
|
363
|
+
* where every later section starts.
|
|
364
|
+
*/
|
|
365
|
+
private readRegex(delimiter: string): boolean {
|
|
366
|
+
while (!this.atEnd()) {
|
|
367
|
+
const next = this.peek();
|
|
368
|
+
if (next === "\n") return false;
|
|
369
|
+
if (next === delimiter) {
|
|
370
|
+
this.position++;
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
if (next === "\\") {
|
|
374
|
+
this.position += 2;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (next === "[") {
|
|
378
|
+
if (!this.readBracket(delimiter)) return false;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
this.position++;
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** `[…]`, with `]` literal first and `[:class:]` / `[.x.]` / `[=x=]` inside. */
|
|
387
|
+
private readBracket(delimiter: string): boolean {
|
|
388
|
+
this.position++;
|
|
389
|
+
if (this.peek() === "^") this.position++;
|
|
390
|
+
if (this.peek() === "]") this.position++;
|
|
391
|
+
while (!this.atEnd()) {
|
|
392
|
+
const next = this.peek();
|
|
393
|
+
if (next === "]") {
|
|
394
|
+
this.position++;
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
if (next === delimiter || next === "\n") return false;
|
|
398
|
+
if (next === "[" && this.opensBracketClass()) {
|
|
399
|
+
if (!this.skipBracketClass(delimiter)) return false;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
this.position++;
|
|
403
|
+
}
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private opensBracketClass(): boolean {
|
|
408
|
+
const marker = this.source[this.position + 1];
|
|
409
|
+
return marker === ":" || marker === "." || marker === "=";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private skipBracketClass(delimiter: string): boolean {
|
|
413
|
+
const marker = this.source[this.position + 1];
|
|
414
|
+
const close = this.source.indexOf(`${marker}]`, this.position + 2);
|
|
415
|
+
if (close === -1) return false;
|
|
416
|
+
const body = this.source.slice(this.position + 2, close);
|
|
417
|
+
if (body.includes(delimiter)) return false;
|
|
418
|
+
this.position = close + 2;
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* A replacement or `y` operand up to its closing delimiter.
|
|
424
|
+
*
|
|
425
|
+
* Brackets mean nothing here, so `s/x/[]/` is the literal `[]`.
|
|
426
|
+
*/
|
|
427
|
+
private readLiteral(delimiter: string): boolean {
|
|
428
|
+
while (!this.atEnd()) {
|
|
429
|
+
const next = this.peek();
|
|
430
|
+
if (next === "\n") return false;
|
|
431
|
+
if (next === delimiter) {
|
|
432
|
+
this.position++;
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
this.position += next === "\\" ? 2 : 1;
|
|
436
|
+
}
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private skipSeparators(): void {
|
|
441
|
+
while (this.atSeparator() || this.isBlank(this.peek())) this.position++;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private skipBlanks(): void {
|
|
445
|
+
while (this.isBlank(this.peek())) this.position++;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Advance over digits; true when there was at least one. */
|
|
449
|
+
private skipDigits(): boolean {
|
|
450
|
+
const start = this.position;
|
|
451
|
+
while (/[0-9]/.test(this.peek() ?? "")) this.position++;
|
|
452
|
+
return this.position > start;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private atSeparator(): boolean {
|
|
456
|
+
const next = this.peek();
|
|
457
|
+
return next === ";" || next === "\n";
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private isBlank(character: string | undefined): boolean {
|
|
461
|
+
return character === " " || character === "\t";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private atEnd(): boolean {
|
|
465
|
+
return this.position >= this.source.length;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private peek(): string | undefined {
|
|
469
|
+
return this.source[this.position];
|
|
470
|
+
}
|
|
471
|
+
}
|
|
@@ -4,12 +4,15 @@ import { proveCommandEffect } from "./command-effects";
|
|
|
4
4
|
import { EXECUTION_HOST_TYPES, forEachExecutionIn } from "./nested-execution";
|
|
5
5
|
import {
|
|
6
6
|
ARG_NODE_TYPES,
|
|
7
|
+
type ArgWord,
|
|
7
8
|
hasComputedPart,
|
|
9
|
+
readArgWord,
|
|
8
10
|
resolveNodeText,
|
|
9
11
|
SKIP_SUBTREE_TYPES,
|
|
10
12
|
} from "./node-text";
|
|
11
13
|
import type { TSNode } from "./parser";
|
|
12
14
|
import {
|
|
15
|
+
REDIRECT_NODE_TYPES,
|
|
13
16
|
redirectEffectForDestination,
|
|
14
17
|
redirectTargetIndex,
|
|
15
18
|
} from "./redirect-analysis";
|
|
@@ -310,15 +313,20 @@ export function extractCommandWord(node: TSNode): string | undefined {
|
|
|
310
313
|
*
|
|
311
314
|
* Reads the argument nodes directly rather than the collected tokens, because
|
|
312
315
|
* a guard fires on an *option* (`find -delete`) and no collector emits one.
|
|
316
|
+
*
|
|
317
|
+
* Every named child but the prefix and a hosted redirect is a word, including
|
|
318
|
+
* a bare `$opt` or `$(cmd)` outside {@link ARG_NODE_TYPES}: such a word
|
|
319
|
+
* reaches the program as an argument too, and a proof that never saw it could
|
|
320
|
+
* not know it might spell `-i`.
|
|
313
321
|
*/
|
|
314
|
-
function commandArgumentWords(node: TSNode):
|
|
315
|
-
const words:
|
|
322
|
+
function commandArgumentWords(node: TSNode): ArgWord[] {
|
|
323
|
+
const words: ArgWord[] = [];
|
|
316
324
|
for (let i = 0; i < node.childCount; i++) {
|
|
317
325
|
const child = node.child(i);
|
|
318
|
-
if (!child) continue;
|
|
326
|
+
if (!child?.isNamed) continue;
|
|
319
327
|
if (COMMAND_PREFIX_TYPES.has(child.type)) continue;
|
|
320
|
-
if (
|
|
321
|
-
words.push(
|
|
328
|
+
if (REDIRECT_NODE_TYPES.has(child.type)) continue;
|
|
329
|
+
words.push(readArgWord(child));
|
|
322
330
|
}
|
|
323
331
|
return words;
|
|
324
332
|
}
|
|
@@ -10,9 +10,16 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { proveCommandEffect } from "./command-effects";
|
|
13
|
+
import type { ArgWord } from "./node-text";
|
|
13
14
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* One word of a command unit: its source text and its offset into the unit's
|
|
17
|
+
* text, beside the value the program receives once the shell removes quotes.
|
|
18
|
+
*
|
|
19
|
+
* The source text is what unwrapping and slicing read; the value is what a
|
|
20
|
+
* capability proof reads, since `'-o'` reaches `sort` as `-o`.
|
|
21
|
+
*/
|
|
22
|
+
export interface CommandWord extends ArgWord {
|
|
16
23
|
readonly text: string;
|
|
17
24
|
readonly offset: number;
|
|
18
25
|
}
|
|
@@ -172,8 +179,7 @@ export function isTransparentWrapper(
|
|
|
172
179
|
if (unwrapped.kind === "opaque" || unwrapped.layers === 0) return false;
|
|
173
180
|
|
|
174
181
|
const head = unwrapped.words.at(0)?.text ?? "";
|
|
175
|
-
|
|
176
|
-
return proveCommandEffect(head, args).effect === "read";
|
|
182
|
+
return proveCommandEffect(head, unwrapped.words.slice(1)).effect === "read";
|
|
177
183
|
}
|
|
178
184
|
|
|
179
185
|
// ── Unwrapping ───────────────────────────────────────────────────────────────
|
|
@@ -354,7 +360,7 @@ function rebase(
|
|
|
354
360
|
const origin = words[start].offset;
|
|
355
361
|
return words
|
|
356
362
|
.slice(start, end)
|
|
357
|
-
.map((word) => ({
|
|
363
|
+
.map((word) => ({ ...word, offset: word.offset - origin }));
|
|
358
364
|
}
|
|
359
365
|
|
|
360
366
|
/** True for a `NAME=value` environment prefix. */
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { BashProgram } from "#src/access-intent/bash/program";
|
|
2
|
-
import type { PathNormalizer } from "#src/path/path-normalizer";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Extract paths from a bash command that resolve outside CWD.
|
|
6
|
-
*
|
|
7
|
-
* Thin facade over {@link BashProgram.externalAccesses}; parses the command
|
|
8
|
-
* through the injected {@link PathNormalizer} (platform + cwd baked in) and
|
|
9
|
-
* returns the cd-aware external paths in their lexical (as-typed) string form.
|
|
10
|
-
* See `BashProgram` for the parsing and resolution semantics.
|
|
11
|
-
*
|
|
12
|
-
* Returns `string[]` (not `AccessPath[]`) so the large projection-correctness
|
|
13
|
-
* test suite in `bash-external-directory.test.ts` can assert path values
|
|
14
|
-
* without migrating to the `AccessPath` accessors.
|
|
15
|
-
*/
|
|
16
|
-
export async function extractExternalPathsFromBashCommand(
|
|
17
|
-
command: string,
|
|
18
|
-
normalizer: PathNormalizer,
|
|
19
|
-
): Promise<string[]> {
|
|
20
|
-
return (await BashProgram.parse(command, normalizer))
|
|
21
|
-
.externalAccesses()
|
|
22
|
-
.map(({ path }) => path.value());
|
|
23
|
-
}
|