@smthrs/std 1.0.0-rc.7 → 1.0.0-rc.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +40 -36
- package/dist/cjs/Checkpoints.d.ts +37 -0
- package/dist/cjs/Checkpoints.d.ts.map +1 -1
- package/dist/cjs/Checkpoints.js +75 -0
- package/dist/cjs/Checkpoints.js.map +2 -2
- package/dist/cjs/PortableSearch.d.ts +13 -1
- package/dist/cjs/PortableSearch.d.ts.map +1 -1
- package/dist/cjs/PortableSearch.js +8 -1
- package/dist/cjs/PortableSearch.js.map +2 -2
- package/dist/cjs/RemoteSearch.d.ts +54 -0
- package/dist/cjs/RemoteSearch.d.ts.map +1 -0
- package/dist/cjs/RemoteSearch.js +134 -0
- package/dist/cjs/RemoteSearch.js.map +7 -0
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +2 -0
- package/dist/cjs/index.js.map +2 -2
- package/dist/cjs/internal/EnvelopePrecheck.d.ts +2 -0
- package/dist/cjs/internal/EnvelopePrecheck.d.ts.map +1 -1
- package/dist/cjs/internal/EnvelopePrecheck.js +235 -36
- package/dist/cjs/internal/EnvelopePrecheck.js.map +3 -3
- package/dist/cjs/internal/Exec.d.ts +12 -7
- package/dist/cjs/internal/Exec.d.ts.map +1 -1
- package/dist/cjs/internal/Exec.js +8 -4
- package/dist/cjs/internal/Exec.js.map +2 -2
- package/dist/cjs/internal/RemoteTree.d.ts +60 -0
- package/dist/cjs/internal/RemoteTree.d.ts.map +1 -0
- package/dist/cjs/internal/RemoteTree.js +123 -0
- package/dist/cjs/internal/RemoteTree.js.map +7 -0
- package/dist/cjs/internal/Tar.d.ts +23 -0
- package/dist/cjs/internal/Tar.d.ts.map +1 -0
- package/dist/cjs/internal/Tar.js +86 -0
- package/dist/cjs/internal/Tar.js.map +7 -0
- package/dist/esm/Checkpoints.d.ts +37 -0
- package/dist/esm/Checkpoints.d.ts.map +1 -1
- package/dist/esm/Checkpoints.js +85 -0
- package/dist/esm/Checkpoints.js.map +1 -1
- package/dist/esm/PortableSearch.d.ts +13 -1
- package/dist/esm/PortableSearch.d.ts.map +1 -1
- package/dist/esm/PortableSearch.js +16 -0
- package/dist/esm/PortableSearch.js.map +1 -1
- package/dist/esm/RemoteSearch.d.ts +54 -0
- package/dist/esm/RemoteSearch.d.ts.map +1 -0
- package/dist/esm/RemoteSearch.js +162 -0
- package/dist/esm/RemoteSearch.js.map +1 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/internal/EnvelopePrecheck.d.ts +2 -0
- package/dist/esm/internal/EnvelopePrecheck.d.ts.map +1 -1
- package/dist/esm/internal/EnvelopePrecheck.js +321 -62
- package/dist/esm/internal/EnvelopePrecheck.js.map +1 -1
- package/dist/esm/internal/Exec.d.ts +12 -7
- package/dist/esm/internal/Exec.d.ts.map +1 -1
- package/dist/esm/internal/Exec.js +30 -9
- package/dist/esm/internal/Exec.js.map +1 -1
- package/dist/esm/internal/RemoteTree.d.ts +60 -0
- package/dist/esm/internal/RemoteTree.d.ts.map +1 -0
- package/dist/esm/internal/RemoteTree.js +132 -0
- package/dist/esm/internal/RemoteTree.js.map +1 -0
- package/dist/esm/internal/Tar.d.ts +23 -0
- package/dist/esm/internal/Tar.d.ts.map +1 -0
- package/dist/esm/internal/Tar.js +97 -0
- package/dist/esm/internal/Tar.js.map +1 -0
- package/docs/README.md +3 -1
- package/docs/api.md +48 -21
- package/docs/concepts/effects-and-capabilities.md +3 -1
- package/docs/guides/bind-the-standard-flows.md +3 -1
- package/docs/guides/pin-a-checkpoint.md +20 -0
- package/docs/guides/run-a-shell-command.md +4 -1
- package/docs/installation.md +13 -9
- package/docs/quickstart.md +1 -1
- package/docs/troubleshooting.md +20 -0
- package/package.json +16 -6
- package/src/Checkpoints.ts +137 -0
- package/src/PortableSearch.ts +20 -0
- package/src/RemoteSearch.ts +199 -0
- package/src/index.ts +3 -0
- package/src/internal/EnvelopePrecheck.ts +320 -64
- package/src/internal/Exec.ts +32 -8
- package/src/internal/RemoteTree.ts +159 -0
- package/src/internal/Tar.ts +95 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The search contract for a tree on another machine, in two round trips.
|
|
3
|
+
*
|
|
4
|
+
* `PortableSearch` asks `FileSystem` one question per directory, per entry
|
|
5
|
+
* and per file. On a machine reached through a transport, such as a
|
|
6
|
+
* benchmark's task container behind `docker exec`, every question is a
|
|
7
|
+
* process start, so one `grep` over a 6,000-file repository is some 20,000 of
|
|
8
|
+
* them. This peer asks the machine's own shell twice instead. `find` lists
|
|
9
|
+
* the tree under the root, leaving out the directories every walk skips, and
|
|
10
|
+
* `tar` returns its `.gitignore` files with the listing. The in-process peer
|
|
11
|
+
* then decides which files the search reads, a second `tar` returns exactly
|
|
12
|
+
* those, and `PortableSearch` answers over that copy. Every result, notice,
|
|
13
|
+
* and refusal is therefore the in-process peer's own: this module decides
|
|
14
|
+
* only where the bytes come from.
|
|
15
|
+
*
|
|
16
|
+
* It runs through the machine's own `ChildProcessSpawner`, the one
|
|
17
|
+
* `Sandbox.hostContext` serves beside the machine's `FileSystem`. The machine
|
|
18
|
+
* needs `sh`, `find`, `xargs`, `tar`, `head`, and `base64`; GNU, BusyBox, and
|
|
19
|
+
* BSD userlands all have them. Output crosses as base64, so a transport whose
|
|
20
|
+
* channel carries only text returns it intact. A search reads at most
|
|
21
|
+
* {@link maxBytes} of file content and refuses beyond that, naming the root
|
|
22
|
+
* to narrow.
|
|
23
|
+
*
|
|
24
|
+
* @since 1.0.0-rc.8
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type * as ChildProcessSpawner from "@smthrs/kernel/ChildProcessSpawner"
|
|
28
|
+
import * as Path from "@smthrs/kernel/Path"
|
|
29
|
+
import { Context, Effect } from "effect"
|
|
30
|
+
import * as Base64 from "effect/encoding/Base64"
|
|
31
|
+
import * as FileSystem from "effect/FileSystem"
|
|
32
|
+
import * as Result from "effect/Result"
|
|
33
|
+
import * as Exec from "./internal/Exec.ts"
|
|
34
|
+
import * as Prune from "./internal/Prune.ts"
|
|
35
|
+
import * as RemoteTree from "./internal/RemoteTree.ts"
|
|
36
|
+
import * as Tar from "./internal/Tar.ts"
|
|
37
|
+
import * as PortableSearch from "./PortableSearch.ts"
|
|
38
|
+
import * as Search from "./Search.ts"
|
|
39
|
+
import * as StdError from "./StdError.ts"
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The most file content one search brings back from the machine.
|
|
43
|
+
*
|
|
44
|
+
* The copy is held in memory while the in-process peer reads it, so the
|
|
45
|
+
* bound is on this process's memory rather than on the search: a root that
|
|
46
|
+
* holds more is refused with `response_too_large` and a narrower root or
|
|
47
|
+
* globs answer it.
|
|
48
|
+
*
|
|
49
|
+
* @category constants
|
|
50
|
+
* @since 1.0.0-rc.8
|
|
51
|
+
*/
|
|
52
|
+
export const maxBytes = 256 * 1024 * 1024
|
|
53
|
+
|
|
54
|
+
/** The most a listing, with its `.gitignore` files, may take. */
|
|
55
|
+
const maxListingBytes = 64 * 1024 * 1024
|
|
56
|
+
|
|
57
|
+
const quoted = (value: string): string => `'${value.replaceAll("'", `'\\''`)}'`
|
|
58
|
+
|
|
59
|
+
const pruned = `\\( ${Prune.repositoryDirectories.map((name) => `-name ${quoted(name)}`).join(" -o ")} \\) -prune`
|
|
60
|
+
|
|
61
|
+
/** The refusal for a machine without one of the tools a search runs. */
|
|
62
|
+
const missing = (tool: string) => `searching this machine needs ${tool}, which it does not have`
|
|
63
|
+
|
|
64
|
+
const requireTools = `for t in find xargs tar head; do command -v "$t" >/dev/null || { ` +
|
|
65
|
+
`echo "${missing("$t")}" >&2; exit 3; }; done`
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `$1` is the root, `$2` the byte bound, and `$3` is `1` to send the
|
|
69
|
+
* `.gitignore` files after the listing. Records are NUL-terminated `<kind><path>`;
|
|
70
|
+
* an `E` record ends the listing, and a tar stream of the ignore files follows.
|
|
71
|
+
*/
|
|
72
|
+
const listing = [
|
|
73
|
+
"set -f",
|
|
74
|
+
requireTools,
|
|
75
|
+
"r=$1",
|
|
76
|
+
"{",
|
|
77
|
+
" if [ -d \"$r\" ]; then if [ -r \"$r\" ] && [ -x \"$r\" ]; then printf 'd%s\\0' \"$r\"; else printf 'u%s\\0' \"$r\"; fi",
|
|
78
|
+
" elif [ -f \"$r\" ]; then printf 'f%s\\0' \"$r\"",
|
|
79
|
+
" elif [ -e \"$r\" ]; then printf 'o%s\\0' \"$r\"",
|
|
80
|
+
" fi",
|
|
81
|
+
// No `-exec … {} +` inside parentheses: BusyBox `find` never runs one there.
|
|
82
|
+
` find -H "$r" -mindepth 1 ${pruned} -o -type d -exec printf 'd%s\\0' {} + ` +
|
|
83
|
+
`-o -type f -exec printf 'f%s\\0' {} + ` +
|
|
84
|
+
`-o -type l -exec sh -c 'for p do if [ -d "$p" ]; then k=D; elif [ -f "$p" ]; then k=F; else k=L; fi; ` +
|
|
85
|
+
`printf "%s%s\\0" "$k" "$p"; done' sh {} + -o -exec printf 'o%s\\0' {} + 2>/dev/null`,
|
|
86
|
+
" printf 'E\\0'",
|
|
87
|
+
` if [ "$3" = 1 ]; then find -H "$r" -mindepth 1 ${pruned} -o -type f -name .gitignore -print0 2>/dev/null | ` +
|
|
88
|
+
"(cd / && COPYFILE_DISABLE=1 xargs -0 -r tar -cf - -- 2>/dev/null); fi",
|
|
89
|
+
"} | head -c \"$2\" | base64"
|
|
90
|
+
].join("\n")
|
|
91
|
+
|
|
92
|
+
/** `$1` is the byte bound; standard input is the NUL-terminated absolute paths to send. */
|
|
93
|
+
const fetching = [
|
|
94
|
+
requireTools,
|
|
95
|
+
"cd / && COPYFILE_DISABLE=1 xargs -0 -r tar -chf - -- 2>/dev/null | head -c \"$1\" | base64"
|
|
96
|
+
].join("\n")
|
|
97
|
+
|
|
98
|
+
const failed = (root: string, reason: string): StdError.StdError =>
|
|
99
|
+
new StdError.StdError({
|
|
100
|
+
code: "command_failed",
|
|
101
|
+
message: `Could not search ${root} on this machine: ${reason}`,
|
|
102
|
+
path: root
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const tooLarge = (root: string, bound: number): StdError.StdError =>
|
|
106
|
+
new StdError.StdError({
|
|
107
|
+
code: "response_too_large",
|
|
108
|
+
message: `The files under ${root} come to more than ${bound} bytes; narrow the root or the globs`,
|
|
109
|
+
path: root
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Reads the program from the first line of standard input, as base64, and
|
|
114
|
+
* runs it with the arguments and the rest of standard input. A program in
|
|
115
|
+
* the argv would be quoted once by the spawner and again by the transport,
|
|
116
|
+
* and a sealed host refuses to start a command line past 4,096 characters;
|
|
117
|
+
* this line stays short whatever the program says.
|
|
118
|
+
*/
|
|
119
|
+
const bootstrap = `command -v base64 >/dev/null || { echo "${missing("base64")}" >&2; exit 3; }; ` +
|
|
120
|
+
"IFS= read -r p && p=$(printf %s \"$p\" | base64 -d) && exec sh -c \"$p\" sh \"$@\""
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Runs one script on the machine and decodes the base64 it prints, refusing
|
|
124
|
+
* output that reached `bound`: `head` cut it, so it is not the whole answer.
|
|
125
|
+
*/
|
|
126
|
+
const run = (
|
|
127
|
+
root: string,
|
|
128
|
+
script: string,
|
|
129
|
+
args: ReadonlyArray<string>,
|
|
130
|
+
bound: number,
|
|
131
|
+
stdin = ""
|
|
132
|
+
): Effect.Effect<Uint8Array, StdError.StdError, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
133
|
+
Effect.gen(function*() {
|
|
134
|
+
const result = yield* Exec.exec("sh", {
|
|
135
|
+
args: ["-c", bootstrap, "sh", ...args],
|
|
136
|
+
stdin: `${Base64.encode(new TextEncoder().encode(script))}\n${stdin}`
|
|
137
|
+
}).pipe(Effect.mapError((error) => failed(root, error.message)))
|
|
138
|
+
if (result.exitCode !== 0) {
|
|
139
|
+
return yield* Effect.fail(failed(root, result.stderr.trim() || `exit ${result.exitCode}`))
|
|
140
|
+
}
|
|
141
|
+
const decoded = Base64.decode(result.stdout.replaceAll(/\s/g, ""))
|
|
142
|
+
if (Result.isFailure(decoded)) return yield* Effect.fail(failed(root, "the machine answered with invalid base64"))
|
|
143
|
+
if (decoded.success.length >= bound) return yield* Effect.fail(tooLarge(root, bound - 1))
|
|
144
|
+
return decoded.success
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Builds the peer over a machine's spawner and its `Path`, which resolves a
|
|
149
|
+
* relative root where the machine's file operations do. `maxBytes` lowers
|
|
150
|
+
* the bound on the file content one search brings back; the default is
|
|
151
|
+
* {@link maxBytes}.
|
|
152
|
+
*
|
|
153
|
+
* @category constructors
|
|
154
|
+
* @since 1.0.0-rc.8
|
|
155
|
+
*/
|
|
156
|
+
export const make = (
|
|
157
|
+
services: Context.Context<ChildProcessSpawner.ChildProcessSpawner | Path.Path>,
|
|
158
|
+
options?: { readonly maxBytes?: number | undefined }
|
|
159
|
+
): Search.Search => {
|
|
160
|
+
const path = Context.get(services, Path.Path)
|
|
161
|
+
const contentBound = (options?.maxBytes ?? maxBytes) + 1
|
|
162
|
+
const copy = (tree: RemoteTree.Tree) =>
|
|
163
|
+
Context.make(FileSystem.FileSystem, RemoteTree.fileSystem(tree, path)).pipe(Context.add(Path.Path, path))
|
|
164
|
+
const listed = (root: string, noIgnore: boolean | undefined) => {
|
|
165
|
+
const resolved = path.resolve(root)
|
|
166
|
+
const bound = maxListingBytes + 1
|
|
167
|
+
return Effect.flatMap(
|
|
168
|
+
run(root, listing, [resolved, String(bound), noIgnore === true ? "0" : "1"], bound),
|
|
169
|
+
(bytes) => {
|
|
170
|
+
const tree = RemoteTree.parse(bytes, resolved, path)
|
|
171
|
+
return tree === undefined ? Effect.fail(failed(root, "the listing came back malformed")) : Effect.succeed(tree)
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
return Search.make({
|
|
176
|
+
grep: (input) =>
|
|
177
|
+
Effect.gen(function*() {
|
|
178
|
+
const tree = yield* listed(input.root, input.noIgnore)
|
|
179
|
+
const read = yield* PortableSearch.searched(input).pipe(Effect.provide(copy(tree)))
|
|
180
|
+
const files = [...new Set(read.map((file) => path.resolve(file)))]
|
|
181
|
+
const bytes = files.length === 0 ? new Uint8Array() : yield* run(
|
|
182
|
+
input.root,
|
|
183
|
+
fetching,
|
|
184
|
+
[String(contentBound)],
|
|
185
|
+
contentBound,
|
|
186
|
+
files.map((file) => `${file}\0`).join("")
|
|
187
|
+
)
|
|
188
|
+
// `tar` ends every archive it writes, even one whose files it could not read.
|
|
189
|
+
const members = files.length === 0 || bytes.length > 0 ? Tar.files(bytes) : undefined
|
|
190
|
+
if (members === undefined) return yield* Effect.fail(failed(input.root, "the files came back malformed"))
|
|
191
|
+
const contents = new Map([...tree.contents, ...RemoteTree.absolute(members)])
|
|
192
|
+
return yield* PortableSearch.make(copy({ ...tree, contents })).grep(input)
|
|
193
|
+
}).pipe(Effect.provide(services)),
|
|
194
|
+
glob: (input) =>
|
|
195
|
+
Effect.flatMap(listed(input.root, input.noIgnore), (tree) => PortableSearch.make(copy(tree)).glob(input)).pipe(
|
|
196
|
+
Effect.provide(services)
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,9 @@ export * as PortableSearch from "./PortableSearch.ts"
|
|
|
56
56
|
/** @category layers @since 1.0.0 */
|
|
57
57
|
export * as NativeSearch from "./NativeSearch.ts"
|
|
58
58
|
|
|
59
|
+
/** @category layers @since 1.0.0-rc.8 */
|
|
60
|
+
export * as RemoteSearch from "./RemoteSearch.ts"
|
|
61
|
+
|
|
59
62
|
/**
|
|
60
63
|
* @category flows
|
|
61
64
|
* @since 1.0.0
|
|
@@ -55,37 +55,254 @@ export interface PathReference {
|
|
|
55
55
|
readonly value: string
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
const commandSeparators = new Set(["&&", "||", ";", "|", "&", "\n"])
|
|
59
|
-
const
|
|
58
|
+
const commandSeparators = new Set(["&&", "||", ";", ";;", ";&", ";;&", "|", "|&", "&", "\n", "(", ")"])
|
|
59
|
+
const writeRedirections = new Set([">", ">>", ">|", "&>", "&>>", ">&", "<>"])
|
|
60
|
+
const readRedirections = new Set(["<", "<&"])
|
|
61
|
+
// A here-document delimiter and a here-string are text, not file names.
|
|
62
|
+
const textRedirections = new Set(["<<", "<<-", "<<<"])
|
|
63
|
+
const isRedirection = (token: Token): boolean =>
|
|
64
|
+
token.operator &&
|
|
65
|
+
(writeRedirections.has(token.text) || readRedirections.has(token.text) || textRedirections.has(token.text))
|
|
66
|
+
// Longest first, so `&>>` is not read as `&` then `>>`.
|
|
67
|
+
const operators = [
|
|
68
|
+
";;&",
|
|
69
|
+
"&>>",
|
|
70
|
+
"<<<",
|
|
71
|
+
"<<-",
|
|
72
|
+
";;",
|
|
73
|
+
";&",
|
|
74
|
+
"&&",
|
|
75
|
+
"||",
|
|
76
|
+
"|&",
|
|
77
|
+
"&>",
|
|
78
|
+
">>",
|
|
79
|
+
">|",
|
|
80
|
+
">&",
|
|
81
|
+
"<>",
|
|
82
|
+
"<<",
|
|
83
|
+
"<&",
|
|
84
|
+
"\n",
|
|
85
|
+
";",
|
|
86
|
+
"|",
|
|
87
|
+
"&",
|
|
88
|
+
"(",
|
|
89
|
+
")",
|
|
90
|
+
">",
|
|
91
|
+
"<"
|
|
92
|
+
]
|
|
93
|
+
// Reserved words and grouping braces open or close a compound command; the
|
|
94
|
+
// command they wrap stands after them.
|
|
95
|
+
const reservedWords = new Set([
|
|
96
|
+
"!",
|
|
97
|
+
"{",
|
|
98
|
+
"}",
|
|
99
|
+
"case",
|
|
100
|
+
"do",
|
|
101
|
+
"done",
|
|
102
|
+
"elif",
|
|
103
|
+
"else",
|
|
104
|
+
"esac",
|
|
105
|
+
"fi",
|
|
106
|
+
"for",
|
|
107
|
+
"function",
|
|
108
|
+
"if",
|
|
109
|
+
"in",
|
|
110
|
+
"select",
|
|
111
|
+
"then",
|
|
112
|
+
"until",
|
|
113
|
+
"while"
|
|
114
|
+
])
|
|
60
115
|
const writeCommands = new Set(["chmod", "chown", "mkdir", "rm", "rmdir", "tee", "touch", "truncate", "unlink"])
|
|
61
116
|
const destinationCommands = new Set(["cp", "install", "mv"])
|
|
62
117
|
const prefixWrappers = new Set(["env", "nice", "time", "sudo", "nohup", "xargs", "command", "exec"])
|
|
118
|
+
// A shell runs the argument of its `-c` flag as shell text of its own.
|
|
119
|
+
const shells = new Set(["ash", "bash", "dash", "fish", "ksh", "mksh", "sh", "zsh"])
|
|
120
|
+
const commandFlag = /^-[A-Za-z]*c[A-Za-z]*$/
|
|
63
121
|
|
|
64
|
-
|
|
65
|
-
|
|
122
|
+
/**
|
|
123
|
+
* One lexical unit of shell text: an operator, or a word with its quotes
|
|
124
|
+
* removed. A command substitution inside a word stands as `$()` in the word;
|
|
125
|
+
* its body is scanned as a command of its own.
|
|
126
|
+
*/
|
|
127
|
+
interface Token {
|
|
128
|
+
readonly operator: boolean
|
|
129
|
+
readonly text: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface Lexed {
|
|
133
|
+
readonly tokens: ReadonlyArray<Token>
|
|
134
|
+
readonly substitutions: ReadonlyArray<string>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The end of a `$(` body that starts at `from`: the index of its closing
|
|
138
|
+
// parenthesis, skipping quoted spans and nested parentheses. An unclosed body
|
|
139
|
+
// runs to the end of the text, which is how a fail-closed scan must read it.
|
|
140
|
+
// Substitutions and quotes nest without limit in a shell, but each level
|
|
141
|
+
// costs this scan a pass over the rest of the text and a stack frame. Fifty
|
|
142
|
+
// thousand `$(` used to run for half a minute and then overflow the stack, so
|
|
143
|
+
// past this depth the text is refused rather than scanned.
|
|
144
|
+
const maxNesting = 32
|
|
145
|
+
|
|
146
|
+
const tooDeep = (): StdError.StdError =>
|
|
147
|
+
new StdError.StdError({
|
|
148
|
+
code: "invalid_input",
|
|
149
|
+
message: `Command nests substitutions or quotes deeper than ${maxNesting} levels`
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const substitutionEnd = (text: string, from: number, nesting: number): number => {
|
|
153
|
+
if (nesting > maxNesting) throw tooDeep()
|
|
154
|
+
let depth = 1
|
|
155
|
+
let index = from
|
|
156
|
+
while (index < text.length) {
|
|
157
|
+
const char = text[index]!
|
|
158
|
+
if (char === "\\") {
|
|
159
|
+
index += 2
|
|
160
|
+
} else if (char === "'") {
|
|
161
|
+
const close = text.indexOf("'", index + 1)
|
|
162
|
+
index = close === -1 ? text.length : close + 1
|
|
163
|
+
} else if (char === "\"") {
|
|
164
|
+
index = doubleQuoteEnd(text, index + 1, nesting + 1) + 1
|
|
165
|
+
} else if (char === "`") {
|
|
166
|
+
index = backtickEnd(text, index + 1) + 1
|
|
167
|
+
} else {
|
|
168
|
+
if (char === "(") depth++
|
|
169
|
+
if (char === ")" && --depth === 0) return index
|
|
170
|
+
index++
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return text.length
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const backtickEnd = (text: string, from: number): number => {
|
|
177
|
+
let index = from
|
|
178
|
+
while (index < text.length && text[index] !== "`") index += text[index] === "\\" ? 2 : 1
|
|
179
|
+
return Math.min(index, text.length)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const doubleQuoteEnd = (text: string, from: number, nesting: number): number => {
|
|
183
|
+
if (nesting > maxNesting) throw tooDeep()
|
|
184
|
+
let index = from
|
|
185
|
+
while (index < text.length && text[index] !== "\"") {
|
|
186
|
+
const char = text[index]!
|
|
187
|
+
if (char === "\\") index += 2
|
|
188
|
+
else if (char === "`") index = backtickEnd(text, index + 1) + 1
|
|
189
|
+
else if (char === "$" && text[index + 1] === "(") index = substitutionEnd(text, index + 2, nesting + 1) + 1
|
|
190
|
+
else index++
|
|
191
|
+
}
|
|
192
|
+
return Math.min(index, text.length)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A hand-rolled lexer rather than one regular expression: a word is any run
|
|
196
|
+
// of bare characters, quoted spans, and substitutions, and a substitution can
|
|
197
|
+
// hold quotes and operators of its own. Splitting on operator characters
|
|
198
|
+
// alone let `echo "$(rm /work/target)"` hide a delete inside one quoted word,
|
|
199
|
+
// read `>|` as a pipe that turned the target into the next command's name,
|
|
200
|
+
// and read `(rm /work/target)` as a command named `(rm`.
|
|
66
201
|
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// the
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
202
|
+
// Quotes are removed from a word's text, because `"/outs"ide/x` names
|
|
203
|
+
// `/outside/x`; keeping the quotes made it a relative path under the
|
|
204
|
+
// working directory.
|
|
205
|
+
// Sticky, so the match starts at `lastIndex` without copying the rest of the
|
|
206
|
+
// text for every word.
|
|
207
|
+
const descriptorPattern = /\d+(?=[<>])/y
|
|
208
|
+
|
|
209
|
+
const lex = (command: string, nesting: number): Lexed => {
|
|
210
|
+
const tokens: Array<Token> = []
|
|
211
|
+
const substitutions: Array<string> = []
|
|
212
|
+
let index = 0
|
|
213
|
+
const substitution = (from: number, to: number): string => {
|
|
214
|
+
substitutions.push(command.slice(from, to))
|
|
215
|
+
return "$()"
|
|
216
|
+
}
|
|
217
|
+
while (index < command.length) {
|
|
218
|
+
const char = command[index]!
|
|
219
|
+
// Every blank the word loop stops at must be skipped here, or the lexer
|
|
220
|
+
// would push an empty word without advancing.
|
|
221
|
+
if (char !== "\n" && /\s/.test(char)) {
|
|
222
|
+
index++
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
if (char === "\\" && command[index + 1] === "\n") {
|
|
226
|
+
index += 2
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
if (char === "#") {
|
|
230
|
+
while (index < command.length && command[index] !== "\n") index++
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
// A file-descriptor number belongs to the redirection after it: in
|
|
234
|
+
// `2>/dev/null rm /work/target` the command is `rm`, not `2`.
|
|
235
|
+
descriptorPattern.lastIndex = index
|
|
236
|
+
const descriptor = descriptorPattern.exec(command)
|
|
237
|
+
if (descriptor !== null) {
|
|
238
|
+
index += descriptor[0].length
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
const operator = operators.find((candidate) => command.startsWith(candidate, index))
|
|
242
|
+
if (operator !== undefined) {
|
|
243
|
+
tokens.push({ operator: true, text: operator })
|
|
244
|
+
index += operator.length
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
let text = ""
|
|
248
|
+
while (index < command.length) {
|
|
249
|
+
const next = command[index]!
|
|
250
|
+
if (/[\s<>;|&()]/.test(next)) break
|
|
251
|
+
if (next === "\\") {
|
|
252
|
+
// A backslash escapes shell syntax. Before any other character it is
|
|
253
|
+
// kept, so `C:\work\input` stays the path it names on Windows rather
|
|
254
|
+
// than collapsing to a word the scan cannot see as a path.
|
|
255
|
+
const escaped = command[index + 1] ?? ""
|
|
256
|
+
if (escaped !== "\n") text += /[\s<>;|&()'"`$\\#]/.test(escaped) ? escaped : `\\${escaped}`
|
|
257
|
+
index += 2
|
|
258
|
+
} else if (next === "'") {
|
|
259
|
+
const close = command.indexOf("'", index + 1)
|
|
260
|
+
const end = close === -1 ? command.length : close
|
|
261
|
+
text += command.slice(index + 1, end)
|
|
262
|
+
index = end + 1
|
|
263
|
+
} else if (next === "\"") {
|
|
264
|
+
const end = doubleQuoteEnd(command, index + 1, nesting + 1)
|
|
265
|
+
let inner = index + 1
|
|
266
|
+
while (inner < end) {
|
|
267
|
+
const quoted = command[inner]!
|
|
268
|
+
if (quoted === "\\" && /[$`"\\\n]/.test(command[inner + 1] ?? "")) {
|
|
269
|
+
if (command[inner + 1] !== "\n") text += command[inner + 1]
|
|
270
|
+
inner += 2
|
|
271
|
+
} else if (quoted === "`") {
|
|
272
|
+
const close = backtickEnd(command, inner + 1)
|
|
273
|
+
text += substitution(inner + 1, close)
|
|
274
|
+
inner = close + 1
|
|
275
|
+
} else if (quoted === "$" && command[inner + 1] === "(") {
|
|
276
|
+
const close = substitutionEnd(command, inner + 2, nesting + 2)
|
|
277
|
+
text += substitution(inner + 2, close)
|
|
278
|
+
inner = close + 1
|
|
279
|
+
} else {
|
|
280
|
+
text += quoted
|
|
281
|
+
inner++
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
index = end + 1
|
|
285
|
+
} else if (next === "`") {
|
|
286
|
+
const close = backtickEnd(command, index + 1)
|
|
287
|
+
text += substitution(index + 1, close)
|
|
288
|
+
index = close + 1
|
|
289
|
+
} else if (next === "$" && command[index + 1] === "(") {
|
|
290
|
+
const close = substitutionEnd(command, index + 2, nesting + 1)
|
|
291
|
+
text += substitution(index + 2, close)
|
|
292
|
+
index = close + 1
|
|
293
|
+
} else {
|
|
294
|
+
text += next
|
|
295
|
+
index++
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
tokens.push({ operator: false, text })
|
|
299
|
+
}
|
|
300
|
+
return { tokens, substitutions }
|
|
83
301
|
}
|
|
84
302
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
.replace(/[),]+$/, "")
|
|
303
|
+
// A parenthesis is an operator to the lexer, so one left in a word was quoted
|
|
304
|
+
// and is part of the name; only a trailing comma is punctuation.
|
|
305
|
+
const pathValue = (token: string): string => token.replace(/,+$/, "")
|
|
89
306
|
|
|
90
307
|
const isPathToken = (path: Path.Path, token: string): boolean =>
|
|
91
308
|
token === "." ||
|
|
@@ -96,72 +313,103 @@ const isPathToken = (path: Path.Path, token: string): boolean =>
|
|
|
96
313
|
/^[A-Za-z]:[\\/]/.test(token) ||
|
|
97
314
|
token.includes("/")
|
|
98
315
|
|
|
99
|
-
const segmentReferences = (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
316
|
+
const segmentReferences = (
|
|
317
|
+
path: Path.Path,
|
|
318
|
+
tokens: ReadonlyArray<Token>,
|
|
319
|
+
nesting: number
|
|
320
|
+
): ReadonlyArray<PathReference> => {
|
|
321
|
+
// The word after a redirection operator is its target, never the command.
|
|
322
|
+
const isTarget = (index: number): boolean => index > 0 && isRedirection(tokens[index - 1]!)
|
|
323
|
+
const isWord = (index: number): boolean => !tokens[index]!.operator && !isTarget(index)
|
|
324
|
+
const name = (index: number): string => path.basename(pathValue(tokens[index]!.text))
|
|
325
|
+
const hasWrapper = tokens.some((_, index) => isWord(index) && prefixWrappers.has(name(index)))
|
|
326
|
+
let commandIndex = tokens.findIndex((token, index) =>
|
|
327
|
+
isWord(index) &&
|
|
328
|
+
!token.text.includes("=") &&
|
|
329
|
+
!prefixWrappers.has(name(index)) &&
|
|
330
|
+
!reservedWords.has(token.text) &&
|
|
331
|
+
!token.text.startsWith("-") &&
|
|
332
|
+
token.text !== ""
|
|
109
333
|
)
|
|
110
334
|
if (hasWrapper) {
|
|
111
335
|
// Wrapper options can carry their own values. Prefer a later known
|
|
112
336
|
// mutating command so an option value cannot lend it a read classification.
|
|
113
|
-
const mutatingIndex =
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
})
|
|
337
|
+
const mutatingIndex = tokens.findIndex((_, index) =>
|
|
338
|
+
isWord(index) && (writeCommands.has(name(index)) || destinationCommands.has(name(index)))
|
|
339
|
+
)
|
|
117
340
|
if (mutatingIndex !== -1) commandIndex = mutatingIndex
|
|
118
341
|
}
|
|
119
|
-
if (commandIndex === -1) return []
|
|
120
342
|
|
|
121
|
-
const command =
|
|
343
|
+
const command = commandIndex === -1 ? "" : name(commandIndex)
|
|
344
|
+
// Shell text handed to `eval` or to a shell's `-c` flag is a command, not a
|
|
345
|
+
// path: reading `sh -c 'rm /work/target'` as one quoted word let a delete
|
|
346
|
+
// pass as a read of a file named `rm /work/target`.
|
|
347
|
+
const scripts = new Set<number>()
|
|
348
|
+
if (command === "eval") {
|
|
349
|
+
for (let index = commandIndex + 1; index < tokens.length; index++) if (isWord(index)) scripts.add(index)
|
|
350
|
+
} else if (shells.has(command)) {
|
|
351
|
+
const flag = tokens.findIndex((token, index) =>
|
|
352
|
+
index > commandIndex && isWord(index) && commandFlag.test(token.text)
|
|
353
|
+
)
|
|
354
|
+
if (flag !== -1 && flag + 1 < tokens.length && isWord(flag + 1)) scripts.add(flag + 1)
|
|
355
|
+
}
|
|
122
356
|
const candidates: Array<{ readonly index: number; readonly value: string }> = []
|
|
123
|
-
for (let index = 0; index <
|
|
124
|
-
const token =
|
|
125
|
-
if (index === commandIndex ||
|
|
126
|
-
|
|
357
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
358
|
+
const token = tokens[index]!
|
|
359
|
+
if (index === commandIndex || scripts.has(index) || token.operator) continue
|
|
360
|
+
if (!isTarget(index) && token.text.startsWith("-")) continue
|
|
361
|
+
if (isTarget(index) && textRedirections.has(tokens[index - 1]!.text)) continue
|
|
362
|
+
const value = pathValue(token.text)
|
|
127
363
|
if (value !== "" && isPathToken(path, value)) candidates.push({ index, value })
|
|
128
364
|
}
|
|
129
365
|
|
|
130
366
|
const destinationIndex = destinationCommands.has(command)
|
|
131
|
-
? candidates
|
|
367
|
+
? candidates.filter(({ index }) => !isTarget(index)).at(-1)?.index
|
|
132
368
|
: undefined
|
|
133
369
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
370
|
+
const script = [...scripts].map((index) => tokens[index]!.text).join(" ")
|
|
371
|
+
const nested = script === "" ? [] : references(path, script, nesting + 1)
|
|
372
|
+
const named = candidates.map(({ index, value }): PathReference => {
|
|
373
|
+
const access: Access = isTarget(index)
|
|
374
|
+
? writeRedirections.has(tokens[index - 1]!.text) ? "write" : "read"
|
|
375
|
+
: writeCommands.has(command) || destinationIndex === index
|
|
138
376
|
? "write"
|
|
139
377
|
: "read"
|
|
140
378
|
return { access, value }
|
|
141
379
|
})
|
|
380
|
+
return [...named, ...nested]
|
|
142
381
|
}
|
|
143
382
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
let segment: Array<string> = []
|
|
153
|
-
for (const token of tokenize(command)) {
|
|
154
|
-
if (commandSeparators.has(token)) {
|
|
155
|
-
references.push(...segmentReferences(path, segment))
|
|
383
|
+
const references = (path: Path.Path, command: string, nesting: number): ReadonlyArray<PathReference> => {
|
|
384
|
+
if (nesting > maxNesting) throw tooDeep()
|
|
385
|
+
const { substitutions, tokens } = lex(command, nesting)
|
|
386
|
+
const found: Array<PathReference> = []
|
|
387
|
+
let segment: Array<Token> = []
|
|
388
|
+
for (const token of tokens) {
|
|
389
|
+
if (token.operator && commandSeparators.has(token.text)) {
|
|
390
|
+
found.push(...segmentReferences(path, segment, nesting))
|
|
156
391
|
segment = []
|
|
157
392
|
} else {
|
|
158
393
|
segment.push(token)
|
|
159
394
|
}
|
|
160
395
|
}
|
|
161
|
-
|
|
162
|
-
|
|
396
|
+
found.push(...segmentReferences(path, segment, nesting))
|
|
397
|
+
// A substitution runs its body as a command; its paths count like any other.
|
|
398
|
+
for (const body of substitutions) found.push(...references(path, body, nesting + 1))
|
|
399
|
+
return found
|
|
163
400
|
}
|
|
164
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Every path token the shell text names, one segment at a time.
|
|
404
|
+
*
|
|
405
|
+
* @throws {StdError.StdError} `invalid_input` when substitutions or quotes nest
|
|
406
|
+
* deeper than the scan reads.
|
|
407
|
+
* @category pre-check
|
|
408
|
+
* @since 1.0.0
|
|
409
|
+
*/
|
|
410
|
+
export const commandReferences = (path: Path.Path, command: string): ReadonlyArray<PathReference> =>
|
|
411
|
+
references(path, command, 0)
|
|
412
|
+
|
|
165
413
|
const resolvePath = (
|
|
166
414
|
path: Path.Path,
|
|
167
415
|
cwd: string,
|
|
@@ -213,7 +461,15 @@ export const outsideEnvelope = (
|
|
|
213
461
|
// This lexical scan is intentionally only a fail-closed pre-check for
|
|
214
462
|
// explicit path tokens. It cannot prove confinement; a kernel/host sandbox
|
|
215
463
|
// must eventually enforce and report the complete read and write sets.
|
|
216
|
-
|
|
464
|
+
let named: ReadonlyArray<PathReference>
|
|
465
|
+
try {
|
|
466
|
+
named = commandReferences(path, text)
|
|
467
|
+
} catch {
|
|
468
|
+
// The depth bound is the one failure the scan raises, and a text the scan
|
|
469
|
+
// cannot read is refused whatever the reason, never run unchecked.
|
|
470
|
+
return tooDeep()
|
|
471
|
+
}
|
|
472
|
+
for (const reference of named) {
|
|
217
473
|
const resolved = resolvePath(path, cwd, reference.value)
|
|
218
474
|
// /dev/* is the process plumbing every shell one-liner leans on:
|
|
219
475
|
// `2>/dev/null`, `cat /dev/stdin`. Normalize first so a dot-dot segment
|