@gotgenes/pi-permission-system 36.2.0 → 36.2.1
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 +15 -0
- package/docs/configuration.md +2 -2
- package/package.json +1 -1
- package/src/access-intent/bash/bash-path-resolver.ts +22 -10
- package/src/access-intent/bash/command-enumeration.ts +48 -20
- package/src/access-intent/bash/node-text.ts +87 -31
- package/src/access-intent/bash/program.ts +8 -2
- package/src/access-intent/bash/shell-variable-expansion.ts +207 -10
- package/src/access-intent/bash/sync-commands.ts +11 -4
- package/src/access-intent/bash/token-collection.ts +66 -45
- package/src/logging/command-redaction.ts +15 -9
- package/src/path/expand-home.ts +23 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ 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
|
+
## [36.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v36.2.0...pi-permission-system-v36.2.1) (2026-10-01)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** a reassigned $HOME or $PWD is no longer resolved to its startup value ([#995](https://github.com/gotgenes/pi-packages/issues/995)) ([956fa32](https://github.com/gotgenes/pi-packages/commit/956fa32ecbe6ee3b2954cebbfed8d9a44857c617)), closes [#995](https://github.com/gotgenes/pi-packages/issues/995)
|
|
14
|
+
* **pi-permission-system:** a name read, printf -v, nameref, eval, or source counts as reassigning $HOME ([#995](https://github.com/gotgenes/pi-packages/issues/995)) ([a05a6d3](https://github.com/gotgenes/pi-packages/commit/a05a6d391eb0281d5977a2264aa69958ce8d7549))
|
|
15
|
+
* **pi-permission-system:** ~ is no longer read as the home directory after the command reassigns HOME ([#995](https://github.com/gotgenes/pi-packages/issues/995)) ([998d51b](https://github.com/gotgenes/pi-packages/commit/998d51b36989bf0397beb4028d76e75d3e37666d))
|
|
16
|
+
* **pi-permission-system:** ~ may spell an option when the inherited HOME begins with a dash ([#995](https://github.com/gotgenes/pi-packages/issues/995)) ([f1ee69a](https://github.com/gotgenes/pi-packages/commit/f1ee69a19b3f6880228da88ad2683f9b1f79a980))
|
|
17
|
+
* **pi-permission-system:** a quoted export, let, trap, or quoted eval counts as reassigning $HOME ([#995](https://github.com/gotgenes/pi-packages/issues/995)) ([4406ef4](https://github.com/gotgenes/pi-packages/commit/4406ef429706aa0b7cd77ba9608d0bcb1c2982bc))
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* **pi-permission-system:** record that a reassigned $HOME or $PWD is not resolved ([#995](https://github.com/gotgenes/pi-packages/issues/995)) ([e7c8b88](https://github.com/gotgenes/pi-packages/commit/e7c8b88167f05531507cdfaf584dd9e4905a0387))
|
|
22
|
+
|
|
8
23
|
## [36.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v36.1.0...pi-permission-system-v36.2.0) (2026-09-30)
|
|
9
24
|
|
|
10
25
|
|
package/docs/configuration.md
CHANGED
|
@@ -785,11 +785,11 @@ Quoting is understood, so `ls "$HOME/x"` and `ls $HOME/x` are treated alike.
|
|
|
785
785
|
What the bash projection resolves:
|
|
786
786
|
|
|
787
787
|
- Absolute, home-relative (`~/`), parent-traversal (`../`), and separator-bearing tokens, plus redirect targets (`> out.txt`, including a file the redirect creates) and values embedded in long options (`--file=/tmp/patterns`).
|
|
788
|
-
- The plain shell variables `$HOME` / `${HOME}` and `$PWD` / `${PWD}`, so `$HOME/x` is gated exactly as `~/x` and the literal absolute spelling, whether or not the target exists.
|
|
788
|
+
- The plain shell variables `$HOME` / `${HOME}` and `$PWD` / `${PWD}`, so `$HOME/x` is gated exactly as `~/x` and the literal absolute spelling, whether or not the target exists, as long as the command does not reassign them.
|
|
789
789
|
- Relative tokens, against the working directory produced by folding literal current-shell `cd` commands.
|
|
790
790
|
- A bare token (`cat id_rsa`) when it names an existing filesystem entry.
|
|
791
791
|
|
|
792
|
-
What it deliberately does not resolve: any other variable (`$CONFIG_DIR`), a command substitution (`$(cmd)`), an expansion carrying an operator (`${HOME:-/tmp}`),
|
|
792
|
+
What it deliberately does not resolve: any other variable (`$CONFIG_DIR`), a command substitution (`$(cmd)`), an expansion carrying an operator (`${HOME:-/tmp}`), a variable reached through an assignment (`CURRENT="$HOME"; ls "$CURRENT"`), and `$HOME`, `$PWD`, or a leading `~` in a command that reassigns the variable anywhere (`HOME=/etc; cat "$HOME/shadow"`), including through `read`, `unset`, `eval`, or `source`.
|
|
793
793
|
A non-literal `cd` (`cd "$DIR"`) makes the working directory unknown, after which relative tokens are kept literal rather than resolved against a guess.
|
|
794
794
|
Commands whose payload is opaque (`bash -c`, `eval`, `sudo`, `xargs`) are floored to `ask` instead of projected.
|
|
795
795
|
The governing record is [ADR 0009](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0009-bash-path-projection-completeness-contract.md), which states what the projection guarantees and which gaps are accepted residuals rather than bugs.
|
package/package.json
CHANGED
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
|
|
8
8
|
import type { PathNormalizer } from "#src/path/path-normalizer";
|
|
9
9
|
import { isSafeSystemPath } from "#src/path/safe-system-paths";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
ARG_NODE_TYPES,
|
|
12
|
+
SKIP_SUBTREE_TYPES,
|
|
13
|
+
type WordReader,
|
|
14
|
+
} from "./node-text";
|
|
11
15
|
import type { TSNode } from "./parser";
|
|
12
16
|
import { REDIRECT_NODE_TYPES } from "./redirect-analysis";
|
|
13
17
|
import {
|
|
@@ -121,6 +125,7 @@ const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
|
|
|
121
125
|
export class BashPathResolver {
|
|
122
126
|
constructor(
|
|
123
127
|
private readonly normalizer: PathNormalizer,
|
|
128
|
+
private readonly words: WordReader,
|
|
124
129
|
private readonly workdir?: string,
|
|
125
130
|
) {}
|
|
126
131
|
|
|
@@ -146,6 +151,10 @@ export class BashPathResolver {
|
|
|
146
151
|
* could then allow this access. #393's unknown base declines the claim
|
|
147
152
|
* instead, keeping an absolute token literal-only and unconditionally
|
|
148
153
|
* external while a relative one is not projected at all.
|
|
154
|
+
*
|
|
155
|
+
* A token spelled from a `HOME` the program rebinds leaves both slices before
|
|
156
|
+
* projection: path normalization would otherwise expand its `~`/`$HOME` prefix
|
|
157
|
+
* to the startup home, naming a directory the shell never touches.
|
|
149
158
|
*/
|
|
150
159
|
resolve(
|
|
151
160
|
rootNode: TSNode,
|
|
@@ -155,10 +164,13 @@ export class BashPathResolver {
|
|
|
155
164
|
this.workdir === undefined
|
|
156
165
|
? CWD_BASE
|
|
157
166
|
: this.deriveBaseFromCdTarget(CWD_BASE, this.workdir);
|
|
158
|
-
const
|
|
167
|
+
const collected = this.collectPathCandidates(rootNode, initialBase);
|
|
159
168
|
for (const salvaged of salvagedRoots) {
|
|
160
|
-
this.walkForCandidates(salvaged, UNKNOWN_BASE,
|
|
169
|
+
this.walkForCandidates(salvaged, UNKNOWN_BASE, collected);
|
|
161
170
|
}
|
|
171
|
+
const candidates = collected.filter(
|
|
172
|
+
({ token }) => !this.words.spellsReboundHome(token),
|
|
173
|
+
);
|
|
162
174
|
return {
|
|
163
175
|
externalAccesses: this.withWorkdirExternal(
|
|
164
176
|
this.projectExternalPaths(candidates),
|
|
@@ -236,7 +248,7 @@ export class BashPathResolver {
|
|
|
236
248
|
case "redirected_statement":
|
|
237
249
|
return this.walkCurrentShellSequence(node, base, out);
|
|
238
250
|
case "command":
|
|
239
|
-
tagTokens(collectCommandTokens(node), base, out);
|
|
251
|
+
tagTokens(collectCommandTokens(node, this.words), base, out);
|
|
240
252
|
return this.foldCd(node, base);
|
|
241
253
|
case "pipeline":
|
|
242
254
|
// tree-sitter-bash mis-groups a redirect-bearing `&&`/`;` list as the
|
|
@@ -261,7 +273,7 @@ export class BashPathResolver {
|
|
|
261
273
|
// substitution interiors: collect every candidate in the subtree tagged
|
|
262
274
|
// with the enclosing base and do not fold their internal `cd`s. (Folding
|
|
263
275
|
// inside substitutions is deferred — conservative, never under-flags.)
|
|
264
|
-
tagTokens(collectPathCandidateTokens(node), base, out);
|
|
276
|
+
tagTokens(collectPathCandidateTokens(node, this.words), base, out);
|
|
265
277
|
return base;
|
|
266
278
|
}
|
|
267
279
|
}
|
|
@@ -321,7 +333,7 @@ export class BashPathResolver {
|
|
|
321
333
|
}
|
|
322
334
|
// Downstream stage (after a `|`): subshell — collect against the folded
|
|
323
335
|
// base, do not fold.
|
|
324
|
-
tagTokens(collectPathCandidateTokens(child), current, out);
|
|
336
|
+
tagTokens(collectPathCandidateTokens(child, this.words), current, out);
|
|
325
337
|
}
|
|
326
338
|
return current;
|
|
327
339
|
}
|
|
@@ -351,7 +363,7 @@ export class BashPathResolver {
|
|
|
351
363
|
if (child.type === "file_redirect") {
|
|
352
364
|
// Redirect destinations are part of the piped stage; collect them
|
|
353
365
|
// against the folded base without folding.
|
|
354
|
-
tagTokens(collectRedirectTokens(child), current, out);
|
|
366
|
+
tagTokens(collectRedirectTokens(child, this.words), current, out);
|
|
355
367
|
continue;
|
|
356
368
|
}
|
|
357
369
|
// The inner statement is the `list`/`command` being redirected; fold its
|
|
@@ -361,7 +373,7 @@ export class BashPathResolver {
|
|
|
361
373
|
return current;
|
|
362
374
|
}
|
|
363
375
|
// Bare `command` or any other shape: a true subshell first stage.
|
|
364
|
-
tagTokens(collectPathCandidateTokens(node), base, out);
|
|
376
|
+
tagTokens(collectPathCandidateTokens(node, this.words), base, out);
|
|
365
377
|
return base;
|
|
366
378
|
}
|
|
367
379
|
|
|
@@ -389,7 +401,7 @@ export class BashPathResolver {
|
|
|
389
401
|
current = this.walkForCandidates(child, current, out);
|
|
390
402
|
} else {
|
|
391
403
|
// Terminal child = the real pipe stage; collect without folding.
|
|
392
|
-
tagTokens(collectPathCandidateTokens(child), current, out);
|
|
404
|
+
tagTokens(collectPathCandidateTokens(child, this.words), current, out);
|
|
393
405
|
}
|
|
394
406
|
}
|
|
395
407
|
return current;
|
|
@@ -412,7 +424,7 @@ export class BashPathResolver {
|
|
|
412
424
|
* {@link PathNormalizer}; this method owns only the base-folding state.
|
|
413
425
|
*/
|
|
414
426
|
private foldCd(commandNode: TSNode, base: EffectiveBase): EffectiveBase {
|
|
415
|
-
if (extractCommandName(commandNode) !== "cd") return base;
|
|
427
|
+
if (extractCommandName(commandNode, this.words) !== "cd") return base;
|
|
416
428
|
const target = cdLiteralTarget(commandNode);
|
|
417
429
|
if (target === null) return UNKNOWN_BASE;
|
|
418
430
|
return this.deriveBaseFromCdTarget(base, target);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BashCommandContext, FloorExemption } from "#src/types";
|
|
2
2
|
import { EXECUTION_HOST_TYPES, forEachExecutionIn } from "./nested-execution";
|
|
3
|
-
import {
|
|
3
|
+
import type { WordReader } from "./node-text";
|
|
4
4
|
import { parseUnresolvedWithin } from "./parse-health";
|
|
5
5
|
import type { TSNode } from "./parser";
|
|
6
6
|
import { REDIRECT_NODE_TYPES, redirectMayWriteFile } from "./redirect-analysis";
|
|
@@ -105,14 +105,22 @@ interface UnitScope {
|
|
|
105
105
|
* everything found inside a salvaged region is salvaged.
|
|
106
106
|
*/
|
|
107
107
|
readonly salvaged: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* How the program's shell expands an argument word. Relayed unchanged: a
|
|
110
|
+
* program's variables are the same wherever in it a word sits.
|
|
111
|
+
*/
|
|
112
|
+
readonly words: WordReader;
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
/** A top-level command in the current shell, writing no file, fully parsed. */
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
function topLevelScope(words: WordReader): UnitScope {
|
|
117
|
+
return {
|
|
118
|
+
writesViaRedirect: false,
|
|
119
|
+
parseUnresolved: false,
|
|
120
|
+
salvaged: false,
|
|
121
|
+
words,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
116
124
|
|
|
117
125
|
/**
|
|
118
126
|
* The scope a salvaged region's own units run under.
|
|
@@ -123,11 +131,14 @@ const TOP_LEVEL_SCOPE: UnitScope = {
|
|
|
123
131
|
* {@link collectHostedCommands} resets it: a redirect established outside the
|
|
124
132
|
* region is the enclosing statement's, not the region's.
|
|
125
133
|
*/
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
134
|
+
function salvagedScope(words: WordReader): UnitScope {
|
|
135
|
+
return {
|
|
136
|
+
writesViaRedirect: false,
|
|
137
|
+
parseUnresolved: true,
|
|
138
|
+
salvaged: true,
|
|
139
|
+
words,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
131
142
|
|
|
132
143
|
// ── Node-type vocabulary ─────────────────────────────────────────────────────
|
|
133
144
|
|
|
@@ -244,9 +255,12 @@ const STATEMENT_TYPES = new Set([
|
|
|
244
255
|
* wrapper unit (`bash -c`/`eval`, or an indirection wrapper such as `sudo`) is
|
|
245
256
|
* tagged with a {@link WrapperKind} so its decision is later floored to `ask`.
|
|
246
257
|
*/
|
|
247
|
-
export function collectCommands(
|
|
258
|
+
export function collectCommands(
|
|
259
|
+
node: TSNode,
|
|
260
|
+
words: WordReader,
|
|
261
|
+
): BashCommand[] {
|
|
248
262
|
const out: BashCommand[] = [];
|
|
249
|
-
collectCommandsInto(node,
|
|
263
|
+
collectCommandsInto(node, topLevelScope(words), out);
|
|
250
264
|
return out;
|
|
251
265
|
}
|
|
252
266
|
|
|
@@ -260,9 +274,12 @@ export function collectCommands(node: TSNode): BashCommand[] {
|
|
|
260
274
|
* explicit `deny` fires — while its `allow` is still floored to `ask` by the
|
|
261
275
|
* verdict fold (#840).
|
|
262
276
|
*/
|
|
263
|
-
export function collectSalvagedCommands(
|
|
277
|
+
export function collectSalvagedCommands(
|
|
278
|
+
node: TSNode,
|
|
279
|
+
words: WordReader,
|
|
280
|
+
): BashCommand[] {
|
|
264
281
|
const out: BashCommand[] = [];
|
|
265
|
-
collectCommandsInto(node,
|
|
282
|
+
collectCommandsInto(node, salvagedScope(words), out);
|
|
266
283
|
return out;
|
|
267
284
|
}
|
|
268
285
|
|
|
@@ -282,11 +299,14 @@ export function collectSalvagedCommands(node: TSNode): BashCommand[] {
|
|
|
282
299
|
* (`python3 -c`, `node -e`) out: its payload is another language, so re-parsing
|
|
283
300
|
* it as bash would read a secret out of embedded Python.
|
|
284
301
|
*/
|
|
285
|
-
export function inlineShellPayloadNode(
|
|
302
|
+
export function inlineShellPayloadNode(
|
|
303
|
+
command: TSNode,
|
|
304
|
+
words: WordReader,
|
|
305
|
+
): TSNode | null {
|
|
286
306
|
const nodes = commandWordNodes(command);
|
|
287
307
|
const index = inlineShellPayloadIndex(
|
|
288
308
|
nodes.map((node) => ({
|
|
289
|
-
...
|
|
309
|
+
...words.argWord(node),
|
|
290
310
|
text: node.text,
|
|
291
311
|
offset: node.startIndex,
|
|
292
312
|
})),
|
|
@@ -426,7 +446,7 @@ function makeUnit(
|
|
|
426
446
|
* surely as one on the enclosing statement.
|
|
427
447
|
*/
|
|
428
448
|
function makeCommandUnit(node: TSNode, scope: UnitScope): BashCommand {
|
|
429
|
-
const { text, words } = readCommandUnit(node);
|
|
449
|
+
const { text, words } = readCommandUnit(node, scope.words);
|
|
430
450
|
return makeUnit(text, scope, {
|
|
431
451
|
wrapperKind: classifyWrapperWords(words),
|
|
432
452
|
executedUnit: executedUnitOf(text, words) ?? undefined,
|
|
@@ -482,7 +502,10 @@ function redirectedScope(node: TSNode, scope: UnitScope): UnitScope {
|
|
|
482
502
|
* A pure assignment (`FOO=bar`, no `command_name`) runs no command, has no
|
|
483
503
|
* words, and keeps its whole text.
|
|
484
504
|
*/
|
|
485
|
-
function readCommandUnit(
|
|
505
|
+
function readCommandUnit(
|
|
506
|
+
node: TSNode,
|
|
507
|
+
reader: WordReader,
|
|
508
|
+
): {
|
|
486
509
|
text: string;
|
|
487
510
|
words: CommandWord[];
|
|
488
511
|
} {
|
|
@@ -495,7 +518,11 @@ function readCommandUnit(node: TSNode): {
|
|
|
495
518
|
let previous: TSNode | undefined;
|
|
496
519
|
for (const word of nodes) {
|
|
497
520
|
if (previous) text += gapBetween(node, previous, word, redirects);
|
|
498
|
-
words.push({
|
|
521
|
+
words.push({
|
|
522
|
+
...reader.argWord(word),
|
|
523
|
+
text: word.text,
|
|
524
|
+
offset: text.length,
|
|
525
|
+
});
|
|
499
526
|
text += word.text;
|
|
500
527
|
previous = word;
|
|
501
528
|
}
|
|
@@ -626,6 +653,7 @@ function collectHostedCommands(
|
|
|
626
653
|
writesViaRedirect: false,
|
|
627
654
|
parseUnresolved: false,
|
|
628
655
|
salvaged: scope.salvaged,
|
|
656
|
+
words: scope.words,
|
|
629
657
|
},
|
|
630
658
|
out,
|
|
631
659
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TSNode } from "./parser";
|
|
2
|
-
import {
|
|
2
|
+
import type { ShellVariables } from "./shell-variable-expansion";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Node types whose text content is never a command argument, so no path
|
|
@@ -35,13 +35,13 @@ export const ARG_NODE_TYPES = new Set([
|
|
|
35
35
|
* since a word only the shell decides can spell any option at all.
|
|
36
36
|
*/
|
|
37
37
|
export interface ArgWord {
|
|
38
|
-
/** The string the shell passes after quote removal ({@link
|
|
38
|
+
/** The string the shell passes after quote removal ({@link resolveText}). */
|
|
39
39
|
readonly value: string;
|
|
40
40
|
/**
|
|
41
41
|
* Whether `value` may differ from what the program receives: a part only
|
|
42
|
-
* running the command decides ({@link
|
|
42
|
+
* running the command decides ({@link computedPart}), or a spelling the
|
|
43
43
|
* shell rewrites before the program sees it — an escape, a glob, a brace
|
|
44
|
-
* expansion, an ANSI-C string — which {@link
|
|
44
|
+
* expansion, an ANSI-C string — which {@link resolveText} passes through as written.
|
|
45
45
|
*/
|
|
46
46
|
readonly computed: boolean;
|
|
47
47
|
/**
|
|
@@ -53,17 +53,67 @@ export interface ArgWord {
|
|
|
53
53
|
readonly mayLeadWithDash: boolean;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Reads argument nodes as one program's shell expands them: the node-text
|
|
58
|
+
* reads bound to the {@link ShellVariables} that program rebinds.
|
|
59
|
+
*/
|
|
60
|
+
export class WordReader {
|
|
61
|
+
constructor(private readonly variables: ShellVariables) {}
|
|
62
|
+
|
|
63
|
+
/** Read an argument node into the word the program receives. */
|
|
64
|
+
argWord(node: TSNode): ArgWord {
|
|
65
|
+
const value = this.text(node);
|
|
66
|
+
const tilde = this.variables.readTilde(leadingUnquotedText(node));
|
|
67
|
+
if (tilde?.known === false) {
|
|
68
|
+
return { value, computed: true, mayLeadWithDash: true };
|
|
69
|
+
}
|
|
70
|
+
const computed =
|
|
71
|
+
this.isComputed(node) || !isSpelledExactly(node, this.variables);
|
|
72
|
+
if (tilde) {
|
|
73
|
+
// The spelling `~` is not what the program receives; it is exact enough
|
|
74
|
+
// to prove with only while the home it stands for cannot be an option.
|
|
75
|
+
return {
|
|
76
|
+
value,
|
|
77
|
+
computed: computed || tilde.leadsWithDash,
|
|
78
|
+
mayLeadWithDash:
|
|
79
|
+
tilde.leadsWithDash || (computed && maySplitIntoWords(node, false)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
value,
|
|
84
|
+
computed,
|
|
85
|
+
mayLeadWithDash: computed
|
|
86
|
+
? mayExpandToDashWord(node)
|
|
87
|
+
: value.startsWith("-"),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The string the shell passes after quote removal ({@link resolveText}). */
|
|
92
|
+
text(node: TSNode): string {
|
|
93
|
+
return resolveText(node, this.variables);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether the node's value is decided at run time ({@link computedPart}). */
|
|
97
|
+
isComputed(node: TSNode): boolean {
|
|
98
|
+
return computedPart(node, this.variables);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether a collected token is spelled from a rebound `HOME` ({@link ShellVariables.spellsReboundHome}). */
|
|
102
|
+
spellsReboundHome(token: string): boolean {
|
|
103
|
+
return this.variables.spellsReboundHome(token);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The unquoted literal an argument opens with, where bash expands a tilde
|
|
109
|
+
* prefix: the word itself, or a concatenation's first part when that is a
|
|
110
|
+
* word. A tilde after any other part, or inside quotes, stays literal.
|
|
111
|
+
*/
|
|
112
|
+
function leadingUnquotedText(node: TSNode): string {
|
|
113
|
+
if (node.type === "word") return node.text;
|
|
114
|
+
if (node.type !== "concatenation") return "";
|
|
115
|
+
const first = node.child(0);
|
|
116
|
+
return first?.type === "word" ? first.text : "";
|
|
67
117
|
}
|
|
68
118
|
|
|
69
119
|
/**
|
|
@@ -182,12 +232,12 @@ function quotedLiteralMayLeadWithDash(text: string): boolean | undefined {
|
|
|
182
232
|
}
|
|
183
233
|
|
|
184
234
|
/**
|
|
185
|
-
* Whether {@link
|
|
235
|
+
* Whether {@link resolveText} returns exactly the string the shell passes.
|
|
186
236
|
*
|
|
187
237
|
* Answers `false` for any node type it does not know, which is the
|
|
188
238
|
* fail-closed direction for a caller proving what a word cannot be.
|
|
189
239
|
*/
|
|
190
|
-
function isSpelledExactly(node: TSNode): boolean {
|
|
240
|
+
function isSpelledExactly(node: TSNode, variables: ShellVariables): boolean {
|
|
191
241
|
switch (node.type) {
|
|
192
242
|
case "raw_string":
|
|
193
243
|
return true;
|
|
@@ -197,27 +247,33 @@ function isSpelledExactly(node: TSNode): boolean {
|
|
|
197
247
|
return !node.text.includes("\\");
|
|
198
248
|
case "simple_expansion":
|
|
199
249
|
case "expansion":
|
|
200
|
-
return
|
|
250
|
+
return variables.resolveReference(node) !== null;
|
|
201
251
|
case "string":
|
|
202
252
|
// A digit run is an ordinary word to the shell; the grammar's `10#$x` form
|
|
203
253
|
// carries an expansion child, which answers for itself.
|
|
204
254
|
case "number":
|
|
205
|
-
return childrenSpelledExactly(node);
|
|
255
|
+
return childrenSpelledExactly(node, variables);
|
|
206
256
|
case "concatenation":
|
|
207
257
|
// The grammar splits `{-i,-n}` into plain words, so the expansion is
|
|
208
258
|
// visible only across the whole concatenation's text.
|
|
209
|
-
return
|
|
259
|
+
return (
|
|
260
|
+
!BRACE_EXPANSION.test(node.text) &&
|
|
261
|
+
childrenSpelledExactly(node, variables)
|
|
262
|
+
);
|
|
210
263
|
default:
|
|
211
264
|
return false;
|
|
212
265
|
}
|
|
213
266
|
}
|
|
214
267
|
|
|
215
268
|
/** A `"` delimiter is spelled exactly; every named child must be too. */
|
|
216
|
-
function childrenSpelledExactly(
|
|
269
|
+
function childrenSpelledExactly(
|
|
270
|
+
node: TSNode,
|
|
271
|
+
variables: ShellVariables,
|
|
272
|
+
): boolean {
|
|
217
273
|
for (let i = 0; i < node.childCount; i++) {
|
|
218
274
|
const child = node.child(i);
|
|
219
275
|
if (!child || child.type === '"') continue;
|
|
220
|
-
if (!isSpelledExactly(child)) return false;
|
|
276
|
+
if (!isSpelledExactly(child, variables)) return false;
|
|
221
277
|
}
|
|
222
278
|
return true;
|
|
223
279
|
}
|
|
@@ -236,22 +292,22 @@ const BRACE_EXPANSION = /\{[^}]*(,|\.\.)[^}]*\}/;
|
|
|
236
292
|
/**
|
|
237
293
|
* Whether an argument node's value is decided at run time: it contains a
|
|
238
294
|
* command or process substitution, an arithmetic expansion, or a variable
|
|
239
|
-
* expansion {@link
|
|
295
|
+
* expansion {@link ShellVariables.resolveReference} cannot resolve.
|
|
240
296
|
*
|
|
241
|
-
* The complement of what {@link
|
|
297
|
+
* The complement of what {@link resolveText} can spell exactly. A plain
|
|
242
298
|
* `$HOME` / `$PWD` reference resolves, so `"$HOME/out"` is not computed; any
|
|
243
299
|
* other expansion falls back to its own source text there, which names a file
|
|
244
300
|
* that is not the one the shell will touch (ADR 0009's computed-path residual).
|
|
245
301
|
* A single-quoted `'$x'` is a literal.
|
|
246
302
|
*/
|
|
247
|
-
|
|
303
|
+
function computedPart(node: TSNode, variables: ShellVariables): boolean {
|
|
248
304
|
if (COMPUTED_NODE_TYPES.has(node.type)) return true;
|
|
249
305
|
if (VARIABLE_EXPANSION_TYPES.has(node.type)) {
|
|
250
|
-
return
|
|
306
|
+
return variables.resolveReference(node) === null;
|
|
251
307
|
}
|
|
252
308
|
for (let i = 0; i < node.childCount; i++) {
|
|
253
309
|
const child = node.child(i);
|
|
254
|
-
if (child &&
|
|
310
|
+
if (child && computedPart(child, variables)) return true;
|
|
255
311
|
}
|
|
256
312
|
return false;
|
|
257
313
|
}
|
|
@@ -281,7 +337,7 @@ const VARIABLE_EXPANSION_TYPES: ReadonlySet<string> = new Set([
|
|
|
281
337
|
* else `.text` (see `shell-variable-expansion.ts`)
|
|
282
338
|
* - other → `.text` as fallback
|
|
283
339
|
*/
|
|
284
|
-
|
|
340
|
+
function resolveText(node: TSNode, variables: ShellVariables): string {
|
|
285
341
|
switch (node.type) {
|
|
286
342
|
case "word":
|
|
287
343
|
return node.text;
|
|
@@ -302,7 +358,7 @@ export function resolveNodeText(node: TSNode): string {
|
|
|
302
358
|
if (!child) continue;
|
|
303
359
|
// Skip the literal `"` delimiters
|
|
304
360
|
if (child.type === '"') continue;
|
|
305
|
-
result +=
|
|
361
|
+
result += resolveText(child, variables);
|
|
306
362
|
}
|
|
307
363
|
return result;
|
|
308
364
|
}
|
|
@@ -310,13 +366,13 @@ export function resolveNodeText(node: TSNode): string {
|
|
|
310
366
|
return node.text;
|
|
311
367
|
case "simple_expansion":
|
|
312
368
|
case "expansion":
|
|
313
|
-
return
|
|
369
|
+
return variables.resolveReference(node) ?? node.text;
|
|
314
370
|
case "concatenation": {
|
|
315
371
|
let result = "";
|
|
316
372
|
for (let i = 0; i < node.childCount; i++) {
|
|
317
373
|
const child = node.child(i);
|
|
318
374
|
if (!child) continue;
|
|
319
|
-
result +=
|
|
375
|
+
result += resolveText(child, variables);
|
|
320
376
|
}
|
|
321
377
|
return result;
|
|
322
378
|
}
|
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
collectCommands,
|
|
10
10
|
collectSalvagedCommands,
|
|
11
11
|
} from "./command-enumeration";
|
|
12
|
+
import { WordReader } from "./node-text";
|
|
12
13
|
import { getParser } from "./parser";
|
|
14
|
+
import { ShellVariables } from "./shell-variable-expansion";
|
|
13
15
|
import { withSalvagedRoots } from "./unresolved-salvage";
|
|
14
16
|
|
|
15
17
|
export type { BashCommand, BashExternalPath, BashPathRuleCandidate };
|
|
@@ -62,15 +64,19 @@ export class BashProgram {
|
|
|
62
64
|
|
|
63
65
|
try {
|
|
64
66
|
return withSalvagedRoots(tree.rootNode, parser, (salvaged) => {
|
|
67
|
+
const words = new WordReader(
|
|
68
|
+
ShellVariables.scan([tree.rootNode, ...salvaged]),
|
|
69
|
+
);
|
|
65
70
|
const { externalAccesses, ruleCandidates } = new BashPathResolver(
|
|
66
71
|
normalizer,
|
|
72
|
+
words,
|
|
67
73
|
options?.workdir,
|
|
68
74
|
).resolve(tree.rootNode, salvaged);
|
|
69
75
|
return new BashProgram(
|
|
70
76
|
command,
|
|
71
77
|
[
|
|
72
|
-
...collectCommands(tree.rootNode),
|
|
73
|
-
...salvaged.flatMap(collectSalvagedCommands),
|
|
78
|
+
...collectCommands(tree.rootNode, words),
|
|
79
|
+
...salvaged.flatMap((root) => collectSalvagedCommands(root, words)),
|
|
74
80
|
],
|
|
75
81
|
externalAccesses,
|
|
76
82
|
ruleCandidates,
|
|
@@ -17,24 +17,98 @@
|
|
|
17
17
|
* See `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
|
|
18
18
|
*/
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
|
+
import { hasHomePrefix } from "#src/path/expand-home";
|
|
20
21
|
|
|
21
22
|
import type { TSNode } from "./parser";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
|
-
* The
|
|
25
|
-
*
|
|
25
|
+
* The resolvable variables as one program sees them: which of them the program
|
|
26
|
+
* rebinds, so that a reference to a rebound one no longer reads as its startup
|
|
27
|
+
* value.
|
|
26
28
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* delimiters. An operator form (`${HOME:-/tmp}`, `${#HOME}`, `${HOME%/*}`)
|
|
30
|
-
* carries additional children and is therefore rejected without this module
|
|
31
|
-
* needing to enumerate bash's expansion operators.
|
|
29
|
+
* A closed set of rebound names, never their values — tracking what a program
|
|
30
|
+
* assigns is the dataflow ADR 0009 declines.
|
|
32
31
|
*/
|
|
33
|
-
export
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
export class ShellVariables {
|
|
33
|
+
/** A program that rebinds none of the resolvable variables. */
|
|
34
|
+
static readonly UNREBOUND = new ShellVariables(new Set());
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The resolvable variables `roots` rebind: the primary parse and every
|
|
38
|
+
* salvaged region, since an assignment in one governs a reference in another.
|
|
39
|
+
*
|
|
40
|
+
* A resolvable name rebinds when a `variable_name` carries it anywhere but as
|
|
41
|
+
* a plain reference's name: an assignment (a prefix one too), a declaration, a
|
|
42
|
+
* `for` variable, `unset`, an arithmetic assignment, `${HOME:=x}`. It also
|
|
43
|
+
* rebinds when a name-binding builtin is handed it as an argument (`read`,
|
|
44
|
+
* `printf -v`, `let`, a quoted `export "HOME=…"`), and both rebind under a
|
|
45
|
+
* command running code the walk never parses (`eval`, `source`, `.`, `trap`). Position is
|
|
46
|
+
* ignored, because a loop or a function body can run a later assignment
|
|
47
|
+
* first; a prefix assignment and an operator read (`${HOME:-x}`) count
|
|
48
|
+
* although neither rebinds the current shell, which costs only the
|
|
49
|
+
* projection of a program already spelling the name oddly.
|
|
50
|
+
*/
|
|
51
|
+
static scan(roots: readonly TSNode[]): ShellVariables {
|
|
52
|
+
const rebound = new Set<string>();
|
|
53
|
+
for (const root of roots) collectRebound(root, rebound, false);
|
|
54
|
+
return rebound.size === 0
|
|
55
|
+
? ShellVariables.UNREBOUND
|
|
56
|
+
: new ShellVariables(rebound);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private constructor(private readonly rebound: ReadonlySet<string>) {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The value of a plain `$NAME` / `${NAME}` reference, or `null` when the
|
|
63
|
+
* node is not a plain reference or names a variable outside the resolvable
|
|
64
|
+
* set.
|
|
65
|
+
*
|
|
66
|
+
* Plainness is decided structurally, not by matching the node's text: a
|
|
67
|
+
* plain reference carries exactly one `variable_name` child and nothing else
|
|
68
|
+
* but delimiters. An operator form (`${HOME:-/tmp}`, `${#HOME}`,
|
|
69
|
+
* `${HOME%/*}`) carries additional children and is therefore rejected without
|
|
70
|
+
* this module needing to enumerate bash's expansion operators.
|
|
71
|
+
*/
|
|
72
|
+
resolveReference(node: TSNode): string | null {
|
|
73
|
+
const name = plainVariableName(node);
|
|
74
|
+
if (name === null || this.rebound.has(name)) return null;
|
|
75
|
+
return RESOLVABLE_VARIABLES.get(name)?.() ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether a collected path token is spelled from a `HOME` this program
|
|
80
|
+
* rebinds (`$HOME/x`, `${HOME}`, `~/x`).
|
|
81
|
+
*
|
|
82
|
+
* Such a token names no path the projection can know, yet path normalization
|
|
83
|
+
* expands its prefix to the startup home exactly as it does for a config
|
|
84
|
+
* pattern, so it must leave the path surfaces before it reaches them.
|
|
85
|
+
*/
|
|
86
|
+
spellsReboundHome(token: string): boolean {
|
|
87
|
+
return this.rebound.has("HOME") && hasHomePrefix(token);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How a word's leading unquoted text expands when it opens with a tilde
|
|
92
|
+
* prefix that reads `HOME` (`~` or `~/…`; `~user` reads the password
|
|
93
|
+
* database instead), or `undefined` when it does not.
|
|
94
|
+
*
|
|
95
|
+
* Unknown once the program rebinds `HOME`: bash 3.2, which Pi runs as
|
|
96
|
+
* `/bin/bash` on macOS, expands it from the reassigned value. Otherwise it is
|
|
97
|
+
* the inherited `HOME`, which `homedir()` returns verbatim, so the word leads
|
|
98
|
+
* with whatever that does.
|
|
99
|
+
*/
|
|
100
|
+
readTilde(leadingText: string): TildeReading | undefined {
|
|
101
|
+
if (leadingText !== "~" && !leadingText.startsWith("~/")) return undefined;
|
|
102
|
+
if (this.rebound.has("HOME")) return { known: false };
|
|
103
|
+
return { known: true, leadsWithDash: homedir().startsWith("-") };
|
|
104
|
+
}
|
|
36
105
|
}
|
|
37
106
|
|
|
107
|
+
/** What a leading tilde prefix expands to, as far as the program shows. */
|
|
108
|
+
export type TildeReading =
|
|
109
|
+
| { readonly known: false }
|
|
110
|
+
| { readonly known: true; readonly leadsWithDash: boolean };
|
|
111
|
+
|
|
38
112
|
/**
|
|
39
113
|
* How each resolvable variable is spelled as a path.
|
|
40
114
|
*
|
|
@@ -50,6 +124,129 @@ const RESOLVABLE_VARIABLES: ReadonlyMap<string, () => string> = new Map([
|
|
|
50
124
|
["PWD", () => "."],
|
|
51
125
|
]);
|
|
52
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Record each resolvable name `node` binds.
|
|
129
|
+
*
|
|
130
|
+
* `bindsNames` holds beneath a builtin that binds the names it is handed
|
|
131
|
+
* (`read HOME`, `export "HOME=/etc"`, `declare -n r=HOME`), and only there does
|
|
132
|
+
* an argument spelling a name count: `grep HOME ~/.bashrc` binds nothing, and
|
|
133
|
+
* reading it as a rebinding would drop the `~/.bashrc` it does read.
|
|
134
|
+
*/
|
|
135
|
+
function collectRebound(
|
|
136
|
+
node: TSNode,
|
|
137
|
+
rebound: Set<string>,
|
|
138
|
+
inheritedBindsNames: boolean,
|
|
139
|
+
): void {
|
|
140
|
+
if (REFERENCE_TYPES.has(node.type) && plainVariableName(node) !== null) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const bindsNames = bindsNamesBeneath(node, inheritedBindsNames);
|
|
144
|
+
if (node.type === "variable_name" && RESOLVABLE_VARIABLES.has(node.text)) {
|
|
145
|
+
rebound.add(node.text);
|
|
146
|
+
}
|
|
147
|
+
if (bindsNames && NAME_ARGUMENT_TYPES.has(node.type)) {
|
|
148
|
+
const name = NAMED_ARGUMENT.exec(node.text.replace(QUOTING, ""))?.[1];
|
|
149
|
+
if (name) rebound.add(name);
|
|
150
|
+
}
|
|
151
|
+
if (node.type === "command" && UNSEEN_CODE_COMMANDS.has(commandName(node))) {
|
|
152
|
+
for (const name of RESOLVABLE_VARIABLES.keys()) rebound.add(name);
|
|
153
|
+
}
|
|
154
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
155
|
+
const child = node.child(i);
|
|
156
|
+
if (child) collectRebound(child, rebound, bindsNames);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether the arguments beneath `node` are names its command binds. A command
|
|
162
|
+
* decides for itself; a `declare`-family or `unset` statement always binds;
|
|
163
|
+
* anything else inherits.
|
|
164
|
+
*/
|
|
165
|
+
function bindsNamesBeneath(node: TSNode, inherited: boolean): boolean {
|
|
166
|
+
if (node.type === "declaration_command" || node.type === "unset_command") {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
if (node.type !== "command") return inherited;
|
|
170
|
+
const name = commandName(node);
|
|
171
|
+
if (name === "printf") return hasArgument(node, "-v");
|
|
172
|
+
return NAME_BINDING_COMMANDS.has(name);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Builtins that bind a name passed as an argument. `declare` and its family
|
|
177
|
+
* parse as a `declaration_command` when spelled plainly and as a `command`
|
|
178
|
+
* when quoted, so they are listed here too; `printf` binds only under `-v`.
|
|
179
|
+
*/
|
|
180
|
+
const NAME_BINDING_COMMANDS: ReadonlySet<string> = new Set([
|
|
181
|
+
"read",
|
|
182
|
+
"mapfile",
|
|
183
|
+
"readarray",
|
|
184
|
+
"getopts",
|
|
185
|
+
"let",
|
|
186
|
+
"unset",
|
|
187
|
+
"declare",
|
|
188
|
+
"typeset",
|
|
189
|
+
"local",
|
|
190
|
+
"export",
|
|
191
|
+
"readonly",
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Argument nodes that can spell a name a builtin binds, whole or as the head
|
|
196
|
+
* of an assignment (`HOME=…`, `HOME+=…`, `HOME[0]=…`, `let HOME++`).
|
|
197
|
+
*/
|
|
198
|
+
const NAME_ARGUMENT_TYPES: ReadonlySet<string> = new Set([
|
|
199
|
+
"word",
|
|
200
|
+
"string",
|
|
201
|
+
"raw_string",
|
|
202
|
+
"concatenation",
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
/** A resolvable name leading an argument, not followed by more of an identifier. */
|
|
206
|
+
const NAMED_ARGUMENT = new RegExp(
|
|
207
|
+
`^(${[...RESOLVABLE_VARIABLES.keys()].join("|")})(?![A-Za-z0-9_])`,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
/** Quote and escape characters, which the shell removes before a builtin sees the name. */
|
|
211
|
+
const QUOTING = /["'\\]/g;
|
|
212
|
+
|
|
213
|
+
/** A command's name as the shell reads it, quotes and escapes removed. */
|
|
214
|
+
function commandName(command: TSNode): string {
|
|
215
|
+
for (let i = 0; i < command.childCount; i++) {
|
|
216
|
+
const child = command.child(i);
|
|
217
|
+
if (child?.type === "command_name") return child.text.replace(QUOTING, "");
|
|
218
|
+
}
|
|
219
|
+
return "";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Whether a command carries `flag` as one of its own arguments. */
|
|
223
|
+
function hasArgument(command: TSNode, flag: string): boolean {
|
|
224
|
+
for (let i = 0; i < command.childCount; i++) {
|
|
225
|
+
const child = command.child(i);
|
|
226
|
+
if (child && child.type !== "command_name" && child.text === flag) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Commands that run code the walk never parses, which may rebind anything:
|
|
235
|
+
* `eval`'s joined arguments, a file `source`/`.` reads, and a `trap` action.
|
|
236
|
+
*/
|
|
237
|
+
const UNSEEN_CODE_COMMANDS: ReadonlySet<string> = new Set([
|
|
238
|
+
"eval",
|
|
239
|
+
"source",
|
|
240
|
+
".",
|
|
241
|
+
"trap",
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
/** The node types a variable reference parses as. */
|
|
245
|
+
const REFERENCE_TYPES: ReadonlySet<string> = new Set([
|
|
246
|
+
"simple_expansion",
|
|
247
|
+
"expansion",
|
|
248
|
+
]);
|
|
249
|
+
|
|
53
250
|
/** Node types that delimit an expansion without altering what it evaluates to. */
|
|
54
251
|
const EXPANSION_DELIMITERS: ReadonlySet<string> = new Set(["$", "${", "}"]);
|
|
55
252
|
|
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
collectCommands,
|
|
4
4
|
collectSalvagedCommands,
|
|
5
5
|
} from "./command-enumeration";
|
|
6
|
+
import { WordReader } from "./node-text";
|
|
6
7
|
import { getWarmBashParser } from "./parser";
|
|
8
|
+
import { ShellVariables } from "./shell-variable-expansion";
|
|
7
9
|
import { withSalvagedRoots } from "./unresolved-salvage";
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -26,10 +28,15 @@ export function parseBashCommandsSync(command: string): BashCommand[] | null {
|
|
|
26
28
|
const tree = parser.parse(command);
|
|
27
29
|
if (!tree) return [];
|
|
28
30
|
try {
|
|
29
|
-
return withSalvagedRoots(tree.rootNode, parser, (salvaged) =>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
return withSalvagedRoots(tree.rootNode, parser, (salvaged) => {
|
|
32
|
+
const words = new WordReader(
|
|
33
|
+
ShellVariables.scan([tree.rootNode, ...salvaged]),
|
|
34
|
+
);
|
|
35
|
+
return [
|
|
36
|
+
...collectCommands(tree.rootNode, words),
|
|
37
|
+
...salvaged.flatMap((root) => collectSalvagedCommands(root, words)),
|
|
38
|
+
];
|
|
39
|
+
});
|
|
33
40
|
} finally {
|
|
34
41
|
tree.delete();
|
|
35
42
|
}
|
|
@@ -5,10 +5,8 @@ import { EXECUTION_HOST_TYPES, forEachExecutionIn } from "./nested-execution";
|
|
|
5
5
|
import {
|
|
6
6
|
ARG_NODE_TYPES,
|
|
7
7
|
type ArgWord,
|
|
8
|
-
hasComputedPart,
|
|
9
|
-
readArgWord,
|
|
10
|
-
resolveNodeText,
|
|
11
8
|
SKIP_SUBTREE_TYPES,
|
|
9
|
+
type WordReader,
|
|
12
10
|
} from "./node-text";
|
|
13
11
|
import type { TSNode } from "./parser";
|
|
14
12
|
import {
|
|
@@ -63,24 +61,27 @@ export interface PathToken {
|
|
|
63
61
|
* `script`-role flag's argument for an interpreter. For all other commands,
|
|
64
62
|
* collects all arguments generically.
|
|
65
63
|
*/
|
|
66
|
-
export function collectPathCandidateTokens(
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
export function collectPathCandidateTokens(
|
|
65
|
+
node: TSNode,
|
|
66
|
+
words: WordReader,
|
|
67
|
+
): PathToken[] {
|
|
68
|
+
if (node.type === "command") return collectCommandTokens(node, words);
|
|
69
|
+
if (node.type === "file_redirect") return collectRedirectTokens(node, words);
|
|
69
70
|
if (node.type === "for_statement") {
|
|
70
|
-
return collectStatementOperandTokens(node, "after-in");
|
|
71
|
+
return collectStatementOperandTokens(node, "after-in", words);
|
|
71
72
|
}
|
|
72
73
|
if (node.type === "case_statement") {
|
|
73
|
-
return collectStatementOperandTokens(node, "before-in");
|
|
74
|
+
return collectStatementOperandTokens(node, "before-in", words);
|
|
74
75
|
}
|
|
75
76
|
if (EXECUTION_HOST_TYPES.has(node.type)) {
|
|
76
|
-
return collectHostedExecutionTokens(node);
|
|
77
|
+
return collectHostedExecutionTokens(node, words);
|
|
77
78
|
}
|
|
78
79
|
if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
|
|
79
80
|
|
|
80
81
|
const tokens: PathToken[] = [];
|
|
81
82
|
for (let i = 0; i < node.childCount; i++) {
|
|
82
83
|
const child = node.child(i);
|
|
83
|
-
if (child) tokens.push(...collectPathCandidateTokens(child));
|
|
84
|
+
if (child) tokens.push(...collectPathCandidateTokens(child, words));
|
|
84
85
|
}
|
|
85
86
|
return tokens;
|
|
86
87
|
}
|
|
@@ -95,19 +96,22 @@ export function collectPathCandidateTokens(node: TSNode): PathToken[] {
|
|
|
95
96
|
* spelling proves nothing. A nested execution collected along the way keeps
|
|
96
97
|
* its own command's attribution instead.
|
|
97
98
|
*/
|
|
98
|
-
export function collectCommandTokens(
|
|
99
|
+
export function collectCommandTokens(
|
|
100
|
+
node: TSNode,
|
|
101
|
+
words: WordReader,
|
|
102
|
+
): PathToken[] {
|
|
99
103
|
const effect = proveCommandEffect(
|
|
100
|
-
extractCommandWord(node) ?? "",
|
|
101
|
-
commandArgumentWords(node),
|
|
104
|
+
extractCommandWord(node, words) ?? "",
|
|
105
|
+
commandArgumentWords(node, words),
|
|
102
106
|
);
|
|
103
|
-
const commandName = extractCommandName(node);
|
|
107
|
+
const commandName = extractCommandName(node, words);
|
|
104
108
|
const config = commandName
|
|
105
109
|
? PATTERN_FIRST_COMMANDS.get(commandName)
|
|
106
110
|
: undefined;
|
|
107
|
-
if (config) return collectPatternCommandTokens(node, config, effect);
|
|
111
|
+
if (config) return collectPatternCommandTokens(node, config, effect, words);
|
|
108
112
|
return [
|
|
109
|
-
...collectGenericCommandTokens(node, effect),
|
|
110
|
-
...collectEmbeddedOptionValues(node, effect),
|
|
113
|
+
...collectGenericCommandTokens(node, effect, words),
|
|
114
|
+
...collectEmbeddedOptionValues(node, effect, words),
|
|
111
115
|
];
|
|
112
116
|
}
|
|
113
117
|
|
|
@@ -139,7 +143,10 @@ export function collectCommandTokens(node: TSNode): PathToken[] {
|
|
|
139
143
|
* Reading the redirect node itself belongs to `redirect-analysis.ts`, which
|
|
140
144
|
* the command enumerator consults for the same fact (#803).
|
|
141
145
|
*/
|
|
142
|
-
export function collectRedirectTokens(
|
|
146
|
+
export function collectRedirectTokens(
|
|
147
|
+
node: TSNode,
|
|
148
|
+
words: WordReader,
|
|
149
|
+
): PathToken[] {
|
|
143
150
|
const target = redirectTargetIndex(node);
|
|
144
151
|
const tokens: PathToken[] = [];
|
|
145
152
|
for (let i = 0; i < node.childCount; i++) {
|
|
@@ -148,15 +155,15 @@ export function collectRedirectTokens(node: TSNode): PathToken[] {
|
|
|
148
155
|
if (ARG_NODE_TYPES.has(child.type)) {
|
|
149
156
|
const effect = redirectEffectForDestination(node, child);
|
|
150
157
|
if (effect) {
|
|
151
|
-
const token =
|
|
158
|
+
const token = words.text(child);
|
|
152
159
|
const role: TokenRole =
|
|
153
|
-
i === target && provesTarget(effect, child, token)
|
|
160
|
+
i === target && provesTarget(effect, child, token, words)
|
|
154
161
|
? "redirect-destination"
|
|
155
162
|
: "operand";
|
|
156
163
|
tokens.push({ token, effect, role });
|
|
157
164
|
}
|
|
158
165
|
}
|
|
159
|
-
tokens.push(...collectHostedExecutionTokens(child));
|
|
166
|
+
tokens.push(...collectHostedExecutionTokens(child, words));
|
|
160
167
|
}
|
|
161
168
|
return tokens;
|
|
162
169
|
}
|
|
@@ -174,9 +181,10 @@ function provesTarget(
|
|
|
174
181
|
effect: TokenEffect,
|
|
175
182
|
destination: TSNode,
|
|
176
183
|
token: string,
|
|
184
|
+
words: WordReader,
|
|
177
185
|
): boolean {
|
|
178
186
|
return (
|
|
179
|
-
effect.source === "syntax" && !
|
|
187
|
+
effect.source === "syntax" && !words.isComputed(destination) && token !== ""
|
|
180
188
|
);
|
|
181
189
|
}
|
|
182
190
|
|
|
@@ -193,10 +201,13 @@ function provesTarget(
|
|
|
193
201
|
* (`> ${DIR}/$(cmd)`), so the traversal is the root-inclusive
|
|
194
202
|
* `forEachExecutionIn`.
|
|
195
203
|
*/
|
|
196
|
-
function collectHostedExecutionTokens(
|
|
204
|
+
function collectHostedExecutionTokens(
|
|
205
|
+
node: TSNode,
|
|
206
|
+
words: WordReader,
|
|
207
|
+
): PathToken[] {
|
|
197
208
|
const tokens: PathToken[] = [];
|
|
198
209
|
forEachExecutionIn(node, (contextNode) => {
|
|
199
|
-
tokens.push(...collectPathCandidateTokens(contextNode));
|
|
210
|
+
tokens.push(...collectPathCandidateTokens(contextNode, words));
|
|
200
211
|
});
|
|
201
212
|
return tokens;
|
|
202
213
|
}
|
|
@@ -244,6 +255,7 @@ type OperandSide = "before-in" | "after-in";
|
|
|
244
255
|
function collectStatementOperandTokens(
|
|
245
256
|
node: TSNode,
|
|
246
257
|
operandSide: OperandSide,
|
|
258
|
+
words: WordReader,
|
|
247
259
|
): PathToken[] {
|
|
248
260
|
const tokens: PathToken[] = [];
|
|
249
261
|
let seenIn = false;
|
|
@@ -256,15 +268,15 @@ function collectStatementOperandTokens(
|
|
|
256
268
|
}
|
|
257
269
|
const side: OperandSide = seenIn ? "after-in" : "before-in";
|
|
258
270
|
if (side !== operandSide || !ARG_NODE_TYPES.has(child.type)) {
|
|
259
|
-
tokens.push(...collectPathCandidateTokens(child));
|
|
271
|
+
tokens.push(...collectPathCandidateTokens(child, words));
|
|
260
272
|
continue;
|
|
261
273
|
}
|
|
262
274
|
tokens.push({
|
|
263
|
-
token:
|
|
275
|
+
token: words.text(child),
|
|
264
276
|
effect: UNPROVEN_EFFECT,
|
|
265
277
|
role: "operand",
|
|
266
278
|
});
|
|
267
|
-
tokens.push(...collectHostedExecutionTokens(child));
|
|
279
|
+
tokens.push(...collectHostedExecutionTokens(child, words));
|
|
268
280
|
}
|
|
269
281
|
return tokens;
|
|
270
282
|
}
|
|
@@ -279,8 +291,11 @@ function collectStatementOperandTokens(
|
|
|
279
291
|
* capability claim, where the directory prefix is the whole point — use
|
|
280
292
|
* {@link extractCommandWord} there.
|
|
281
293
|
*/
|
|
282
|
-
export function extractCommandName(
|
|
283
|
-
|
|
294
|
+
export function extractCommandName(
|
|
295
|
+
node: TSNode,
|
|
296
|
+
words: WordReader,
|
|
297
|
+
): string | undefined {
|
|
298
|
+
const word = extractCommandWord(node, words);
|
|
284
299
|
return word === undefined ? undefined : basename(word);
|
|
285
300
|
}
|
|
286
301
|
|
|
@@ -294,12 +309,15 @@ export function extractCommandName(node: TSNode): string | undefined {
|
|
|
294
309
|
* Documented against {@link extractCommandName}, which answers the other
|
|
295
310
|
* question.
|
|
296
311
|
*/
|
|
297
|
-
export function extractCommandWord(
|
|
312
|
+
export function extractCommandWord(
|
|
313
|
+
node: TSNode,
|
|
314
|
+
words: WordReader,
|
|
315
|
+
): string | undefined {
|
|
298
316
|
for (let i = 0; i < node.childCount; i++) {
|
|
299
317
|
const child = node.child(i);
|
|
300
318
|
if (!child) continue;
|
|
301
319
|
if (child.type === "command_name") {
|
|
302
|
-
const text =
|
|
320
|
+
const text = words.text(child);
|
|
303
321
|
return text === "" ? undefined : text;
|
|
304
322
|
}
|
|
305
323
|
}
|
|
@@ -319,16 +337,16 @@ export function extractCommandWord(node: TSNode): string | undefined {
|
|
|
319
337
|
* reaches the program as an argument too, and a proof that never saw it could
|
|
320
338
|
* not know it might spell `-i`.
|
|
321
339
|
*/
|
|
322
|
-
function commandArgumentWords(node: TSNode): ArgWord[] {
|
|
323
|
-
const
|
|
340
|
+
function commandArgumentWords(node: TSNode, words: WordReader): ArgWord[] {
|
|
341
|
+
const argWords: ArgWord[] = [];
|
|
324
342
|
for (let i = 0; i < node.childCount; i++) {
|
|
325
343
|
const child = node.child(i);
|
|
326
344
|
if (!child?.isNamed) continue;
|
|
327
345
|
if (COMMAND_PREFIX_TYPES.has(child.type)) continue;
|
|
328
346
|
if (REDIRECT_NODE_TYPES.has(child.type)) continue;
|
|
329
|
-
|
|
347
|
+
argWords.push(words.argWord(child));
|
|
330
348
|
}
|
|
331
|
-
return
|
|
349
|
+
return argWords;
|
|
332
350
|
}
|
|
333
351
|
|
|
334
352
|
/**
|
|
@@ -376,6 +394,7 @@ const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
|
|
|
376
394
|
function collectEmbeddedOptionValues(
|
|
377
395
|
node: TSNode,
|
|
378
396
|
effect: TokenEffect,
|
|
397
|
+
words: WordReader,
|
|
379
398
|
): PathToken[] {
|
|
380
399
|
const values: PathToken[] = [];
|
|
381
400
|
for (let i = 0; i < node.childCount; i++) {
|
|
@@ -384,7 +403,7 @@ function collectEmbeddedOptionValues(
|
|
|
384
403
|
if (COMMAND_PREFIX_TYPES.has(child.type)) continue;
|
|
385
404
|
if (!ARG_NODE_TYPES.has(child.type)) continue;
|
|
386
405
|
|
|
387
|
-
const value = OPTION_VALUE_PATTERN.exec(
|
|
406
|
+
const value = OPTION_VALUE_PATTERN.exec(words.text(child))?.[1];
|
|
388
407
|
if (value !== undefined) {
|
|
389
408
|
values.push({ token: value, effect, role: "operand" });
|
|
390
409
|
}
|
|
@@ -768,6 +787,7 @@ function collectPatternCommandTokens(
|
|
|
768
787
|
node: TSNode,
|
|
769
788
|
config: PatternCommandConfig,
|
|
770
789
|
effect: TokenEffect,
|
|
790
|
+
words: WordReader,
|
|
771
791
|
): PathToken[] {
|
|
772
792
|
const patternPositionals = config.patternPositionals ?? 1;
|
|
773
793
|
let hasExplicitScript = false;
|
|
@@ -782,12 +802,12 @@ function collectPatternCommandTokens(
|
|
|
782
802
|
|
|
783
803
|
if (COMMAND_PREFIX_TYPES.has(child.type)) {
|
|
784
804
|
// Supplies no operand of its own, but may host one that really runs.
|
|
785
|
-
tokens.push(...collectHostedExecutionTokens(child));
|
|
805
|
+
tokens.push(...collectHostedExecutionTokens(child, words));
|
|
786
806
|
continue;
|
|
787
807
|
}
|
|
788
808
|
|
|
789
809
|
const isArgNode = ARG_NODE_TYPES.has(child.type);
|
|
790
|
-
const text =
|
|
810
|
+
const text = words.text(child);
|
|
791
811
|
|
|
792
812
|
// An argument is read for its text below, and its text alone — but a
|
|
793
813
|
// quoted substitution sitting there really runs, and its own operands are
|
|
@@ -796,7 +816,7 @@ function collectPatternCommandTokens(
|
|
|
796
816
|
// recursions below; the quoted one parses as a `string` and would
|
|
797
817
|
// otherwise stop here, whichever role the argument turns out to have
|
|
798
818
|
// (#945).
|
|
799
|
-
if (isArgNode) tokens.push(...collectHostedExecutionTokens(child));
|
|
819
|
+
if (isArgNode) tokens.push(...collectHostedExecutionTokens(child, words));
|
|
800
820
|
|
|
801
821
|
// Handle the argument a previous flag consumed. The consumption discharges
|
|
802
822
|
// on whatever node type follows, not only on an ARG_NODE_TYPES one: a bare
|
|
@@ -810,7 +830,7 @@ function collectPatternCommandTokens(
|
|
|
810
830
|
if (!isArgNode) {
|
|
811
831
|
// Contributes no operand text of its own, but may host a nested
|
|
812
832
|
// execution whose operands are candidates (#741).
|
|
813
|
-
tokens.push(...collectPathCandidateTokens(child));
|
|
833
|
+
tokens.push(...collectPathCandidateTokens(child, words));
|
|
814
834
|
continue;
|
|
815
835
|
}
|
|
816
836
|
const discharge = dischargePendingConsumption(consumption, text, effect);
|
|
@@ -838,7 +858,7 @@ function collectPatternCommandTokens(
|
|
|
838
858
|
positionalsSeen++;
|
|
839
859
|
}
|
|
840
860
|
// Recurse for nested commands (e.g. command_substitution).
|
|
841
|
-
tokens.push(...collectPathCandidateTokens(child));
|
|
861
|
+
tokens.push(...collectPathCandidateTokens(child, words));
|
|
842
862
|
continue;
|
|
843
863
|
}
|
|
844
864
|
|
|
@@ -963,6 +983,7 @@ function dischargePendingConsumption(
|
|
|
963
983
|
function collectGenericCommandTokens(
|
|
964
984
|
node: TSNode,
|
|
965
985
|
effect: TokenEffect,
|
|
986
|
+
words: WordReader,
|
|
966
987
|
): PathToken[] {
|
|
967
988
|
const tokens: PathToken[] = [];
|
|
968
989
|
let seenCommandName = false;
|
|
@@ -974,7 +995,7 @@ function collectGenericCommandTokens(
|
|
|
974
995
|
if (COMMAND_PREFIX_TYPES.has(child.type)) {
|
|
975
996
|
// Supplies no operand of its own, but may host one that really runs.
|
|
976
997
|
if (child.type === "command_name") seenCommandName = true;
|
|
977
|
-
tokens.push(...collectHostedExecutionTokens(child));
|
|
998
|
+
tokens.push(...collectHostedExecutionTokens(child, words));
|
|
978
999
|
continue;
|
|
979
1000
|
}
|
|
980
1001
|
|
|
@@ -983,7 +1004,7 @@ function collectGenericCommandTokens(
|
|
|
983
1004
|
// sits (ADR 0009's positional invariance, #945). The unquoted spelling
|
|
984
1005
|
// reaches the nested command through the trailing recursion.
|
|
985
1006
|
if (ARG_NODE_TYPES.has(child.type))
|
|
986
|
-
tokens.push(...collectHostedExecutionTokens(child));
|
|
1007
|
+
tokens.push(...collectHostedExecutionTokens(child, words));
|
|
987
1008
|
|
|
988
1009
|
// If there was no explicit command_name node, the first word-like
|
|
989
1010
|
// child is the command name itself — skip it.
|
|
@@ -994,12 +1015,12 @@ function collectGenericCommandTokens(
|
|
|
994
1015
|
|
|
995
1016
|
// Argument nodes: resolve their text and collect.
|
|
996
1017
|
if (ARG_NODE_TYPES.has(child.type)) {
|
|
997
|
-
tokens.push({ token:
|
|
1018
|
+
tokens.push({ token: words.text(child), effect, role: "operand" });
|
|
998
1019
|
continue;
|
|
999
1020
|
}
|
|
1000
1021
|
|
|
1001
1022
|
// Recurse into other children (e.g. command_substitution nested in args)
|
|
1002
|
-
tokens.push(...collectPathCandidateTokens(child));
|
|
1023
|
+
tokens.push(...collectPathCandidateTokens(child, words));
|
|
1003
1024
|
}
|
|
1004
1025
|
|
|
1005
1026
|
return tokens;
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { inlineShellPayloadNode } from "#src/access-intent/bash/command-enumeration";
|
|
2
|
-
import {
|
|
3
|
-
ARG_NODE_TYPES,
|
|
4
|
-
resolveNodeText,
|
|
5
|
-
} from "#src/access-intent/bash/node-text";
|
|
2
|
+
import { ARG_NODE_TYPES, WordReader } from "#src/access-intent/bash/node-text";
|
|
6
3
|
import {
|
|
7
4
|
type BashReparser,
|
|
8
5
|
getWarmBashParser,
|
|
9
6
|
type TSNode,
|
|
10
7
|
} from "#src/access-intent/bash/parser";
|
|
8
|
+
import { ShellVariables } from "#src/access-intent/bash/shell-variable-expansion";
|
|
11
9
|
import { isPlainRecord } from "#src/value-guards";
|
|
12
10
|
import { isSensitiveName, REDACTED_PLACEHOLDER } from "./log-redaction";
|
|
13
11
|
|
|
@@ -80,7 +78,7 @@ export function redactCommandSecrets(command: string): string {
|
|
|
80
78
|
* carries.
|
|
81
79
|
*
|
|
82
80
|
* A payload is re-parsed from its **verbatim inner slice** rather than
|
|
83
|
-
* `
|
|
81
|
+
* `WordReader.text`'s shell value: the resolved text concatenates children and
|
|
84
82
|
* expands `$HOME`, which destroys the offset correspondence this shift relies
|
|
85
83
|
* on. Because the slice excludes the payload's quotes, no span recovered from it
|
|
86
84
|
* can reach one, so the masked payload stays quoted as it was written.
|
|
@@ -124,6 +122,14 @@ function collectSpansIn(
|
|
|
124
122
|
}
|
|
125
123
|
}
|
|
126
124
|
|
|
125
|
+
/**
|
|
126
|
+
* How the masker reads a word: at the startup values of `HOME` and `PWD`,
|
|
127
|
+
* whatever the command rebinds. What it masks is decided by the *name* a value
|
|
128
|
+
* is bound to, and neither spelling of either variable is a sensitive name, so
|
|
129
|
+
* reading them as rebound would change no span.
|
|
130
|
+
*/
|
|
131
|
+
const WORDS = new WordReader(ShellVariables.UNREBOUND);
|
|
132
|
+
|
|
127
133
|
/**
|
|
128
134
|
* How many payload layers to descend.
|
|
129
135
|
*
|
|
@@ -175,7 +181,7 @@ function collectInlineShellPayloads(
|
|
|
175
181
|
payloads: PayloadSource[],
|
|
176
182
|
): void {
|
|
177
183
|
if (node.type === "command") {
|
|
178
|
-
const payload = inlineShellPayloadNode(node);
|
|
184
|
+
const payload = inlineShellPayloadNode(node, WORDS);
|
|
179
185
|
if (payload) payloads.push(payloadSourceOf(payload));
|
|
180
186
|
}
|
|
181
187
|
for (let i = 0; i < node.childCount; i++) {
|
|
@@ -191,7 +197,7 @@ function collectInlineShellPayloads(
|
|
|
191
197
|
* A `word` payload is the program already. A `string`/`raw_string` wraps it in
|
|
192
198
|
* one quote pair and an `ansi_c_string` in a `$` plus one quote pair, so each is
|
|
193
199
|
* a slice at a known offset. Anything else — a `concatenation`, an expansion —
|
|
194
|
-
* is stitched: `
|
|
200
|
+
* is stitched: `WordReader.text` knows how to read its shell value, and that
|
|
195
201
|
* value's own offsets describe no span of the command.
|
|
196
202
|
*/
|
|
197
203
|
function payloadSourceOf(node: TSNode): PayloadSource {
|
|
@@ -216,7 +222,7 @@ function payloadSourceOf(node: TSNode): PayloadSource {
|
|
|
216
222
|
start: node.startIndex + quoteAt + 1,
|
|
217
223
|
end: node.endIndex - 1,
|
|
218
224
|
}
|
|
219
|
-
: { kind: "stitched", text:
|
|
225
|
+
: { kind: "stitched", text: WORDS.text(node), ...bounds };
|
|
220
226
|
}
|
|
221
227
|
|
|
222
228
|
/**
|
|
@@ -318,7 +324,7 @@ const HEADER_FIELD = /^([A-Za-z][A-Za-z0-9_-]*)[ \t]*:[ \t]*\S/;
|
|
|
318
324
|
|
|
319
325
|
function headerValueSpan(node: TSNode): MaskSpan | null {
|
|
320
326
|
if (!ARG_NODE_TYPES.has(node.type)) return null;
|
|
321
|
-
const match = HEADER_FIELD.exec(
|
|
327
|
+
const match = HEADER_FIELD.exec(WORDS.text(node));
|
|
322
328
|
const field = match?.[1];
|
|
323
329
|
if (!field || !isSensitiveName(field) || isCamelCased(field)) return null;
|
|
324
330
|
const colon = node.text.indexOf(":");
|
package/src/path/expand-home.ts
CHANGED
|
@@ -29,14 +29,30 @@ const HOME_PREFIXES = ["~", "$HOME", "${HOME}"] as const;
|
|
|
29
29
|
* All other patterns are returned unchanged.
|
|
30
30
|
*/
|
|
31
31
|
export function expandHomePath(pattern: string): string {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
const rest = afterHomePrefix(pattern);
|
|
33
|
+
if (rest === undefined) return pattern;
|
|
34
|
+
return rest === "" ? homedir() : join(homedir(), rest.slice(1));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether {@link expandHomePath} reads `value` as starting at the home
|
|
39
|
+
* directory.
|
|
40
|
+
*/
|
|
41
|
+
export function hasHomePrefix(value: string): boolean {
|
|
42
|
+
return afterHomePrefix(value) !== undefined;
|
|
43
|
+
}
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
/**
|
|
46
|
+
* What follows a recognized home prefix (`""`, or a separator and the rest),
|
|
47
|
+
* or `undefined` when `value` has none.
|
|
48
|
+
*/
|
|
49
|
+
function afterHomePrefix(value: string): string | undefined {
|
|
50
|
+
for (const prefix of HOME_PREFIXES) {
|
|
51
|
+
if (!value.startsWith(prefix)) continue;
|
|
52
|
+
const rest = value.slice(prefix.length);
|
|
53
|
+
if (rest === "" || rest.startsWith("/") || rest.startsWith("\\")) {
|
|
54
|
+
return rest;
|
|
39
55
|
}
|
|
40
56
|
}
|
|
41
|
-
return
|
|
57
|
+
return undefined;
|
|
42
58
|
}
|