@smthrs/std 1.0.0-rc.7 → 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.
- package/CHANGELOG.md +4 -0
- package/README.md +38 -36
- package/dist/cjs/Checkpoints.d.ts +37 -0
- package/dist/cjs/Checkpoints.d.ts.map +1 -1
- package/dist/cjs/Checkpoints.js +75 -0
- package/dist/cjs/Checkpoints.js.map +2 -2
- package/dist/cjs/internal/EnvelopePrecheck.d.ts +2 -0
- package/dist/cjs/internal/EnvelopePrecheck.d.ts.map +1 -1
- package/dist/cjs/internal/EnvelopePrecheck.js +217 -34
- package/dist/cjs/internal/EnvelopePrecheck.js.map +3 -3
- package/dist/cjs/internal/Exec.d.ts +12 -7
- package/dist/cjs/internal/Exec.d.ts.map +1 -1
- package/dist/cjs/internal/Exec.js +8 -4
- package/dist/cjs/internal/Exec.js.map +2 -2
- package/dist/esm/Checkpoints.d.ts +37 -0
- package/dist/esm/Checkpoints.d.ts.map +1 -1
- package/dist/esm/Checkpoints.js +85 -0
- package/dist/esm/Checkpoints.js.map +1 -1
- package/dist/esm/internal/EnvelopePrecheck.d.ts +2 -0
- package/dist/esm/internal/EnvelopePrecheck.d.ts.map +1 -1
- package/dist/esm/internal/EnvelopePrecheck.js +295 -60
- package/dist/esm/internal/EnvelopePrecheck.js.map +1 -1
- package/dist/esm/internal/Exec.d.ts +12 -7
- package/dist/esm/internal/Exec.d.ts.map +1 -1
- package/dist/esm/internal/Exec.js +30 -9
- package/dist/esm/internal/Exec.js.map +1 -1
- package/docs/README.md +3 -1
- package/docs/api.md +20 -17
- package/docs/guides/pin-a-checkpoint.md +20 -0
- package/docs/installation.md +13 -9
- package/docs/quickstart.md +1 -1
- package/docs/troubleshooting.md +12 -0
- package/package.json +6 -6
- package/src/Checkpoints.ts +137 -0
- package/src/internal/EnvelopePrecheck.ts +295 -63
- package/src/internal/Exec.ts +32 -8
|
@@ -55,37 +55,251 @@ export interface PathReference {
|
|
|
55
55
|
readonly value: string
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
const commandSeparators = new Set(["&&", "||", ";", "|", "&", "\n"])
|
|
59
|
-
const
|
|
58
|
+
const commandSeparators = new Set(["&&", "||", ";", ";;", ";&", ";;&", "|", "|&", "&", "\n", "(", ")"])
|
|
59
|
+
const writeRedirections = new Set([">", ">>", ">|", "&>", "&>>", ">&", "<>"])
|
|
60
|
+
const readRedirections = new Set(["<", "<&"])
|
|
61
|
+
// A here-document delimiter and a here-string are text, not file names.
|
|
62
|
+
const textRedirections = new Set(["<<", "<<-", "<<<"])
|
|
63
|
+
const isRedirection = (token: Token): boolean =>
|
|
64
|
+
token.operator &&
|
|
65
|
+
(writeRedirections.has(token.text) || readRedirections.has(token.text) || textRedirections.has(token.text))
|
|
66
|
+
// Longest first, so `&>>` is not read as `&` then `>>`.
|
|
67
|
+
const operators = [
|
|
68
|
+
";;&",
|
|
69
|
+
"&>>",
|
|
70
|
+
"<<<",
|
|
71
|
+
"<<-",
|
|
72
|
+
";;",
|
|
73
|
+
";&",
|
|
74
|
+
"&&",
|
|
75
|
+
"||",
|
|
76
|
+
"|&",
|
|
77
|
+
"&>",
|
|
78
|
+
">>",
|
|
79
|
+
">|",
|
|
80
|
+
">&",
|
|
81
|
+
"<>",
|
|
82
|
+
"<<",
|
|
83
|
+
"<&",
|
|
84
|
+
"\n",
|
|
85
|
+
";",
|
|
86
|
+
"|",
|
|
87
|
+
"&",
|
|
88
|
+
"(",
|
|
89
|
+
")",
|
|
90
|
+
">",
|
|
91
|
+
"<"
|
|
92
|
+
]
|
|
93
|
+
// Reserved words and grouping braces open or close a compound command; the
|
|
94
|
+
// command they wrap stands after them.
|
|
95
|
+
const reservedWords = new Set([
|
|
96
|
+
"!",
|
|
97
|
+
"{",
|
|
98
|
+
"}",
|
|
99
|
+
"case",
|
|
100
|
+
"do",
|
|
101
|
+
"done",
|
|
102
|
+
"elif",
|
|
103
|
+
"else",
|
|
104
|
+
"esac",
|
|
105
|
+
"fi",
|
|
106
|
+
"for",
|
|
107
|
+
"function",
|
|
108
|
+
"if",
|
|
109
|
+
"in",
|
|
110
|
+
"select",
|
|
111
|
+
"then",
|
|
112
|
+
"until",
|
|
113
|
+
"while"
|
|
114
|
+
])
|
|
60
115
|
const writeCommands = new Set(["chmod", "chown", "mkdir", "rm", "rmdir", "tee", "touch", "truncate", "unlink"])
|
|
61
116
|
const destinationCommands = new Set(["cp", "install", "mv"])
|
|
62
117
|
const prefixWrappers = new Set(["env", "nice", "time", "sudo", "nohup", "xargs", "command", "exec"])
|
|
63
118
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// the
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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<
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
!token.
|
|
108
|
-
|
|
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 =
|
|
114
|
-
|
|
115
|
-
|
|
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 =
|
|
336
|
+
const command = commandIndex === -1 ? "" : name(commandIndex)
|
|
122
337
|
const candidates: Array<{ readonly index: number; readonly value: string }> = []
|
|
123
|
-
for (let index = 0; index <
|
|
124
|
-
const token =
|
|
125
|
-
if (index === commandIndex ||
|
|
126
|
-
|
|
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
|
|
347
|
+
? candidates.filter(({ index }) => !isTarget(index)).at(-1)?.index
|
|
132
348
|
: undefined
|
|
133
349
|
|
|
134
350
|
return candidates.map(({ index, value }) => {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
package/src/internal/Exec.ts
CHANGED
|
@@ -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
|
|
48
|
-
*
|
|
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.
|
|
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.
|
|
384
|
-
*
|
|
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
|
})
|