@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +40 -36
  3. package/dist/cjs/Checkpoints.d.ts +37 -0
  4. package/dist/cjs/Checkpoints.d.ts.map +1 -1
  5. package/dist/cjs/Checkpoints.js +75 -0
  6. package/dist/cjs/Checkpoints.js.map +2 -2
  7. package/dist/cjs/PortableSearch.d.ts +13 -1
  8. package/dist/cjs/PortableSearch.d.ts.map +1 -1
  9. package/dist/cjs/PortableSearch.js +8 -1
  10. package/dist/cjs/PortableSearch.js.map +2 -2
  11. package/dist/cjs/RemoteSearch.d.ts +54 -0
  12. package/dist/cjs/RemoteSearch.d.ts.map +1 -0
  13. package/dist/cjs/RemoteSearch.js +134 -0
  14. package/dist/cjs/RemoteSearch.js.map +7 -0
  15. package/dist/cjs/index.d.ts +2 -0
  16. package/dist/cjs/index.d.ts.map +1 -1
  17. package/dist/cjs/index.js +2 -0
  18. package/dist/cjs/index.js.map +2 -2
  19. package/dist/cjs/internal/EnvelopePrecheck.d.ts +2 -0
  20. package/dist/cjs/internal/EnvelopePrecheck.d.ts.map +1 -1
  21. package/dist/cjs/internal/EnvelopePrecheck.js +235 -36
  22. package/dist/cjs/internal/EnvelopePrecheck.js.map +3 -3
  23. package/dist/cjs/internal/Exec.d.ts +12 -7
  24. package/dist/cjs/internal/Exec.d.ts.map +1 -1
  25. package/dist/cjs/internal/Exec.js +8 -4
  26. package/dist/cjs/internal/Exec.js.map +2 -2
  27. package/dist/cjs/internal/RemoteTree.d.ts +60 -0
  28. package/dist/cjs/internal/RemoteTree.d.ts.map +1 -0
  29. package/dist/cjs/internal/RemoteTree.js +123 -0
  30. package/dist/cjs/internal/RemoteTree.js.map +7 -0
  31. package/dist/cjs/internal/Tar.d.ts +23 -0
  32. package/dist/cjs/internal/Tar.d.ts.map +1 -0
  33. package/dist/cjs/internal/Tar.js +86 -0
  34. package/dist/cjs/internal/Tar.js.map +7 -0
  35. package/dist/esm/Checkpoints.d.ts +37 -0
  36. package/dist/esm/Checkpoints.d.ts.map +1 -1
  37. package/dist/esm/Checkpoints.js +85 -0
  38. package/dist/esm/Checkpoints.js.map +1 -1
  39. package/dist/esm/PortableSearch.d.ts +13 -1
  40. package/dist/esm/PortableSearch.d.ts.map +1 -1
  41. package/dist/esm/PortableSearch.js +16 -0
  42. package/dist/esm/PortableSearch.js.map +1 -1
  43. package/dist/esm/RemoteSearch.d.ts +54 -0
  44. package/dist/esm/RemoteSearch.d.ts.map +1 -0
  45. package/dist/esm/RemoteSearch.js +162 -0
  46. package/dist/esm/RemoteSearch.js.map +1 -0
  47. package/dist/esm/index.d.ts +2 -0
  48. package/dist/esm/index.d.ts.map +1 -1
  49. package/dist/esm/index.js +2 -0
  50. package/dist/esm/index.js.map +1 -1
  51. package/dist/esm/internal/EnvelopePrecheck.d.ts +2 -0
  52. package/dist/esm/internal/EnvelopePrecheck.d.ts.map +1 -1
  53. package/dist/esm/internal/EnvelopePrecheck.js +321 -62
  54. package/dist/esm/internal/EnvelopePrecheck.js.map +1 -1
  55. package/dist/esm/internal/Exec.d.ts +12 -7
  56. package/dist/esm/internal/Exec.d.ts.map +1 -1
  57. package/dist/esm/internal/Exec.js +30 -9
  58. package/dist/esm/internal/Exec.js.map +1 -1
  59. package/dist/esm/internal/RemoteTree.d.ts +60 -0
  60. package/dist/esm/internal/RemoteTree.d.ts.map +1 -0
  61. package/dist/esm/internal/RemoteTree.js +132 -0
  62. package/dist/esm/internal/RemoteTree.js.map +1 -0
  63. package/dist/esm/internal/Tar.d.ts +23 -0
  64. package/dist/esm/internal/Tar.d.ts.map +1 -0
  65. package/dist/esm/internal/Tar.js +97 -0
  66. package/dist/esm/internal/Tar.js.map +1 -0
  67. package/docs/README.md +3 -1
  68. package/docs/api.md +48 -21
  69. package/docs/concepts/effects-and-capabilities.md +3 -1
  70. package/docs/guides/bind-the-standard-flows.md +3 -1
  71. package/docs/guides/pin-a-checkpoint.md +20 -0
  72. package/docs/guides/run-a-shell-command.md +4 -1
  73. package/docs/installation.md +13 -9
  74. package/docs/quickstart.md +1 -1
  75. package/docs/troubleshooting.md +20 -0
  76. package/package.json +16 -6
  77. package/src/Checkpoints.ts +137 -0
  78. package/src/PortableSearch.ts +20 -0
  79. package/src/RemoteSearch.ts +199 -0
  80. package/src/index.ts +3 -0
  81. package/src/internal/EnvelopePrecheck.ts +320 -64
  82. package/src/internal/Exec.ts +32 -8
  83. package/src/internal/RemoteTree.ts +159 -0
  84. 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 redirections = new Set([">", ">>", "<", "<<"])
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
- // Shell spaces may disappear, but a physical line ends one command. Retaining
65
- // LF prevents a command on line one from classifying every later line's paths.
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
- // A word is one or more runs of bare characters and quoted sections, not a
68
- // bare run alone: `FOO="a b" rm /work/target` used to tokenize as `FOO="a`,
69
- // `b"`, `rm`, `/work/target`, and since only the first token carried an `=`
70
- // the command was read as `b"` rather than `rm` — so the delete was
71
- // classified as a read and a hermetic call with `writes: []` spawned. The
72
- // quoted alternatives come first inside the group so a quoted span is taken
73
- // whole; the bare class excludes quotes so the branches cannot overlap.
74
- const tokenize = (command: string): ReadonlyArray<string> =>
75
- command.match(/\n|>>|<<|&&|\|\||[<>;|&]|(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s<>;|&"']+)+/g) ?? []
76
-
77
- const unquote = (token: string): string => {
78
- const first = token[0]
79
- const last = token[token.length - 1]
80
- return token.length >= 2 && (first === "'" || first === "\"") && last === first
81
- ? token.slice(1, -1)
82
- : token
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
- const pathValue = (token: string): string =>
86
- unquote(token)
87
- .replace(/^\$\(+/, "")
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 = (path: Path.Path, tokens: ReadonlyArray<string>): ReadonlyArray<PathReference> => {
100
- const commentIndex = tokens.findIndex((token) => token.startsWith("#"))
101
- const active = commentIndex === -1 ? tokens : tokens.slice(0, commentIndex)
102
- const hasWrapper = active.some((token) => prefixWrappers.has(path.basename(pathValue(token))))
103
- let commandIndex = active.findIndex((token) =>
104
- !redirections.has(token) &&
105
- !token.includes("=") &&
106
- !prefixWrappers.has(path.basename(pathValue(token))) &&
107
- !token.startsWith("-") &&
108
- token !== ""
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 = active.findIndex((token) => {
114
- const command = path.basename(pathValue(token))
115
- return writeCommands.has(command) || destinationCommands.has(command)
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 = path.basename(pathValue(active[commandIndex]!))
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 < active.length; index++) {
124
- const token = active[index]!
125
- if (index === commandIndex || redirections.has(token) || token.startsWith("-")) continue
126
- const value = pathValue(token)
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[candidates.length - 1]?.index
367
+ ? candidates.filter(({ index }) => !isTarget(index)).at(-1)?.index
132
368
  : undefined
133
369
 
134
- return candidates.map(({ index, value }) => {
135
- const previous = active[index - 1]
136
- const access: Access = previous === ">" || previous === ">>" ||
137
- writeCommands.has(command) || destinationIndex === index
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
- * Every path token the shell text names, one segment at a time.
146
- *
147
- * @category pre-check
148
- * @since 1.0.0
149
- */
150
- export const commandReferences = (path: Path.Path, command: string): ReadonlyArray<PathReference> => {
151
- const references: Array<PathReference> = []
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
- references.push(...segmentReferences(path, segment))
162
- return references
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
- for (const reference of commandReferences(path, text)) {
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