@smthrs/std 1.0.0-rc.8 → 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 +10 -0
- package/README.md +3 -1
- 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.map +1 -1
- package/dist/cjs/internal/EnvelopePrecheck.js +21 -5
- package/dist/cjs/internal/EnvelopePrecheck.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/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.map +1 -1
- package/dist/esm/internal/EnvelopePrecheck.js +29 -5
- package/dist/esm/internal/EnvelopePrecheck.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/api.md +28 -4
- package/docs/concepts/effects-and-capabilities.md +3 -1
- package/docs/guides/bind-the-standard-flows.md +3 -1
- package/docs/guides/run-a-shell-command.md +4 -1
- package/docs/troubleshooting.md +8 -0
- package/package.json +16 -6
- package/src/PortableSearch.ts +20 -0
- package/src/RemoteSearch.ts +199 -0
- package/src/index.ts +3 -0
- package/src/internal/EnvelopePrecheck.ts +29 -5
- package/src/internal/RemoteTree.ts +159 -0
- package/src/internal/Tar.ts +95 -0
package/docs/troubleshooting.md
CHANGED
|
@@ -149,6 +149,14 @@ not covered by its own `reads` or `writes`. The message names the resolved path.
|
|
|
149
149
|
check is lexical and fail-closed: it reads the tokens in the command line, not
|
|
150
150
|
what the process will actually touch.
|
|
151
151
|
|
|
152
|
+
## invalid_input: a command nested deeper than 32 levels
|
|
153
|
+
|
|
154
|
+
**What happened.** A hermetic `bash` call nested command substitutions, quotes,
|
|
155
|
+
shell `-c` arguments, or `eval` calls more than 32 levels deep. The pre-check
|
|
156
|
+
refuses text it cannot read in bounded time rather than run it unchecked.
|
|
157
|
+
|
|
158
|
+
**What to change.** Flatten the command, or use `mode: "unhermetic"`.
|
|
159
|
+
|
|
152
160
|
## invalid_input: a hermetic call that names a container
|
|
153
161
|
|
|
154
162
|
**What happened.** `mode: "hermetic"` together with `container`. A container has
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smthrs/std",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.9",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"smthrs": {
|
|
7
7
|
"group": "agent"
|
|
@@ -252,6 +252,16 @@
|
|
|
252
252
|
"default": "./dist/cjs/Relocate.js"
|
|
253
253
|
}
|
|
254
254
|
},
|
|
255
|
+
"./RemoteSearch": {
|
|
256
|
+
"import": {
|
|
257
|
+
"types": "./dist/esm/RemoteSearch.d.ts",
|
|
258
|
+
"default": "./dist/esm/RemoteSearch.js"
|
|
259
|
+
},
|
|
260
|
+
"require": {
|
|
261
|
+
"types": "./dist/cjs/RemoteSearch.d.ts",
|
|
262
|
+
"default": "./dist/cjs/RemoteSearch.js"
|
|
263
|
+
}
|
|
264
|
+
},
|
|
255
265
|
"./Search": {
|
|
256
266
|
"import": {
|
|
257
267
|
"types": "./dist/esm/Search.d.ts",
|
|
@@ -401,11 +411,11 @@
|
|
|
401
411
|
"tag": "next"
|
|
402
412
|
},
|
|
403
413
|
"dependencies": {
|
|
404
|
-
"@smthrs/capability": "1.0.0-rc.
|
|
405
|
-
"@smthrs/control": "1.0.0-rc.
|
|
406
|
-
"@smthrs/core": "1.0.0-rc.
|
|
407
|
-
"@smthrs/kernel": "1.0.0-rc.
|
|
408
|
-
"@smthrs/model": "1.0.0-rc.
|
|
414
|
+
"@smthrs/capability": "1.0.0-rc.9",
|
|
415
|
+
"@smthrs/control": "1.0.0-rc.9",
|
|
416
|
+
"@smthrs/core": "1.0.0-rc.9",
|
|
417
|
+
"@smthrs/kernel": "1.0.0-rc.9",
|
|
418
|
+
"@smthrs/model": "1.0.0-rc.9"
|
|
409
419
|
},
|
|
410
420
|
"devDependencies": {
|
|
411
421
|
"@effect/language-service": "0.87.2",
|
package/src/PortableSearch.ts
CHANGED
|
@@ -240,6 +240,26 @@ const glob = (
|
|
|
240
240
|
}
|
|
241
241
|
})
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The files this peer's `grep` reads for `input`, in the order it reads
|
|
245
|
+
* them: the walk under `input.root` with its `.gitignore` rules and
|
|
246
|
+
* `input.globs` applied and symbolic links left out, or the root itself when
|
|
247
|
+
* it names one file. A peer that has to bring a tree's bytes to this process
|
|
248
|
+
* before it can search them (`RemoteSearch`) fetches exactly these.
|
|
249
|
+
*
|
|
250
|
+
* @category queries
|
|
251
|
+
* @since 1.0.0-rc.8
|
|
252
|
+
*/
|
|
253
|
+
export const searched = (
|
|
254
|
+
input: Pick<Search.GrepInput, "root" | "hidden" | "noIgnore" | "globs">
|
|
255
|
+
): Effect.Effect<ReadonlyArray<string>, StdError.StdError, FileSystem.FileSystem | Path.Path> =>
|
|
256
|
+
Effect.gen(function*() {
|
|
257
|
+
const fileSystem = yield* FileSystem.FileSystem
|
|
258
|
+
const path = yield* Path.Path
|
|
259
|
+
const walked = yield* Walk.files(fileSystem, path, input.root, input.hidden, input.noIgnore)
|
|
260
|
+
return yield* candidates(fileSystem, path, walked, input.root, input.globs)
|
|
261
|
+
})
|
|
262
|
+
|
|
243
263
|
/**
|
|
244
264
|
* Captures filesystem and path services in the portable peer.
|
|
245
265
|
*
|
|
@@ -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
|
|
@@ -115,6 +115,9 @@ const reservedWords = new Set([
|
|
|
115
115
|
const writeCommands = new Set(["chmod", "chown", "mkdir", "rm", "rmdir", "tee", "touch", "truncate", "unlink"])
|
|
116
116
|
const destinationCommands = new Set(["cp", "install", "mv"])
|
|
117
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]*$/
|
|
118
121
|
|
|
119
122
|
/**
|
|
120
123
|
* One lexical unit of shell text: an operator, or a word with its quotes
|
|
@@ -310,7 +313,11 @@ const isPathToken = (path: Path.Path, token: string): boolean =>
|
|
|
310
313
|
/^[A-Za-z]:[\\/]/.test(token) ||
|
|
311
314
|
token.includes("/")
|
|
312
315
|
|
|
313
|
-
const segmentReferences = (
|
|
316
|
+
const segmentReferences = (
|
|
317
|
+
path: Path.Path,
|
|
318
|
+
tokens: ReadonlyArray<Token>,
|
|
319
|
+
nesting: number
|
|
320
|
+
): ReadonlyArray<PathReference> => {
|
|
314
321
|
// The word after a redirection operator is its target, never the command.
|
|
315
322
|
const isTarget = (index: number): boolean => index > 0 && isRedirection(tokens[index - 1]!)
|
|
316
323
|
const isWord = (index: number): boolean => !tokens[index]!.operator && !isTarget(index)
|
|
@@ -334,10 +341,23 @@ const segmentReferences = (path: Path.Path, tokens: ReadonlyArray<Token>): Reado
|
|
|
334
341
|
}
|
|
335
342
|
|
|
336
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
|
+
}
|
|
337
356
|
const candidates: Array<{ readonly index: number; readonly value: string }> = []
|
|
338
357
|
for (let index = 0; index < tokens.length; index++) {
|
|
339
358
|
const token = tokens[index]!
|
|
340
|
-
if (index === commandIndex ||
|
|
359
|
+
if (index === commandIndex || scripts.has(index) || token.operator) continue
|
|
360
|
+
if (!isTarget(index) && token.text.startsWith("-")) continue
|
|
341
361
|
if (isTarget(index) && textRedirections.has(tokens[index - 1]!.text)) continue
|
|
342
362
|
const value = pathValue(token.text)
|
|
343
363
|
if (value !== "" && isPathToken(path, value)) candidates.push({ index, value })
|
|
@@ -347,7 +367,9 @@ const segmentReferences = (path: Path.Path, tokens: ReadonlyArray<Token>): Reado
|
|
|
347
367
|
? candidates.filter(({ index }) => !isTarget(index)).at(-1)?.index
|
|
348
368
|
: undefined
|
|
349
369
|
|
|
350
|
-
|
|
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 => {
|
|
351
373
|
const access: Access = isTarget(index)
|
|
352
374
|
? writeRedirections.has(tokens[index - 1]!.text) ? "write" : "read"
|
|
353
375
|
: writeCommands.has(command) || destinationIndex === index
|
|
@@ -355,21 +377,23 @@ const segmentReferences = (path: Path.Path, tokens: ReadonlyArray<Token>): Reado
|
|
|
355
377
|
: "read"
|
|
356
378
|
return { access, value }
|
|
357
379
|
})
|
|
380
|
+
return [...named, ...nested]
|
|
358
381
|
}
|
|
359
382
|
|
|
360
383
|
const references = (path: Path.Path, command: string, nesting: number): ReadonlyArray<PathReference> => {
|
|
384
|
+
if (nesting > maxNesting) throw tooDeep()
|
|
361
385
|
const { substitutions, tokens } = lex(command, nesting)
|
|
362
386
|
const found: Array<PathReference> = []
|
|
363
387
|
let segment: Array<Token> = []
|
|
364
388
|
for (const token of tokens) {
|
|
365
389
|
if (token.operator && commandSeparators.has(token.text)) {
|
|
366
|
-
found.push(...segmentReferences(path, segment))
|
|
390
|
+
found.push(...segmentReferences(path, segment, nesting))
|
|
367
391
|
segment = []
|
|
368
392
|
} else {
|
|
369
393
|
segment.push(token)
|
|
370
394
|
}
|
|
371
395
|
}
|
|
372
|
-
found.push(...segmentReferences(path, segment))
|
|
396
|
+
found.push(...segmentReferences(path, segment, nesting))
|
|
373
397
|
// A substitution runs its body as a command; its paths count like any other.
|
|
374
398
|
for (const body of substitutions) found.push(...references(path, body, nesting + 1))
|
|
375
399
|
return found
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tree another machine listed, served as a read-only `FileSystem`.
|
|
3
|
+
*
|
|
4
|
+
* `RemoteSearch` lists a root on the machine with `find` and brings back the
|
|
5
|
+
* bytes the search reads with `tar`; this module holds what came back and
|
|
6
|
+
* answers what the walk, the globs, and the matcher ask of a filesystem, so
|
|
7
|
+
* `PortableSearch` can search the copy as it searches a host.
|
|
8
|
+
*
|
|
9
|
+
* @since 1.0.0-rc.8
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type * as Path from "@smthrs/kernel/Path"
|
|
13
|
+
import { Effect, Option, Stream } from "effect"
|
|
14
|
+
import * as ByteSize from "effect/ByteSize"
|
|
15
|
+
import * as FileSystem from "effect/FileSystem"
|
|
16
|
+
import * as PlatformError from "effect/PlatformError"
|
|
17
|
+
import * as Tar from "./Tar.ts"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What one listed path is: `d` a directory, `u` a directory the machine's
|
|
21
|
+
* user may not list, `f` a file, `o` anything else, and a symbolic link to a
|
|
22
|
+
* directory (`D`), to a file (`F`), or to neither (`L`).
|
|
23
|
+
*
|
|
24
|
+
* @private
|
|
25
|
+
* @since 1.0.0-rc.8
|
|
26
|
+
*/
|
|
27
|
+
export type Kind = "d" | "u" | "f" | "o" | "D" | "F" | "L"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One machine's listing of a root, and the bytes it brought back, by
|
|
31
|
+
* absolute path.
|
|
32
|
+
*
|
|
33
|
+
* @private
|
|
34
|
+
* @since 1.0.0-rc.8
|
|
35
|
+
*/
|
|
36
|
+
export interface Tree {
|
|
37
|
+
readonly kinds: ReadonlyMap<string, Kind>
|
|
38
|
+
readonly children: ReadonlyMap<string, ReadonlyArray<string>>
|
|
39
|
+
readonly contents: ReadonlyMap<string, Uint8Array>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const kinds: ReadonlySet<string> = new Set(["d", "u", "f", "o", "D", "F", "L"])
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The member names `tar` wrote for absolute paths, which it strips of their
|
|
46
|
+
* leading `/`, as those paths again.
|
|
47
|
+
*
|
|
48
|
+
* @private
|
|
49
|
+
* @since 1.0.0-rc.8
|
|
50
|
+
*/
|
|
51
|
+
export const absolute = (members: ReadonlyMap<string, Uint8Array>): ReadonlyMap<string, Uint8Array> =>
|
|
52
|
+
new Map([...members].map(([name, bytes]) => [`/${name}`, bytes]))
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reads NUL-terminated `<kind><path>` records up to an `E` record, then the
|
|
56
|
+
* tar stream of `.gitignore` files after it. `root` is the listed root, which
|
|
57
|
+
* is nobody's child. `undefined` when the bytes are not that shape.
|
|
58
|
+
*
|
|
59
|
+
* @private
|
|
60
|
+
* @since 1.0.0-rc.8
|
|
61
|
+
*/
|
|
62
|
+
export const parse = (bytes: Uint8Array, root: string, path: Path.Path): Tree | undefined => {
|
|
63
|
+
const decoder = new TextDecoder()
|
|
64
|
+
const listed = new Map<string, Kind>()
|
|
65
|
+
const children = new Map<string, Array<string>>()
|
|
66
|
+
let offset = 0
|
|
67
|
+
for (;;) {
|
|
68
|
+
const end = bytes.indexOf(0, offset)
|
|
69
|
+
if (end === -1) return undefined
|
|
70
|
+
const record = decoder.decode(bytes.subarray(offset, end))
|
|
71
|
+
offset = end + 1
|
|
72
|
+
if (record === "E") break
|
|
73
|
+
const kind = record.slice(0, 1)
|
|
74
|
+
if (!kinds.has(kind)) return undefined
|
|
75
|
+
const entry = record.slice(1)
|
|
76
|
+
listed.set(entry, kind as Kind)
|
|
77
|
+
if (entry === root) continue
|
|
78
|
+
const parent = path.dirname(entry)
|
|
79
|
+
const siblings = children.get(parent)
|
|
80
|
+
if (siblings === undefined) children.set(parent, [path.basename(entry)])
|
|
81
|
+
else siblings.push(path.basename(entry))
|
|
82
|
+
}
|
|
83
|
+
const ignores = Tar.files(bytes.subarray(offset))
|
|
84
|
+
return ignores === undefined ? undefined : { kinds: listed, children, contents: absolute(ignores) }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const refusal = (tag: PlatformError.SystemErrorTag, method: string, path: string): PlatformError.PlatformError =>
|
|
88
|
+
PlatformError.systemError({ _tag: tag, module: "FileSystem", method, pathOrDescriptor: path })
|
|
89
|
+
|
|
90
|
+
/** What `stat` says each kind is: links resolved, a dangling one absent. */
|
|
91
|
+
const types: Readonly<Record<Kind, FileSystem.File.Type | undefined>> = {
|
|
92
|
+
d: "Directory",
|
|
93
|
+
u: "Directory",
|
|
94
|
+
D: "Directory",
|
|
95
|
+
f: "File",
|
|
96
|
+
F: "File",
|
|
97
|
+
o: "Unknown",
|
|
98
|
+
L: undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const info = (type: FileSystem.File.Type, size: number): FileSystem.File.Info => ({
|
|
102
|
+
type,
|
|
103
|
+
size: ByteSize.bytes(BigInt(size)),
|
|
104
|
+
mtime: Option.none(),
|
|
105
|
+
atime: Option.none(),
|
|
106
|
+
birthtime: Option.none(),
|
|
107
|
+
dev: 0,
|
|
108
|
+
ino: Option.none(),
|
|
109
|
+
mode: 0,
|
|
110
|
+
nlink: Option.none(),
|
|
111
|
+
uid: Option.none(),
|
|
112
|
+
gid: Option.none(),
|
|
113
|
+
rdev: Option.none(),
|
|
114
|
+
blksize: Option.none(),
|
|
115
|
+
blocks: Option.none()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The tree as a read-only `FileSystem`, resolving paths with `path`. A file
|
|
120
|
+
* whose bytes did not come back is one the machine's user could not read,
|
|
121
|
+
* which the in-process peer skips exactly as it skips such a file on a host.
|
|
122
|
+
*
|
|
123
|
+
* @private
|
|
124
|
+
* @since 1.0.0-rc.8
|
|
125
|
+
*/
|
|
126
|
+
export const fileSystem = (tree: Tree, path: Path.Path): FileSystem.FileSystem => {
|
|
127
|
+
const kindOf = (value: string) => tree.kinds.get(path.resolve(value))
|
|
128
|
+
const readFile = (value: string) => {
|
|
129
|
+
const bytes = tree.contents.get(path.resolve(value))
|
|
130
|
+
if (bytes !== undefined) return Effect.succeed(bytes)
|
|
131
|
+
const type = types[kindOf(value) ?? "L"]
|
|
132
|
+
return Effect.fail(
|
|
133
|
+
refusal(type === "File" ? "PermissionDenied" : type === undefined ? "NotFound" : "BadResource", "readFile", value)
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
return FileSystem.makeNoop({
|
|
137
|
+
stat: (value) => {
|
|
138
|
+
const type = types[kindOf(value) ?? "L"]
|
|
139
|
+
return type === undefined
|
|
140
|
+
? Effect.fail(refusal("NotFound", "stat", value))
|
|
141
|
+
: Effect.succeed(info(type, tree.contents.get(path.resolve(value))?.length ?? 0))
|
|
142
|
+
},
|
|
143
|
+
readDirectory: (value) => {
|
|
144
|
+
const kind = kindOf(value)
|
|
145
|
+
if (kind === "d") return Effect.succeed([...tree.children.get(path.resolve(value)) ?? []])
|
|
146
|
+
const tag = kind === "u" ? "PermissionDenied" : kind === undefined || kind === "L" ? "NotFound" : "BadResource"
|
|
147
|
+
return Effect.fail(refusal(tag, "readDirectory", value))
|
|
148
|
+
},
|
|
149
|
+
readLink: (value) => {
|
|
150
|
+
const kind = kindOf(value)
|
|
151
|
+
return kind === "D" || kind === "F" || kind === "L"
|
|
152
|
+
? Effect.succeed("")
|
|
153
|
+
: Effect.fail(refusal(kind === undefined ? "NotFound" : "BadResource", "readLink", value))
|
|
154
|
+
},
|
|
155
|
+
readFile,
|
|
156
|
+
readFileString: (value) => Effect.map(readFile(value), (bytes) => new TextDecoder().decode(bytes)),
|
|
157
|
+
stream: (value) => Stream.fromEffect(readFile(value))
|
|
158
|
+
})
|
|
159
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The regular files of a tar stream.
|
|
3
|
+
*
|
|
4
|
+
* `RemoteSearch` brings a machine's files back with its own `tar`, because
|
|
5
|
+
* every image ships one and it frames each file with its name and size. GNU
|
|
6
|
+
* and BusyBox `tar` write GNU long names, `bsdtar` writes POSIX extended
|
|
7
|
+
* headers, and `xargs` may start `tar` more than once, so the stream can hold
|
|
8
|
+
* several archives one after another. This reads all of them.
|
|
9
|
+
*
|
|
10
|
+
* @since 1.0.0-rc.8
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const decoder = new TextDecoder()
|
|
14
|
+
|
|
15
|
+
/** Bytes up to the first NUL, as text. */
|
|
16
|
+
const text = (bytes: Uint8Array): string => {
|
|
17
|
+
const end = bytes.indexOf(0)
|
|
18
|
+
return decoder.decode(end === -1 ? bytes : bytes.subarray(0, end))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** An octal header field, or `undefined` when it holds anything else. */
|
|
22
|
+
const octal = (bytes: Uint8Array): number | undefined => {
|
|
23
|
+
const digits = text(bytes).trim()
|
|
24
|
+
return /^[0-7]+$/.test(digits) ? Number.parseInt(digits, 8) : undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The header's checksum, computed with the checksum field read as spaces. */
|
|
28
|
+
const checksum = (header: Uint8Array): number => {
|
|
29
|
+
let sum = 0
|
|
30
|
+
for (let index = 0; index < 512; index++) sum += index >= 148 && index < 156 ? 32 : header[index]!
|
|
31
|
+
return sum
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The `path` record of a POSIX extended header: `<length> <key>=<value>\n` records. */
|
|
35
|
+
const paxPath = (data: Uint8Array): string | undefined => {
|
|
36
|
+
let offset = 0
|
|
37
|
+
let path: string | undefined
|
|
38
|
+
while (offset < data.length) {
|
|
39
|
+
const space = data.indexOf(32, offset)
|
|
40
|
+
const length = space === -1 ? Number.NaN : Number(decoder.decode(data.subarray(offset, space)))
|
|
41
|
+
if (!Number.isSafeInteger(length) || length <= space - offset || offset + length > data.length) return path
|
|
42
|
+
const record = decoder.decode(data.subarray(space + 1, offset + length - 1))
|
|
43
|
+
if (record.startsWith("path=")) path = record.slice("path=".length)
|
|
44
|
+
offset += length
|
|
45
|
+
}
|
|
46
|
+
return path
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every regular file the stream holds, by member name, or `undefined` when
|
|
51
|
+
* the stream is not whole tar archives: a header whose checksum does not
|
|
52
|
+
* hold, a member cut short, or a last archive with no end. A `tar` that
|
|
53
|
+
* stopped between two members wrote no end, so its missing files are not
|
|
54
|
+
* mistaken for files it could not read.
|
|
55
|
+
*
|
|
56
|
+
* @private
|
|
57
|
+
* @since 1.0.0-rc.8
|
|
58
|
+
*/
|
|
59
|
+
export const files = (stream: Uint8Array): ReadonlyMap<string, Uint8Array> | undefined => {
|
|
60
|
+
const found = new Map<string, Uint8Array>()
|
|
61
|
+
let longName: string | undefined
|
|
62
|
+
let extended: string | undefined
|
|
63
|
+
let ended = true
|
|
64
|
+
for (let offset = 0; offset + 512 <= stream.length;) {
|
|
65
|
+
const header = stream.subarray(offset, offset + 512)
|
|
66
|
+
offset += 512
|
|
67
|
+
// Zero blocks end one archive; `xargs` may have started another.
|
|
68
|
+
ended = header.every((byte) => byte === 0)
|
|
69
|
+
if (ended) continue
|
|
70
|
+
const size = octal(header.subarray(124, 136))
|
|
71
|
+
if (size === undefined || octal(header.subarray(148, 156)) !== checksum(header)) return undefined
|
|
72
|
+
if (offset + size > stream.length) return undefined
|
|
73
|
+
const data = stream.subarray(offset, offset + size)
|
|
74
|
+
offset += Math.ceil(size / 512) * 512
|
|
75
|
+
const type = String.fromCharCode(header[156]!)
|
|
76
|
+
if (type === "L") {
|
|
77
|
+
longName = text(data)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
if (type === "x") {
|
|
81
|
+
extended = paxPath(data) ?? extended
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
// A long link name and a global header describe no member of their own.
|
|
85
|
+
if (type === "K" || type === "g") continue
|
|
86
|
+
if (type === "0" || type === "\0" || type === "7") {
|
|
87
|
+
const prefix = decoder.decode(header.subarray(257, 263)) === "ustar\0" ? text(header.subarray(345, 500)) : ""
|
|
88
|
+
const short = text(header.subarray(0, 100))
|
|
89
|
+
found.set(extended ?? longName ?? (prefix === "" ? short : `${prefix}/${short}`), data.slice())
|
|
90
|
+
}
|
|
91
|
+
longName = undefined
|
|
92
|
+
extended = undefined
|
|
93
|
+
}
|
|
94
|
+
return ended ? found : undefined
|
|
95
|
+
}
|