relmio 0.3.0 → 0.4.0

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.
@@ -0,0 +1,375 @@
1
+ import { spawn } from "node:child_process";
2
+ import { isAbsolute } from "node:path";
3
+
4
+ const DEFAULT_TIMEOUT_MS = 180_000;
5
+ const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
6
+ const DEFAULT_TERMINATION_GRACE_MS = 2_000;
7
+ const MAX_ARGUMENT_BYTES = 16 * 1024;
8
+ const MAX_INPUT_BYTES = 1_000_000;
9
+ const MAX_DOCKER_HOST_BYTES = 4 * 1024;
10
+ const DOCKER_SELECTION_ENVIRONMENT_VARIABLES = new Set([
11
+ "BUILDKIT_HOST",
12
+ "DOCKER_CERT_PATH",
13
+ "DOCKER_CONFIG",
14
+ "DOCKER_CONTEXT",
15
+ "DOCKER_HOST",
16
+ "DOCKER_TLS_VERIFY",
17
+ ]);
18
+
19
+ export function validateLocalDockerHost(
20
+ value,
21
+ { platform = process.platform } = {},
22
+ ) {
23
+ if (platform === "win32") {
24
+ throw new TypeError(
25
+ "Native Windows Docker hosts are unsupported for local endpoints.",
26
+ );
27
+ }
28
+ if (
29
+ typeof platform !== "string" ||
30
+ typeof value !== "string" ||
31
+ value.length === 0 ||
32
+ Buffer.byteLength(value) > MAX_DOCKER_HOST_BYTES ||
33
+ /[\0\r\n%]/u.test(value) ||
34
+ !value.startsWith("unix:///")
35
+ ) {
36
+ throw new TypeError("The local Docker host must be a Unix socket URI.");
37
+ }
38
+
39
+ let url;
40
+ try {
41
+ url = new URL(value);
42
+ } catch {
43
+ throw new TypeError("The local Docker host must be a Unix socket URI.");
44
+ }
45
+ if (
46
+ url.protocol !== "unix:" ||
47
+ url.host !== "" ||
48
+ url.username !== "" ||
49
+ url.password !== "" ||
50
+ url.search !== "" ||
51
+ url.hash !== "" ||
52
+ !url.pathname.startsWith("/") ||
53
+ url.pathname === "/" ||
54
+ url.href !== value
55
+ ) {
56
+ throw new TypeError("The local Docker host must be a Unix socket URI.");
57
+ }
58
+ return value;
59
+ }
60
+
61
+ export function createLocalDockerEnvironment(environment = process.env) {
62
+ if (
63
+ environment === null ||
64
+ typeof environment !== "object" ||
65
+ Array.isArray(environment)
66
+ ) {
67
+ throw new TypeError("The local Docker process environment is invalid.");
68
+ }
69
+
70
+ const sanitized = {};
71
+ for (const [name, value] of Object.entries(environment)) {
72
+ if (!DOCKER_SELECTION_ENVIRONMENT_VARIABLES.has(name.toUpperCase())) {
73
+ sanitized[name] = value;
74
+ }
75
+ }
76
+ return sanitized;
77
+ }
78
+
79
+ function validateProcessSpec({
80
+ file,
81
+ args,
82
+ cwd,
83
+ input,
84
+ dockerHost,
85
+ timeoutMs = DEFAULT_TIMEOUT_MS,
86
+ maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
87
+ }) {
88
+ if (file !== "docker") {
89
+ throw new TypeError("Only the local Docker process is allowed.");
90
+ }
91
+ if (
92
+ !Array.isArray(args) ||
93
+ args.length === 0 ||
94
+ args.some(
95
+ (argument) =>
96
+ typeof argument !== "string" ||
97
+ argument.length === 0 ||
98
+ argument.includes("\0") ||
99
+ /[\r\n]/u.test(argument),
100
+ ) ||
101
+ Buffer.byteLength(args.join("\0")) > MAX_ARGUMENT_BYTES
102
+ ) {
103
+ throw new TypeError("Local Docker process arguments are invalid.");
104
+ }
105
+ if (typeof cwd !== "string" || !isAbsolute(cwd) || cwd.includes("\0")) {
106
+ throw new TypeError("Local Docker working directory is invalid.");
107
+ }
108
+ if (
109
+ !Number.isInteger(timeoutMs) ||
110
+ timeoutMs < 1 ||
111
+ timeoutMs > 600_000
112
+ ) {
113
+ throw new TypeError("Local Docker process timeout is invalid.");
114
+ }
115
+ if (
116
+ !Number.isInteger(maxOutputBytes) ||
117
+ maxOutputBytes < 1 ||
118
+ maxOutputBytes > 10_000_000
119
+ ) {
120
+ throw new TypeError("Local Docker output limit is invalid.");
121
+ }
122
+
123
+ const inputBuffer =
124
+ input === undefined
125
+ ? null
126
+ : Buffer.isBuffer(input)
127
+ ? input
128
+ : typeof input === "string"
129
+ ? Buffer.from(input)
130
+ : null;
131
+ if (
132
+ input !== undefined &&
133
+ (!inputBuffer || inputBuffer.length > MAX_INPUT_BYTES)
134
+ ) {
135
+ throw new TypeError("Local Docker process input is invalid.");
136
+ }
137
+
138
+ return {
139
+ file,
140
+ args: [...args],
141
+ cwd,
142
+ dockerHost:
143
+ dockerHost === undefined ? null : validateLocalDockerHost(dockerHost),
144
+ input: inputBuffer,
145
+ timeoutMs,
146
+ maxOutputBytes,
147
+ };
148
+ }
149
+
150
+ function validateTerminationGrace(milliseconds) {
151
+ if (
152
+ !Number.isSafeInteger(milliseconds) ||
153
+ milliseconds < 1 ||
154
+ milliseconds > 60_000
155
+ ) {
156
+ throw new TypeError("Local Docker termination grace is invalid.");
157
+ }
158
+ }
159
+
160
+ export function runLocalProcess(
161
+ spec,
162
+ {
163
+ spawnProcess = spawn,
164
+ setTimer = setTimeout,
165
+ clearTimer = clearTimeout,
166
+ terminationGraceMs = DEFAULT_TERMINATION_GRACE_MS,
167
+ environment = process.env,
168
+ } = {},
169
+ ) {
170
+ let validated;
171
+ let childEnvironment;
172
+ try {
173
+ validated = validateProcessSpec(spec);
174
+ validateTerminationGrace(terminationGraceMs);
175
+ childEnvironment = createLocalDockerEnvironment(environment);
176
+ } catch (error) {
177
+ return Promise.reject(error);
178
+ }
179
+
180
+ return new Promise((resolve, reject) => {
181
+ let child;
182
+ try {
183
+ child = spawnProcess(
184
+ validated.file,
185
+ validated.dockerHost === null
186
+ ? validated.args
187
+ : ["--host", validated.dockerHost, ...validated.args],
188
+ {
189
+ cwd: validated.cwd,
190
+ env: childEnvironment,
191
+ shell: false,
192
+ stdio: ["pipe", "pipe", "pipe"],
193
+ windowsHide: true,
194
+ },
195
+ );
196
+ } catch {
197
+ reject(new Error("The local Docker process could not start."));
198
+ return;
199
+ }
200
+
201
+ if (
202
+ !child ||
203
+ typeof child.once !== "function" ||
204
+ typeof child.kill !== "function" ||
205
+ typeof child.stdin?.end !== "function" ||
206
+ typeof child.stdout?.on !== "function" ||
207
+ typeof child.stderr?.on !== "function"
208
+ ) {
209
+ reject(new Error("The local Docker process could not start."));
210
+ return;
211
+ }
212
+
213
+ const stdout = [];
214
+ const stderr = [];
215
+ let outputBytes = 0;
216
+ let settled = false;
217
+ let closed = false;
218
+ let terminalError = null;
219
+ let timeoutTimer;
220
+ let killTimer;
221
+ let forceSettleTimer;
222
+
223
+ const clearScheduledTimer = (timer) => {
224
+ if (timer === undefined) {
225
+ return;
226
+ }
227
+ try {
228
+ clearTimer(timer);
229
+ } catch {
230
+ // Timer cleanup must not replace the selected generic process result.
231
+ }
232
+ };
233
+
234
+ const clearAllTimers = () => {
235
+ clearScheduledTimer(timeoutTimer);
236
+ clearScheduledTimer(killTimer);
237
+ clearScheduledTimer(forceSettleTimer);
238
+ timeoutTimer = undefined;
239
+ killTimer = undefined;
240
+ forceSettleTimer = undefined;
241
+ };
242
+
243
+ const settle = (error, result) => {
244
+ if (settled) {
245
+ return;
246
+ }
247
+ settled = true;
248
+ clearAllTimers();
249
+ if (error) {
250
+ reject(error);
251
+ } else {
252
+ resolve(result);
253
+ }
254
+ };
255
+
256
+ const signalChild = (signal) => {
257
+ try {
258
+ child.kill(signal);
259
+ } catch {
260
+ // Never expose platform- or process-specific termination details.
261
+ }
262
+ };
263
+
264
+ const requestTermination = (error) => {
265
+ if (settled || terminalError) {
266
+ return;
267
+ }
268
+ terminalError = error;
269
+ clearScheduledTimer(timeoutTimer);
270
+ timeoutTimer = undefined;
271
+ signalChild("SIGTERM");
272
+ if (closed || settled) {
273
+ return;
274
+ }
275
+ try {
276
+ killTimer = setTimer(() => {
277
+ killTimer = undefined;
278
+ if (!closed && !settled) {
279
+ signalChild("SIGKILL");
280
+ try {
281
+ forceSettleTimer = setTimer(() => {
282
+ forceSettleTimer = undefined;
283
+ if (!closed && !settled) {
284
+ settle(terminalError);
285
+ }
286
+ }, terminationGraceMs);
287
+ } catch {
288
+ settle(terminalError);
289
+ }
290
+ }
291
+ }, terminationGraceMs);
292
+ } catch {
293
+ signalChild("SIGKILL");
294
+ settle(terminalError);
295
+ }
296
+ };
297
+
298
+ const capture = (target, chunk) => {
299
+ if (settled || terminalError) {
300
+ return;
301
+ }
302
+ let buffer;
303
+ try {
304
+ buffer = Buffer.from(chunk);
305
+ } catch {
306
+ requestTermination(
307
+ new Error("The local Docker process returned invalid output."),
308
+ );
309
+ return;
310
+ }
311
+ outputBytes += buffer.length;
312
+ if (outputBytes > validated.maxOutputBytes) {
313
+ requestTermination(
314
+ new Error("The local Docker process exceeded its output limit."),
315
+ );
316
+ return;
317
+ }
318
+ target.push(buffer);
319
+ };
320
+
321
+ child.stdout.on("data", (chunk) => capture(stdout, chunk));
322
+ child.stderr.on("data", (chunk) => capture(stderr, chunk));
323
+ child.stdin.on?.("error", () => {
324
+ if (!terminalError) {
325
+ requestTermination(
326
+ new Error("The local Docker process could not start."),
327
+ );
328
+ }
329
+ });
330
+ child.once("error", () => {
331
+ if (!terminalError) {
332
+ requestTermination(
333
+ new Error("The local Docker process could not start."),
334
+ );
335
+ }
336
+ });
337
+ child.once("close", (code) => {
338
+ closed = true;
339
+ if (terminalError) {
340
+ settle(terminalError);
341
+ return;
342
+ }
343
+ settle(null, {
344
+ stdout: Buffer.concat(stdout).toString("utf8"),
345
+ stderr: Buffer.concat(stderr).toString("utf8"),
346
+ code: Number.isInteger(code) ? code : 1,
347
+ });
348
+ });
349
+
350
+ try {
351
+ timeoutTimer = setTimer(() => {
352
+ timeoutTimer = undefined;
353
+ requestTermination(new Error("The local Docker process timed out."));
354
+ }, validated.timeoutMs);
355
+ } catch {
356
+ requestTermination(
357
+ new Error("The local Docker process could not start."),
358
+ );
359
+ }
360
+
361
+ if (!terminalError) {
362
+ try {
363
+ if (validated.input) {
364
+ child.stdin.end(validated.input);
365
+ } else {
366
+ child.stdin.end();
367
+ }
368
+ } catch {
369
+ requestTermination(
370
+ new Error("The local Docker process could not start."),
371
+ );
372
+ }
373
+ }
374
+ });
375
+ }