@taliesinb/mash 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +275 -2
- package/bin/mash.ts +253 -0
- package/dist/bin/mash.d.ts +39 -0
- package/dist/bin/mash.d.ts.map +1 -0
- package/dist/bin/mash.js +281 -0
- package/dist/bin/mash.js.map +1 -0
- package/dist/src/analysis/analyze.d.ts +97 -0
- package/dist/src/analysis/analyze.d.ts.map +1 -0
- package/dist/src/analysis/analyze.js +1406 -0
- package/dist/src/analysis/analyze.js.map +1 -0
- package/dist/src/analysis/fs.d.ts +39 -0
- package/dist/src/analysis/fs.d.ts.map +1 -0
- package/dist/src/analysis/fs.js +117 -0
- package/dist/src/analysis/fs.js.map +1 -0
- package/dist/src/analysis/outputs.d.ts +16 -0
- package/dist/src/analysis/outputs.d.ts.map +1 -0
- package/dist/src/analysis/outputs.js +277 -0
- package/dist/src/analysis/outputs.js.map +1 -0
- package/dist/src/analysis/value.d.ts +76 -0
- package/dist/src/analysis/value.d.ts.map +1 -0
- package/dist/src/analysis/value.js +248 -0
- package/dist/src/analysis/value.js.map +1 -0
- package/dist/src/behaviors/defs/core.d.ts +15 -0
- package/dist/src/behaviors/defs/core.d.ts.map +1 -0
- package/dist/src/behaviors/defs/core.js +1084 -0
- package/dist/src/behaviors/defs/core.js.map +1 -0
- package/dist/src/behaviors/defs/devtools.d.ts +9 -0
- package/dist/src/behaviors/defs/devtools.d.ts.map +1 -0
- package/dist/src/behaviors/defs/devtools.js +432 -0
- package/dist/src/behaviors/defs/devtools.js.map +1 -0
- package/dist/src/behaviors/defs/macos.d.ts +12 -0
- package/dist/src/behaviors/defs/macos.d.ts.map +1 -0
- package/dist/src/behaviors/defs/macos.js +454 -0
- package/dist/src/behaviors/defs/macos.js.map +1 -0
- package/dist/src/behaviors/defs/tools.d.ts +9 -0
- package/dist/src/behaviors/defs/tools.d.ts.map +1 -0
- package/dist/src/behaviors/defs/tools.js +639 -0
- package/dist/src/behaviors/defs/tools.js.map +1 -0
- package/dist/src/behaviors/dsl.d.ts +95 -0
- package/dist/src/behaviors/dsl.d.ts.map +1 -0
- package/dist/src/behaviors/dsl.js +267 -0
- package/dist/src/behaviors/dsl.js.map +1 -0
- package/dist/src/behaviors/index.d.ts +12 -0
- package/dist/src/behaviors/index.d.ts.map +1 -0
- package/dist/src/behaviors/index.js +18 -0
- package/dist/src/behaviors/index.js.map +1 -0
- package/dist/src/behaviors/registry.d.ts +45 -0
- package/dist/src/behaviors/registry.d.ts.map +1 -0
- package/dist/src/behaviors/registry.js +266 -0
- package/dist/src/behaviors/registry.js.map +1 -0
- package/dist/src/behaviors/roles.d.ts +29 -0
- package/dist/src/behaviors/roles.d.ts.map +1 -0
- package/dist/src/behaviors/roles.js +198 -0
- package/dist/src/behaviors/roles.js.map +1 -0
- package/dist/src/behaviors/types.d.ts +120 -0
- package/dist/src/behaviors/types.d.ts.map +1 -0
- package/dist/src/behaviors/types.js +12 -0
- package/dist/src/behaviors/types.js.map +1 -0
- package/dist/src/index.d.ts +27 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +24 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/integrations/deepsea.d.ts +121 -0
- package/dist/src/integrations/deepsea.d.ts.map +1 -0
- package/dist/src/integrations/deepsea.js +199 -0
- package/dist/src/integrations/deepsea.js.map +1 -0
- package/dist/src/machine.d.ts +139 -0
- package/dist/src/machine.d.ts.map +1 -0
- package/dist/src/machine.js +400 -0
- package/dist/src/machine.js.map +1 -0
- package/dist/src/parser/ast.d.ts +369 -0
- package/dist/src/parser/ast.d.ts.map +1 -0
- package/dist/src/parser/ast.js +17 -0
- package/dist/src/parser/ast.js.map +1 -0
- package/dist/src/parser/parser.d.ts +48 -0
- package/dist/src/parser/parser.d.ts.map +1 -0
- package/dist/src/parser/parser.js +1950 -0
- package/dist/src/parser/parser.js.map +1 -0
- package/dist/src/policy/effects.d.ts +86 -0
- package/dist/src/policy/effects.d.ts.map +1 -0
- package/dist/src/policy/effects.js +53 -0
- package/dist/src/policy/effects.js.map +1 -0
- package/dist/src/policy/policy.d.ts +130 -0
- package/dist/src/policy/policy.d.ts.map +1 -0
- package/dist/src/policy/policy.js +532 -0
- package/dist/src/policy/policy.js.map +1 -0
- package/dist/src/printer/doc.d.ts +151 -0
- package/dist/src/printer/doc.d.ts.map +1 -0
- package/dist/src/printer/doc.js +409 -0
- package/dist/src/printer/doc.js.map +1 -0
- package/dist/src/printer/highlight.d.ts +53 -0
- package/dist/src/printer/highlight.d.ts.map +1 -0
- package/dist/src/printer/highlight.js +339 -0
- package/dist/src/printer/highlight.js.map +1 -0
- package/dist/src/printer/html.d.ts +72 -0
- package/dist/src/printer/html.d.ts.map +1 -0
- package/dist/src/printer/html.js +423 -0
- package/dist/src/printer/html.js.map +1 -0
- package/dist/src/printer/print.d.ts +63 -0
- package/dist/src/printer/print.d.ts.map +1 -0
- package/dist/src/printer/print.js +484 -0
- package/dist/src/printer/print.js.map +1 -0
- package/dist/src/printer/summary.d.ts +50 -0
- package/dist/src/printer/summary.d.ts.map +1 -0
- package/dist/src/printer/summary.js +621 -0
- package/dist/src/printer/summary.js.map +1 -0
- package/dist/src/trace/events.d.ts +183 -0
- package/dist/src/trace/events.d.ts.map +1 -0
- package/dist/src/trace/events.js +9 -0
- package/dist/src/trace/events.js.map +1 -0
- package/dist/src/trace/progress.d.ts +58 -0
- package/dist/src/trace/progress.d.ts.map +1 -0
- package/dist/src/trace/progress.js +83 -0
- package/dist/src/trace/progress.js.map +1 -0
- package/dist/src/vm/arith.d.ts +29 -0
- package/dist/src/vm/arith.d.ts.map +1 -0
- package/dist/src/vm/arith.js +258 -0
- package/dist/src/vm/arith.js.map +1 -0
- package/dist/src/vm/builtins.d.ts +31 -0
- package/dist/src/vm/builtins.d.ts.map +1 -0
- package/dist/src/vm/builtins.js +1773 -0
- package/dist/src/vm/builtins.js.map +1 -0
- package/dist/src/vm/emulate.d.ts +75 -0
- package/dist/src/vm/emulate.d.ts.map +1 -0
- package/dist/src/vm/emulate.js +380 -0
- package/dist/src/vm/emulate.js.map +1 -0
- package/dist/src/vm/expand.d.ts +68 -0
- package/dist/src/vm/expand.d.ts.map +1 -0
- package/dist/src/vm/expand.js +744 -0
- package/dist/src/vm/expand.js.map +1 -0
- package/dist/src/vm/external.d.ts +34 -0
- package/dist/src/vm/external.d.ts.map +1 -0
- package/dist/src/vm/external.js +228 -0
- package/dist/src/vm/external.js.map +1 -0
- package/dist/src/vm/fifo.d.ts +66 -0
- package/dist/src/vm/fifo.d.ts.map +1 -0
- package/dist/src/vm/fifo.js +232 -0
- package/dist/src/vm/fifo.js.map +1 -0
- package/dist/src/vm/interp.d.ts +209 -0
- package/dist/src/vm/interp.d.ts.map +1 -0
- package/dist/src/vm/interp.js +1641 -0
- package/dist/src/vm/interp.js.map +1 -0
- package/dist/src/vm/io.d.ts +180 -0
- package/dist/src/vm/io.d.ts.map +1 -0
- package/dist/src/vm/io.js +502 -0
- package/dist/src/vm/io.js.map +1 -0
- package/dist/src/vm/pattern.d.ts +46 -0
- package/dist/src/vm/pattern.d.ts.map +1 -0
- package/dist/src/vm/pattern.js +498 -0
- package/dist/src/vm/pattern.js.map +1 -0
- package/dist/src/vm/proc.d.ts +94 -0
- package/dist/src/vm/proc.d.ts.map +1 -0
- package/dist/src/vm/proc.js +130 -0
- package/dist/src/vm/proc.js.map +1 -0
- package/dist/src/vm/state.d.ts +104 -0
- package/dist/src/vm/state.d.ts.map +1 -0
- package/dist/src/vm/state.js +233 -0
- package/dist/src/vm/state.js.map +1 -0
- package/dist/src/vm/test.d.ts +11 -0
- package/dist/src/vm/test.d.ts.map +1 -0
- package/dist/src/vm/test.js +277 -0
- package/dist/src/vm/test.js.map +1 -0
- package/package.json +63 -4
- package/src/analysis/analyze.ts +1395 -0
- package/src/analysis/fs.ts +121 -0
- package/src/analysis/outputs.ts +242 -0
- package/src/analysis/value.ts +264 -0
- package/src/behaviors/defs/core.ts +970 -0
- package/src/behaviors/defs/devtools.ts +400 -0
- package/src/behaviors/defs/macos.ts +426 -0
- package/src/behaviors/defs/tools.ts +596 -0
- package/src/behaviors/dsl.ts +293 -0
- package/src/behaviors/index.ts +19 -0
- package/src/behaviors/registry.ts +261 -0
- package/src/behaviors/roles.ts +187 -0
- package/src/behaviors/types.ts +127 -0
- package/src/index.ts +27 -0
- package/src/integrations/deepsea.ts +259 -0
- package/src/machine.ts +483 -0
- package/src/parser/ast.ts +472 -0
- package/src/parser/parser.ts +1826 -0
- package/src/policy/effects.ts +102 -0
- package/src/policy/policy.ts +578 -0
- package/src/printer/doc.ts +454 -0
- package/src/printer/highlight.ts +283 -0
- package/src/printer/html.ts +441 -0
- package/src/printer/print.ts +528 -0
- package/src/printer/summary.ts +601 -0
- package/src/trace/events.ts +102 -0
- package/src/trace/progress.ts +119 -0
- package/src/vm/arith.ts +236 -0
- package/src/vm/builtins.ts +1590 -0
- package/src/vm/emulate.ts +337 -0
- package/src/vm/expand.ts +721 -0
- package/src/vm/external.ts +265 -0
- package/src/vm/fifo.ts +226 -0
- package/src/vm/interp.ts +1623 -0
- package/src/vm/io.ts +570 -0
- package/src/vm/pattern.ts +477 -0
- package/src/vm/proc.ts +147 -0
- package/src/vm/state.ts +267 -0
- package/src/vm/test.ts +253 -0
|
@@ -0,0 +1,1406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static analysis by abstract interpretation.
|
|
3
|
+
*
|
|
4
|
+
* Walks the AST with an abstract environment (variables → {@link AValue}s,
|
|
5
|
+
* abstract cwd, positional parameters), expands every simple command's words
|
|
6
|
+
* abstractly, applies the same behavior definitions the VM uses at runtime,
|
|
7
|
+
* and checks the predicted effects against the policy.
|
|
8
|
+
*
|
|
9
|
+
* Each command site gets a reachability: `must` when it runs on every
|
|
10
|
+
* execution that does not terminate earlier (not under a condition, loop body
|
|
11
|
+
* that may not run, `&&`/`||`, case arm, or uncalled function), else `may`.
|
|
12
|
+
* The report's verdict is `reject` when some `must` site has a definite
|
|
13
|
+
* policy violation — the snippet would certainly violate the policy if it
|
|
14
|
+
* gets that far — and `check-at-runtime` when violations are only possible.
|
|
15
|
+
*/
|
|
16
|
+
import * as os from 'node:os';
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import { parse, lineColumn, literalOf } from "../parser/parser.js";
|
|
19
|
+
import { basename, resolvePath } from "../behaviors/registry.js";
|
|
20
|
+
import { describeEffect, withVia } from "../policy/effects.js";
|
|
21
|
+
import { checkCommandName, checkEffects, checkExecEffects, shapesOf, worstVerdict } from "../policy/policy.js";
|
|
22
|
+
import { braceExpand } from "../vm/expand.js";
|
|
23
|
+
import { hasGlobChars, quotePattern, removePrefix, removeSuffix, replacePattern } from "../vm/pattern.js";
|
|
24
|
+
import { BUILTINS } from "../vm/builtins.js";
|
|
25
|
+
import { evaluateArith } from "../vm/arith.js";
|
|
26
|
+
import { refineOutput } from "./outputs.js";
|
|
27
|
+
import { AFs } from "./fs.js";
|
|
28
|
+
import { concat, concrete, describe, hole, guardEmpty, isFullyKnown, isNameLike, isSlashFree, join, known, typeOf, unknownString, } from "./value.js";
|
|
29
|
+
class AEnv {
|
|
30
|
+
vars = new Map();
|
|
31
|
+
cwd;
|
|
32
|
+
positional;
|
|
33
|
+
terminated = false;
|
|
34
|
+
functions = new Map();
|
|
35
|
+
/** What the snippet has done to the file system on this path. */
|
|
36
|
+
fs = new AFs();
|
|
37
|
+
/** Shared analysis flags (`mayHaveCreated` affects file-system lookups). */
|
|
38
|
+
flags = { mayHaveCreated: false };
|
|
39
|
+
constructor(cwd, positional) {
|
|
40
|
+
this.cwd = cwd;
|
|
41
|
+
this.positional = positional;
|
|
42
|
+
}
|
|
43
|
+
clone() {
|
|
44
|
+
const e = new AEnv(this.cwd, [...this.positional]);
|
|
45
|
+
for (const [k, v] of this.vars)
|
|
46
|
+
e.vars.set(k, { ...v, array: v.array ? [...v.array] : undefined });
|
|
47
|
+
e.terminated = this.terminated;
|
|
48
|
+
e.functions = new Map(this.functions);
|
|
49
|
+
e.fs = this.fs.clone();
|
|
50
|
+
e.flags = this.flags;
|
|
51
|
+
return e;
|
|
52
|
+
}
|
|
53
|
+
/** The file-system state of a concrete path on this path. */
|
|
54
|
+
fsState(p) {
|
|
55
|
+
return this.fs.lookup(p, this.flags.mayHaveCreated);
|
|
56
|
+
}
|
|
57
|
+
/** Join two environments (control-flow merge). */
|
|
58
|
+
static join(a, b) {
|
|
59
|
+
if (a.terminated)
|
|
60
|
+
return b.clone();
|
|
61
|
+
if (b.terminated)
|
|
62
|
+
return a.clone();
|
|
63
|
+
const e = new AEnv(join(a.cwd, b.cwd), a.positional.length === b.positional.length ? a.positional.map((f, i) => ({ value: join(f.value, b.positional[i].value), spread: f.spread || b.positional[i].spread })) : [{ value: unknownString('$@'), spread: true }]);
|
|
64
|
+
const names = new Set([...a.vars.keys(), ...b.vars.keys()]);
|
|
65
|
+
for (const n of names) {
|
|
66
|
+
const x = a.vars.get(n);
|
|
67
|
+
const y = b.vars.get(n);
|
|
68
|
+
if (x && y)
|
|
69
|
+
e.vars.set(n, { value: join(x.value, y.value), exported: x.exported || y.exported, array: x.array && y.array && x.array.length === y.array.length ? x.array.map((v, i) => join(v, y.array[i])) : x.array || y.array ? [unknownString(`${n}[@]`)] : undefined, unsetMaybe: x.unsetMaybe || y.unsetMaybe });
|
|
70
|
+
else {
|
|
71
|
+
const v = (x ?? y);
|
|
72
|
+
e.vars.set(n, { ...v, value: join(v.value, known('')), unsetMaybe: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const [k, f] of b.functions)
|
|
76
|
+
e.functions.set(k, f);
|
|
77
|
+
e.flags = a.flags;
|
|
78
|
+
e.fs = AFs.join(a.fs, b.fs, a.flags.mayHaveCreated);
|
|
79
|
+
return e;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const MAX_CALL_DEPTH = 6;
|
|
83
|
+
export function analyzeScript(script, source, opts) {
|
|
84
|
+
return new Analyzer(source, opts).run(script);
|
|
85
|
+
}
|
|
86
|
+
/** Parse and analyze in one step. */
|
|
87
|
+
export function analyzeSource(source, opts) {
|
|
88
|
+
return analyzeScript(parse(source), source, opts);
|
|
89
|
+
}
|
|
90
|
+
const MAX_INLINE_BYTES = 256 * 1024;
|
|
91
|
+
class Analyzer {
|
|
92
|
+
sites = new Map();
|
|
93
|
+
source;
|
|
94
|
+
opts;
|
|
95
|
+
shared;
|
|
96
|
+
/** Where recorded sites go: the report, or the inlining analyzer (adding provenance). */
|
|
97
|
+
emit;
|
|
98
|
+
lastOutput = null;
|
|
99
|
+
/** Typed stdout of each external simple command (by node id), for `$(a | b)`. */
|
|
100
|
+
outputs = new Map();
|
|
101
|
+
constructor(source, opts, parent) {
|
|
102
|
+
this.source = source;
|
|
103
|
+
this.opts = opts;
|
|
104
|
+
this.shared = parent?.shared ?? { calledFunctions: new Set(), mayHaveCreated: false, written: new Set(), mayHaveWritten: false, nextId: 1_500_000_000 };
|
|
105
|
+
this.emit = parent?.emit ?? ((site) => {
|
|
106
|
+
const existing = this.sites.get(site.nodeId);
|
|
107
|
+
const rank = { must: 2, happy: 1, may: 0 };
|
|
108
|
+
if (existing && rank[existing.reach] > rank[site.reach])
|
|
109
|
+
site.reach = existing.reach;
|
|
110
|
+
this.sites.set(site.nodeId, site);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* `rm -f` of a path that is missing (on disk, after what the snippet did)
|
|
115
|
+
* does nothing: not a violation. If it may exist, it is only possibly one.
|
|
116
|
+
*/
|
|
117
|
+
missingOk(f, env) {
|
|
118
|
+
const e = f.effect;
|
|
119
|
+
if (f.verdict !== 'deny' || e.kind !== 'delete' || !e.ifExists || this.opts.policy.missingDeletes !== 'allow')
|
|
120
|
+
return f;
|
|
121
|
+
const paths = e.path.alts.map((a) => concrete(normalizeKnownPath({ alts: [a] })));
|
|
122
|
+
const states = paths.map((p) => (p === undefined ? 'unknown' : env.fsState(p)));
|
|
123
|
+
if (states.every((s) => s === 'absent'))
|
|
124
|
+
return { ...f, verdict: 'allow', reason: 'delete: nothing to delete (missing path, -f)' };
|
|
125
|
+
if (states.every((s) => s === 'dir' || s === 'file'))
|
|
126
|
+
return f;
|
|
127
|
+
return { ...f, verdict: 'unknown', reason: `${f.reason} (if it exists)` };
|
|
128
|
+
}
|
|
129
|
+
/** Update the simulated file system with a command's effects (on the path where it succeeds). */
|
|
130
|
+
applyFs(env, effects, argv0) {
|
|
131
|
+
const name = argv0 === undefined ? '' : basename(argv0);
|
|
132
|
+
for (const e of effects) {
|
|
133
|
+
if (e.kind !== 'write' && e.kind !== 'delete')
|
|
134
|
+
continue;
|
|
135
|
+
const c = e.maybe ? undefined : concrete(normalizeKnownPath(e.path));
|
|
136
|
+
if (c === undefined) {
|
|
137
|
+
env.fs.fuzz(shapesOf(e.path));
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (e.kind === 'delete') {
|
|
141
|
+
env.fs.remove(c, 'unknown');
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const up = c.slice(0, c.lastIndexOf('/')) || '/';
|
|
145
|
+
env.fs.mkdirs(up);
|
|
146
|
+
if (name === 'mkdir' || (e.recursive && DIR_CREATORS.has(name)))
|
|
147
|
+
env.fs.mkdirs(c);
|
|
148
|
+
else if (e.recursive) {
|
|
149
|
+
// chmod -R and the like: the path keeps its kind
|
|
150
|
+
}
|
|
151
|
+
else if (env.fsState(c) !== 'dir')
|
|
152
|
+
env.fs.set(c, name === 'mv' || name === 'ln' ? 'unknown' : 'file');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* What a successful or failed test tells about the file system:
|
|
157
|
+
* `[ -d x ]`, `test -f x`, `[[ -e x ]]`, negated with `!`.
|
|
158
|
+
*/
|
|
159
|
+
refine(cmd, env, success) {
|
|
160
|
+
if (!cmd)
|
|
161
|
+
return;
|
|
162
|
+
const apply = (op, w, ok) => {
|
|
163
|
+
if (op !== '-d' && op !== '-f' && op !== '-e')
|
|
164
|
+
return;
|
|
165
|
+
if (hasSubstitution(w))
|
|
166
|
+
return; // re-expanding would analyze it again
|
|
167
|
+
const c = concrete(normalizeKnownPath(resolvePath(this.stringValue(w, env, NO_CTX), env.cwd)));
|
|
168
|
+
if (c === undefined || !c.startsWith('/'))
|
|
169
|
+
return;
|
|
170
|
+
const cur = env.fsState(c);
|
|
171
|
+
if (ok) {
|
|
172
|
+
if (op === '-d')
|
|
173
|
+
env.fs.mkdirs(c);
|
|
174
|
+
else if (op === '-f') {
|
|
175
|
+
env.fs.mkdirs(c.slice(0, c.lastIndexOf('/')) || '/');
|
|
176
|
+
env.fs.set(c, 'file');
|
|
177
|
+
}
|
|
178
|
+
else if (cur === 'absent')
|
|
179
|
+
env.fs.set(c, 'unknown');
|
|
180
|
+
}
|
|
181
|
+
else if (op === '-e')
|
|
182
|
+
env.fs.remove(c, 'absent');
|
|
183
|
+
else if (cur === (op === '-d' ? 'dir' : 'file'))
|
|
184
|
+
env.fs.set(c, 'unknown');
|
|
185
|
+
};
|
|
186
|
+
switch (cmd.type) {
|
|
187
|
+
case 'Pipeline':
|
|
188
|
+
if (cmd.stages.length === 1)
|
|
189
|
+
this.refine(cmd.stages[0], env, cmd.negated ? !success : success);
|
|
190
|
+
return;
|
|
191
|
+
case 'CondCommand': {
|
|
192
|
+
let e = cmd.expr;
|
|
193
|
+
let ok = success;
|
|
194
|
+
while (e.type === 'CondNot' || e.type === 'CondGroup') {
|
|
195
|
+
if (e.type === 'CondNot')
|
|
196
|
+
ok = !ok;
|
|
197
|
+
e = e.type === 'CondNot' ? e.operand : e.expr;
|
|
198
|
+
}
|
|
199
|
+
if (e.type === 'CondUnary')
|
|
200
|
+
apply(e.op, e.operand, ok);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
case 'SimpleCommand': {
|
|
204
|
+
const words = cmd.words.filter((w) => w.type === 'Word');
|
|
205
|
+
if (words.length !== cmd.words.length || cmd.redirs.length > 0)
|
|
206
|
+
return;
|
|
207
|
+
const lits = words.map((w) => literalOf(w));
|
|
208
|
+
let args = words.slice(1);
|
|
209
|
+
if (lits[0] === '[') {
|
|
210
|
+
if (lits[lits.length - 1] !== ']')
|
|
211
|
+
return;
|
|
212
|
+
args = args.slice(0, -1);
|
|
213
|
+
}
|
|
214
|
+
else if (lits[0] !== 'test')
|
|
215
|
+
return;
|
|
216
|
+
let ok = success;
|
|
217
|
+
if (args.length === 3 && literalOf(args[0]) === '!') {
|
|
218
|
+
ok = !ok;
|
|
219
|
+
args = args.slice(1);
|
|
220
|
+
}
|
|
221
|
+
if (args.length === 2) {
|
|
222
|
+
const op = literalOf(args[0]);
|
|
223
|
+
if (op)
|
|
224
|
+
apply(op, args[1], ok);
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
default:
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Did the snippet (possibly) write `p` or a directory containing it? */
|
|
233
|
+
mayHaveChanged(p) {
|
|
234
|
+
if (this.shared.mayHaveWritten)
|
|
235
|
+
return true;
|
|
236
|
+
for (const w of this.shared.written)
|
|
237
|
+
if (p === w || p.startsWith(w === '/' ? '/' : w + '/'))
|
|
238
|
+
return true;
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Analyze inlined code (`eval` text, a sourced file, a trap action) in the
|
|
243
|
+
* current environment. Its sites are reported at the inlining command, with
|
|
244
|
+
* each effect saying which inner command (and file line) it comes from.
|
|
245
|
+
*/
|
|
246
|
+
inline(cmd, code, env, ctx, file) {
|
|
247
|
+
let script;
|
|
248
|
+
try {
|
|
249
|
+
script = parse(code, { firstId: this.shared.nextId });
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
this.shared.nextId = script.id + 1;
|
|
255
|
+
const outer = { span: cmd.span, line: this.line(cmd.span), text: this.text(cmd.span) };
|
|
256
|
+
const sub = new Analyzer(code, { ...this.opts, nested: true }, {
|
|
257
|
+
shared: this.shared,
|
|
258
|
+
emit: (site) => {
|
|
259
|
+
const hop = { text: site.text, line: site.line, ...(file ? { file } : {}) };
|
|
260
|
+
this.emit({
|
|
261
|
+
...site,
|
|
262
|
+
...outer,
|
|
263
|
+
effects: site.effects.map((e) => withVia(e, hop)),
|
|
264
|
+
findings: site.findings.map((f) => ({ ...f, effect: withVia(f.effect, hop) })),
|
|
265
|
+
});
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
return sub.list(script.body, env, { ...ctx, depth: ctx.depth + 1 });
|
|
269
|
+
}
|
|
270
|
+
run(script) {
|
|
271
|
+
const positional = (this.opts.positional ?? []).map((v) => ({ value: v, spread: false }));
|
|
272
|
+
const env = new AEnv(this.opts.cwd, positional);
|
|
273
|
+
env.flags = this.shared;
|
|
274
|
+
for (const [k, v] of Object.entries(this.opts.env))
|
|
275
|
+
env.vars.set(k, { value: known(v), exported: true });
|
|
276
|
+
const ctx = { reach: 'must', loop: false, subshell: false, cmdsubst: false, pipeline: false, background: false, stdin: 'unknown', depth: 0 };
|
|
277
|
+
const final = this.list(script.body, env, ctx);
|
|
278
|
+
// functions that were defined but never called: analyze as `may` with unknown arguments
|
|
279
|
+
if (!this.opts.nested) {
|
|
280
|
+
for (const [name, fn] of final.functions) {
|
|
281
|
+
// functions of sourced files are library code: only their calls are analyzed
|
|
282
|
+
if (this.shared.calledFunctions.has(name) || fn.owner !== this)
|
|
283
|
+
continue;
|
|
284
|
+
const fenv = final.clone();
|
|
285
|
+
fenv.positional = [{ value: unknownString(`${name} args`), spread: true }];
|
|
286
|
+
fenv.terminated = false;
|
|
287
|
+
fn.owner.command(fn.def.body, fenv, { ...ctx, reach: 'may', function: name, depth: 1 });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const commands = [...this.sites.values()].sort((a, b) => a.span.start - b.span.start);
|
|
291
|
+
const violations = [];
|
|
292
|
+
for (const c of commands) {
|
|
293
|
+
for (const f of c.findings) {
|
|
294
|
+
if (f.verdict === 'allow')
|
|
295
|
+
continue;
|
|
296
|
+
violations.push({ nodeId: c.nodeId, line: c.line, text: c.text, reach: c.reach, verdict: f.verdict, reason: f.reason, effect: describeEffect(f.effect), ...(f.effect.via ? { via: f.effect.via } : {}) });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const rejectOn = this.opts.rejectOn ?? ['must', 'happy'];
|
|
300
|
+
const definite = violations.filter((v) => v.verdict === 'deny' && rejectOn.includes(v.reach));
|
|
301
|
+
const verdict = definite.length > 0 ? 'reject' : violations.some((v) => v.verdict === 'deny' || v.verdict === 'unknown') ? 'check-at-runtime' : 'allow';
|
|
302
|
+
const variables = [];
|
|
303
|
+
for (const [name, v] of final.vars) {
|
|
304
|
+
if (name in this.opts.env && concrete(v.value) === this.opts.env[name])
|
|
305
|
+
continue;
|
|
306
|
+
variables.push({ name, value: v.array ? `(${v.array.map(describe).join(' ')})` : describe(v.value), type: v.array ? 'array' : typeOf(v.value) });
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
commands,
|
|
310
|
+
variables,
|
|
311
|
+
functions: [...final.functions.keys()],
|
|
312
|
+
violations,
|
|
313
|
+
verdict,
|
|
314
|
+
reasons: definite.map((v) => `line ${v.line}: \`${v.text}\` would ${v.effect}: ${v.reason}`),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
line(span) {
|
|
318
|
+
return lineColumn(this.source, span.start).line;
|
|
319
|
+
}
|
|
320
|
+
text(span) {
|
|
321
|
+
return this.source.slice(span.start, span.end);
|
|
322
|
+
}
|
|
323
|
+
// -------------------------------------------------------------------------
|
|
324
|
+
// Statements
|
|
325
|
+
// -------------------------------------------------------------------------
|
|
326
|
+
list(stmts, env, ctx) {
|
|
327
|
+
let e = env;
|
|
328
|
+
for (const s of stmts) {
|
|
329
|
+
if (e.terminated) {
|
|
330
|
+
// unreachable code: analyze as `may` for completeness, but it cannot be `must`
|
|
331
|
+
const dead = e.clone();
|
|
332
|
+
dead.terminated = false;
|
|
333
|
+
this.stmt(s, dead, { ...ctx, reach: 'may' });
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
e = this.stmt(s, e, ctx);
|
|
337
|
+
}
|
|
338
|
+
return e;
|
|
339
|
+
}
|
|
340
|
+
stmt(s, env, ctx) {
|
|
341
|
+
if (s.background) {
|
|
342
|
+
// file-system changes of a background job may or may not have happened yet: keep them (an approximation)
|
|
343
|
+
env.fs = this.command(s.command, env.clone(), { ...ctx, background: true, subshell: true }).fs;
|
|
344
|
+
return env;
|
|
345
|
+
}
|
|
346
|
+
return this.command(s.command, env, ctx);
|
|
347
|
+
}
|
|
348
|
+
command(cmd, env, ctx) {
|
|
349
|
+
switch (cmd.type) {
|
|
350
|
+
case 'SimpleCommand':
|
|
351
|
+
return this.simple(cmd, env, ctx);
|
|
352
|
+
case 'Pipeline': {
|
|
353
|
+
if (cmd.stages.length === 1) {
|
|
354
|
+
env.fs = this.command(cmd.stages[0], env.clone(), ctx).fs;
|
|
355
|
+
return env;
|
|
356
|
+
}
|
|
357
|
+
// stages run in subshells, concurrently; their file-system changes are kept (in stage order)
|
|
358
|
+
cmd.stages.forEach((st, i) => {
|
|
359
|
+
env.fs = this.command(st, env.clone(), { ...ctx, pipeline: true, subshell: true, stdin: i > 0 ? 'pipe' : ctx.stdin }).fs;
|
|
360
|
+
});
|
|
361
|
+
return env;
|
|
362
|
+
}
|
|
363
|
+
case 'AndOr': {
|
|
364
|
+
// `a && b`: b runs on the happy path, in the state a left; after any
|
|
365
|
+
// `||`, or after a predicate (`test -f x && ...`), only conditionally.
|
|
366
|
+
// The state after the list joins every point where it can stop.
|
|
367
|
+
// `a && b || c` is `(a && b) || c`: track the states in which the
|
|
368
|
+
// list so far succeeded and failed; `&&` continues from the first,
|
|
369
|
+
// `||` from the second (tests refine them: `[ -d x ] || mkdir x`).
|
|
370
|
+
const outcome = (item, out, ok) => {
|
|
371
|
+
const e = out.clone();
|
|
372
|
+
this.refine(item, e, ok);
|
|
373
|
+
return e;
|
|
374
|
+
};
|
|
375
|
+
const first = this.command(cmd.items[0], env, ctx);
|
|
376
|
+
let succ = outcome(cmd.items[0], first, true);
|
|
377
|
+
let fail = outcome(cmd.items[0], first, false);
|
|
378
|
+
let reach = ctx.reach === 'may' ? 'may' : 'happy';
|
|
379
|
+
for (let i = 1; i < cmd.items.length; i++) {
|
|
380
|
+
const and = cmd.ops[i - 1] === '&&';
|
|
381
|
+
if (!and || isPredicate(cmd.items[i - 1]))
|
|
382
|
+
reach = 'may';
|
|
383
|
+
const item = cmd.items[i];
|
|
384
|
+
const out = this.command(item, and ? succ : fail, { ...ctx, reach });
|
|
385
|
+
if (and) {
|
|
386
|
+
fail = AEnv.join(fail, outcome(item, out, false));
|
|
387
|
+
succ = outcome(item, out, true);
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
succ = AEnv.join(succ, outcome(item, out, true));
|
|
391
|
+
fail = outcome(item, out, false);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return AEnv.join(succ, fail);
|
|
395
|
+
}
|
|
396
|
+
case 'FunctionDef':
|
|
397
|
+
env.functions.set(cmd.name, { def: cmd, owner: this });
|
|
398
|
+
return env;
|
|
399
|
+
case 'Group':
|
|
400
|
+
return this.withRedirs(cmd.redirs, env, ctx, () => this.list(cmd.body, env, ctx));
|
|
401
|
+
case 'Subshell': {
|
|
402
|
+
this.withRedirs(cmd.redirs, env, ctx, () => {
|
|
403
|
+
env.fs = this.list(cmd.body, env.clone(), { ...ctx, subshell: true }).fs;
|
|
404
|
+
return env;
|
|
405
|
+
});
|
|
406
|
+
return env;
|
|
407
|
+
}
|
|
408
|
+
case 'If':
|
|
409
|
+
return this.withRedirs(cmd.redirs, env, ctx, () => {
|
|
410
|
+
let e = env;
|
|
411
|
+
const outs = [];
|
|
412
|
+
for (let i = 0; i < cmd.clauses.length; i++) {
|
|
413
|
+
const c = cmd.clauses[i];
|
|
414
|
+
e = this.list(c.cond, e, i === 0 ? ctx : { ...ctx, reach: 'may' });
|
|
415
|
+
const last = c.cond[c.cond.length - 1]?.command;
|
|
416
|
+
const yes = e.clone();
|
|
417
|
+
this.refine(last, yes, true);
|
|
418
|
+
outs.push(this.list(c.body, yes, { ...ctx, reach: 'may' }));
|
|
419
|
+
e = e.clone();
|
|
420
|
+
this.refine(last, e, false);
|
|
421
|
+
}
|
|
422
|
+
outs.push(cmd.elseBody ? this.list(cmd.elseBody, e.clone(), { ...ctx, reach: 'may' }) : e);
|
|
423
|
+
return outs.reduce((a, b) => AEnv.join(a, b));
|
|
424
|
+
});
|
|
425
|
+
case 'Loop':
|
|
426
|
+
return this.withRedirs(cmd.redirs, env, ctx, () => {
|
|
427
|
+
// condition runs at least once; body may not
|
|
428
|
+
let e = this.list(cmd.cond, env, ctx);
|
|
429
|
+
const inner = { ...ctx, reach: 'may', loop: true };
|
|
430
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
431
|
+
const after = this.list(cmd.body, e.clone(), inner);
|
|
432
|
+
const again = this.list(cmd.cond, after, { ...ctx, reach: 'may', loop: true });
|
|
433
|
+
e = AEnv.join(e, again);
|
|
434
|
+
}
|
|
435
|
+
e.terminated = false;
|
|
436
|
+
return e;
|
|
437
|
+
});
|
|
438
|
+
case 'For':
|
|
439
|
+
return this.withRedirs(cmd.redirs, env, ctx, () => {
|
|
440
|
+
const items = cmd.items === null ? env.positional : this.fields(cmd.items, env, ctx);
|
|
441
|
+
const definitelyRuns = items.length > 0 && items.some((f) => !f.spread || f.atLeastOne);
|
|
442
|
+
let value = items.length === 0 ? known('') : join(...items.map((f) => f.value));
|
|
443
|
+
if (items.length === 0)
|
|
444
|
+
value = unknownString(`$${cmd.variable}`);
|
|
445
|
+
let e = env;
|
|
446
|
+
const inner = { ...ctx, reach: definitelyRuns ? ctx.reach : 'may', loop: true };
|
|
447
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
448
|
+
const body = e.clone();
|
|
449
|
+
body.vars.set(cmd.variable, { value, exported: body.vars.get(cmd.variable)?.exported ?? false });
|
|
450
|
+
const after = this.list(cmd.body, body, inner);
|
|
451
|
+
e = pass === 0 && definitelyRuns ? after : AEnv.join(e, after);
|
|
452
|
+
}
|
|
453
|
+
e.terminated = false;
|
|
454
|
+
return e;
|
|
455
|
+
});
|
|
456
|
+
case 'ArithFor':
|
|
457
|
+
return this.withRedirs(cmd.redirs, env, ctx, () => {
|
|
458
|
+
let e = env;
|
|
459
|
+
for (const ex of [cmd.init, cmd.cond, cmd.step])
|
|
460
|
+
if (ex)
|
|
461
|
+
this.arithAssignments(ex, e);
|
|
462
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
463
|
+
const after = this.list(cmd.body, e.clone(), { ...ctx, reach: 'may', loop: true });
|
|
464
|
+
e = AEnv.join(e, after);
|
|
465
|
+
}
|
|
466
|
+
e.terminated = false;
|
|
467
|
+
return e;
|
|
468
|
+
});
|
|
469
|
+
case 'Select':
|
|
470
|
+
return this.withRedirs(cmd.redirs, env, ctx, () => {
|
|
471
|
+
const items = cmd.items === null ? env.positional : this.fields(cmd.items, env, ctx);
|
|
472
|
+
const e = env.clone();
|
|
473
|
+
e.vars.set(cmd.variable, { value: items.length ? join(...items.map((f) => f.value)) : unknownString(cmd.variable), exported: false });
|
|
474
|
+
return AEnv.join(env, this.list(cmd.body, e, { ...ctx, reach: 'may', loop: true }));
|
|
475
|
+
});
|
|
476
|
+
case 'Case':
|
|
477
|
+
return this.withRedirs(cmd.redirs, env, ctx, () => {
|
|
478
|
+
const word = this.stringValue(cmd.word, env, ctx);
|
|
479
|
+
const outs = [];
|
|
480
|
+
let anyCatchAll = false;
|
|
481
|
+
for (const item of cmd.items) {
|
|
482
|
+
const pats = item.patterns.map((p) => this.stringValue(p, env, ctx));
|
|
483
|
+
// a literal word and literal patterns: decide statically
|
|
484
|
+
const w = concrete(word);
|
|
485
|
+
if (w !== undefined && pats.every((p) => concrete(p) !== undefined)) {
|
|
486
|
+
const { matchPattern } = patternModule;
|
|
487
|
+
if (!item.patterns.some((p) => matchPattern(w, this.patternText(p, env, ctx))))
|
|
488
|
+
continue;
|
|
489
|
+
outs.push(this.list(item.body, env.clone(), ctx));
|
|
490
|
+
anyCatchAll = true;
|
|
491
|
+
if (item.terminator === ';;')
|
|
492
|
+
break;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (pats.some((p) => concrete(p) === '*'))
|
|
496
|
+
anyCatchAll = true;
|
|
497
|
+
outs.push(this.list(item.body, env.clone(), { ...ctx, reach: 'may' }));
|
|
498
|
+
}
|
|
499
|
+
if (!anyCatchAll)
|
|
500
|
+
outs.push(env);
|
|
501
|
+
return outs.length === 0 ? env : outs.reduce((a, b) => AEnv.join(a, b));
|
|
502
|
+
});
|
|
503
|
+
case 'ArithCommand':
|
|
504
|
+
return this.withRedirs(cmd.redirs, env, ctx, () => {
|
|
505
|
+
this.arithAssignments(cmd.expr, env);
|
|
506
|
+
return env;
|
|
507
|
+
});
|
|
508
|
+
case 'CondCommand':
|
|
509
|
+
return this.withRedirs(cmd.redirs, env, ctx, () => {
|
|
510
|
+
this.condWords(cmd.expr, env, ctx);
|
|
511
|
+
return env;
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
withRedirs(redirs, env, ctx, fn) {
|
|
516
|
+
if (redirs.length > 0) {
|
|
517
|
+
const effects = this.redirectEffects(redirs, env, ctx);
|
|
518
|
+
if (effects.length > 0) {
|
|
519
|
+
const r = redirs[0];
|
|
520
|
+
this.record({
|
|
521
|
+
nodeId: r.id, span: r.span, argv: [], kind: 'builtin', env, ctx, effects, complete: true, display: redirs.map((x) => this.text(x.span)).join(' '),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return fn();
|
|
526
|
+
}
|
|
527
|
+
condWords(e, env, ctx) {
|
|
528
|
+
switch (e.type) {
|
|
529
|
+
case 'CondWord':
|
|
530
|
+
this.stringValue(e.word, env, ctx);
|
|
531
|
+
break;
|
|
532
|
+
case 'CondUnary':
|
|
533
|
+
this.stringValue(e.operand, env, ctx);
|
|
534
|
+
break;
|
|
535
|
+
case 'CondBinary':
|
|
536
|
+
this.stringValue(e.left, env, ctx);
|
|
537
|
+
this.stringValue(e.right, env, ctx);
|
|
538
|
+
break;
|
|
539
|
+
case 'CondNot':
|
|
540
|
+
this.condWords(e.operand, env, ctx);
|
|
541
|
+
break;
|
|
542
|
+
case 'CondAndOr':
|
|
543
|
+
this.condWords(e.left, env, ctx);
|
|
544
|
+
this.condWords(e.right, env, ctx);
|
|
545
|
+
break;
|
|
546
|
+
case 'CondGroup':
|
|
547
|
+
this.condWords(e.expr, env, ctx);
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/** Variables assigned inside arithmetic become int-typed. */
|
|
552
|
+
arithAssignments(e, env) {
|
|
553
|
+
const visit = (x) => {
|
|
554
|
+
switch (x.type) {
|
|
555
|
+
case 'ArithAssign':
|
|
556
|
+
env.vars.set(x.target.name, { value: hole('int', `$${x.target.name}`), exported: env.vars.get(x.target.name)?.exported ?? false });
|
|
557
|
+
visit(x.value);
|
|
558
|
+
break;
|
|
559
|
+
case 'ArithIncDec':
|
|
560
|
+
env.vars.set(x.target.name, { value: hole('int', `$${x.target.name}`), exported: env.vars.get(x.target.name)?.exported ?? false });
|
|
561
|
+
break;
|
|
562
|
+
case 'ArithBinary':
|
|
563
|
+
visit(x.left);
|
|
564
|
+
visit(x.right);
|
|
565
|
+
break;
|
|
566
|
+
case 'ArithUnary':
|
|
567
|
+
visit(x.operand);
|
|
568
|
+
break;
|
|
569
|
+
case 'ArithTernary':
|
|
570
|
+
visit(x.cond);
|
|
571
|
+
visit(x.then);
|
|
572
|
+
visit(x.else);
|
|
573
|
+
break;
|
|
574
|
+
case 'ArithGroup':
|
|
575
|
+
visit(x.expr);
|
|
576
|
+
break;
|
|
577
|
+
default: break;
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
visit(e);
|
|
581
|
+
}
|
|
582
|
+
// -------------------------------------------------------------------------
|
|
583
|
+
// Simple commands
|
|
584
|
+
// -------------------------------------------------------------------------
|
|
585
|
+
simple(cmd, env, ctx) {
|
|
586
|
+
const argvFields = [];
|
|
587
|
+
const declAssigns = [];
|
|
588
|
+
for (const w of cmd.words) {
|
|
589
|
+
if (w.type === 'Assign') {
|
|
590
|
+
declAssigns.push(w);
|
|
591
|
+
argvFields.push({ value: known(this.text(w.span)), spread: false });
|
|
592
|
+
}
|
|
593
|
+
else
|
|
594
|
+
argvFields.push(...this.fields([w], env, ctx));
|
|
595
|
+
}
|
|
596
|
+
const redirEffects = this.redirectEffects(cmd.redirs, env, ctx);
|
|
597
|
+
if (argvFields.length === 0) {
|
|
598
|
+
for (const a of cmd.assigns)
|
|
599
|
+
this.assign(a, env, ctx, false);
|
|
600
|
+
if (redirEffects.length > 0 || cmd.assigns.some((a) => a.value?.type === 'Word' && a.value.parts.some((p) => p.type === 'CmdSubst'))) {
|
|
601
|
+
this.record({ nodeId: cmd.id, span: cmd.span, argv: [], kind: 'assignment', env, ctx, effects: redirEffects, complete: true });
|
|
602
|
+
}
|
|
603
|
+
return env;
|
|
604
|
+
}
|
|
605
|
+
const argv = argvFields.map((f) => f.value);
|
|
606
|
+
const spread = argvFields.some((f) => f.spread);
|
|
607
|
+
const name = concrete(argv[0]);
|
|
608
|
+
const prefixEnv = env.clone();
|
|
609
|
+
for (const a of cmd.assigns)
|
|
610
|
+
this.assign(a, prefixEnv, ctx, true);
|
|
611
|
+
if (name === undefined) {
|
|
612
|
+
this.record({
|
|
613
|
+
nodeId: cmd.id, span: cmd.span, argv, kind: 'unknown', env, ctx,
|
|
614
|
+
effects: [...redirEffects, { kind: 'unknown', detail: `command name ${describe(argv[0])} is not known statically` }], complete: false,
|
|
615
|
+
});
|
|
616
|
+
return env;
|
|
617
|
+
}
|
|
618
|
+
// functions (inline at the call site)
|
|
619
|
+
const fn = env.functions.get(name);
|
|
620
|
+
if (fn) {
|
|
621
|
+
this.shared.calledFunctions.add(name);
|
|
622
|
+
this.record({ nodeId: cmd.id, span: cmd.span, argv, kind: 'function', env, ctx, effects: redirEffects, complete: true });
|
|
623
|
+
if (ctx.depth >= MAX_CALL_DEPTH)
|
|
624
|
+
return env;
|
|
625
|
+
const fenv = prefixEnv.clone();
|
|
626
|
+
fenv.positional = argvFields.slice(1);
|
|
627
|
+
const out = fn.owner.command(fn.def.body, fenv, { ...ctx, function: name, depth: ctx.depth + 1 });
|
|
628
|
+
// positional parameters are restored after the call; `exit` inside the function terminates the caller too
|
|
629
|
+
out.positional = env.positional;
|
|
630
|
+
return out;
|
|
631
|
+
}
|
|
632
|
+
if (BUILTINS.has(name))
|
|
633
|
+
return this.builtin(name, cmd, argvFields, declAssigns, env, prefixEnv, ctx, redirEffects);
|
|
634
|
+
// external
|
|
635
|
+
const inv = this.invocation(argv, prefixEnv, ctx);
|
|
636
|
+
const r = this.opts.behaviors.analyze(inv);
|
|
637
|
+
const effects = [...redirEffects, ...r.effects];
|
|
638
|
+
if (r.complete === false)
|
|
639
|
+
this.shared.mayHaveCreated = true;
|
|
640
|
+
const nameFinding = checkCommandName(name, this.opts.policy);
|
|
641
|
+
this.record({ nodeId: cmd.id, span: cmd.span, argv, kind: 'external', env, ctx, effects, complete: r.complete !== false && !spread, extraFindings: nameFinding ? [nameFinding] : [] });
|
|
642
|
+
this.lastOutput = this.outputValue(refineOutput(inv, r.stdout), name);
|
|
643
|
+
this.outputs.set(cmd.id, this.lastOutput);
|
|
644
|
+
return env;
|
|
645
|
+
}
|
|
646
|
+
outputValue(info, name) {
|
|
647
|
+
if (!info)
|
|
648
|
+
return null;
|
|
649
|
+
if (info.value)
|
|
650
|
+
return info.value;
|
|
651
|
+
return hole(info.type, `$(${name})`, info.under ? { under: info.under } : {});
|
|
652
|
+
}
|
|
653
|
+
invocation(argv, env, ctx) {
|
|
654
|
+
return {
|
|
655
|
+
name: basename(concrete(argv[0]) ?? '?'),
|
|
656
|
+
argv,
|
|
657
|
+
cwd: env.cwd,
|
|
658
|
+
env: (n) => {
|
|
659
|
+
const v = env.vars.get(n);
|
|
660
|
+
if (v)
|
|
661
|
+
return v.value;
|
|
662
|
+
return this.opts.envValues?.(n);
|
|
663
|
+
},
|
|
664
|
+
stdin: ctx.stdin,
|
|
665
|
+
mayHaveChanged: (p) => this.mayHaveChanged(p),
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
builtin(name, cmd, fields, declAssigns, env, prefixEnv, ctx, redirEffects) {
|
|
669
|
+
const argv = fields.map((f) => f.value);
|
|
670
|
+
const args = argv.slice(1);
|
|
671
|
+
const rec = (effects = [], complete = true) => {
|
|
672
|
+
this.record({ nodeId: cmd.id, span: cmd.span, argv, kind: 'builtin', env, ctx, effects: [...redirEffects, ...effects], complete });
|
|
673
|
+
};
|
|
674
|
+
switch (name) {
|
|
675
|
+
case 'cd':
|
|
676
|
+
case 'pushd': {
|
|
677
|
+
const target = args[0] ?? env.vars.get('HOME')?.value ?? unknownString('$HOME');
|
|
678
|
+
const t = concrete(target);
|
|
679
|
+
const dest = normalizeKnownPath(t === '-' ? (env.vars.get('OLDPWD')?.value ?? hole('path', '$OLDPWD')) : resolvePath(target, env.cwd));
|
|
680
|
+
rec([{ kind: 'chdir', path: dest }]);
|
|
681
|
+
// `cd` only changes directory if it succeeds: a concrete target that is
|
|
682
|
+
// not a directory now leaves the cwd as is; an unknown one may or may not
|
|
683
|
+
const d = concrete(dest);
|
|
684
|
+
const st = d === undefined ? 'unknown' : env.fsState(d);
|
|
685
|
+
// a concrete path that is not a directory (now, after what the snippet did): cd fails
|
|
686
|
+
if (st === 'absent' || st === 'file')
|
|
687
|
+
return env;
|
|
688
|
+
env.vars.set('OLDPWD', { value: env.cwd, exported: true });
|
|
689
|
+
env.cwd = st === 'dir' ? dest : join(env.cwd, dest);
|
|
690
|
+
env.vars.set('PWD', { value: env.cwd, exported: true });
|
|
691
|
+
return env;
|
|
692
|
+
}
|
|
693
|
+
case 'popd':
|
|
694
|
+
rec();
|
|
695
|
+
env.cwd = hole('path', 'popd');
|
|
696
|
+
return env;
|
|
697
|
+
case 'export':
|
|
698
|
+
case 'declare':
|
|
699
|
+
case 'typeset':
|
|
700
|
+
case 'local':
|
|
701
|
+
case 'readonly': {
|
|
702
|
+
rec();
|
|
703
|
+
for (const a of declAssigns)
|
|
704
|
+
this.assign(a, env, ctx, name === 'export');
|
|
705
|
+
for (const f of args) {
|
|
706
|
+
const s = concrete(f);
|
|
707
|
+
if (s === undefined || s.startsWith('-'))
|
|
708
|
+
continue;
|
|
709
|
+
const eq = s.indexOf('=');
|
|
710
|
+
if (eq > 0 && !s.startsWith('\u0000')) {
|
|
711
|
+
if (declAssigns.some((a) => this.text(a.span) === s))
|
|
712
|
+
continue;
|
|
713
|
+
env.vars.set(s.slice(0, eq), { value: known(s.slice(eq + 1)), exported: name === 'export' });
|
|
714
|
+
}
|
|
715
|
+
else if (name === 'export') {
|
|
716
|
+
const v = env.vars.get(s);
|
|
717
|
+
if (v)
|
|
718
|
+
v.exported = true;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return env;
|
|
722
|
+
}
|
|
723
|
+
case 'unset':
|
|
724
|
+
rec();
|
|
725
|
+
for (const a of args) {
|
|
726
|
+
const s = concrete(a);
|
|
727
|
+
if (s && !s.startsWith('-'))
|
|
728
|
+
env.vars.delete(s);
|
|
729
|
+
}
|
|
730
|
+
return env;
|
|
731
|
+
case 'read':
|
|
732
|
+
case 'mapfile':
|
|
733
|
+
case 'readarray': {
|
|
734
|
+
rec();
|
|
735
|
+
const names = [];
|
|
736
|
+
for (let i = 0; i < args.length; i++) {
|
|
737
|
+
const s = concrete(args[i]);
|
|
738
|
+
if (s === undefined)
|
|
739
|
+
continue;
|
|
740
|
+
if (s === '-a' || (name !== 'read' && !s.startsWith('-'))) {
|
|
741
|
+
const n = s === '-a' ? concrete(args[++i] ?? known('')) : s;
|
|
742
|
+
if (n)
|
|
743
|
+
env.vars.set(n, { value: unknownString(`$${n}`), array: [unknownString(`${n}[@]`)], exported: false });
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (['-d', '-n', '-N', '-p', '-t', '-u', '-i', '-C', '-c', '-O', '-s'].includes(s)) {
|
|
747
|
+
i++;
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
if (s.startsWith('-'))
|
|
751
|
+
continue;
|
|
752
|
+
names.push(s);
|
|
753
|
+
}
|
|
754
|
+
if (name === 'read') {
|
|
755
|
+
for (const n of names.length ? names : ['REPLY'])
|
|
756
|
+
env.vars.set(n, { value: unknownString(`$${n}`), exported: env.vars.get(n)?.exported ?? false });
|
|
757
|
+
}
|
|
758
|
+
else if (names.length === 0)
|
|
759
|
+
env.vars.set('MAPFILE', { value: unknownString('MAPFILE'), array: [unknownString('MAPFILE[@]')], exported: false });
|
|
760
|
+
return env;
|
|
761
|
+
}
|
|
762
|
+
case 'printf': {
|
|
763
|
+
rec();
|
|
764
|
+
const i = args.findIndex((a) => concrete(a) === '-v');
|
|
765
|
+
if (i >= 0) {
|
|
766
|
+
const n = concrete(args[i + 1] ?? known(''));
|
|
767
|
+
if (n)
|
|
768
|
+
env.vars.set(n, { value: unknownString(`$${n}`), exported: false });
|
|
769
|
+
}
|
|
770
|
+
return env;
|
|
771
|
+
}
|
|
772
|
+
case 'set': {
|
|
773
|
+
rec();
|
|
774
|
+
const i = args.findIndex((a) => concrete(a) === '--');
|
|
775
|
+
if (i >= 0)
|
|
776
|
+
env.positional = fields.slice(i + 2);
|
|
777
|
+
else if (args.length > 0 && !/^[-+]/.test(concrete(args[0]) ?? '-'))
|
|
778
|
+
env.positional = fields.slice(1);
|
|
779
|
+
return env;
|
|
780
|
+
}
|
|
781
|
+
case 'shift': {
|
|
782
|
+
rec();
|
|
783
|
+
const n = Number(concrete(args[0] ?? known('1')) ?? NaN);
|
|
784
|
+
if (Number.isInteger(n) && !env.positional.some((f) => f.spread))
|
|
785
|
+
env.positional = env.positional.slice(n);
|
|
786
|
+
else
|
|
787
|
+
env.positional = [{ value: unknownString('$@'), spread: true }];
|
|
788
|
+
return env;
|
|
789
|
+
}
|
|
790
|
+
case 'exit':
|
|
791
|
+
rec();
|
|
792
|
+
if (ctx.subshell)
|
|
793
|
+
return env;
|
|
794
|
+
env.terminated = true;
|
|
795
|
+
return env;
|
|
796
|
+
case 'return':
|
|
797
|
+
rec();
|
|
798
|
+
if (ctx.function)
|
|
799
|
+
env.terminated = true;
|
|
800
|
+
return env;
|
|
801
|
+
case 'exec': {
|
|
802
|
+
const rest = args.filter((a, i) => !(i === 0 && (concrete(a) ?? '').startsWith('-')));
|
|
803
|
+
if (rest.length === 0) {
|
|
804
|
+
rec();
|
|
805
|
+
return env;
|
|
806
|
+
}
|
|
807
|
+
const r = this.opts.behaviors.analyze(this.invocation(rest, prefixEnv, ctx));
|
|
808
|
+
rec(r.effects, r.complete !== false);
|
|
809
|
+
if (!ctx.subshell)
|
|
810
|
+
env.terminated = true;
|
|
811
|
+
return env;
|
|
812
|
+
}
|
|
813
|
+
case 'command':
|
|
814
|
+
case 'builtin':
|
|
815
|
+
case 'time': {
|
|
816
|
+
const rest = args.filter((a) => !(concrete(a) ?? '').startsWith('-'));
|
|
817
|
+
if (rest.length === 0 || args.some((a) => ['-v', '-V'].includes(concrete(a) ?? ''))) {
|
|
818
|
+
rec();
|
|
819
|
+
return env;
|
|
820
|
+
}
|
|
821
|
+
const sub = concrete(rest[0]);
|
|
822
|
+
if (sub && BUILTINS.has(sub)) {
|
|
823
|
+
return this.builtin(sub, cmd, [{ value: rest[0], spread: false }, ...rest.slice(1).map((v) => ({ value: v, spread: false }))], [], env, prefixEnv, ctx, redirEffects);
|
|
824
|
+
}
|
|
825
|
+
const r = this.opts.behaviors.analyze(this.invocation(rest, prefixEnv, ctx));
|
|
826
|
+
rec(r.effects, r.complete !== false);
|
|
827
|
+
return env;
|
|
828
|
+
}
|
|
829
|
+
case 'eval': {
|
|
830
|
+
const text = concat(...args.flatMap((a, i) => (i === 0 ? [a] : [known(' '), a])));
|
|
831
|
+
const t = concrete(text);
|
|
832
|
+
if (t === undefined) {
|
|
833
|
+
rec([{ kind: 'unknown', detail: `eval of ${describe(text)}` }], false);
|
|
834
|
+
return env;
|
|
835
|
+
}
|
|
836
|
+
rec();
|
|
837
|
+
return this.inline(cmd, t, env, ctx) ?? env;
|
|
838
|
+
}
|
|
839
|
+
case 'source':
|
|
840
|
+
case '.': {
|
|
841
|
+
const file = args[0];
|
|
842
|
+
if (!file) {
|
|
843
|
+
rec();
|
|
844
|
+
return env;
|
|
845
|
+
}
|
|
846
|
+
const p = normalizeKnownPath(resolvePath(file, env.cwd));
|
|
847
|
+
const read = { kind: 'read', path: p };
|
|
848
|
+
const c = concrete(p);
|
|
849
|
+
// follow a file that exists now and that the snippet has not (possibly) written
|
|
850
|
+
const text = c !== undefined && !this.mayHaveChanged(c) && ctx.depth < MAX_CALL_DEPTH ? readSmallFile(c) : undefined;
|
|
851
|
+
if (text === undefined) {
|
|
852
|
+
rec([read, { kind: 'unknown', detail: `source of ${describe(p)}: contents not known statically` }], false);
|
|
853
|
+
return env;
|
|
854
|
+
}
|
|
855
|
+
rec([read]);
|
|
856
|
+
const saved = env.positional;
|
|
857
|
+
if (args.length > 1)
|
|
858
|
+
env.positional = fields.slice(2);
|
|
859
|
+
const out = this.inline(cmd, text, env, ctx, c) ?? env;
|
|
860
|
+
if (args.length > 1)
|
|
861
|
+
out.positional = saved;
|
|
862
|
+
return out;
|
|
863
|
+
}
|
|
864
|
+
case 'trap': {
|
|
865
|
+
rec();
|
|
866
|
+
const action = concrete(args[0] ?? known(''));
|
|
867
|
+
if (action && args.length > 1 && action !== '-')
|
|
868
|
+
this.inline(cmd, action, env.clone(), { ...ctx, reach: 'may' });
|
|
869
|
+
return env;
|
|
870
|
+
}
|
|
871
|
+
case 'kill':
|
|
872
|
+
rec([{ kind: 'process', detail: `kill ${args.map(describe).join(' ')}` }]);
|
|
873
|
+
return env;
|
|
874
|
+
default:
|
|
875
|
+
rec();
|
|
876
|
+
return env;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
assign(a, env, ctx, exported) {
|
|
880
|
+
const prev = env.vars.get(a.name);
|
|
881
|
+
if (a.value?.type === 'ArrayLiteral') {
|
|
882
|
+
const elems = a.value.elems.flatMap((el) => this.fields([el.value], env, ctx).map((f) => f.value));
|
|
883
|
+
env.vars.set(a.name, { value: elems[0] ?? known(''), array: elems, exported: exported || (prev?.exported ?? false) });
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
this.lastOutput = null;
|
|
887
|
+
let value = a.value ? this.stringValue(a.value, env, ctx, true) : known('');
|
|
888
|
+
// `x=$(cmd)`: use the command's inferred output type
|
|
889
|
+
if (a.value && a.value.parts.length === 1 && a.value.parts[0].type === 'CmdSubst' && this.lastOutput && !isFullyKnown(value))
|
|
890
|
+
value = this.lastOutput;
|
|
891
|
+
if (a.index) {
|
|
892
|
+
env.vars.set(a.name, { value: join(prev?.value ?? known(''), value), array: [unknownString(`${a.name}[@]`)], exported: prev?.exported ?? false });
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (a.append)
|
|
896
|
+
value = concat(prev?.value ?? known(''), value);
|
|
897
|
+
env.vars.set(a.name, { value, exported: exported || (prev?.exported ?? false) });
|
|
898
|
+
}
|
|
899
|
+
redirectEffects(redirs, env, ctx) {
|
|
900
|
+
const effects = [];
|
|
901
|
+
for (const r of redirs) {
|
|
902
|
+
if (r.op === '<<' || r.op === '<<-' || r.op === '<<<') {
|
|
903
|
+
if (r.heredoc && !r.heredoc.quoted)
|
|
904
|
+
this.stringValue(r.heredoc.body, env, ctx);
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
if (r.op === '<&' || r.op === '>&') {
|
|
908
|
+
const t = literalOf(r.target);
|
|
909
|
+
if (t !== null && /^(\d+-?|-)$/.test(t))
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
const target = guardEmpty(this.stringValue(r.target, env, ctx));
|
|
913
|
+
const t = concrete(target);
|
|
914
|
+
if (t === '/dev/null' || t === '/dev/stdout' || t === '/dev/stderr' || t === '/dev/stdin' || (t && /^\/dev\/fd\/\d+$/.test(t)))
|
|
915
|
+
continue;
|
|
916
|
+
const p = resolvePath(target, env.cwd);
|
|
917
|
+
if (r.op === '<')
|
|
918
|
+
effects.push({ kind: 'read', path: p, source: 'redirect' });
|
|
919
|
+
else if (r.op === '<>')
|
|
920
|
+
effects.push({ kind: 'read', path: p, source: 'redirect' }, { kind: 'write', path: p, source: 'redirect' });
|
|
921
|
+
else
|
|
922
|
+
effects.push({ kind: 'write', path: p, append: r.op === '>>' || r.op === '&>>', source: 'redirect' });
|
|
923
|
+
}
|
|
924
|
+
return effects;
|
|
925
|
+
}
|
|
926
|
+
record(r) {
|
|
927
|
+
const findings = [...checkExecEffects(r.effects, this.opts.policy), ...(r.extraFindings ?? []), ...checkEffects(r.effects, this.opts.policy).map((f) => this.missingOk(f, r.env))];
|
|
928
|
+
for (const e of r.effects) {
|
|
929
|
+
if (e.kind !== 'write' && e.kind !== 'delete')
|
|
930
|
+
continue;
|
|
931
|
+
const c = concrete(normalizeKnownPath(e.path));
|
|
932
|
+
if (c !== undefined)
|
|
933
|
+
this.shared.written.add(c);
|
|
934
|
+
else
|
|
935
|
+
this.shared.mayHaveWritten = true;
|
|
936
|
+
}
|
|
937
|
+
if (!r.complete)
|
|
938
|
+
this.shared.mayHaveWritten = true;
|
|
939
|
+
this.applyFs(r.env, r.effects, r.argv[0] ? concrete(r.argv[0]) : undefined);
|
|
940
|
+
this.emit({
|
|
941
|
+
nodeId: r.nodeId,
|
|
942
|
+
span: r.span,
|
|
943
|
+
line: this.line(r.span),
|
|
944
|
+
text: this.text(r.span),
|
|
945
|
+
argv: r.argv,
|
|
946
|
+
display: r.display ?? r.argv.map(describe).join(' '),
|
|
947
|
+
kind: r.kind,
|
|
948
|
+
reach: r.ctx.reach,
|
|
949
|
+
context: { loop: r.ctx.loop, subshell: r.ctx.subshell, cmdsubst: r.ctx.cmdsubst, pipeline: r.ctx.pipeline, background: r.ctx.background, function: r.ctx.function },
|
|
950
|
+
cwd: r.env.cwd,
|
|
951
|
+
effects: r.effects,
|
|
952
|
+
complete: r.complete,
|
|
953
|
+
findings,
|
|
954
|
+
verdict: worstVerdict(findings),
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
// -------------------------------------------------------------------------
|
|
958
|
+
// Abstract expansion
|
|
959
|
+
// -------------------------------------------------------------------------
|
|
960
|
+
patternText(w, env, ctx) {
|
|
961
|
+
let s = '';
|
|
962
|
+
for (const p of w.parts) {
|
|
963
|
+
const v = concrete(this.partValue(p, env, ctx, false)) ?? '*';
|
|
964
|
+
s += p.type === 'Lit' ? v : quotePattern(v);
|
|
965
|
+
}
|
|
966
|
+
return s;
|
|
967
|
+
}
|
|
968
|
+
/** Expand a word without splitting (assignment values, redirect targets). */
|
|
969
|
+
stringValue(w, env, ctx, assignment = false) {
|
|
970
|
+
const parts = w.parts.map((p, i) => this.partValue(p, env, ctx, false, i === 0 || assignment));
|
|
971
|
+
return concat(...parts);
|
|
972
|
+
}
|
|
973
|
+
/** Expand words to abstract fields (splitting and globbing approximated). */
|
|
974
|
+
fields(words, env, ctx) {
|
|
975
|
+
const out = [];
|
|
976
|
+
for (const word of words) {
|
|
977
|
+
for (const w of braceExpand(word)) {
|
|
978
|
+
// "$@" and unquoted unknown expansions spread
|
|
979
|
+
if (w.parts.length === 1 && w.parts[0].type === 'DblQuoted' && w.parts[0].parts.length === 1 && w.parts[0].parts[0].type === 'ParamExp' && isAllParam(w.parts[0].parts[0])) {
|
|
980
|
+
out.push(...this.allParamFields(w.parts[0].parts[0], env));
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
if (w.parts.length === 1 && w.parts[0].type === 'ParamExp' && isAllParam(w.parts[0])) {
|
|
984
|
+
out.push(...this.allParamFields(w.parts[0], env).map((f) => ({ ...f, spread: f.spread || !isFullyKnown(f.value) })));
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
let spread = false;
|
|
988
|
+
const vals = [];
|
|
989
|
+
w.parts.forEach((p, i) => {
|
|
990
|
+
const v = this.partValue(p, env, ctx, true, i === 0);
|
|
991
|
+
if ((p.type === 'ParamExp' || p.type === 'CmdSubst') && !isFullyKnown(v))
|
|
992
|
+
spread = true;
|
|
993
|
+
vals.push(v);
|
|
994
|
+
});
|
|
995
|
+
let value = concat(...vals);
|
|
996
|
+
// unquoted known expansions: split on whitespace
|
|
997
|
+
const c = concrete(value);
|
|
998
|
+
const hasUnquotedExpansion = w.parts.some((p) => p.type === 'ParamExp' || p.type === 'CmdSubst' || p.type === 'ArithExp');
|
|
999
|
+
if (c !== undefined && hasUnquotedExpansion) {
|
|
1000
|
+
const pieces = c.split(/[ \t\n]+/).filter((x) => x !== '');
|
|
1001
|
+
for (const piece of pieces)
|
|
1002
|
+
out.push({ value: known(piece), spread: false });
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
// globs in unquoted literal parts
|
|
1006
|
+
const globbed = this.globValue(w, value);
|
|
1007
|
+
if (globbed) {
|
|
1008
|
+
value = globbed;
|
|
1009
|
+
out.push({ value, spread: true, atLeastOne: !spread });
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
out.push({ value: guardEmpty(value), spread });
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return out;
|
|
1016
|
+
}
|
|
1017
|
+
/** `*.txt` → `⟨*.txt:filename⟩`, `/etc/*.conf` → `/etc/⟨*.conf:filename⟩`. */
|
|
1018
|
+
globValue(w, value) {
|
|
1019
|
+
if (!w.parts.some((p) => p.type === 'Lit' && hasGlobChars(p.value)))
|
|
1020
|
+
return null;
|
|
1021
|
+
const c = concrete(value);
|
|
1022
|
+
if (c === undefined)
|
|
1023
|
+
return null;
|
|
1024
|
+
const comps = c.split('/');
|
|
1025
|
+
const segs = [];
|
|
1026
|
+
comps.forEach((comp, i) => {
|
|
1027
|
+
if (i > 0)
|
|
1028
|
+
segs.push(known('/'));
|
|
1029
|
+
if (hasGlobChars(comp))
|
|
1030
|
+
segs.push(hole('filename', comp, { pattern: comp }));
|
|
1031
|
+
else
|
|
1032
|
+
segs.push(known(comp));
|
|
1033
|
+
});
|
|
1034
|
+
return concat(...segs);
|
|
1035
|
+
}
|
|
1036
|
+
allParamFields(p, env) {
|
|
1037
|
+
if (p.name === '@' || p.name === '*')
|
|
1038
|
+
return env.positional;
|
|
1039
|
+
const v = env.vars.get(p.name);
|
|
1040
|
+
if (v?.array)
|
|
1041
|
+
return v.array.map((value) => ({ value, spread: !isFullyKnown(value) }));
|
|
1042
|
+
return [{ value: unknownString(`\${${p.name}[@]}`), spread: true }];
|
|
1043
|
+
}
|
|
1044
|
+
partValue(p, env, ctx, _split, first = false) {
|
|
1045
|
+
switch (p.type) {
|
|
1046
|
+
case 'Lit': {
|
|
1047
|
+
if (first && p.value.startsWith('~')) {
|
|
1048
|
+
// with HOME unset, bash uses the user's home directory from the password database
|
|
1049
|
+
const home = env.vars.get('HOME')?.value ?? this.opts.envValues?.('HOME') ?? known(this.opts.policy.home ?? os.homedir());
|
|
1050
|
+
const slash = p.value.indexOf('/');
|
|
1051
|
+
const user = slash === -1 ? p.value.slice(1) : p.value.slice(1, slash);
|
|
1052
|
+
if (user === '')
|
|
1053
|
+
return concat(home, known(slash === -1 ? '' : p.value.slice(slash)));
|
|
1054
|
+
}
|
|
1055
|
+
return known(p.value);
|
|
1056
|
+
}
|
|
1057
|
+
case 'SglQuoted':
|
|
1058
|
+
case 'Escaped':
|
|
1059
|
+
return known(p.value);
|
|
1060
|
+
case 'DblQuoted':
|
|
1061
|
+
return concat(...p.parts.map((q) => this.partValue(q, env, ctx, false)));
|
|
1062
|
+
case 'ParamExp':
|
|
1063
|
+
return this.param(p, env, ctx);
|
|
1064
|
+
case 'CmdSubst':
|
|
1065
|
+
return this.cmdSubst(p, env, ctx);
|
|
1066
|
+
case 'ArithExp':
|
|
1067
|
+
return this.arith(p.expr, env);
|
|
1068
|
+
case 'ProcSubst':
|
|
1069
|
+
env.fs = this.list(p.body, env.clone(), { ...ctx, subshell: true, cmdsubst: true }).fs;
|
|
1070
|
+
return known('/dev/fd/63');
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
cmdSubst(p, env, ctx) {
|
|
1074
|
+
const saved = this.lastOutput;
|
|
1075
|
+
this.lastOutput = null;
|
|
1076
|
+
env.fs = this.list(p.body, env.clone(), { ...ctx, subshell: true, cmdsubst: true }).fs;
|
|
1077
|
+
this.lastOutput = saved;
|
|
1078
|
+
// the output is that of the body's last command: `$(cmd)`, `$(a | b)`
|
|
1079
|
+
const last = p.body.length === 1 ? finalSimple(p.body[0].command) : undefined;
|
|
1080
|
+
const out = last ? this.outputs.get(last.id) : undefined;
|
|
1081
|
+
if (out) {
|
|
1082
|
+
this.lastOutput = out;
|
|
1083
|
+
return out;
|
|
1084
|
+
}
|
|
1085
|
+
// $(pwd) / $(cd x && pwd)
|
|
1086
|
+
const text = this.text(p.span);
|
|
1087
|
+
if (/^\$\(\s*pwd\s*\)$/.test(text))
|
|
1088
|
+
return env.cwd;
|
|
1089
|
+
return hole('string', text.length > 40 ? text.slice(0, 37) + '...' : text);
|
|
1090
|
+
}
|
|
1091
|
+
arith(expr, env) {
|
|
1092
|
+
let ok = true;
|
|
1093
|
+
const aenv = {
|
|
1094
|
+
get: (name) => {
|
|
1095
|
+
const v = env.vars.get(name);
|
|
1096
|
+
if (!v) {
|
|
1097
|
+
// unknown to the analysis (RANDOM, SECONDS, unset or environment-dependent): the result is unknown
|
|
1098
|
+
ok = false;
|
|
1099
|
+
return undefined;
|
|
1100
|
+
}
|
|
1101
|
+
const c = concrete(v.value);
|
|
1102
|
+
if (c === undefined)
|
|
1103
|
+
ok = false;
|
|
1104
|
+
return c ?? '0';
|
|
1105
|
+
},
|
|
1106
|
+
isAssoc: () => false,
|
|
1107
|
+
set: () => { },
|
|
1108
|
+
expandWord: async (w) => {
|
|
1109
|
+
const v = concrete(this.stringValue(w, env, { reach: 'may', loop: false, subshell: false, cmdsubst: false, pipeline: false, background: false, stdin: 'unknown', depth: 0 }));
|
|
1110
|
+
if (v === undefined)
|
|
1111
|
+
ok = false;
|
|
1112
|
+
return v ?? '0';
|
|
1113
|
+
},
|
|
1114
|
+
nounset: false,
|
|
1115
|
+
};
|
|
1116
|
+
return syncArith(expr, aenv, () => ok) ?? hole('int', '$((…))');
|
|
1117
|
+
}
|
|
1118
|
+
param(p, env, ctx) {
|
|
1119
|
+
const special = () => {
|
|
1120
|
+
switch (p.name) {
|
|
1121
|
+
case '?':
|
|
1122
|
+
case '#':
|
|
1123
|
+
case '$':
|
|
1124
|
+
case '!': return hole('int', `$${p.name}`);
|
|
1125
|
+
case '0': return known('mash');
|
|
1126
|
+
case '-': return hole('string', '$-');
|
|
1127
|
+
case '@':
|
|
1128
|
+
case '*': {
|
|
1129
|
+
const fs = env.positional;
|
|
1130
|
+
if (fs.length === 0)
|
|
1131
|
+
return known('');
|
|
1132
|
+
return concat(...fs.flatMap((f, i) => (i === 0 ? [f.value] : [known(' '), f.value])));
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (/^\d+$/.test(p.name)) {
|
|
1136
|
+
const f = env.positional[Number(p.name) - 1];
|
|
1137
|
+
if (env.positional.some((x) => x.spread))
|
|
1138
|
+
return unknownString(`$${p.name}`);
|
|
1139
|
+
return f?.value ?? known('');
|
|
1140
|
+
}
|
|
1141
|
+
return undefined;
|
|
1142
|
+
};
|
|
1143
|
+
if (p.length)
|
|
1144
|
+
return hole('int', `\${#${p.name}}`);
|
|
1145
|
+
if (p.indirect || p.prefixNames)
|
|
1146
|
+
return unknownString(this.text(p.span));
|
|
1147
|
+
let base = special();
|
|
1148
|
+
let unset = false;
|
|
1149
|
+
if (base === undefined) {
|
|
1150
|
+
const v = env.vars.get(p.name);
|
|
1151
|
+
if (p.index && v?.array) {
|
|
1152
|
+
const idx = literalOf(p.index);
|
|
1153
|
+
if (idx === '@' || idx === '*')
|
|
1154
|
+
base = v.array.length ? concat(...v.array.flatMap((x, i) => (i === 0 ? [x] : [known(' '), x]))) : known('');
|
|
1155
|
+
else if (idx !== null && /^\d+$/.test(idx))
|
|
1156
|
+
base = v.array[Number(idx)] ?? known('');
|
|
1157
|
+
else
|
|
1158
|
+
base = join(...v.array);
|
|
1159
|
+
}
|
|
1160
|
+
else if (v) {
|
|
1161
|
+
base = v.value;
|
|
1162
|
+
if (v.unsetMaybe)
|
|
1163
|
+
unset = true;
|
|
1164
|
+
}
|
|
1165
|
+
else {
|
|
1166
|
+
const ev = this.opts.envValues?.(p.name);
|
|
1167
|
+
if (ev)
|
|
1168
|
+
base = ev;
|
|
1169
|
+
else if (INT_SHELL_VARS.has(p.name))
|
|
1170
|
+
base = hole('int', `$${p.name}`);
|
|
1171
|
+
else {
|
|
1172
|
+
base = unknownString(`$${p.name}`);
|
|
1173
|
+
unset = true;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
const op = p.op;
|
|
1178
|
+
if (!op)
|
|
1179
|
+
return base;
|
|
1180
|
+
switch (op.kind) {
|
|
1181
|
+
case 'default':
|
|
1182
|
+
case 'assign': {
|
|
1183
|
+
const d = op.word ? this.stringValue(op.word, env, ctx) : known('');
|
|
1184
|
+
const c = concrete(base);
|
|
1185
|
+
let out;
|
|
1186
|
+
if (c !== undefined && !unset && (c !== '' || !op.colon))
|
|
1187
|
+
out = base;
|
|
1188
|
+
else if (c === '' && op.colon && !unset)
|
|
1189
|
+
out = d;
|
|
1190
|
+
else if (env.vars.has(p.name) && !unset && !op.colon)
|
|
1191
|
+
out = base;
|
|
1192
|
+
else
|
|
1193
|
+
out = env.vars.has(p.name) || this.opts.envValues?.(p.name) ? join(base, d) : d;
|
|
1194
|
+
if (op.kind === 'assign')
|
|
1195
|
+
env.vars.set(p.name, { value: out, exported: env.vars.get(p.name)?.exported ?? false });
|
|
1196
|
+
return out;
|
|
1197
|
+
}
|
|
1198
|
+
case 'alternative': {
|
|
1199
|
+
const c = concrete(base);
|
|
1200
|
+
const alt = op.word ? this.stringValue(op.word, env, ctx) : known('');
|
|
1201
|
+
if (c !== undefined && !unset)
|
|
1202
|
+
return c === '' && op.colon ? known('') : alt;
|
|
1203
|
+
return join(alt, known(''));
|
|
1204
|
+
}
|
|
1205
|
+
case 'error':
|
|
1206
|
+
return base;
|
|
1207
|
+
case 'removePrefix':
|
|
1208
|
+
case 'removeSuffix':
|
|
1209
|
+
case 'replace': {
|
|
1210
|
+
const c = concrete(base);
|
|
1211
|
+
const pat = op.kind === 'replace' ? op.pattern : op.pattern;
|
|
1212
|
+
const patText = this.patternText(pat, env, ctx);
|
|
1213
|
+
const patKnown = concrete(this.stringValue(pat, env, ctx)) !== undefined;
|
|
1214
|
+
if (c !== undefined && patKnown) {
|
|
1215
|
+
if (op.kind === 'removePrefix')
|
|
1216
|
+
return known(removePrefix(c, patText, op.longest));
|
|
1217
|
+
if (op.kind === 'removeSuffix')
|
|
1218
|
+
return known(removeSuffix(c, patText, op.longest));
|
|
1219
|
+
if (op.kind === 'replace') {
|
|
1220
|
+
const rep = op.replacement ? concrete(this.stringValue(op.replacement, env, ctx)) : '';
|
|
1221
|
+
if (rep !== undefined)
|
|
1222
|
+
return known(replacePattern(c, patText, rep, op.mode));
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
const origin = this.text(p.span);
|
|
1226
|
+
// `${p##*/}` and `${p%%/*}` never contain `/`; nor does `${p//\//_}`
|
|
1227
|
+
if (patKnown && op.kind !== 'replace' && op.longest && patText === (op.kind === 'removePrefix' ? '*/' : '/*'))
|
|
1228
|
+
return hole('segment', origin);
|
|
1229
|
+
if (op.kind === 'replace' && op.mode === 'all' && patKnown && (patText === '/' || patText === '\\/')) {
|
|
1230
|
+
const rep = op.replacement ? concrete(this.stringValue(op.replacement, env, ctx)) : '';
|
|
1231
|
+
if (rep !== undefined && !rep.includes('/'))
|
|
1232
|
+
return hole('segment', origin);
|
|
1233
|
+
}
|
|
1234
|
+
// removing part of a slash-free value keeps it slash-free (but it may become empty, `.` or `..`)
|
|
1235
|
+
const t = typeOf(base);
|
|
1236
|
+
if (t !== 'mixed' && t !== 'literal' && isSlashFree(t))
|
|
1237
|
+
return hole(op.kind === 'replace' ? 'string' : 'segment', origin);
|
|
1238
|
+
// `${p%/*}` of a path is a path
|
|
1239
|
+
if (t === 'path')
|
|
1240
|
+
return hole('path', origin);
|
|
1241
|
+
return unknownString(origin);
|
|
1242
|
+
}
|
|
1243
|
+
case 'case': {
|
|
1244
|
+
const c = concrete(base);
|
|
1245
|
+
if (c !== undefined && !op.pattern)
|
|
1246
|
+
return known(op.upper ? (op.all ? c.toUpperCase() : c.slice(0, 1).toUpperCase() + c.slice(1)) : op.all ? c.toLowerCase() : c.slice(0, 1).toLowerCase() + c.slice(1));
|
|
1247
|
+
// case changes keep a slash-free value slash-free, and a name a name
|
|
1248
|
+
const t = typeOf(base);
|
|
1249
|
+
if (t !== 'mixed' && t !== 'literal' && isSlashFree(t))
|
|
1250
|
+
return hole(isNameLike(t) ? 'identifier' : 'segment', this.text(p.span));
|
|
1251
|
+
return unknownString(this.text(p.span));
|
|
1252
|
+
}
|
|
1253
|
+
case 'substring': {
|
|
1254
|
+
const c = concrete(base);
|
|
1255
|
+
const off = concrete(this.arith(op.offset, env));
|
|
1256
|
+
const len = op.length ? concrete(this.arith(op.length, env)) : undefined;
|
|
1257
|
+
if (c !== undefined && off !== undefined && (op.length === null || len !== undefined)) {
|
|
1258
|
+
let start = Number(off);
|
|
1259
|
+
if (start < 0)
|
|
1260
|
+
start = Math.max(0, c.length + start);
|
|
1261
|
+
const end = len === undefined ? c.length : Number(len) < 0 ? c.length + Number(len) : start + Number(len);
|
|
1262
|
+
return known(c.slice(start, end));
|
|
1263
|
+
}
|
|
1264
|
+
const t = typeOf(base);
|
|
1265
|
+
if (t !== 'mixed' && t !== 'literal' && isSlashFree(t))
|
|
1266
|
+
return hole('segment', this.text(p.span));
|
|
1267
|
+
return unknownString(this.text(p.span));
|
|
1268
|
+
}
|
|
1269
|
+
case 'transform':
|
|
1270
|
+
return unknownString(this.text(p.span));
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
import * as patternModule from "../vm/pattern.js";
|
|
1275
|
+
function readSmallFile(p) {
|
|
1276
|
+
try {
|
|
1277
|
+
const st = fs.statSync(p);
|
|
1278
|
+
if (!st.isFile() || st.size > MAX_INLINE_BYTES)
|
|
1279
|
+
return undefined;
|
|
1280
|
+
return fs.readFileSync(p, 'utf8');
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
return undefined;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
function hasSubstitution(w) {
|
|
1287
|
+
const visit = (parts) => parts.some((p) => p.type === 'CmdSubst' || p.type === 'ProcSubst' || (p.type === 'DblQuoted' && visit(p.parts)));
|
|
1288
|
+
return visit(w.parts);
|
|
1289
|
+
}
|
|
1290
|
+
/** Commands whose recursive writes create the directory written (`git clone x`, `tar -C x -x`, `cp -r a x`). */
|
|
1291
|
+
const DIR_CREATORS = new Set(['git', 'tar', 'unzip', 'cp', 'rsync', 'install', 'mktemp', 'ditto', 'curl', 'wget']);
|
|
1292
|
+
const NO_CTX = { reach: 'may', loop: false, subshell: false, cmdsubst: false, pipeline: false, background: false, stdin: 'unknown', depth: 0 };
|
|
1293
|
+
/** Variables bash itself sets to integers (when the snippet and environment do not define them). */
|
|
1294
|
+
const INT_SHELL_VARS = new Set(['RANDOM', 'SRANDOM', 'SECONDS', 'LINENO', 'BASHPID', 'PPID', 'UID', 'EUID', 'EPOCHSECONDS']);
|
|
1295
|
+
const PREDICATES = new Set(['test', '[', 'true', 'false', 'which', 'type', 'hash', 'pgrep', 'id', 'cmp', 'diff', 'tty', 'getent', 'grep', 'egrep', 'fgrep', 'rg', 'command']);
|
|
1296
|
+
/** Commands whose exit status is a yes/no answer: `x && y` after them is a guard, not a happy path. */
|
|
1297
|
+
function isPredicate(cmd) {
|
|
1298
|
+
switch (cmd.type) {
|
|
1299
|
+
case 'CondCommand':
|
|
1300
|
+
case 'ArithCommand':
|
|
1301
|
+
return true;
|
|
1302
|
+
case 'Pipeline':
|
|
1303
|
+
return cmd.negated || isPredicate(cmd.stages[cmd.stages.length - 1]);
|
|
1304
|
+
case 'AndOr':
|
|
1305
|
+
return isPredicate(cmd.items[cmd.items.length - 1]);
|
|
1306
|
+
case 'SimpleCommand': {
|
|
1307
|
+
const w = cmd.words[0];
|
|
1308
|
+
const name = w && w.type === 'Word' ? literalOf(w) : null;
|
|
1309
|
+
if (name === null || !PREDICATES.has(name))
|
|
1310
|
+
return false;
|
|
1311
|
+
const args = cmd.words.slice(1).map((x) => (x.type === 'Word' ? literalOf(x) ?? '' : ''));
|
|
1312
|
+
if (name === 'grep' || name === 'egrep' || name === 'fgrep' || name === 'rg')
|
|
1313
|
+
return args.some((a) => /^-[a-zA-Z]*q/.test(a) || a === '--quiet' || a === '--silent');
|
|
1314
|
+
if (name === 'command')
|
|
1315
|
+
return args.some((a) => a === '-v' || a === '-V');
|
|
1316
|
+
return true;
|
|
1317
|
+
}
|
|
1318
|
+
default:
|
|
1319
|
+
return false;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
/** The simple command whose stdout is a command's stdout: itself, or the last stage of a pipeline. */
|
|
1323
|
+
function finalSimple(cmd) {
|
|
1324
|
+
if (cmd.type === 'SimpleCommand')
|
|
1325
|
+
return cmd;
|
|
1326
|
+
if (cmd.type === 'Pipeline' && !cmd.negated) {
|
|
1327
|
+
const st = cmd.stages[cmd.stages.length - 1];
|
|
1328
|
+
return st.type === 'SimpleCommand' ? st : undefined;
|
|
1329
|
+
}
|
|
1330
|
+
return undefined;
|
|
1331
|
+
}
|
|
1332
|
+
function isAllParam(p) {
|
|
1333
|
+
if (p.length || p.op)
|
|
1334
|
+
return false;
|
|
1335
|
+
if (p.name === '@' && !p.index)
|
|
1336
|
+
return true;
|
|
1337
|
+
return p.index !== null && literalOf(p.index) === '@';
|
|
1338
|
+
}
|
|
1339
|
+
/** `/a/b/../c` → `/a/c` for fully known paths (cwd tracking). */
|
|
1340
|
+
function normalizeKnownPath(v) {
|
|
1341
|
+
const c = concrete(v);
|
|
1342
|
+
if (c === undefined || !c.startsWith('/'))
|
|
1343
|
+
return v;
|
|
1344
|
+
const out = [];
|
|
1345
|
+
for (const part of c.split('/')) {
|
|
1346
|
+
if (part === '' || part === '.')
|
|
1347
|
+
continue;
|
|
1348
|
+
if (part === '..')
|
|
1349
|
+
out.pop();
|
|
1350
|
+
else
|
|
1351
|
+
out.push(part);
|
|
1352
|
+
}
|
|
1353
|
+
return known('/' + out.join('/'));
|
|
1354
|
+
}
|
|
1355
|
+
/** Synchronous arithmetic for fully known operands (static analysis). */
|
|
1356
|
+
function syncArith(expr, env, ok) {
|
|
1357
|
+
const words = [];
|
|
1358
|
+
const visit = (e) => {
|
|
1359
|
+
switch (e.type) {
|
|
1360
|
+
case 'ArithWord':
|
|
1361
|
+
words.push(e);
|
|
1362
|
+
break;
|
|
1363
|
+
case 'ArithVar':
|
|
1364
|
+
if (e.index)
|
|
1365
|
+
visit(e.index);
|
|
1366
|
+
break;
|
|
1367
|
+
case 'ArithUnary':
|
|
1368
|
+
visit(e.operand);
|
|
1369
|
+
break;
|
|
1370
|
+
case 'ArithBinary':
|
|
1371
|
+
visit(e.left);
|
|
1372
|
+
visit(e.right);
|
|
1373
|
+
break;
|
|
1374
|
+
case 'ArithAssign':
|
|
1375
|
+
visit(e.value);
|
|
1376
|
+
break;
|
|
1377
|
+
case 'ArithTernary':
|
|
1378
|
+
visit(e.cond);
|
|
1379
|
+
visit(e.then);
|
|
1380
|
+
visit(e.else);
|
|
1381
|
+
break;
|
|
1382
|
+
case 'ArithGroup':
|
|
1383
|
+
visit(e.expr);
|
|
1384
|
+
break;
|
|
1385
|
+
default: break;
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
visit(expr);
|
|
1389
|
+
if (words.length > 0)
|
|
1390
|
+
return null;
|
|
1391
|
+
try {
|
|
1392
|
+
const { evaluateArithText } = arithModule;
|
|
1393
|
+
const { arithText } = parserModule;
|
|
1394
|
+
const text = arithText(expr);
|
|
1395
|
+
if (text === undefined)
|
|
1396
|
+
return null;
|
|
1397
|
+
const v = evaluateArithText(text, env);
|
|
1398
|
+
return ok() ? known(String(v)) : null;
|
|
1399
|
+
}
|
|
1400
|
+
catch {
|
|
1401
|
+
return null;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
import * as arithModule from "../vm/arith.js";
|
|
1405
|
+
import * as parserModule from "../parser/parser.js";
|
|
1406
|
+
//# sourceMappingURL=analyze.js.map
|