@crewhaus/tool-executor 0.7.0 → 0.8.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 +201 -0
- package/NOTICE +9 -0
- package/README.md +11 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +15 -1
- package/dist/permission-subject.d.ts +365 -0
- package/dist/permission-subject.js +1688 -0
- package/package.json +12 -8
|
@@ -0,0 +1,1688 @@
|
|
|
1
|
+
import { climbsAfterName, holdsUnexpanded, linesRunBy, normalizePathLexically, programWords, readsShellOnInput, shellProgramWords, withShellPwd, } from "@crewhaus/tool-permission-matcher";
|
|
2
|
+
import { validateToolInput } from "@crewhaus/tool-validate";
|
|
3
|
+
const kindsAgree = true;
|
|
4
|
+
void kindsAgree;
|
|
5
|
+
/**
|
|
6
|
+
* Parse `rawInput` with the tool's schema and read its operative values. The
|
|
7
|
+
* input passed here must be exactly the one that will be handed to
|
|
8
|
+
* `executeTool`, so the rule and the tool see the same call.
|
|
9
|
+
*/
|
|
10
|
+
export function preparePermissionSubject(tool, rawInput, opts = {}) {
|
|
11
|
+
const validation = validateToolInput(tool, rawInput);
|
|
12
|
+
if (!validation.ok)
|
|
13
|
+
return { ok: false, reason: validation.error.message };
|
|
14
|
+
const operativeValues = operativeValuesFor(tool, validation.value, opts);
|
|
15
|
+
return {
|
|
16
|
+
ok: true,
|
|
17
|
+
input: validation.value,
|
|
18
|
+
...(operativeValues !== undefined ? { operativeValues } : {}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The canonical operative values of an ALREADY PARSED input, or `undefined`
|
|
23
|
+
* when the tool declares no `operativeArgs`, or declares `[]` (the matcher
|
|
24
|
+
* then falls back to the input's string values).
|
|
25
|
+
*/
|
|
26
|
+
export function operativeValuesFor(tool, parsedInput, opts = {}) {
|
|
27
|
+
return valuesOf(tool.operativeArgs, parsedInput, opts, serviceDefaultsOf(tool, parsedInput, opts.toolConfig));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* {@link operativeValuesFor} from a declaration alone — for a caller that has
|
|
31
|
+
* a tool's `operativeArgs` as data (the builtin manifest) but not the tool.
|
|
32
|
+
* The values are read the same way, except that a call naming no API in a
|
|
33
|
+
* `service` field is read without an origin: which API it goes to is the
|
|
34
|
+
* tool's to say. The caller is responsible for passing an input the tool's
|
|
35
|
+
* schema has parsed, when it can.
|
|
36
|
+
*/
|
|
37
|
+
export function operativeValuesOf(operativeArgs, parsedInput, opts = {}) {
|
|
38
|
+
return valuesOf(operativeArgs, parsedInput, opts, undefined);
|
|
39
|
+
}
|
|
40
|
+
/** A service the tool could not name: nothing an allow could grant. */
|
|
41
|
+
const UNPLACEABLE_SERVICE = { url: "", otherOrigins: [], hosts: [] };
|
|
42
|
+
function serviceDefaultsOf(tool, parsedInput, toolConfig) {
|
|
43
|
+
const { serviceDefaults } = tool;
|
|
44
|
+
if (serviceDefaults === undefined)
|
|
45
|
+
return undefined;
|
|
46
|
+
let read;
|
|
47
|
+
try {
|
|
48
|
+
read = serviceDefaults(parsedInput, toolConfig);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// The tool cannot say where the call goes (a config it cannot read,
|
|
52
|
+
// which fails the call too): read as a place no allow names.
|
|
53
|
+
return { unnamed: UNPLACEABLE_SERVICE };
|
|
54
|
+
}
|
|
55
|
+
if (read === undefined)
|
|
56
|
+
return undefined;
|
|
57
|
+
const home = serviceOf(read.home).origin;
|
|
58
|
+
return { unnamed: serviceOf(read.unnamed), ...(home !== undefined ? { home } : {}) };
|
|
59
|
+
}
|
|
60
|
+
function valuesOf(operativeArgs, parsedInput, opts, services) {
|
|
61
|
+
if (operativeArgs === undefined || operativeArgs.length === 0)
|
|
62
|
+
return undefined;
|
|
63
|
+
const canonicalizePath = opts.canonicalizePath ?? lexicalPathValues;
|
|
64
|
+
const values = [];
|
|
65
|
+
// The defaults of relocating fields the call left out. Whether an allow
|
|
66
|
+
// reads them depends on what else the call carries, so they are placed
|
|
67
|
+
// last (see `relocates` on OperativeArg).
|
|
68
|
+
const relocated = [];
|
|
69
|
+
for (const arg of operativeArgs) {
|
|
70
|
+
const from = values.length;
|
|
71
|
+
for (const { value: raw, words, runsIn, env, stdin, dir, leavesDir, unqualified, every, anyAfter, defaulted, anyQualifier, prefixOf, service, overlay, } of readField(parsedInput, arg, services)) {
|
|
72
|
+
if (arg.relocates === true && defaulted === true) {
|
|
73
|
+
if (arg.kind === "url")
|
|
74
|
+
relocated.push(canonicalUrl(raw));
|
|
75
|
+
else {
|
|
76
|
+
// A store the tool reads whole, whatever record the call names
|
|
77
|
+
// (DatasetMine's sessions directory), is read with what is in it.
|
|
78
|
+
const { beneath } = arg;
|
|
79
|
+
const read = canonicalizePath(raw);
|
|
80
|
+
relocated.push(...(beneath !== undefined ? read.map((v) => withBeneath(v, beneath)) : read), ...openedValues(raw, arg, canonicalizePath));
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
switch (arg.kind) {
|
|
85
|
+
case "path": {
|
|
86
|
+
if (every === true) {
|
|
87
|
+
values.push(...everyPathValues(dir ?? ".", canonicalizePath));
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
if (leavesDir !== undefined) {
|
|
91
|
+
values.push(leavingValue(raw, leavesDir.written));
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
const read = canonicalizePath(raw);
|
|
95
|
+
const { beneath } = arg;
|
|
96
|
+
if (arg.glob === true) {
|
|
97
|
+
// A pattern the tool lists the paths of: the matcher reads it as
|
|
98
|
+
// every path it can list.
|
|
99
|
+
values.push(...read.map((v) => (v.outsideWorkspace === true ? v : { ...v, globPattern: true })));
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
// A walk that skips hidden names opens them too when the call sets
|
|
103
|
+
// the switch its declaration names (Tree's `includeHidden`). A tool
|
|
104
|
+
// that acts beneath the path only when the call sets a switch
|
|
105
|
+
// (TempDir's `clear`) otherwise acts on the path alone.
|
|
106
|
+
const walk = arg.beneathWhen !== undefined && !switchedOn(parsedInput, arg.beneathWhen)
|
|
107
|
+
? undefined
|
|
108
|
+
: beneath === "visible" &&
|
|
109
|
+
arg.hiddenWhen !== undefined &&
|
|
110
|
+
switchedOn(parsedInput, arg.hiddenWhen)
|
|
111
|
+
? "all"
|
|
112
|
+
: beneath;
|
|
113
|
+
values.push(...(walk !== undefined ? read.map((v) => withBeneath(v, walk)) : read));
|
|
114
|
+
values.push(...openedValues(raw, arg, canonicalizePath));
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case "url":
|
|
118
|
+
values.push(every === true
|
|
119
|
+
? { kind: "url", canonical: [ANY_VALUE], standsForAny: [""] }
|
|
120
|
+
: service !== undefined
|
|
121
|
+
? apiRootValue(service)
|
|
122
|
+
: canonicalUrl(raw));
|
|
123
|
+
break;
|
|
124
|
+
case "command": {
|
|
125
|
+
const dirValues = runsIn !== undefined ? canonicalizePath(runsIn) : undefined;
|
|
126
|
+
const elsewhere = runsIn !== undefined && dirValues !== undefined && !namesWorkspaceRoot(dirValues);
|
|
127
|
+
const setsEnv = env !== undefined && Object.keys(env).length > 0;
|
|
128
|
+
const argv = words ?? raw.split(/\s+/).filter((w) => w !== "");
|
|
129
|
+
// A shell parses a string it is handed; an argv reaches one only
|
|
130
|
+
// when the program it names is a shell, an eval or an env -S.
|
|
131
|
+
const shellLine = arg.shell === true && words === undefined;
|
|
132
|
+
const shell = shellLine ? { shell: true } : {};
|
|
133
|
+
// The lines an argv hands a shell: one it runs with `-c`, and the
|
|
134
|
+
// text the call puts on the input of a shell given no script.
|
|
135
|
+
const shellLines = words !== undefined
|
|
136
|
+
? [
|
|
137
|
+
...linesRunBy(words),
|
|
138
|
+
...(stdin !== undefined && readsShellOnInput(words) ? [stdin] : []),
|
|
139
|
+
]
|
|
140
|
+
: [];
|
|
141
|
+
const where = { runsIn, readProgramPath: opts.readProgramPath };
|
|
142
|
+
const read = commandSpellings(argv, elsewhere ? runDirSpellings(runsIn, dirValues) : [], setsEnv ? env : undefined, canonicalizePath, where);
|
|
143
|
+
// The words that name a file, read where the OS finds them: an
|
|
144
|
+
// argv's own, and those of each command of a line a shell runs
|
|
145
|
+
// (the Bash tool's, or one an argv hands to `sh -c`). A string that
|
|
146
|
+
// is neither, and runs nowhere in particular, is code for a sandboxed
|
|
147
|
+
// interpreter (`Python`'s `code`), not a command line.
|
|
148
|
+
const found = programWords(argv);
|
|
149
|
+
const own = shellLine
|
|
150
|
+
? readShellPrograms([raw], where)
|
|
151
|
+
: words !== undefined || arg.within !== undefined
|
|
152
|
+
? readCommand(argv, found, [{ runsIn, prefix: "" }], where, {
|
|
153
|
+
rewrite: true,
|
|
154
|
+
shell: false,
|
|
155
|
+
})
|
|
156
|
+
: NOTHING_READ;
|
|
157
|
+
// A line an argv hands a shell starts where the wrappers before
|
|
158
|
+
// the shell moved to (`env -C dir sh -c …`).
|
|
159
|
+
const handed = readShellPrograms(shellLines, where, chdirPrefix(found));
|
|
160
|
+
// A word that lands elsewhere than it reads is put in its place for
|
|
161
|
+
// an allow. A line an argv hands a shell cannot have it put in, so
|
|
162
|
+
// no scoped allow grants the argv; one the Bash tool runs is
|
|
163
|
+
// granted only by an allow that also names each command with it
|
|
164
|
+
// put in (`placed`, below).
|
|
165
|
+
const ungrantable = own.unplaceable ||
|
|
166
|
+
own.ungrantable ||
|
|
167
|
+
handed.unplaceable ||
|
|
168
|
+
handed.moved ||
|
|
169
|
+
handed.ungrantable;
|
|
170
|
+
const unplaceable = read.unreadable || own.unplaceable || handed.unplaceable;
|
|
171
|
+
const programSpellings = shellLine ? [] : own.spellings;
|
|
172
|
+
if (elsewhere || setsEnv || ungrantable) {
|
|
173
|
+
// Run in another directory, or with an environment the call set,
|
|
174
|
+
// the same words may be another program: nothing canonical for an
|
|
175
|
+
// allow to grant. A deny or ask reads what was written, and each
|
|
176
|
+
// word also as the file it names there — `release.sh` run in
|
|
177
|
+
// `scripts/` is `scripts/release.sh`, which a deny may name.
|
|
178
|
+
values.push({
|
|
179
|
+
kind: "command",
|
|
180
|
+
canonical: [],
|
|
181
|
+
spellings: [
|
|
182
|
+
...new Set([raw, ...(words ?? []), ...read.spellings, ...programSpellings]),
|
|
183
|
+
],
|
|
184
|
+
...shell,
|
|
185
|
+
...(unplaceable ? { outsideWorkspace: true } : {}),
|
|
186
|
+
...(every === true ? { standsForAny: [""] } : {}),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
// A program word that lands elsewhere than it reads is read where
|
|
191
|
+
// it lands: `./scripts/../evil.sh` is `./evil.sh`, which
|
|
192
|
+
// `RunCommand(./scripts/**)` does not grant.
|
|
193
|
+
const canonical = own.rewritten?.join(" ") ?? raw;
|
|
194
|
+
const spellings = [
|
|
195
|
+
...new Set([
|
|
196
|
+
...(canonical !== raw ? [raw] : []),
|
|
197
|
+
...(words ?? []),
|
|
198
|
+
...read.spellings,
|
|
199
|
+
...programSpellings,
|
|
200
|
+
]),
|
|
201
|
+
];
|
|
202
|
+
values.push({
|
|
203
|
+
kind: "command",
|
|
204
|
+
canonical: [canonical],
|
|
205
|
+
...(spellings.length > 0 ? { spellings } : {}),
|
|
206
|
+
...shell,
|
|
207
|
+
...(anyAfter !== undefined ? { standsForAny: anyAfter } : {}),
|
|
208
|
+
});
|
|
209
|
+
// A word node would open elsewhere than the kernel walks to is
|
|
210
|
+
// read in both places: `RunCommand(node safe/**)` does not grant
|
|
211
|
+
// `node lnk/../x.mjs` (lnk → safe/sub), which runs `./x.mjs`.
|
|
212
|
+
if (own.rewrittenAsText !== undefined) {
|
|
213
|
+
values.push({ kind: "command", canonical: [own.rewrittenAsText.join(" ")] });
|
|
214
|
+
}
|
|
215
|
+
// A line a shell parses cannot have a word put in its place, so
|
|
216
|
+
// an allow reads each command whose words moved, with them put
|
|
217
|
+
// in, as well as the line: `Bash(./scripts/**)` does not grant
|
|
218
|
+
// `./scripts/lnk/run.sh` (lnk → ../evil), and `Bash(node **)`
|
|
219
|
+
// still grants a script through a linked package directory.
|
|
220
|
+
for (const command of new Set(own.placed)) {
|
|
221
|
+
values.push({ kind: "command", canonical: [command] });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
values.push(...shellLineValues(shellLines));
|
|
225
|
+
// What a deny or ask reads of the program words in a line a shell
|
|
226
|
+
// parses, as plain text: read as a line again, a word with its
|
|
227
|
+
// quoting taken off could split into commands the line never runs.
|
|
228
|
+
values.push(...programPlaceValues(shellLine ? [...own.spellings, ...handed.spellings] : handed.spellings, [...own.patterns, ...handed.patterns]));
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
default: {
|
|
232
|
+
if (arg.secretRef === true) {
|
|
233
|
+
values.push(...secretRefValues(raw, canonicalizePath));
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
const spellings = [...(words ?? []), ...(unqualified ?? [])];
|
|
237
|
+
const read = {
|
|
238
|
+
kind: arg.kind,
|
|
239
|
+
canonical: [raw],
|
|
240
|
+
...(spellings.length > 0 ? { spellings } : {}),
|
|
241
|
+
...(HEX_ID.test(raw) ? { caseInsensitive: true } : {}),
|
|
242
|
+
...(anyAfter !== undefined ? { standsForAny: anyAfter } : {}),
|
|
243
|
+
...(anyQualifier === true ? { anyQualifier: true } : {}),
|
|
244
|
+
// A prefix stands for every value that starts with it, and a
|
|
245
|
+
// key may hold `/`: what follows is any run, not one segment.
|
|
246
|
+
...(prefixOf !== undefined ? { standsForAnyRun: true } : {}),
|
|
247
|
+
};
|
|
248
|
+
// An owner's own copy of the value (a tenant's overlay of a pin).
|
|
249
|
+
const value = overlay !== undefined ? overlaidValue(read, overlay) : read;
|
|
250
|
+
// A value on a service is that service's: an allow names its
|
|
251
|
+
// origin, a deny also reads the URL as a URL.
|
|
252
|
+
if (service !== undefined) {
|
|
253
|
+
values.push(servedValue(value, service));
|
|
254
|
+
if (service.url !== "") {
|
|
255
|
+
values.push({ ...canonicalUrl(service.url), restrictOnly: true });
|
|
256
|
+
// The tool drops one trailing slash from an API root before it
|
|
257
|
+
// joins a request path, so `…/api/v3/` is the API `…/api/v3`:
|
|
258
|
+
// a deny or ask written on that one meets it too.
|
|
259
|
+
if (service.url.endsWith("/") && service.url.length > 1) {
|
|
260
|
+
values.push({ ...canonicalUrl(service.url.slice(0, -1)), restrictOnly: true });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
else
|
|
265
|
+
values.push(value);
|
|
266
|
+
// The prefix alone, for a deny or ask written without the
|
|
267
|
+
// qualifier (`alwaysDeny KvGet(apikey)`, as for any `within`
|
|
268
|
+
// field). Read one segment on: with a run, a deny naming another
|
|
269
|
+
// namespace's key (`secrets/apikey`) would fire on every listing
|
|
270
|
+
// with no prefix, whatever namespace it lists.
|
|
271
|
+
if (prefixOf?.unqualified !== undefined) {
|
|
272
|
+
values.push({
|
|
273
|
+
kind: arg.kind,
|
|
274
|
+
canonical: [],
|
|
275
|
+
standsForAny: [prefixOf.unqualified],
|
|
276
|
+
restrictOnly: true,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Where the tool takes a value from, or a field it ignores because the
|
|
283
|
+
// call names another: a deny or ask reads it, an allow is about where
|
|
284
|
+
// the tool acts (`source` and `ignoredWhen` on OperativeArg).
|
|
285
|
+
if (arg.source === true ||
|
|
286
|
+
(arg.ignoredWhen !== undefined && qualifierOf(parsedInput, arg.ignoredWhen) !== undefined)) {
|
|
287
|
+
for (let i = from; i < values.length; i++) {
|
|
288
|
+
values[i] = { ...values[i], restrictOnly: true };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// A relocating field's default is a place the call did not name. When the
|
|
293
|
+
// call names a record there, an allow is about the record and skips the
|
|
294
|
+
// default; a deny or ask still reads it. With nothing else to read, the
|
|
295
|
+
// default is the call's only place, and every rule reads it.
|
|
296
|
+
if (values.length > 0) {
|
|
297
|
+
for (const v of relocated)
|
|
298
|
+
values.push({ ...v, restrictOnly: true });
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
values.push(...relocated);
|
|
302
|
+
}
|
|
303
|
+
return values;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The lines an argv hands to a shell (`["sh", "-c", "…"]`), as values only a
|
|
307
|
+
* deny or ask reads: no allow is widened or narrowed by them, and a deny
|
|
308
|
+
* reads each simple command in them.
|
|
309
|
+
*/
|
|
310
|
+
function shellLineValues(lines) {
|
|
311
|
+
if (lines.length === 0)
|
|
312
|
+
return [];
|
|
313
|
+
return [
|
|
314
|
+
{ kind: "command", canonical: [], spellings: [...lines], shell: true, restrictOnly: true },
|
|
315
|
+
];
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Where the words of a command that name code it runs land, as values only a
|
|
319
|
+
* deny or ask reads, never read as a shell line: see {@link readCommand}.
|
|
320
|
+
* A word the shell expands as a pattern is read as every path it can list.
|
|
321
|
+
*/
|
|
322
|
+
function programPlaceValues(spellings, patterns) {
|
|
323
|
+
const out = [];
|
|
324
|
+
if (spellings.length > 0) {
|
|
325
|
+
out.push({
|
|
326
|
+
kind: "command",
|
|
327
|
+
canonical: [],
|
|
328
|
+
spellings: [...new Set(spellings)],
|
|
329
|
+
restrictOnly: true,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
if (patterns.length > 0) {
|
|
333
|
+
out.push({
|
|
334
|
+
kind: "command",
|
|
335
|
+
canonical: [],
|
|
336
|
+
spellings: [...new Set(patterns)],
|
|
337
|
+
globPattern: true,
|
|
338
|
+
restrictOnly: true,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* A secret reference (`secretRef` on its OperativeArg), read the way
|
|
345
|
+
* `@crewhaus/tool-secrets`' `parseRef` reads it: with the whitespace around
|
|
346
|
+
* it trimmed, and its `file:` and `envfile:` forms naming a workspace file.
|
|
347
|
+
* Such a reference is matched with the file's canonical path in it — where
|
|
348
|
+
* it lands, `./`, `..` and links resolved — so `file:./secrets/token`,
|
|
349
|
+
* `file:pub/../secrets/token` and `file:pub/lnk/token` (a link to
|
|
350
|
+
* `../secrets`) are all `file:secrets/token`, which an allow must name and a
|
|
351
|
+
* deny on `file:secrets/**` names. The reference as written and trimmed is
|
|
352
|
+
* a spelling a deny or ask also reads, and the file is a path value only a
|
|
353
|
+
* deny or ask reads (`alwaysDeny *(secrets/**)`). A file outside the
|
|
354
|
+
* workspace is outside: every deny or ask fires, no allow grants it. Any
|
|
355
|
+
* other form (`env:NAME`, `keychain:…`, `op://…`) is an id. A bare NAME is
|
|
356
|
+
* an id an allow names as written; it is also looked up in the files of the
|
|
357
|
+
* local chain ({@link secretChain}), which a deny or ask reads as those
|
|
358
|
+
* references and those files, so `alwaysDeny *(.env)` fires on `API_KEY`.
|
|
359
|
+
* An `env:NAME` is an id an allow names as written too, but the environment
|
|
360
|
+
* it reads holds the chain's env files in every harness — the supervisor
|
|
361
|
+
* merges `.env.local` and `.env` into the environment it spawns a harness
|
|
362
|
+
* with, and Bun loads them from the directory it starts in — so a deny or ask
|
|
363
|
+
* reads those two the same way: `alwaysDeny *(.env)` fires on `env:API_KEY`.
|
|
364
|
+
*/
|
|
365
|
+
function secretRefValues(raw, canonicalizePath) {
|
|
366
|
+
const ref = raw.trim();
|
|
367
|
+
const place = secretRefPlace(ref);
|
|
368
|
+
if (place === undefined) {
|
|
369
|
+
const id = {
|
|
370
|
+
kind: "id",
|
|
371
|
+
canonical: [ref],
|
|
372
|
+
...(ref !== raw ? { spellings: [raw] } : {}),
|
|
373
|
+
...(HEX_ID.test(ref) ? { caseInsensitive: true } : {}),
|
|
374
|
+
};
|
|
375
|
+
const envName = ENV_SECRET_REF.exec(ref)?.[1];
|
|
376
|
+
const steps = BARE_SECRET_NAME.test(ref)
|
|
377
|
+
? secretChain(ref)
|
|
378
|
+
: envName !== undefined
|
|
379
|
+
? secretChain(envName).filter((step) => step.scheme === "envfile")
|
|
380
|
+
: [];
|
|
381
|
+
return [
|
|
382
|
+
id,
|
|
383
|
+
...steps.flatMap((step) => placeValues(step, canonicalizePath).map((v) => ({ ...v, restrictOnly: true }))),
|
|
384
|
+
];
|
|
385
|
+
}
|
|
386
|
+
// The reference as written and as trimmed are spellings a deny reads too.
|
|
387
|
+
return placeValues(place, canonicalizePath).map((v) => v.kind === "id" ? { ...v, spellings: [...new Set([raw, ref, ...(v.spellings ?? [])])] } : v);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* The file one `file:` or `envfile:` reference opens, as the values a rule
|
|
391
|
+
* reads: the reference with the file's canonical path in it, which an allow
|
|
392
|
+
* names, and the file itself as a path only a deny or ask reads.
|
|
393
|
+
*/
|
|
394
|
+
function placeValues(place, canonicalizePath) {
|
|
395
|
+
const named = (path) => `${place.scheme}:${path}${place.key}`;
|
|
396
|
+
const out = [];
|
|
397
|
+
for (const file of canonicalizePath(place.path)) {
|
|
398
|
+
const spellings = [...new Set([named(place.path), ...(file.spellings ?? []).map(named)])];
|
|
399
|
+
out.push(file.outsideWorkspace === true
|
|
400
|
+
? { kind: "id", canonical: [], spellings, outsideWorkspace: true }
|
|
401
|
+
: { kind: "id", canonical: file.canonical.map(named), spellings });
|
|
402
|
+
out.push({ ...file, restrictOnly: true });
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
/** A command none of whose words is read as a place. */
|
|
407
|
+
const NOTHING_READ = {
|
|
408
|
+
moved: false,
|
|
409
|
+
unplaceable: false,
|
|
410
|
+
ungrantable: false,
|
|
411
|
+
spellings: [],
|
|
412
|
+
patterns: [],
|
|
413
|
+
placed: [],
|
|
414
|
+
};
|
|
415
|
+
/** The words a shell runs as `NAME=value`, which name no file. */
|
|
416
|
+
const ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
417
|
+
/**
|
|
418
|
+
* A reference to a directory the shell itself sets, `PWD` or `OLDPWD`, in
|
|
419
|
+
* any spelling: `${PWD%/*}`, `$OLDPWD`, `$(dirname "$PWD")`. Only a plain
|
|
420
|
+
* `$PWD` or `${PWD}` at the start of a word is put in ({@link withShellPwd});
|
|
421
|
+
* any other is the text of a directory a `cd` went through, a link's name
|
|
422
|
+
* and all, which the gate cannot place.
|
|
423
|
+
*/
|
|
424
|
+
const SHELL_DIRECTORY_VARIABLE = /\$\{?[#!]?(?:OLD)?PWD(?![A-Za-z0-9_])/;
|
|
425
|
+
/**
|
|
426
|
+
* Read the words of one command that name a file where the OS finds them,
|
|
427
|
+
* from each directory it may run in ({@link WordDir}):
|
|
428
|
+
*
|
|
429
|
+
* - its programs, through any wrappers, when written with a `/` — and a bare
|
|
430
|
+
* one on each entry of a PATH a wrapper or the line sets (`env PATH=dir
|
|
431
|
+
* x`, `PATH=dir x`), as an argv's `envSet` PATH is read;
|
|
432
|
+
* - each word an interpreter at the end is handed (its script, and what
|
|
433
|
+
* else it loads);
|
|
434
|
+
* - and every other word with a `/`, or any word in a directory the command
|
|
435
|
+
* moved to: an argument, which another program may still run (`caffeinate
|
|
436
|
+
* x`, `find -exec x`). An argv's words are each a spelling a deny reads, so
|
|
437
|
+
* where one lands is too; a shell line's are not, and are only bound to
|
|
438
|
+
* where they land for an approval.
|
|
439
|
+
*
|
|
440
|
+
* Read through `readProgramPath`, each lands where the kernel takes it:
|
|
441
|
+
* every directory on the way followed and each `..` applied after the link
|
|
442
|
+
* before it. A program or script that lands elsewhere than it reads — a
|
|
443
|
+
* `..` after a name, or a symlinked directory on the way — is put in its
|
|
444
|
+
* place ({@link placeForms}), so an allow reads where it lands; a deny or ask
|
|
445
|
+
* also reads that place in each of its forms, and, when the word itself is a
|
|
446
|
+
* symlink, where it leads. A program named by an absolute path outside the
|
|
447
|
+
* workspace with no `..` after a name is not put in its place: the system's
|
|
448
|
+
* own links (`/bin → usr/bin`) are not the call's to move, and an allow
|
|
449
|
+
* written `/bin/sh` still reads `/bin/sh`.
|
|
450
|
+
*
|
|
451
|
+
* Not every program opens a word the way the kernel walks it. node, `bun
|
|
452
|
+
* run` and a script whose `#!` line names node fold its `..` as text first
|
|
453
|
+
* (`path.resolve`, then its links followed), so `node lnk/../x.mjs` with
|
|
454
|
+
* `lnk → safe/sub` runs `./x.mjs`, not the `safe/x.mjs` the kernel walks to.
|
|
455
|
+
* So a word with a `..` after a name is also read that way
|
|
456
|
+
* ({@link foldedAsText}), and where the two readings land apart a deny or
|
|
457
|
+
* ask reads both. An allow must name both too — the command with the word in
|
|
458
|
+
* each place — unless the word is the script of an interpreter measured to
|
|
459
|
+
* open it through the kernel (`kernelScript`: a shell, Python 3, Perl,
|
|
460
|
+
* Ruby). A program run by its path is always read both ways: the
|
|
461
|
+
* interpreter its `#!` line names may be node. A command with more than one
|
|
462
|
+
* word read apart may run them in any mix of places, and no scoped allow
|
|
463
|
+
* grants it. A word a shell started with `$PWD` that climbs out of it is
|
|
464
|
+
* folded against the text of `$PWD` (see the module doc).
|
|
465
|
+
*
|
|
466
|
+
* A word cannot be placed — every deny or ask fires, and only an allow
|
|
467
|
+
* naming every command grants it — when it has a `..` after a name and no
|
|
468
|
+
* reader can follow the links before it (the edge worker), or it is read
|
|
469
|
+
* from a directory that cannot be told; when a shell's word names its own
|
|
470
|
+
* directory in a spelling not put in ({@link SHELL_DIRECTORY_VARIABLE});
|
|
471
|
+
* when a script is a pattern with a wildcard before its last name; or when
|
|
472
|
+
* the reader cannot walk it. A word
|
|
473
|
+
* from a directory that cannot be told, or that a variable the line does
|
|
474
|
+
* not set or a `~` starts, is otherwise read as written.
|
|
475
|
+
*/
|
|
476
|
+
function readCommand(words, found, dirs, where, opts) {
|
|
477
|
+
const spellings = new Set();
|
|
478
|
+
const patterns = new Set();
|
|
479
|
+
const placed = new Set();
|
|
480
|
+
let rewritten;
|
|
481
|
+
let rewrittenAsText;
|
|
482
|
+
let moved = false;
|
|
483
|
+
let unplaceable = false;
|
|
484
|
+
let ungrantable = false;
|
|
485
|
+
const programs = new Set(found.programs);
|
|
486
|
+
const operands = new Set(found.operands);
|
|
487
|
+
// Collapsed as text, once: a deny written against the call's own words.
|
|
488
|
+
for (const i of [...found.programs, ...found.operands]) {
|
|
489
|
+
const word = opts.shell ? withShellPwd(words[i]) : words[i];
|
|
490
|
+
if (programs.has(i) && !/[\\/]/.test(word))
|
|
491
|
+
continue;
|
|
492
|
+
if (word.includes("://"))
|
|
493
|
+
continue;
|
|
494
|
+
for (const c of collapsedWords(word))
|
|
495
|
+
spellings.add(c);
|
|
496
|
+
}
|
|
497
|
+
for (const dir of dirs) {
|
|
498
|
+
// The words with every placed word in one of its forms, per form, and
|
|
499
|
+
// whether one of those is a pattern.
|
|
500
|
+
const lines = new Map();
|
|
501
|
+
const put = (slot, i, form, pattern) => {
|
|
502
|
+
let line = lines.get(slot);
|
|
503
|
+
if (line === undefined) {
|
|
504
|
+
line = { words: [...words], pattern: false };
|
|
505
|
+
lines.set(slot, line);
|
|
506
|
+
}
|
|
507
|
+
line.words[i] = form;
|
|
508
|
+
line.pattern ||= pattern;
|
|
509
|
+
(pattern ? patterns : spellings).add(form);
|
|
510
|
+
};
|
|
511
|
+
// The words with each one that moved put in its place, for an allow.
|
|
512
|
+
const inPlace = [...words];
|
|
513
|
+
let movedHere = false;
|
|
514
|
+
// A bare program found on a PATH entry that moved: one line each.
|
|
515
|
+
const lookups = [];
|
|
516
|
+
// Each word an allow reads in two places, with where it lands folded as
|
|
517
|
+
// text: the command with it there is one more an allow must name.
|
|
518
|
+
const apart = [];
|
|
519
|
+
/** Read one word, or one PATH candidate for word `i`; see above. */
|
|
520
|
+
const readWord = (i, word, role, lookup, fromPwd = false) => {
|
|
521
|
+
const runs = role === "program";
|
|
522
|
+
const pattern = opts.shell && opts.patterns?.has(i) === true && !lookup;
|
|
523
|
+
// The directory it is read from: the command's, then each wrapper's.
|
|
524
|
+
let prefix = dir?.prefix;
|
|
525
|
+
for (const { from, dir: to } of found.chdirs) {
|
|
526
|
+
if (from > i || prefix === undefined)
|
|
527
|
+
continue;
|
|
528
|
+
const written = opts.shell ? withShellPwd(to) : to;
|
|
529
|
+
prefix = opts.shell && holdsUnexpanded(written) ? undefined : joinedDir(prefix, written);
|
|
530
|
+
}
|
|
531
|
+
const cut = Math.max(word.lastIndexOf("/"), word.lastIndexOf("\\")) + 1;
|
|
532
|
+
const ownDir = word.slice(0, cut);
|
|
533
|
+
const known = dir !== undefined &&
|
|
534
|
+
prefix !== undefined &&
|
|
535
|
+
!(opts.shell && holdsUnexpanded(ownDir)) &&
|
|
536
|
+
!(opts.shell && word.startsWith("~"));
|
|
537
|
+
const climbs = climbsAfterName(word);
|
|
538
|
+
const elsewhere = dir === undefined || dir.runsIn !== where.runsIn || prefix !== "";
|
|
539
|
+
// A bare argument names no directory of its own: on an argv, read it
|
|
540
|
+
// where a wrapper moved the command; in a shell line, where only an
|
|
541
|
+
// approval would be bound to it, not at all.
|
|
542
|
+
if (role === "argument" && !/[\\/]/.test(word) && (opts.shell || !elsewhere))
|
|
543
|
+
return;
|
|
544
|
+
if (!known) {
|
|
545
|
+
// With a `..` after a name, or under a directory the shell set
|
|
546
|
+
// (`cd lnk && node ${PWD%/*}/dl/x.mjs` runs dl/x.mjs from the root):
|
|
547
|
+
// it may land anywhere.
|
|
548
|
+
const shellDir = opts.shell && SHELL_DIRECTORY_VARIABLE.test(ownDir);
|
|
549
|
+
if (role !== "argument" && (climbs || shellDir))
|
|
550
|
+
unplaceable = true;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (pattern && (/[*?[]/.test(ownDir) || climbs)) {
|
|
554
|
+
// A wildcard before its last name may match a symlinked directory.
|
|
555
|
+
if (role === "script")
|
|
556
|
+
unplaceable = true;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const absolute = isAbsolutePath(word);
|
|
560
|
+
if (where.readProgramPath === undefined) {
|
|
561
|
+
const anyDotDot = word.split(/[\\/]/).includes("..");
|
|
562
|
+
if (role !== "argument" && (climbs || (elsewhere && !absolute && anyDotDot))) {
|
|
563
|
+
unplaceable = true;
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const readOpts = {
|
|
568
|
+
...(role === "argument" || lookup ? { argument: true } : {}),
|
|
569
|
+
...(prefix !== "" && !absolute ? { prefix } : {}),
|
|
570
|
+
};
|
|
571
|
+
const place = where.readProgramPath(word, dir.runsIn, readOpts);
|
|
572
|
+
// And with its `..` folded as text first, as node and `bun run` read
|
|
573
|
+
// it. `$PWD` is the text of where the shell runs, the name of each
|
|
574
|
+
// link a `cd` went through and all, so one that climbs out of it is
|
|
575
|
+
// folded against that text: after `cd lnk`, node runs `$PWD/../x.mjs`
|
|
576
|
+
// as `./x.mjs`. That text is the root and the `cd`s the shell joined;
|
|
577
|
+
// after a wrapper's or a `cd -P`'s move, or in a call's directory
|
|
578
|
+
// field, it cannot be told.
|
|
579
|
+
const asText = foldedAsText(word);
|
|
580
|
+
const abovePwd = fromPwd && (asText === ".." || asText.startsWith("../"));
|
|
581
|
+
const pwdKnown = where.runsIn === undefined && prefix === "";
|
|
582
|
+
const folded = abovePwd
|
|
583
|
+
? pwdKnown
|
|
584
|
+
? where.readProgramPath(foldedAsText(joinedDir(dir.runsIn ?? "", word)), undefined, readOpts)
|
|
585
|
+
: undefined
|
|
586
|
+
: climbs
|
|
587
|
+
? where.readProgramPath(asText, dir.runsIn, readOpts)
|
|
588
|
+
: undefined;
|
|
589
|
+
const readsAsText = climbs || abovePwd;
|
|
590
|
+
if (place === undefined || (readsAsText && folded === undefined && role !== "argument")) {
|
|
591
|
+
if (role !== "argument")
|
|
592
|
+
unplaceable = true;
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const forms = placeForms(place.at, place.inWorkspace, word, runs);
|
|
596
|
+
const system = absolute && place.inWorkspace === undefined && !place.writtenInWorkspace;
|
|
597
|
+
const lands = climbs || (place.at !== place.asWritten && !system);
|
|
598
|
+
const led = place.target !== undefined
|
|
599
|
+
? placeForms(place.target.at, place.target.inWorkspace, word, runs)
|
|
600
|
+
: [];
|
|
601
|
+
// Where the two readings land apart, the folded one in its forms.
|
|
602
|
+
const split = folded !== undefined && folded.at !== place.at ? folded : undefined;
|
|
603
|
+
const textForms = split !== undefined ? placeForms(split.at, split.inWorkspace, word, runs) : undefined;
|
|
604
|
+
const asTextLed = split?.target !== undefined
|
|
605
|
+
? placeForms(split.target.at, split.target.inWorkspace, word, runs)
|
|
606
|
+
: [];
|
|
607
|
+
// An allow names both unless the word is a script its interpreter
|
|
608
|
+
// opens through the kernel.
|
|
609
|
+
if (textForms !== undefined && role !== "argument" && (runs || i !== found.kernelScript)) {
|
|
610
|
+
if (lookup)
|
|
611
|
+
lookups.push([i, textForms[0]]);
|
|
612
|
+
else
|
|
613
|
+
apart.push([i, textForms[0]]);
|
|
614
|
+
}
|
|
615
|
+
if (role === "argument") {
|
|
616
|
+
// An argv's words are spellings a deny reads; where one lands is too.
|
|
617
|
+
if (opts.shell || !(lands || led.length > 0 || elsewhere))
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
else if (lands) {
|
|
621
|
+
moved = true;
|
|
622
|
+
movedHere = true;
|
|
623
|
+
if (lookup)
|
|
624
|
+
lookups.push([i, forms[0]]);
|
|
625
|
+
else
|
|
626
|
+
inPlace[i] = forms[0];
|
|
627
|
+
if (opts.rewrite) {
|
|
628
|
+
// A word read where a wrapper moved the command, or found on a
|
|
629
|
+
// PATH: one place in the argv cannot say so.
|
|
630
|
+
if (prefix !== "" || lookup)
|
|
631
|
+
ungrantable = true;
|
|
632
|
+
else {
|
|
633
|
+
rewritten ??= [...words];
|
|
634
|
+
rewritten[i] = forms[0];
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
forms.forEach((form, k) => put(k, i, form, pattern));
|
|
639
|
+
led.forEach((form, k) => put(2 + k, i, form, pattern));
|
|
640
|
+
textForms?.forEach((form, k) => put(4 + k, i, form, pattern));
|
|
641
|
+
asTextLed.forEach((form, k) => put(6 + k, i, form, pattern));
|
|
642
|
+
};
|
|
643
|
+
for (let i = 0; i < words.length; i++) {
|
|
644
|
+
const raw = words[i];
|
|
645
|
+
if (raw === "" || raw.includes("://"))
|
|
646
|
+
continue;
|
|
647
|
+
const word = opts.shell ? withShellPwd(raw) : raw;
|
|
648
|
+
const fromPwd = word !== raw;
|
|
649
|
+
if (programs.has(i)) {
|
|
650
|
+
if (/[\\/]/.test(word))
|
|
651
|
+
readWord(i, word, "program", false, fromPwd);
|
|
652
|
+
else {
|
|
653
|
+
const lookUp = (candidate) => readWord(i, candidate, "program", true);
|
|
654
|
+
if (!readLookups(i, word, found, opts, lookUp))
|
|
655
|
+
unplaceable = true;
|
|
656
|
+
}
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (operands.has(i)) {
|
|
660
|
+
readWord(i, word, "script", false, fromPwd);
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
if (word.startsWith("-") || ASSIGNMENT_WORD.test(word))
|
|
664
|
+
continue;
|
|
665
|
+
readWord(i, word, "argument", false, fromPwd);
|
|
666
|
+
}
|
|
667
|
+
for (const line of lines.values()) {
|
|
668
|
+
(line.pattern ? patterns : spellings).add(line.words.join(" "));
|
|
669
|
+
}
|
|
670
|
+
if (movedHere)
|
|
671
|
+
placed.add(inPlace.join(" "));
|
|
672
|
+
for (const [i, form] of [...lookups, ...apart.slice(0, 1)]) {
|
|
673
|
+
const line = [...inPlace];
|
|
674
|
+
line[i] = form;
|
|
675
|
+
placed.add(line.join(" "));
|
|
676
|
+
}
|
|
677
|
+
// Two words read apart may be read in any mix of their places, which no
|
|
678
|
+
// list of commands an allow must name can cover.
|
|
679
|
+
if (apart.length > 1)
|
|
680
|
+
ungrantable = true;
|
|
681
|
+
const [one] = apart;
|
|
682
|
+
if (one !== undefined && opts.rewrite && rewritten !== undefined) {
|
|
683
|
+
rewrittenAsText = [...rewritten];
|
|
684
|
+
rewrittenAsText[one[0]] = one[1];
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
...(rewritten !== undefined ? { rewritten } : {}),
|
|
689
|
+
...(rewrittenAsText !== undefined ? { rewrittenAsText } : {}),
|
|
690
|
+
moved,
|
|
691
|
+
unplaceable,
|
|
692
|
+
ungrantable,
|
|
693
|
+
spellings: [...spellings],
|
|
694
|
+
patterns: [...patterns],
|
|
695
|
+
placed: opts.shell ? [...placed] : [],
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* A bare program looked up on the PATH a wrapper or the line set for it
|
|
700
|
+
* (the last one before it), entry by entry: `read` is handed each entry
|
|
701
|
+
* joined to the program. An entry that a variable the line does not set or
|
|
702
|
+
* a `~` starts is the harness's own PATH, looked up as before. False for a
|
|
703
|
+
* PATH of more than {@link MAX_ENV_WORDS} entries, which cannot be read.
|
|
704
|
+
*/
|
|
705
|
+
function readLookups(i, program, found, opts, read) {
|
|
706
|
+
const path = found.paths.findLast((p) => p.from <= i);
|
|
707
|
+
if (path === undefined)
|
|
708
|
+
return true;
|
|
709
|
+
const entries = path.path.split(":");
|
|
710
|
+
for (const raw of entries.slice(0, MAX_ENV_WORDS)) {
|
|
711
|
+
const trimmed = raw === "" ? "." : withoutTrailingSeparators(raw) || "/";
|
|
712
|
+
const entry = opts.shell ? withShellPwd(trimmed) : trimmed;
|
|
713
|
+
if (opts.shell && holdsUnexpanded(entry))
|
|
714
|
+
continue;
|
|
715
|
+
read(`${entry === "/" ? "" : entry}/${program}`);
|
|
716
|
+
}
|
|
717
|
+
return entries.length <= MAX_ENV_WORDS;
|
|
718
|
+
}
|
|
719
|
+
/** `word` joined after the directory `dir` (`""` for none), as text: an absolute `word` stands alone. */
|
|
720
|
+
function joinedDir(dir, word) {
|
|
721
|
+
if (dir === "" || isAbsolutePath(word))
|
|
722
|
+
return word;
|
|
723
|
+
if (word === "")
|
|
724
|
+
return dir;
|
|
725
|
+
return /[\\/]$/.test(dir) ? `${dir}${word}` : `${dir}/${word}`;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* The directories a command of a shell line may run in, as
|
|
729
|
+
* {@link readCommand} reads them: each of `dirs` (relative to where the
|
|
730
|
+
* line starts) from where it starts — the call's directory field, then
|
|
731
|
+
* `prefix`, walked, when an argv hands the line to a shell after a wrapper
|
|
732
|
+
* moved (`env -C dir sh -c …`). One that leaves where the line started by a
|
|
733
|
+
* leading `..` cannot be told when the shell starts somewhere other than the
|
|
734
|
+
* root: its PWD is then the physical directory, which the text of the
|
|
735
|
+
* directory field does not name.
|
|
736
|
+
*/
|
|
737
|
+
function wordDirsOf(dirs, where, prefix) {
|
|
738
|
+
if (dirs === undefined || prefix === undefined)
|
|
739
|
+
return [undefined];
|
|
740
|
+
return dirs.map((d) => {
|
|
741
|
+
if (prefix !== "" && !isAbsolutePath(d.logical)) {
|
|
742
|
+
// The shell's PWD is physical already: a `cd` is walked from it, but
|
|
743
|
+
// one whose `..` follows a name is folded as text by the shell.
|
|
744
|
+
if (climbsAfterName(d.logical))
|
|
745
|
+
return undefined;
|
|
746
|
+
return {
|
|
747
|
+
runsIn: where.runsIn,
|
|
748
|
+
prefix: joinedDir(joinedDir(prefix, d.logical), d.physical),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
if (d.logical === "")
|
|
752
|
+
return { runsIn: where.runsIn, prefix: d.physical };
|
|
753
|
+
if (isAbsolutePath(d.logical))
|
|
754
|
+
return { runsIn: d.logical, prefix: d.physical };
|
|
755
|
+
if (where.runsIn !== undefined && normalizePathLexically(d.logical).escapes)
|
|
756
|
+
return undefined;
|
|
757
|
+
return {
|
|
758
|
+
runsIn: where.runsIn === undefined ? d.logical : joinedDir(where.runsIn, d.logical),
|
|
759
|
+
prefix: d.physical,
|
|
760
|
+
};
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Where the wrappers of an argv moved before the shell at its end (`env -C
|
|
765
|
+
* dir sh -c …`): their directories joined, as text the kernel walks.
|
|
766
|
+
*/
|
|
767
|
+
function chdirPrefix(found) {
|
|
768
|
+
let prefix = "";
|
|
769
|
+
for (const { dir } of found.chdirs)
|
|
770
|
+
prefix = joinedDir(prefix, dir);
|
|
771
|
+
return prefix;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* {@link readCommand} for each simple command of each line a shell parses,
|
|
775
|
+
* at every level a deny reads (`shellProgramWords`), with their quoting
|
|
776
|
+
* taken off, from each directory it may run in. `prefix` is where a wrapper
|
|
777
|
+
* moved the argv that hands the line to a shell (`""` for nowhere,
|
|
778
|
+
* `undefined` when that cannot be told).
|
|
779
|
+
*/
|
|
780
|
+
function readShellPrograms(lines, where, prefix = "") {
|
|
781
|
+
const spellings = [];
|
|
782
|
+
const patterns = [];
|
|
783
|
+
const placed = [];
|
|
784
|
+
let moved = false;
|
|
785
|
+
let unplaceable = false;
|
|
786
|
+
let ungrantable = false;
|
|
787
|
+
for (const line of lines) {
|
|
788
|
+
for (const command of shellProgramWords(line)) {
|
|
789
|
+
// A script an interpreter reads on its input (`sh < x.sh`) is read as
|
|
790
|
+
// the one it is handed, and the `<` kept in front of it. The shell that
|
|
791
|
+
// runs the line opens it, through the kernel, whatever reads it.
|
|
792
|
+
const words = command.stdinScript !== undefined
|
|
793
|
+
? [...command.words, "<", command.stdinScript]
|
|
794
|
+
: command.words;
|
|
795
|
+
const found = command.stdinScript !== undefined
|
|
796
|
+
? {
|
|
797
|
+
...command.programs,
|
|
798
|
+
operands: [...command.programs.operands, words.length - 1],
|
|
799
|
+
kernelScript: words.length - 1,
|
|
800
|
+
}
|
|
801
|
+
: command.programs;
|
|
802
|
+
const read = readCommand(words, found, wordDirsOf(command.dirs, where, prefix), where, {
|
|
803
|
+
rewrite: false,
|
|
804
|
+
shell: true,
|
|
805
|
+
patterns: new Set(command.patterns),
|
|
806
|
+
});
|
|
807
|
+
moved ||= read.moved;
|
|
808
|
+
unplaceable ||= read.unplaceable;
|
|
809
|
+
ungrantable ||= read.ungrantable;
|
|
810
|
+
spellings.push(...read.spellings);
|
|
811
|
+
patterns.push(...read.patterns);
|
|
812
|
+
placed.push(...read.placed);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return { moved, unplaceable, ungrantable, spellings, patterns, placed };
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* The forms a rule reads of a place a command word lands at: first the one
|
|
819
|
+
* written the way the call wrote the word — absolute when the word was, or
|
|
820
|
+
* when the place is outside the workspace; else relative to the workspace
|
|
821
|
+
* root, with `./` when the word had one, or when it is a program and the
|
|
822
|
+
* place has no `/` left (`deploy.sh` alone would be looked up on PATH) —
|
|
823
|
+
* then the place relative to the root. The workspace's own absolute path is
|
|
824
|
+
* not one of them unless the call wrote it: `RunCommand(**prod**)` does not
|
|
825
|
+
* fire on `sh x.sh` because the workspace lives in `prod-agent/`.
|
|
826
|
+
*/
|
|
827
|
+
function placeForms(at, inWorkspace, word, runs) {
|
|
828
|
+
if (inWorkspace === undefined)
|
|
829
|
+
return [at, at];
|
|
830
|
+
const dotted = startsWithDotSlash(word) || (runs && !inWorkspace.includes("/"));
|
|
831
|
+
const written = isAbsolutePath(word)
|
|
832
|
+
? at
|
|
833
|
+
: dotted && inWorkspace !== "."
|
|
834
|
+
? `./${inWorkspace}`
|
|
835
|
+
: inWorkspace;
|
|
836
|
+
return [written, inWorkspace];
|
|
837
|
+
}
|
|
838
|
+
/** `./` or `.\\` at the start: a word the call wrote relative to its directory. */
|
|
839
|
+
function startsWithDotSlash(word) {
|
|
840
|
+
return word.startsWith("./") || word.startsWith(".\\");
|
|
841
|
+
}
|
|
842
|
+
/** A bare reference: an environment-variable name, which the local chain looks up. */
|
|
843
|
+
const BARE_SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
844
|
+
/** An `env:NAME` reference, as `parseRef` accepts it; the name is group 1. */
|
|
845
|
+
const ENV_SECRET_REF = /^env:([A-Za-z_][A-Za-z0-9_]*)$/;
|
|
846
|
+
/**
|
|
847
|
+
* The files a bare NAME is looked up in after the environment, highest
|
|
848
|
+
* precedence first: `@crewhaus/tool-secrets`' `chainRefs` (the harness
|
|
849
|
+
* supervisor's `.env.local` and `.env`, then the secrets directory). That
|
|
850
|
+
* package's tests hold this list to its own.
|
|
851
|
+
*/
|
|
852
|
+
function secretChain(name) {
|
|
853
|
+
return [
|
|
854
|
+
{ scheme: "envfile", path: ".env.local", key: `#${name}` },
|
|
855
|
+
{ scheme: "envfile", path: ".env", key: `#${name}` },
|
|
856
|
+
{ scheme: "file", path: `.crewhaus/secrets/${name}`, key: "" },
|
|
857
|
+
];
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* The file a trimmed `file:<path>` or `envfile:<path>#KEY` reference opens,
|
|
861
|
+
* split as `parseRef` splits it (an `envfile:` at its LAST `#`), with the
|
|
862
|
+
* rest of the reference around it; `undefined` for any other form, and for
|
|
863
|
+
* one the tool refuses whole (an empty path, no `#KEY`, a newline or NUL).
|
|
864
|
+
*/
|
|
865
|
+
function secretRefPlace(ref) {
|
|
866
|
+
if (/[\0\n\r]/.test(ref))
|
|
867
|
+
return undefined;
|
|
868
|
+
if (ref.startsWith("file:")) {
|
|
869
|
+
const path = ref.slice("file:".length);
|
|
870
|
+
return path === "" ? undefined : { scheme: "file", path, key: "" };
|
|
871
|
+
}
|
|
872
|
+
if (ref.startsWith("envfile:")) {
|
|
873
|
+
const rest = ref.slice("envfile:".length);
|
|
874
|
+
const hash = rest.lastIndexOf("#");
|
|
875
|
+
return hash <= 0
|
|
876
|
+
? undefined
|
|
877
|
+
: { scheme: "envfile", path: rest.slice(0, hash), key: rest.slice(hash) };
|
|
878
|
+
}
|
|
879
|
+
return undefined;
|
|
880
|
+
}
|
|
881
|
+
/** A value that ends in a `0x` (or `0X`) hex id, after any `<qualifier>/`. */
|
|
882
|
+
const HEX_ID = /(?:^|\/)0[xX][0-9a-fA-F]+$/;
|
|
883
|
+
/** Do these canonical values name the workspace root itself, and nothing else? */
|
|
884
|
+
function namesWorkspaceRoot(values) {
|
|
885
|
+
return (values.length > 0 && values.every((v) => v.outsideWorkspace !== true && v.canonical[0] === "."));
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Every value of one declared field. Dots descend into objects; an array
|
|
889
|
+
* anywhere on the way is walked element by element — except a `command`
|
|
890
|
+
* field that IS an array of strings, which is one argv and is joined. A
|
|
891
|
+
* field declared `within` another comes back as `<qualifier>/<value>` — a
|
|
892
|
+
* `..` path too, so a row shows the directory it climbs from — and an
|
|
893
|
+
* absolute path as it is.
|
|
894
|
+
*/
|
|
895
|
+
export function readOperativeField(input, arg) {
|
|
896
|
+
return readField(input, arg).map((r) => {
|
|
897
|
+
const value = r.overlay !== undefined ? `${r.overlay}/${r.value}` : r.value;
|
|
898
|
+
// A URL that is its own service is the API already.
|
|
899
|
+
if (isApiRoot(arg))
|
|
900
|
+
return value;
|
|
901
|
+
return r.service?.origin !== undefined ? `${r.service.origin}/${value}` : value;
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
/** A `url` declared as its own `service`: the API a call goes to, itself. */
|
|
905
|
+
function isApiRoot(arg) {
|
|
906
|
+
return arg.kind === "url" && arg.service !== undefined && arg.service === arg.field;
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* {@link readValues}, with the API the call goes to for a `service` field:
|
|
910
|
+
* the one it names, else the one the tool's config sends it to — the same
|
|
911
|
+
* for every value of the field, since it is a top-level one.
|
|
912
|
+
*/
|
|
913
|
+
function readField(input, arg, services) {
|
|
914
|
+
const values = readValues(input, arg);
|
|
915
|
+
const owner = arg.overlay !== undefined ? qualifierOf(input, arg.overlay) : undefined;
|
|
916
|
+
const read = owner !== undefined ? values.map((r) => ({ ...r, overlay: owner })) : values;
|
|
917
|
+
if (arg.service === undefined)
|
|
918
|
+
return read;
|
|
919
|
+
const url = qualifierOf(input, arg.service);
|
|
920
|
+
const reading = url !== undefined ? serviceOf(url) : services?.unnamed;
|
|
921
|
+
if (reading === undefined)
|
|
922
|
+
return read;
|
|
923
|
+
const home = reading.origin !== undefined && reading.origin === services?.home;
|
|
924
|
+
const service = home ? { ...reading, home: true } : reading;
|
|
925
|
+
// The API itself, left out: the one the tool's config sends the call to
|
|
926
|
+
// stands in, so a rule on that host meets the call whichever field (or
|
|
927
|
+
// none) picked it.
|
|
928
|
+
if (isApiRoot(arg) && read.length === 0)
|
|
929
|
+
return [{ value: reading.url, service }];
|
|
930
|
+
return read.map((r) => ({ ...r, service }));
|
|
931
|
+
}
|
|
932
|
+
/** How a rule reads the service URL a call names: see {@link ServiceReading}. */
|
|
933
|
+
function serviceOf(raw) {
|
|
934
|
+
let url;
|
|
935
|
+
try {
|
|
936
|
+
url = new URL(raw);
|
|
937
|
+
}
|
|
938
|
+
catch {
|
|
939
|
+
return { url: raw, otherOrigins: [], hosts: [] };
|
|
940
|
+
}
|
|
941
|
+
if ((url.protocol !== "https:" && url.protocol !== "http:") ||
|
|
942
|
+
url.hostname === "" ||
|
|
943
|
+
url.username !== "" ||
|
|
944
|
+
url.password !== "") {
|
|
945
|
+
return { url: raw, otherOrigins: [], hosts: [] };
|
|
946
|
+
}
|
|
947
|
+
const names = new Set([url.hostname, withoutTrailingDots(url.hostname)]);
|
|
948
|
+
names.delete("");
|
|
949
|
+
const hosts = new Set();
|
|
950
|
+
for (const name of names) {
|
|
951
|
+
for (const port of new Set([url.port, ""]))
|
|
952
|
+
hosts.add(port !== "" ? `${name}:${port}` : name);
|
|
953
|
+
}
|
|
954
|
+
const schemes = url.protocol === "https:" ? ["https:", "http:"] : ["http:", "https:"];
|
|
955
|
+
const others = new Set();
|
|
956
|
+
for (const scheme of schemes) {
|
|
957
|
+
for (const host of hosts) {
|
|
958
|
+
const origin = `${scheme}//${host}`;
|
|
959
|
+
if (origin !== url.origin)
|
|
960
|
+
others.add(origin);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return { url: raw, origin: url.origin, otherOrigins: [...others], hosts: [...hosts] };
|
|
964
|
+
}
|
|
965
|
+
/** `host` without the root dots at its end, in one pass from the end. */
|
|
966
|
+
function withoutTrailingDots(host) {
|
|
967
|
+
let end = host.length;
|
|
968
|
+
while (end > 0 && host.charCodeAt(end - 1) === 0x2e)
|
|
969
|
+
end--;
|
|
970
|
+
return host.slice(0, end);
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* A value of a field declared `overlay`, read for a call that names whose
|
|
974
|
+
* copy it acts on: matched with the owner in front, so an allow must name it;
|
|
975
|
+
* a deny or ask also reads the value without it (the shared copy's name) and
|
|
976
|
+
* the owner alone.
|
|
977
|
+
*/
|
|
978
|
+
function overlaidValue(value, owner) {
|
|
979
|
+
const at = (v) => `${owner}/${v}`;
|
|
980
|
+
const { standsForAny } = value;
|
|
981
|
+
return {
|
|
982
|
+
...value,
|
|
983
|
+
canonical: value.canonical.map(at),
|
|
984
|
+
spellings: [...new Set([...value.canonical, ...(value.spellings ?? []), owner])],
|
|
985
|
+
...(standsForAny !== undefined
|
|
986
|
+
? { standsForAny: [...new Set([...standsForAny.map(at), ...standsForAny])] }
|
|
987
|
+
: {}),
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* A value of a field declared `service`, read for a call to that service:
|
|
992
|
+
* matched with the service's origin in front, so an allow must name it —
|
|
993
|
+
* unless the service is the tool's `home`, whose values are also matched
|
|
994
|
+
* without it, first. A deny or ask also reads the value without the origin
|
|
995
|
+
* and with the origin's other spellings. A URL that cannot be placed leaves
|
|
996
|
+
* nothing an allow could grant, and every deny or ask fires.
|
|
997
|
+
*/
|
|
998
|
+
function servedValue(value, service) {
|
|
999
|
+
const own = [...value.canonical, ...(value.spellings ?? [])];
|
|
1000
|
+
const { origin } = service;
|
|
1001
|
+
if (origin === undefined) {
|
|
1002
|
+
return {
|
|
1003
|
+
kind: value.kind,
|
|
1004
|
+
canonical: [],
|
|
1005
|
+
spellings: [...own, ...(service.url !== "" ? [service.url] : [])],
|
|
1006
|
+
outsideWorkspace: true,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
const at = (o, v) => `${o}/${v}`;
|
|
1010
|
+
const others = [...service.otherOrigins, ...service.hosts];
|
|
1011
|
+
const { standsForAny } = value;
|
|
1012
|
+
return {
|
|
1013
|
+
...value,
|
|
1014
|
+
canonical: [
|
|
1015
|
+
...(service.home === true ? value.canonical : []),
|
|
1016
|
+
...value.canonical.map((c) => at(origin, c)),
|
|
1017
|
+
],
|
|
1018
|
+
spellings: [
|
|
1019
|
+
...new Set([...others.flatMap((o) => value.canonical.map((c) => at(o, c))), ...own]),
|
|
1020
|
+
],
|
|
1021
|
+
...(standsForAny !== undefined
|
|
1022
|
+
? {
|
|
1023
|
+
standsForAny: [
|
|
1024
|
+
...new Set([
|
|
1025
|
+
...[origin, ...others].flatMap((o) => standsForAny.map((p) => at(o, p))),
|
|
1026
|
+
...standsForAny,
|
|
1027
|
+
]),
|
|
1028
|
+
],
|
|
1029
|
+
}
|
|
1030
|
+
: {}),
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* The API a call goes to, for a `url` declared as its own `service`
|
|
1035
|
+
* (RateLimitStatus's `baseUrl`): the one the call names or the one its
|
|
1036
|
+
* config sends it to, read the way the tool reaches it. The tool drops one
|
|
1037
|
+
* trailing slash and appends each request path as text, so `…/api/v3`,
|
|
1038
|
+
* `…/api/v3/`, `…/api/v3/.` and `…/api/v3/x/..` are one API, which is
|
|
1039
|
+
* matched with or without a slash at the end (as `https://example.com` and
|
|
1040
|
+
* `https://example.com/` are one URL). A deny or ask also reads it after the
|
|
1041
|
+
* origin's other spellings (no root dot, no port, the other of http and
|
|
1042
|
+
* https) and after the host alone (`ghe.example.com/api/v3`), as for a value
|
|
1043
|
+
* on a service, as written, and with each run of slashes read as one, as
|
|
1044
|
+
* many servers read a path: the tool reaches `…/api/v3//` as
|
|
1045
|
+
* `…/api/v3//rate_limit`, which an allow on `…/api/v3` does not grant and a
|
|
1046
|
+
* deny on it stops. A URL that cannot be placed, or that the tool refuses as
|
|
1047
|
+
* a root because it carries a query or a fragment, leaves nothing an allow
|
|
1048
|
+
* could grant, and every deny or ask fires.
|
|
1049
|
+
*/
|
|
1050
|
+
function apiRootValue(service) {
|
|
1051
|
+
const { origin } = service;
|
|
1052
|
+
const root = origin !== undefined ? joinedApiRoot(service.url, origin) : undefined;
|
|
1053
|
+
if (origin === undefined || root === undefined) {
|
|
1054
|
+
return {
|
|
1055
|
+
kind: "url",
|
|
1056
|
+
canonical: [],
|
|
1057
|
+
spellings: service.url !== "" ? [service.url] : [],
|
|
1058
|
+
outsideWorkspace: true,
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
const at = (path) => (o) => path === "" ? [`${o}/`, o] : [`${o}${path}`, `${o}${path}/`];
|
|
1062
|
+
const others = [...service.otherOrigins, ...service.hosts];
|
|
1063
|
+
const folded = root.replace(/\/{2,}/g, "/").replace(/\/$/, "");
|
|
1064
|
+
return {
|
|
1065
|
+
kind: "url",
|
|
1066
|
+
canonical: at(root)(origin),
|
|
1067
|
+
spellings: [
|
|
1068
|
+
...new Set([
|
|
1069
|
+
service.url,
|
|
1070
|
+
...others.flatMap(at(root)),
|
|
1071
|
+
...(folded !== root ? [origin, ...others].flatMap(at(folded)) : []),
|
|
1072
|
+
]),
|
|
1073
|
+
],
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* The path of the API root at `raw` as a tool that drops one trailing slash
|
|
1078
|
+
* and appends its request paths as text reaches it, without its last `/`:
|
|
1079
|
+
* `/api/v3` for `https://h/api/v3/.`, `""` for the origin's own root.
|
|
1080
|
+
* Undefined when a path appended to it would land in a query or a fragment,
|
|
1081
|
+
* or on another origin.
|
|
1082
|
+
*/
|
|
1083
|
+
function joinedApiRoot(raw, origin) {
|
|
1084
|
+
const text = raw.endsWith("/") ? raw.slice(0, -1) : raw;
|
|
1085
|
+
let url;
|
|
1086
|
+
try {
|
|
1087
|
+
url = new URL(`${text}/`);
|
|
1088
|
+
}
|
|
1089
|
+
catch {
|
|
1090
|
+
return undefined;
|
|
1091
|
+
}
|
|
1092
|
+
const { pathname } = url;
|
|
1093
|
+
if (url.origin !== origin || url.search !== "" || url.hash !== "" || !pathname.endsWith("/")) {
|
|
1094
|
+
return undefined;
|
|
1095
|
+
}
|
|
1096
|
+
return pathname.slice(0, -1);
|
|
1097
|
+
}
|
|
1098
|
+
function readValues(input, arg) {
|
|
1099
|
+
const segments = arg.field.split(".");
|
|
1100
|
+
const out = [];
|
|
1101
|
+
const topQualifier = arg.within !== undefined ? qualifierOf(input, arg.within) : undefined;
|
|
1102
|
+
// A command runs in its own object's directory field (a pipeline step's
|
|
1103
|
+
// `cwd`), else the top-level one.
|
|
1104
|
+
const runsInOf = (owner) => {
|
|
1105
|
+
if (arg.kind !== "command" || arg.within === undefined)
|
|
1106
|
+
return {};
|
|
1107
|
+
const dir = qualifierOf(owner, arg.within) ?? topQualifier;
|
|
1108
|
+
return dir !== undefined ? { runsIn: dir } : {};
|
|
1109
|
+
};
|
|
1110
|
+
// And with its own object's environment field, else the top-level one.
|
|
1111
|
+
const topEnv = arg.env !== undefined ? envMapOf(input, arg.env) : undefined;
|
|
1112
|
+
const envOf = (owner) => {
|
|
1113
|
+
if (arg.kind !== "command" || arg.env === undefined)
|
|
1114
|
+
return {};
|
|
1115
|
+
const env = envMapOf(owner, arg.env) ?? topEnv;
|
|
1116
|
+
return env !== undefined ? { env } : {};
|
|
1117
|
+
};
|
|
1118
|
+
// And with its own object's input text, else the top-level one.
|
|
1119
|
+
const topStdin = arg.stdin !== undefined ? qualifierOf(input, arg.stdin) : undefined;
|
|
1120
|
+
const stdinOf = (owner) => {
|
|
1121
|
+
if (arg.kind !== "command" || arg.stdin === undefined)
|
|
1122
|
+
return {};
|
|
1123
|
+
const text = (owner !== input ? qualifierOf(owner, arg.stdin) : undefined) ?? topStdin;
|
|
1124
|
+
return text !== undefined ? { stdin: text } : {};
|
|
1125
|
+
};
|
|
1126
|
+
const placed = (owner) => ({ ...runsInOf(owner), ...envOf(owner), ...stdinOf(owner) });
|
|
1127
|
+
const walk = (value, i, depth, owner) => {
|
|
1128
|
+
if (depth > 64)
|
|
1129
|
+
return;
|
|
1130
|
+
if (Array.isArray(value)) {
|
|
1131
|
+
if (i === segments.length &&
|
|
1132
|
+
arg.kind === "command" &&
|
|
1133
|
+
value.length > 0 &&
|
|
1134
|
+
value.every((v) => typeof v === "string")) {
|
|
1135
|
+
out.push({ value: value.join(" "), words: value, ...placed(owner) });
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
for (const element of value)
|
|
1139
|
+
walk(element, i, depth + 1, owner);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (i === segments.length) {
|
|
1143
|
+
if (typeof value === "string")
|
|
1144
|
+
out.push({ value, ...placed(owner) });
|
|
1145
|
+
else if (arg.kind === "id" && typeof value === "number" && Number.isFinite(value)) {
|
|
1146
|
+
out.push({ value: String(value) });
|
|
1147
|
+
}
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
if (value === null || typeof value !== "object")
|
|
1151
|
+
return;
|
|
1152
|
+
const key = segments[i];
|
|
1153
|
+
if (!Object.hasOwn(value, key))
|
|
1154
|
+
return;
|
|
1155
|
+
walk(value[key], i + 1, depth + 1, value);
|
|
1156
|
+
};
|
|
1157
|
+
walk(input, 0, 0, input);
|
|
1158
|
+
if (arg.prefix === true && arg.kind !== "path" && arg.kind !== "url" && arg.kind !== "command") {
|
|
1159
|
+
return prefixReadings(out, arg, topQualifier);
|
|
1160
|
+
}
|
|
1161
|
+
// A default of `*` is "every value": EvmGetLogs without an `address`
|
|
1162
|
+
// reads every contract's logs.
|
|
1163
|
+
const every = out.length === 0 && arg.default === ANY_VALUE;
|
|
1164
|
+
if (out.length === 0 && arg.default !== undefined) {
|
|
1165
|
+
out.push({ value: arg.default, ...placed(input), defaulted: true });
|
|
1166
|
+
}
|
|
1167
|
+
const anyValue = (rs) => every ? rs.map((r) => ({ ...r, every: true, anyAfter: [""] })) : rs;
|
|
1168
|
+
// A command's `within` is where it runs, carried as `runsIn` above.
|
|
1169
|
+
if (arg.kind === "command")
|
|
1170
|
+
return anyValue(out);
|
|
1171
|
+
// A path whose directory the call left out is relative to the one the
|
|
1172
|
+
// tool uses then, when it declares one (TempDir's `.tmp`).
|
|
1173
|
+
const qualifier = topQualifier ?? (arg.kind === "path" ? arg.withinDefault : undefined);
|
|
1174
|
+
if (qualifier === undefined) {
|
|
1175
|
+
// A path within a directory the call left out is relative to the root,
|
|
1176
|
+
// and handed on as written all the same.
|
|
1177
|
+
if (arg.kind === "path" && arg.within !== undefined && !every) {
|
|
1178
|
+
return out.map((r) => leavesItsDirectory(r.value) ? { ...r, leavesDir: { written: r.value } } : r);
|
|
1179
|
+
}
|
|
1180
|
+
// The qualifier is left out as well: a code search with no owner reaches
|
|
1181
|
+
// every repository, so the value stands for every `<owner>/<repo>` too.
|
|
1182
|
+
const read = anyValue(out);
|
|
1183
|
+
return every && arg.within !== undefined
|
|
1184
|
+
? read.map((r) => ({ ...r, anyQualifier: true }))
|
|
1185
|
+
: read;
|
|
1186
|
+
}
|
|
1187
|
+
return out.map((r) => {
|
|
1188
|
+
// A path relative to a directory field.
|
|
1189
|
+
if (arg.kind === "path") {
|
|
1190
|
+
if (every)
|
|
1191
|
+
return { ...r, value: `${qualifier}/${r.value}`, dir: qualifier, every: true };
|
|
1192
|
+
// A git command given no path acts on the whole repository, wherever
|
|
1193
|
+
// it runs: its default is the workspace root, not the directory.
|
|
1194
|
+
if (r.defaulted === true && arg.defaultAtRoot === true)
|
|
1195
|
+
return r;
|
|
1196
|
+
// One that leaves it is still shown joined to it — an approver needs to
|
|
1197
|
+
// see what `..` climbs from — but not read that way (`leavingValue`).
|
|
1198
|
+
if (leavesItsDirectory(r.value)) {
|
|
1199
|
+
const value = isAbsolutePath(r.value) ? r.value : `${qualifier}/${r.value}`;
|
|
1200
|
+
return { ...r, value, leavesDir: { written: r.value } };
|
|
1201
|
+
}
|
|
1202
|
+
return { ...r, value: `${qualifier}/${r.value}` };
|
|
1203
|
+
}
|
|
1204
|
+
return {
|
|
1205
|
+
...r,
|
|
1206
|
+
value: `${qualifier}/${r.value}`,
|
|
1207
|
+
unqualified: [r.value, qualifier],
|
|
1208
|
+
...(every ? { every: true, anyAfter: [`${qualifier}/`, ""] } : {}),
|
|
1209
|
+
};
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
/** The declared default that stands for every value of its field. */
|
|
1213
|
+
const ANY_VALUE = "*";
|
|
1214
|
+
/**
|
|
1215
|
+
* The readings of a field declared `prefix` (KvList's `prefix`): each value
|
|
1216
|
+
* `p` stands for every value that starts with it, and one left out is the
|
|
1217
|
+
* empty prefix — every value. Qualified, `<qualifier>/<p>` is followed by any
|
|
1218
|
+
* run (a key may hold `/`), and `<p>` alone is kept for a deny written
|
|
1219
|
+
* without the qualifier. With the qualifier left out too, the value stands
|
|
1220
|
+
* for every `<qualifier>/<value>` there is: an allow must name every value.
|
|
1221
|
+
*/
|
|
1222
|
+
function prefixReadings(out, arg, qualifier) {
|
|
1223
|
+
const prefixes = out.length > 0 ? out.map((r) => r.value) : [""];
|
|
1224
|
+
return prefixes.map((p) => {
|
|
1225
|
+
if (qualifier === undefined) {
|
|
1226
|
+
const anyQualifier = arg.within !== undefined;
|
|
1227
|
+
const start = anyQualifier ? "" : p;
|
|
1228
|
+
return {
|
|
1229
|
+
value: `${start}${ANY_VALUE}`,
|
|
1230
|
+
every: true,
|
|
1231
|
+
anyAfter: [start],
|
|
1232
|
+
prefixOf: {},
|
|
1233
|
+
...(out.length === 0 ? { defaulted: true } : {}),
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
return {
|
|
1237
|
+
value: `${qualifier}/${p}${ANY_VALUE}`,
|
|
1238
|
+
unqualified: [`${p}${ANY_VALUE}`, qualifier],
|
|
1239
|
+
every: true,
|
|
1240
|
+
anyAfter: [`${qualifier}/${p}`],
|
|
1241
|
+
prefixOf: { unqualified: p },
|
|
1242
|
+
...(out.length === 0 ? { defaulted: true } : {}),
|
|
1243
|
+
};
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Every path under `dir` — a path field left out whose default is `*` — as
|
|
1248
|
+
* the values a rule reads: for each place `dir` resolves to, its spellings
|
|
1249
|
+
* as prefixes (`sub/`, `./sub/`, `/abs/ws/sub/`; the workspace root is the
|
|
1250
|
+
* empty prefix and `./`) and `<prefix>*` as the canonical spelling an allow
|
|
1251
|
+
* must cover whole. A directory outside the workspace stays outside: every
|
|
1252
|
+
* deny or ask fires on it, and no allow does.
|
|
1253
|
+
*/
|
|
1254
|
+
function everyPathValues(dir, canonicalizePath) {
|
|
1255
|
+
const under = (p) => (p === "." ? "" : p.endsWith("/") ? p : `${p}/`);
|
|
1256
|
+
return canonicalizePath(dir).map((v) => {
|
|
1257
|
+
if (v.outsideWorkspace === true)
|
|
1258
|
+
return v;
|
|
1259
|
+
return {
|
|
1260
|
+
kind: "path",
|
|
1261
|
+
canonical: v.canonical.map((c) => `${under(c)}${ANY_VALUE}`),
|
|
1262
|
+
standsForAny: underPrefixes([...v.canonical, ...(v.spellings ?? [])]),
|
|
1263
|
+
...(v.caseInsensitive === true ? { caseInsensitive: true } : {}),
|
|
1264
|
+
};
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Does a path declared `within` a directory leave it: is it absolute, or does
|
|
1269
|
+
* it have a `..` segment? Where such a path lands cannot be read off the
|
|
1270
|
+
* directory's values.
|
|
1271
|
+
*
|
|
1272
|
+
* - The tool hands the path on as written, to a program that runs in the
|
|
1273
|
+
* directory (git, a formatter, a test runner), and the OS follows each
|
|
1274
|
+
* symlink BEFORE it applies the `..` after it. Through a symlinked
|
|
1275
|
+
* directory (`cwd: "link"`, `link → secrets/deep`), `../x.ts` is
|
|
1276
|
+
* `secrets/x.ts`; through a link inside the path itself (`lnk/../x.ts`),
|
|
1277
|
+
* it climbs out of the link's target, which no join of the path to the
|
|
1278
|
+
* directory can see without a filesystem to ask. Read like every other
|
|
1279
|
+
* path, with `..` collapsed first, both were `x.ts`.
|
|
1280
|
+
* - An absolute path ignores the directory for a program that resolves it,
|
|
1281
|
+
* but not for `path.join`, which a tool may check it with: the two name
|
|
1282
|
+
* different files.
|
|
1283
|
+
*
|
|
1284
|
+
* `\` counts as a separator too, as in tool-git's pathspec check: splitting
|
|
1285
|
+
* at it can only flag more.
|
|
1286
|
+
*/
|
|
1287
|
+
function leavesItsDirectory(value) {
|
|
1288
|
+
return isAbsolutePath(value) || value.split(/[\\/]/).includes("..");
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* The value of a path that leaves its directory ({@link leavesItsDirectory}):
|
|
1292
|
+
* outside the workspace, so no allow rule names it and every deny or ask fires
|
|
1293
|
+
* on it. Every builtin that declares such a field refuses the path itself;
|
|
1294
|
+
* this keeps a rule about another place from granting it to a tool that does
|
|
1295
|
+
* not. The spellings are the path as written, and joined to the directory as
|
|
1296
|
+
* written (`joined`, the reading's value; the same string when the call gave
|
|
1297
|
+
* no directory or the path is absolute).
|
|
1298
|
+
*/
|
|
1299
|
+
function leavingValue(joined, written) {
|
|
1300
|
+
const spellings = joined === written ? [written] : [written, joined];
|
|
1301
|
+
return { kind: "path", canonical: [], spellings, outsideWorkspace: true };
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* A path the tool walks when it names a directory (`beneath` on its
|
|
1305
|
+
* OperativeArg): each of its spellings as a prefix a deny or ask reads with
|
|
1306
|
+
* anything after it. A path the canonicaliser found to be an existing
|
|
1307
|
+
* non-directory stands for itself alone, and one outside the workspace
|
|
1308
|
+
* already fires every deny.
|
|
1309
|
+
*/
|
|
1310
|
+
function withBeneath(value, mode) {
|
|
1311
|
+
if (value.outsideWorkspace === true || value.notDirectory === true)
|
|
1312
|
+
return value;
|
|
1313
|
+
return {
|
|
1314
|
+
...value,
|
|
1315
|
+
beneath: underPrefixes([...value.canonical, ...(value.spellings ?? [])]),
|
|
1316
|
+
...(mode === "visible" ? { beneathSkipsHidden: true } : {}),
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* The prefixes what lies under each of these directory spellings starts
|
|
1321
|
+
* with. The root — `.`, or the empty path a call may write for it — is the
|
|
1322
|
+
* empty prefix, never `/`, which would read as the filesystem root. A
|
|
1323
|
+
* relative prefix is also given its `./` spelling, as a direct path value
|
|
1324
|
+
* carries one (`secrets/x` is also `./secrets/x`): without it a deny written
|
|
1325
|
+
* `./secrets/**` fired on `Tree ./` but not on `Tree .`, `Tree {}` or
|
|
1326
|
+
* `Tree src/..`, which walk the same place.
|
|
1327
|
+
*/
|
|
1328
|
+
function underPrefixes(spellings) {
|
|
1329
|
+
const out = new Set();
|
|
1330
|
+
for (const p of spellings) {
|
|
1331
|
+
const under = p === "." || p === "" ? "" : p.endsWith("/") ? p : `${p}/`;
|
|
1332
|
+
out.add(under);
|
|
1333
|
+
if (!isAbsolutePath(under) && !under.startsWith("./"))
|
|
1334
|
+
out.add(`./${under}`);
|
|
1335
|
+
}
|
|
1336
|
+
return [...out];
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* The fixed places a tool opens inside the directory a `path` names (`opens`
|
|
1340
|
+
* on its OperativeArg — ApprovalStatus's ledger, PermissionsSuggest's
|
|
1341
|
+
* session logs), as values only a deny or ask reads: each joined to the
|
|
1342
|
+
* directory as the call wrote it and canonicalised like any path, so a link
|
|
1343
|
+
* or a `..` on the way is followed, and read with what is beneath it when the
|
|
1344
|
+
* tool reads it whole. An allow reads the directory alone.
|
|
1345
|
+
*/
|
|
1346
|
+
function openedValues(dir, arg, canonicalizePath) {
|
|
1347
|
+
const out = [];
|
|
1348
|
+
for (const opening of arg.opens ?? []) {
|
|
1349
|
+
for (const v of canonicalizePath(joinBelow(dir, opening.path))) {
|
|
1350
|
+
const read = opening.beneath !== undefined ? withBeneath(v, opening.beneath) : v;
|
|
1351
|
+
out.push({ ...read, restrictOnly: true });
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
return out;
|
|
1355
|
+
}
|
|
1356
|
+
/** `rel` inside `dir`: the root (`.`, `./`, the empty path) adds nothing in front. */
|
|
1357
|
+
function joinBelow(dir, rel) {
|
|
1358
|
+
const trimmed = withoutTrailingSeparators(dir);
|
|
1359
|
+
if (trimmed === "")
|
|
1360
|
+
return dir === "" ? rel : `/${rel}`;
|
|
1361
|
+
return trimmed === "." ? rel : `${trimmed}/${rel}`;
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* The most variables, words in one value, and characters in one value, of an
|
|
1365
|
+
* environment a command's call sets that a deny or ask reads one by one. A
|
|
1366
|
+
* call past any of them is flagged `outsideWorkspace` instead: which program
|
|
1367
|
+
* it runs could not be worked out, so every deny or ask fires on it.
|
|
1368
|
+
*/
|
|
1369
|
+
export const MAX_ENV_VARS = 64;
|
|
1370
|
+
export const MAX_ENV_WORDS = 64;
|
|
1371
|
+
export const MAX_ENV_VALUE_CHARS = 8192;
|
|
1372
|
+
/**
|
|
1373
|
+
* The workspace-relative spellings of the directory a command runs in, and
|
|
1374
|
+
* the one the call wrote, without trailing slashes: `scripts/` and
|
|
1375
|
+
* `./scripts` as written, `scripts` as canonicalised, `lnk` for a symlink
|
|
1376
|
+
* the canonicaliser followed to `scripts`. The canonicaliser's absolute
|
|
1377
|
+
* spellings of the workspace root are left out — the call named none of
|
|
1378
|
+
* them, and joining them put every word the workspace's own path holds
|
|
1379
|
+
* (`prod-agent/…`, `/private/var/…`) in front of every command run in a
|
|
1380
|
+
* subdirectory. Its `./sub` spellings are left out too: a word the call did
|
|
1381
|
+
* not write with `./` is not read with one, so `RunCommand(./**)` does not
|
|
1382
|
+
* fire on `git status` run in `sub/`.
|
|
1383
|
+
*/
|
|
1384
|
+
function runDirSpellings(runsIn, dirValues) {
|
|
1385
|
+
const out = new Set();
|
|
1386
|
+
const add = (d) => {
|
|
1387
|
+
const trimmed = withoutTrailingSeparators(d);
|
|
1388
|
+
if (trimmed !== "" && trimmed !== ".")
|
|
1389
|
+
out.add(trimmed);
|
|
1390
|
+
};
|
|
1391
|
+
add(runsIn);
|
|
1392
|
+
for (const v of dirValues) {
|
|
1393
|
+
for (const d of [...v.canonical, ...(v.spellings ?? [])]) {
|
|
1394
|
+
if (isAbsolutePath(d) || d.startsWith("./") || d === ".")
|
|
1395
|
+
continue;
|
|
1396
|
+
add(d);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
return [...out];
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* `text` without its trailing `/` and `\`: what `text.replace(/[\\/]+$/, "")`
|
|
1403
|
+
* returned, in one pass from the end. That pattern tries every start inside
|
|
1404
|
+
* a run of separators that is not at the end and rescans the rest of the run
|
|
1405
|
+
* from each, so it was quadratic in the run's length, and a directory or a
|
|
1406
|
+
* PATH entry is whatever the call wrote.
|
|
1407
|
+
*/
|
|
1408
|
+
export function withoutTrailingSeparators(text) {
|
|
1409
|
+
let end = text.length;
|
|
1410
|
+
for (; end > 0; end--) {
|
|
1411
|
+
const c = text.charCodeAt(end - 1);
|
|
1412
|
+
if (c !== 0x2f && c !== 0x5c)
|
|
1413
|
+
break;
|
|
1414
|
+
}
|
|
1415
|
+
return end === text.length ? text : text.slice(0, end);
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* `dir/word`, as written and with `.`, `..` and doubled `/` collapsed. A join
|
|
1419
|
+
* that climbs out of the directory it starts in is kept only as written: its
|
|
1420
|
+
* collapsed form would drop the climb.
|
|
1421
|
+
*/
|
|
1422
|
+
function joinedSpellings(dir, word) {
|
|
1423
|
+
const written = `${dir}/${word}`;
|
|
1424
|
+
const lexical = normalizePathLexically(written);
|
|
1425
|
+
return lexical.escapes || lexical.path === written ? [written] : [written, lexical.path];
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* A word with separators, collapsed the way {@link joinedSpellings} does,
|
|
1429
|
+
* when that changes it — and again with the `./` the call wrote kept, so
|
|
1430
|
+
* `alwaysDeny RunCommand(./deploy.sh)` reads `./a/../deploy.sh` as well.
|
|
1431
|
+
*/
|
|
1432
|
+
function collapsedWords(word) {
|
|
1433
|
+
if (!/[\\/]/.test(word))
|
|
1434
|
+
return [];
|
|
1435
|
+
const lexical = normalizePathLexically(word);
|
|
1436
|
+
if (lexical.escapes || lexical.path === word || lexical.path === ".")
|
|
1437
|
+
return [];
|
|
1438
|
+
const dotted = startsWithDotSlash(word) ? `./${lexical.path}` : undefined;
|
|
1439
|
+
return dotted !== undefined && dotted !== word ? [lexical.path, dotted] : [lexical.path];
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* A command word with its `.` and `..` folded as text, as `path.resolve`
|
|
1443
|
+
* folds them before anything is opened — the way node and `bun run` read
|
|
1444
|
+
* the script they are handed, and a `#!/usr/bin/env node` script the path it
|
|
1445
|
+
* was run by. A `..` after a name takes the name off; one at the start of a
|
|
1446
|
+
* relative word stays, climbing from the directory the command runs in; one
|
|
1447
|
+
* at the root of an absolute word stays at the root. A relative result
|
|
1448
|
+
* without a `/` keeps a `./`, so it still names a file in that directory,
|
|
1449
|
+
* not a program to look up.
|
|
1450
|
+
*/
|
|
1451
|
+
export function foldedAsText(word) {
|
|
1452
|
+
const drive = /^[A-Za-z]:[\\/]/.test(word) ? word.slice(0, 2) : "";
|
|
1453
|
+
const absolute = drive !== "" || word.startsWith("/") || word.startsWith("\\");
|
|
1454
|
+
const names = [];
|
|
1455
|
+
for (const segment of word.slice(drive.length).split(/[\\/]/)) {
|
|
1456
|
+
if (segment === "" || segment === ".")
|
|
1457
|
+
continue;
|
|
1458
|
+
if (segment !== "..")
|
|
1459
|
+
names.push(segment);
|
|
1460
|
+
else if (names.length > 0 && names[names.length - 1] !== "..")
|
|
1461
|
+
names.pop();
|
|
1462
|
+
else if (!absolute)
|
|
1463
|
+
names.push("..");
|
|
1464
|
+
}
|
|
1465
|
+
const joined = names.join("/");
|
|
1466
|
+
if (absolute)
|
|
1467
|
+
return `${drive}/${joined}`;
|
|
1468
|
+
if (joined === "")
|
|
1469
|
+
return ".";
|
|
1470
|
+
return joined.includes("/") || joined === ".." ? joined : `./${joined}`;
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* The spellings a deny or ask reads for one command beyond the words as
|
|
1474
|
+
* written. Every spelling only widens what a deny or ask catches; none is
|
|
1475
|
+
* canonical, so no allow reads them.
|
|
1476
|
+
*
|
|
1477
|
+
* - Each word that names a file through `.`, `..` or a doubled `/`, with
|
|
1478
|
+
* them collapsed, and the command line with those words collapsed.
|
|
1479
|
+
* - For a command run in a directory other than the root (`dirs`, its
|
|
1480
|
+
* relative spellings): each word that may name a file joined to the
|
|
1481
|
+
* directory — except a bare program name, which PATH finds, not the
|
|
1482
|
+
* directory — and the command line with those words joined, once per
|
|
1483
|
+
* directory. So `scripts/**` fires on `sh x` run in `scripts/`. The
|
|
1484
|
+
* directory on its own is not a spelling: `alwaysDeny RunCommand(rm*)` must
|
|
1485
|
+
* not fire on `ls` run in `rmtemp/`.
|
|
1486
|
+
* - For a call that sets its child's environment (`env`): a bare program as
|
|
1487
|
+
* PATH finds it on each entry the call sets — a relative entry is looked
|
|
1488
|
+
* up from the workspace root and run from the child's directory (Bun's
|
|
1489
|
+
* spawn does both), so both are read, and an absolute entry inside the
|
|
1490
|
+
* workspace is read from the root — and every value the call sets, each of
|
|
1491
|
+
* its words that may name a file (split at spaces, `:`, `=` and `;`, so
|
|
1492
|
+
* `--require=./hook.js` and `a:b` are read) as written, collapsed, and
|
|
1493
|
+
* joined to the child's directory.
|
|
1494
|
+
*/
|
|
1495
|
+
function commandSpellings(argv, dirs, env, canonicalizePath, where) {
|
|
1496
|
+
const out = new Set();
|
|
1497
|
+
const program = argv[0] ?? "";
|
|
1498
|
+
const bareProgram = program !== "" && !/[\\/]/.test(program);
|
|
1499
|
+
// Words collapsed where the call wrote a separator: once as collapsed, and
|
|
1500
|
+
// once with the `./` the call wrote kept.
|
|
1501
|
+
const collapsed = argv.map((w) => (namesAFile(w) ? collapsedWords(w) : []));
|
|
1502
|
+
if (collapsed.some((c) => c.length > 0)) {
|
|
1503
|
+
for (const c of collapsed)
|
|
1504
|
+
for (const form of c)
|
|
1505
|
+
out.add(form);
|
|
1506
|
+
for (const k of [0, 1]) {
|
|
1507
|
+
out.add(argv.map((w, i) => collapsed[i]?.[k] ?? collapsed[i]?.[0] ?? w).join(" "));
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
// Words joined to the directory the command runs in.
|
|
1511
|
+
for (const d of dirs) {
|
|
1512
|
+
const line = argv.map((w, i) => {
|
|
1513
|
+
if ((i === 0 && bareProgram) || !namesAFile(w))
|
|
1514
|
+
return w;
|
|
1515
|
+
const joined = joinedSpellings(d, w);
|
|
1516
|
+
for (const j of joined)
|
|
1517
|
+
out.add(j);
|
|
1518
|
+
return joined[joined.length - 1];
|
|
1519
|
+
});
|
|
1520
|
+
out.add(line.join(" "));
|
|
1521
|
+
}
|
|
1522
|
+
if (env === undefined)
|
|
1523
|
+
return { spellings: [...out], unreadable: false };
|
|
1524
|
+
const entries = Object.entries(env);
|
|
1525
|
+
let unreadable = entries.length > MAX_ENV_VARS;
|
|
1526
|
+
// Where a relative path in a value resolves: the child's directory.
|
|
1527
|
+
const places = dirs.length > 0 ? dirs : ["."];
|
|
1528
|
+
for (const [name, value] of entries.slice(0, MAX_ENV_VARS)) {
|
|
1529
|
+
if (value.length > MAX_ENV_VALUE_CHARS) {
|
|
1530
|
+
unreadable = true;
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
if (value !== "")
|
|
1534
|
+
out.add(value);
|
|
1535
|
+
const words = value.split(/[\s:=;]+/).filter((w) => w !== "");
|
|
1536
|
+
if (words.length > MAX_ENV_WORDS)
|
|
1537
|
+
unreadable = true;
|
|
1538
|
+
for (const w of words.slice(0, MAX_ENV_WORDS)) {
|
|
1539
|
+
if (!namesAFile(w))
|
|
1540
|
+
continue;
|
|
1541
|
+
out.add(w);
|
|
1542
|
+
for (const c of collapsedWords(w))
|
|
1543
|
+
out.add(c);
|
|
1544
|
+
for (const d of dirs)
|
|
1545
|
+
for (const j of joinedSpellings(d, w))
|
|
1546
|
+
out.add(j);
|
|
1547
|
+
}
|
|
1548
|
+
if (name.toUpperCase() !== "PATH" || !bareProgram)
|
|
1549
|
+
continue;
|
|
1550
|
+
const pathEntries = value.split(":");
|
|
1551
|
+
if (pathEntries.length > MAX_ENV_WORDS)
|
|
1552
|
+
unreadable = true;
|
|
1553
|
+
for (const raw of pathEntries.slice(0, MAX_ENV_WORDS)) {
|
|
1554
|
+
const entry = raw === "" ? "." : withoutTrailingSeparators(raw) || "/";
|
|
1555
|
+
const found = new Set();
|
|
1556
|
+
if (isAbsolutePath(entry)) {
|
|
1557
|
+
found.add(`${entry === "/" ? "" : entry}/${program}`);
|
|
1558
|
+
for (const v of canonicalizePath(entry)) {
|
|
1559
|
+
if (v.outsideWorkspace === true)
|
|
1560
|
+
continue;
|
|
1561
|
+
for (const c of [...v.canonical, ...(v.spellings ?? [])]) {
|
|
1562
|
+
if (isAbsolutePath(c) || c.startsWith("./"))
|
|
1563
|
+
continue;
|
|
1564
|
+
found.add(c === "." ? program : `${c}/${program}`);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
else {
|
|
1569
|
+
for (const j of joinedSpellings(entry, program))
|
|
1570
|
+
found.add(j);
|
|
1571
|
+
for (const d of places) {
|
|
1572
|
+
for (const j of joinedSpellings(d === "." ? entry : `${d}/${entry}`, program))
|
|
1573
|
+
found.add(j);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
// And where the kernel finds it there, each link followed before the
|
|
1577
|
+
// `..` after it: `PATH=link/..` is the directory above link's target.
|
|
1578
|
+
// A script found there whose `#!` line names node is opened again by
|
|
1579
|
+
// node, with the `..` folded as text: read there too.
|
|
1580
|
+
if (where.readProgramPath !== undefined) {
|
|
1581
|
+
const candidate = `${entry === "/" ? "" : entry}/${program}`;
|
|
1582
|
+
const from = isAbsolutePath(entry) ? [undefined] : [undefined, where.runsIn];
|
|
1583
|
+
const asRead = climbsAfterName(candidate)
|
|
1584
|
+
? [candidate, foldedAsText(candidate)]
|
|
1585
|
+
: [candidate];
|
|
1586
|
+
for (const runsIn of new Set(from)) {
|
|
1587
|
+
for (const word of asRead) {
|
|
1588
|
+
const place = where.readProgramPath(word, runsIn);
|
|
1589
|
+
if (place === undefined)
|
|
1590
|
+
continue;
|
|
1591
|
+
for (const f of placeForms(place.at, place.inWorkspace, candidate, true))
|
|
1592
|
+
found.add(f);
|
|
1593
|
+
if (place.target === undefined)
|
|
1594
|
+
continue;
|
|
1595
|
+
const { at, inWorkspace } = place.target;
|
|
1596
|
+
for (const f of placeForms(at, inWorkspace, candidate, true))
|
|
1597
|
+
found.add(f);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
for (const f of found) {
|
|
1602
|
+
out.add(f);
|
|
1603
|
+
out.add([f, ...argv.slice(1)].join(" "));
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
return { spellings: [...out], unreadable };
|
|
1608
|
+
}
|
|
1609
|
+
/** The string-valued entries of the object at `field`, when the call carries one. */
|
|
1610
|
+
function envMapOf(input, field) {
|
|
1611
|
+
if (input === null || typeof input !== "object" || !Object.hasOwn(input, field)) {
|
|
1612
|
+
return undefined;
|
|
1613
|
+
}
|
|
1614
|
+
const value = input[field];
|
|
1615
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
1616
|
+
return undefined;
|
|
1617
|
+
const out = {};
|
|
1618
|
+
for (const [name, v] of Object.entries(value))
|
|
1619
|
+
if (typeof v === "string")
|
|
1620
|
+
out[name] = v;
|
|
1621
|
+
return out;
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Could this argv word name a file relative to the working directory? Not a
|
|
1625
|
+
* flag, not an absolute path, not a URL; anything else might be a program
|
|
1626
|
+
* or a script (`release.sh`, `./eslint`, `bin/tool`).
|
|
1627
|
+
*/
|
|
1628
|
+
function namesAFile(word) {
|
|
1629
|
+
return word !== "" && !word.startsWith("-") && !isAbsolutePath(word) && !word.includes("://");
|
|
1630
|
+
}
|
|
1631
|
+
function isAbsolutePath(value) {
|
|
1632
|
+
return value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(value);
|
|
1633
|
+
}
|
|
1634
|
+
/** Does the call set the top-level boolean `field` to `true`? */
|
|
1635
|
+
function switchedOn(input, field) {
|
|
1636
|
+
return (input !== null &&
|
|
1637
|
+
typeof input === "object" &&
|
|
1638
|
+
Object.hasOwn(input, field) &&
|
|
1639
|
+
input[field] === true);
|
|
1640
|
+
}
|
|
1641
|
+
/** The top-level `within` field's value, when the call carries one. */
|
|
1642
|
+
function qualifierOf(input, field) {
|
|
1643
|
+
if (input === null || typeof input !== "object" || !Object.hasOwn(input, field)) {
|
|
1644
|
+
return undefined;
|
|
1645
|
+
}
|
|
1646
|
+
const value = input[field];
|
|
1647
|
+
if (typeof value === "string")
|
|
1648
|
+
return value;
|
|
1649
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
1650
|
+
return String(value);
|
|
1651
|
+
return undefined;
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* The filesystem-free reading of a path: `..` and `.` collapsed, nothing
|
|
1655
|
+
* followed. A relative path that climbs above its start is flagged as
|
|
1656
|
+
* outside. Used where there is no workspace to ask (the edge worker); the
|
|
1657
|
+
* Node runtime passes a canonicaliser that also follows symlinks.
|
|
1658
|
+
*/
|
|
1659
|
+
export function lexicalPathValues(raw) {
|
|
1660
|
+
const lexical = normalizePathLexically(raw);
|
|
1661
|
+
if (lexical.escapes) {
|
|
1662
|
+
return [
|
|
1663
|
+
{ kind: "path", canonical: [], spellings: [raw, lexical.path], outsideWorkspace: true },
|
|
1664
|
+
];
|
|
1665
|
+
}
|
|
1666
|
+
const p = lexical.path;
|
|
1667
|
+
const canonical = p.startsWith("/") || p === "." ? [p] : [p, `./${p}`];
|
|
1668
|
+
return [{ kind: "path", canonical, spellings: [raw] }];
|
|
1669
|
+
}
|
|
1670
|
+
function canonicalUrl(raw) {
|
|
1671
|
+
let url;
|
|
1672
|
+
try {
|
|
1673
|
+
url = new URL(raw);
|
|
1674
|
+
}
|
|
1675
|
+
catch {
|
|
1676
|
+
// Not a URL the tool could fetch. Nothing canonical to grant against; a
|
|
1677
|
+
// deny still reads what was written.
|
|
1678
|
+
return { kind: "url", canonical: [], spellings: [raw] };
|
|
1679
|
+
}
|
|
1680
|
+
const href = url.href;
|
|
1681
|
+
const canonical = [href];
|
|
1682
|
+
// `https://example.com` parses to `https://example.com/`. Both spell the
|
|
1683
|
+
// same request, so a rule written either way matches.
|
|
1684
|
+
if (url.pathname === "/" && url.search === "" && url.hash === "" && href.endsWith("/")) {
|
|
1685
|
+
canonical.push(href.slice(0, -1));
|
|
1686
|
+
}
|
|
1687
|
+
return { kind: "url", canonical, spellings: [raw] };
|
|
1688
|
+
}
|