@smthrs/std 1.0.0-rc.6 → 1.0.0-rc.8

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 (57) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +39 -37
  3. package/dist/cjs/Bash.d.ts +10 -0
  4. package/dist/cjs/Bash.d.ts.map +1 -1
  5. package/dist/cjs/Bash.js +13 -12
  6. package/dist/cjs/Bash.js.map +3 -3
  7. package/dist/cjs/Checkpoints.d.ts +37 -0
  8. package/dist/cjs/Checkpoints.d.ts.map +1 -1
  9. package/dist/cjs/Checkpoints.js +75 -0
  10. package/dist/cjs/Checkpoints.js.map +2 -2
  11. package/dist/cjs/internal/EnvelopePrecheck.d.ts +2 -0
  12. package/dist/cjs/internal/EnvelopePrecheck.d.ts.map +1 -1
  13. package/dist/cjs/internal/EnvelopePrecheck.js +217 -34
  14. package/dist/cjs/internal/EnvelopePrecheck.js.map +3 -3
  15. package/dist/cjs/internal/Exec.d.ts +26 -14
  16. package/dist/cjs/internal/Exec.d.ts.map +1 -1
  17. package/dist/cjs/internal/Exec.js +73 -31
  18. package/dist/cjs/internal/Exec.js.map +2 -2
  19. package/dist/cjs/internal/Text.d.ts +11 -0
  20. package/dist/cjs/internal/Text.d.ts.map +1 -1
  21. package/dist/cjs/internal/Text.js +3 -0
  22. package/dist/cjs/internal/Text.js.map +2 -2
  23. package/dist/esm/Bash.d.ts +10 -0
  24. package/dist/esm/Bash.d.ts.map +1 -1
  25. package/dist/esm/Bash.js +22 -13
  26. package/dist/esm/Bash.js.map +1 -1
  27. package/dist/esm/Checkpoints.d.ts +37 -0
  28. package/dist/esm/Checkpoints.d.ts.map +1 -1
  29. package/dist/esm/Checkpoints.js +85 -0
  30. package/dist/esm/Checkpoints.js.map +1 -1
  31. package/dist/esm/internal/EnvelopePrecheck.d.ts +2 -0
  32. package/dist/esm/internal/EnvelopePrecheck.d.ts.map +1 -1
  33. package/dist/esm/internal/EnvelopePrecheck.js +295 -60
  34. package/dist/esm/internal/EnvelopePrecheck.js.map +1 -1
  35. package/dist/esm/internal/Exec.d.ts +26 -14
  36. package/dist/esm/internal/Exec.d.ts.map +1 -1
  37. package/dist/esm/internal/Exec.js +116 -30
  38. package/dist/esm/internal/Exec.js.map +1 -1
  39. package/dist/esm/internal/Text.d.ts +11 -0
  40. package/dist/esm/internal/Text.d.ts.map +1 -1
  41. package/dist/esm/internal/Text.js +11 -0
  42. package/dist/esm/internal/Text.js.map +1 -1
  43. package/docs/README.md +3 -1
  44. package/docs/api.md +21 -18
  45. package/docs/concepts/limits-and-disclosure.md +8 -5
  46. package/docs/guides/pin-a-checkpoint.md +20 -0
  47. package/docs/guides/run-a-shell-command.md +16 -6
  48. package/docs/installation.md +13 -9
  49. package/docs/quickstart.md +1 -1
  50. package/docs/reference/flows.md +2 -2
  51. package/docs/troubleshooting.md +15 -3
  52. package/package.json +6 -6
  53. package/src/Bash.ts +25 -13
  54. package/src/Checkpoints.ts +137 -0
  55. package/src/internal/EnvelopePrecheck.ts +295 -63
  56. package/src/internal/Exec.ts +157 -51
  57. package/src/internal/Text.ts +15 -0
@@ -55,37 +55,251 @@ 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"])
63
118
 
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.
119
+ /**
120
+ * One lexical unit of shell text: an operator, or a word with its quotes
121
+ * removed. A command substitution inside a word stands as `$()` in the word;
122
+ * its body is scanned as a command of its own.
123
+ */
124
+ interface Token {
125
+ readonly operator: boolean
126
+ readonly text: string
127
+ }
128
+
129
+ interface Lexed {
130
+ readonly tokens: ReadonlyArray<Token>
131
+ readonly substitutions: ReadonlyArray<string>
132
+ }
133
+
134
+ // The end of a `$(` body that starts at `from`: the index of its closing
135
+ // parenthesis, skipping quoted spans and nested parentheses. An unclosed body
136
+ // runs to the end of the text, which is how a fail-closed scan must read it.
137
+ // Substitutions and quotes nest without limit in a shell, but each level
138
+ // costs this scan a pass over the rest of the text and a stack frame. Fifty
139
+ // thousand `$(` used to run for half a minute and then overflow the stack, so
140
+ // past this depth the text is refused rather than scanned.
141
+ const maxNesting = 32
142
+
143
+ const tooDeep = (): StdError.StdError =>
144
+ new StdError.StdError({
145
+ code: "invalid_input",
146
+ message: `Command nests substitutions or quotes deeper than ${maxNesting} levels`
147
+ })
148
+
149
+ const substitutionEnd = (text: string, from: number, nesting: number): number => {
150
+ if (nesting > maxNesting) throw tooDeep()
151
+ let depth = 1
152
+ let index = from
153
+ while (index < text.length) {
154
+ const char = text[index]!
155
+ if (char === "\\") {
156
+ index += 2
157
+ } else if (char === "'") {
158
+ const close = text.indexOf("'", index + 1)
159
+ index = close === -1 ? text.length : close + 1
160
+ } else if (char === "\"") {
161
+ index = doubleQuoteEnd(text, index + 1, nesting + 1) + 1
162
+ } else if (char === "`") {
163
+ index = backtickEnd(text, index + 1) + 1
164
+ } else {
165
+ if (char === "(") depth++
166
+ if (char === ")" && --depth === 0) return index
167
+ index++
168
+ }
169
+ }
170
+ return text.length
171
+ }
172
+
173
+ const backtickEnd = (text: string, from: number): number => {
174
+ let index = from
175
+ while (index < text.length && text[index] !== "`") index += text[index] === "\\" ? 2 : 1
176
+ return Math.min(index, text.length)
177
+ }
178
+
179
+ const doubleQuoteEnd = (text: string, from: number, nesting: number): number => {
180
+ if (nesting > maxNesting) throw tooDeep()
181
+ let index = from
182
+ while (index < text.length && text[index] !== "\"") {
183
+ const char = text[index]!
184
+ if (char === "\\") index += 2
185
+ else if (char === "`") index = backtickEnd(text, index + 1) + 1
186
+ else if (char === "$" && text[index + 1] === "(") index = substitutionEnd(text, index + 2, nesting + 1) + 1
187
+ else index++
188
+ }
189
+ return Math.min(index, text.length)
190
+ }
191
+
192
+ // A hand-rolled lexer rather than one regular expression: a word is any run
193
+ // of bare characters, quoted spans, and substitutions, and a substitution can
194
+ // hold quotes and operators of its own. Splitting on operator characters
195
+ // alone let `echo "$(rm /work/target)"` hide a delete inside one quoted word,
196
+ // read `>|` as a pipe that turned the target into the next command's name,
197
+ // and read `(rm /work/target)` as a command named `(rm`.
66
198
  //
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
199
+ // Quotes are removed from a word's text, because `"/outs"ide/x` names
200
+ // `/outside/x`; keeping the quotes made it a relative path under the
201
+ // working directory.
202
+ // Sticky, so the match starts at `lastIndex` without copying the rest of the
203
+ // text for every word.
204
+ const descriptorPattern = /\d+(?=[<>])/y
205
+
206
+ const lex = (command: string, nesting: number): Lexed => {
207
+ const tokens: Array<Token> = []
208
+ const substitutions: Array<string> = []
209
+ let index = 0
210
+ const substitution = (from: number, to: number): string => {
211
+ substitutions.push(command.slice(from, to))
212
+ return "$()"
213
+ }
214
+ while (index < command.length) {
215
+ const char = command[index]!
216
+ // Every blank the word loop stops at must be skipped here, or the lexer
217
+ // would push an empty word without advancing.
218
+ if (char !== "\n" && /\s/.test(char)) {
219
+ index++
220
+ continue
221
+ }
222
+ if (char === "\\" && command[index + 1] === "\n") {
223
+ index += 2
224
+ continue
225
+ }
226
+ if (char === "#") {
227
+ while (index < command.length && command[index] !== "\n") index++
228
+ continue
229
+ }
230
+ // A file-descriptor number belongs to the redirection after it: in
231
+ // `2>/dev/null rm /work/target` the command is `rm`, not `2`.
232
+ descriptorPattern.lastIndex = index
233
+ const descriptor = descriptorPattern.exec(command)
234
+ if (descriptor !== null) {
235
+ index += descriptor[0].length
236
+ continue
237
+ }
238
+ const operator = operators.find((candidate) => command.startsWith(candidate, index))
239
+ if (operator !== undefined) {
240
+ tokens.push({ operator: true, text: operator })
241
+ index += operator.length
242
+ continue
243
+ }
244
+ let text = ""
245
+ while (index < command.length) {
246
+ const next = command[index]!
247
+ if (/[\s<>;|&()]/.test(next)) break
248
+ if (next === "\\") {
249
+ // A backslash escapes shell syntax. Before any other character it is
250
+ // kept, so `C:\work\input` stays the path it names on Windows rather
251
+ // than collapsing to a word the scan cannot see as a path.
252
+ const escaped = command[index + 1] ?? ""
253
+ if (escaped !== "\n") text += /[\s<>;|&()'"`$\\#]/.test(escaped) ? escaped : `\\${escaped}`
254
+ index += 2
255
+ } else if (next === "'") {
256
+ const close = command.indexOf("'", index + 1)
257
+ const end = close === -1 ? command.length : close
258
+ text += command.slice(index + 1, end)
259
+ index = end + 1
260
+ } else if (next === "\"") {
261
+ const end = doubleQuoteEnd(command, index + 1, nesting + 1)
262
+ let inner = index + 1
263
+ while (inner < end) {
264
+ const quoted = command[inner]!
265
+ if (quoted === "\\" && /[$`"\\\n]/.test(command[inner + 1] ?? "")) {
266
+ if (command[inner + 1] !== "\n") text += command[inner + 1]
267
+ inner += 2
268
+ } else if (quoted === "`") {
269
+ const close = backtickEnd(command, inner + 1)
270
+ text += substitution(inner + 1, close)
271
+ inner = close + 1
272
+ } else if (quoted === "$" && command[inner + 1] === "(") {
273
+ const close = substitutionEnd(command, inner + 2, nesting + 2)
274
+ text += substitution(inner + 2, close)
275
+ inner = close + 1
276
+ } else {
277
+ text += quoted
278
+ inner++
279
+ }
280
+ }
281
+ index = end + 1
282
+ } else if (next === "`") {
283
+ const close = backtickEnd(command, index + 1)
284
+ text += substitution(index + 1, close)
285
+ index = close + 1
286
+ } else if (next === "$" && command[index + 1] === "(") {
287
+ const close = substitutionEnd(command, index + 2, nesting + 1)
288
+ text += substitution(index + 2, close)
289
+ index = close + 1
290
+ } else {
291
+ text += next
292
+ index++
293
+ }
294
+ }
295
+ tokens.push({ operator: false, text })
296
+ }
297
+ return { tokens, substitutions }
83
298
  }
84
299
 
85
- const pathValue = (token: string): string =>
86
- unquote(token)
87
- .replace(/^\$\(+/, "")
88
- .replace(/[),]+$/, "")
300
+ // A parenthesis is an operator to the lexer, so one left in a word was quoted
301
+ // and is part of the name; only a trailing comma is punctuation.
302
+ const pathValue = (token: string): string => token.replace(/,+$/, "")
89
303
 
90
304
  const isPathToken = (path: Path.Path, token: string): boolean =>
91
305
  token === "." ||
@@ -96,72 +310,82 @@ const isPathToken = (path: Path.Path, token: string): boolean =>
96
310
  /^[A-Za-z]:[\\/]/.test(token) ||
97
311
  token.includes("/")
98
312
 
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 !== ""
313
+ const segmentReferences = (path: Path.Path, tokens: ReadonlyArray<Token>): ReadonlyArray<PathReference> => {
314
+ // The word after a redirection operator is its target, never the command.
315
+ const isTarget = (index: number): boolean => index > 0 && isRedirection(tokens[index - 1]!)
316
+ const isWord = (index: number): boolean => !tokens[index]!.operator && !isTarget(index)
317
+ const name = (index: number): string => path.basename(pathValue(tokens[index]!.text))
318
+ const hasWrapper = tokens.some((_, index) => isWord(index) && prefixWrappers.has(name(index)))
319
+ let commandIndex = tokens.findIndex((token, index) =>
320
+ isWord(index) &&
321
+ !token.text.includes("=") &&
322
+ !prefixWrappers.has(name(index)) &&
323
+ !reservedWords.has(token.text) &&
324
+ !token.text.startsWith("-") &&
325
+ token.text !== ""
109
326
  )
110
327
  if (hasWrapper) {
111
328
  // Wrapper options can carry their own values. Prefer a later known
112
329
  // 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
- })
330
+ const mutatingIndex = tokens.findIndex((_, index) =>
331
+ isWord(index) && (writeCommands.has(name(index)) || destinationCommands.has(name(index)))
332
+ )
117
333
  if (mutatingIndex !== -1) commandIndex = mutatingIndex
118
334
  }
119
- if (commandIndex === -1) return []
120
335
 
121
- const command = path.basename(pathValue(active[commandIndex]!))
336
+ const command = commandIndex === -1 ? "" : name(commandIndex)
122
337
  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)
338
+ for (let index = 0; index < tokens.length; index++) {
339
+ const token = tokens[index]!
340
+ if (index === commandIndex || token.operator || (!isTarget(index) && token.text.startsWith("-"))) continue
341
+ if (isTarget(index) && textRedirections.has(tokens[index - 1]!.text)) continue
342
+ const value = pathValue(token.text)
127
343
  if (value !== "" && isPathToken(path, value)) candidates.push({ index, value })
128
344
  }
129
345
 
130
346
  const destinationIndex = destinationCommands.has(command)
131
- ? candidates[candidates.length - 1]?.index
347
+ ? candidates.filter(({ index }) => !isTarget(index)).at(-1)?.index
132
348
  : undefined
133
349
 
134
350
  return candidates.map(({ index, value }) => {
135
- const previous = active[index - 1]
136
- const access: Access = previous === ">" || previous === ">>" ||
137
- writeCommands.has(command) || destinationIndex === index
351
+ const access: Access = isTarget(index)
352
+ ? writeRedirections.has(tokens[index - 1]!.text) ? "write" : "read"
353
+ : writeCommands.has(command) || destinationIndex === index
138
354
  ? "write"
139
355
  : "read"
140
356
  return { access, value }
141
357
  })
142
358
  }
143
359
 
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))
360
+ const references = (path: Path.Path, command: string, nesting: number): ReadonlyArray<PathReference> => {
361
+ const { substitutions, tokens } = lex(command, nesting)
362
+ const found: Array<PathReference> = []
363
+ let segment: Array<Token> = []
364
+ for (const token of tokens) {
365
+ if (token.operator && commandSeparators.has(token.text)) {
366
+ found.push(...segmentReferences(path, segment))
156
367
  segment = []
157
368
  } else {
158
369
  segment.push(token)
159
370
  }
160
371
  }
161
- references.push(...segmentReferences(path, segment))
162
- return references
372
+ found.push(...segmentReferences(path, segment))
373
+ // A substitution runs its body as a command; its paths count like any other.
374
+ for (const body of substitutions) found.push(...references(path, body, nesting + 1))
375
+ return found
163
376
  }
164
377
 
378
+ /**
379
+ * Every path token the shell text names, one segment at a time.
380
+ *
381
+ * @throws {StdError.StdError} `invalid_input` when substitutions or quotes nest
382
+ * deeper than the scan reads.
383
+ * @category pre-check
384
+ * @since 1.0.0
385
+ */
386
+ export const commandReferences = (path: Path.Path, command: string): ReadonlyArray<PathReference> =>
387
+ references(path, command, 0)
388
+
165
389
  const resolvePath = (
166
390
  path: Path.Path,
167
391
  cwd: string,
@@ -213,7 +437,15 @@ export const outsideEnvelope = (
213
437
  // This lexical scan is intentionally only a fail-closed pre-check for
214
438
  // explicit path tokens. It cannot prove confinement; a kernel/host sandbox
215
439
  // must eventually enforce and report the complete read and write sets.
216
- for (const reference of commandReferences(path, text)) {
440
+ let named: ReadonlyArray<PathReference>
441
+ try {
442
+ named = commandReferences(path, text)
443
+ } catch {
444
+ // The depth bound is the one failure the scan raises, and a text the scan
445
+ // cannot read is refused whatever the reason, never run unchecked.
446
+ return tooDeep()
447
+ }
448
+ for (const reference of named) {
217
449
  const resolved = resolvePath(path, cwd, reference.value)
218
450
  // /dev/* is the process plumbing every shell one-liner leans on:
219
451
  // `2>/dev/null`, `cat /dev/stdin`. Normalize first so a dot-dot segment