@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
@@ -30,7 +30,7 @@ import { elideMiddle } from "./Text.ts"
30
30
  * @category models
31
31
  * @since 1.0.0
32
32
  */
33
- export const ExecErrorCode = Schema.Literals(["timeout", "spawn_error", "capture_overflow"])
33
+ export const ExecErrorCode = Schema.Literals(["timeout", "spawn_error", "arguments_too_long", "capture_overflow"])
34
34
 
35
35
  /**
36
36
  * Failure codes a buffered execution can report.
@@ -44,8 +44,10 @@ export type ExecErrorCode = typeof ExecErrorCode.Type
44
44
  * A command that never produced an exit code.
45
45
  *
46
46
  * A non-zero exit is a successful {@link ExecResult}, not a failure; this
47
- * error is reserved for a command that could not start or was cut off by its
48
- * timeout, or whose captured output exceeded a refusing capture bound.
47
+ * error is reserved for a command that could not start (`arguments_too_long`
48
+ * when the operating system refused it for the size of its arguments) or was
49
+ * cut off by its timeout, or whose captured output exceeded a refusing capture
50
+ * bound.
49
51
  *
50
52
  * @category errors
51
53
  * @since 1.0.0
@@ -269,6 +271,23 @@ const capture = <E>(
269
271
  ? boundedEnds(stream, maxBytes)
270
272
  : boundedTail(stream, maxBytes, overflow)
271
273
 
274
+ /**
275
+ * A command whose arguments exceed the operating system's limit fails `execve`
276
+ * with `E2BIG` before it starts: one argument over 128 KiB on Linux, all of
277
+ * them over 1 MiB on macOS. A heredoc in a command line, or a file's text
278
+ * passed as an argument, is one such argument. The platform error's own text
279
+ * repeats the whole command line, so this text is written here instead, and a
280
+ * caller may show it as it stands.
281
+ */
282
+ const argumentsTooLong = (error: { readonly cause?: unknown }): ExecError | undefined =>
283
+ (error.cause as { readonly code?: unknown } | undefined)?.code === "E2BIG"
284
+ ? new ExecError({
285
+ code: "arguments_too_long",
286
+ message:
287
+ "Command not started: its arguments exceed the operating system's size limit (E2BIG). Send long text on standard input or in a file, not in the command line or its arguments."
288
+ })
289
+ : undefined
290
+
272
291
  const unbounded = (
273
292
  command: string,
274
293
  options: ExecOptions
@@ -327,7 +346,7 @@ const unbounded = (
327
346
  }).pipe(
328
347
  Effect.scoped,
329
348
  Effect.mapError((error) =>
330
- error instanceof ExecError ? error : new ExecError({
349
+ error instanceof ExecError ? error : argumentsTooLong(error) ?? new ExecError({
331
350
  code: "spawn_error",
332
351
  message: `exec: ${command}: ${error.message}`
333
352
  })
@@ -368,20 +387,23 @@ export const exec = (
368
387
  *
369
388
  * A timeout keeps its own code, because "the command never finished" and "the
370
389
  * command never started" are different things to a caller deciding whether to
371
- * retry. Every other way of producing no exit code is a host failure.
390
+ * retry. Arguments the operating system refused for their size are the
391
+ * caller's own input, which a shell flow publishes, so they read as
392
+ * `invalid_input`. Every other way of producing no exit code is a host failure.
372
393
  *
373
394
  * @category errors
374
395
  * @since 1.0.0
375
396
  */
376
397
  export const toStdErrorCode = (error: ExecError): StdError.Code =>
377
- error.code === "timeout" ? "timeout" : "command_failed"
398
+ error.code === "timeout" ? "timeout" : error.code === "arguments_too_long" ? "invalid_input" : "command_failed"
378
399
 
379
400
  /**
380
401
  * The model-facing failure one execution error becomes.
381
402
  *
382
403
  * A timeout names the command, which is what the caller asked for and all a
383
- * timeout has to say. Any other failure carries the message the execution
384
- * produced, which already names the command.
404
+ * timeout has to say. Arguments too long to start carry the reason and the
405
+ * fix, and nothing of the command. Any other failure carries the message the
406
+ * execution produced, which already names the command.
385
407
  *
386
408
  * @category errors
387
409
  * @since 1.0.0
@@ -391,6 +413,8 @@ export const toStdError = (command: string, error: ExecError): StdError.StdError
391
413
  code: toStdErrorCode(error),
392
414
  message: error.code === "timeout"
393
415
  ? `Command timed out: ${command}`
416
+ : error.code === "arguments_too_long"
417
+ ? error.message
394
418
  : `Command failed to start: ${error.message}`,
395
419
  ...(error.limitMillis === undefined ? {} : { limitMillis: error.limitMillis })
396
420
  })
@@ -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
+ }