-
-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathcapture.test.ts
More file actions
307 lines (290 loc) · 10.9 KB
/
Copy pathcapture.test.ts
File metadata and controls
307 lines (290 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
/**
* captureCommand + exitCodeForSignal tests.
*
* Covers the streaming-capture invariants and the no-hang property:
* - default mode hashes and counts stdout/stderr without retaining
* a sample buffer
* - raw mode emits a bounded sample only when explicitly enabled
* - exitCodeForSignal maps SIGINT=130, SIGTERM=143, SIGKILL=137
* - the wrapper resolves when the child exits even if parent stdin
* never ends (TTY-like never-ending pipe)
*/
import { describe, it, expect } from 'vitest';
import { Readable } from 'node:stream';
import { captureCommand, exitCodeForSignal } from '../src/lib/capture';
const NODE = process.execPath;
describe('exitCodeForSignal: POSIX signal mapping', () => {
it('maps SIGINT to 130', () => {
expect(exitCodeForSignal('SIGINT')).toBe(130);
});
it('maps SIGTERM to 143', () => {
expect(exitCodeForSignal('SIGTERM')).toBe(143);
});
it('maps SIGKILL to 137', () => {
expect(exitCodeForSignal('SIGKILL')).toBe(137);
});
it('falls back to 128 for unknown signals', () => {
expect(exitCodeForSignal('SIGNOTREAL')).toBe(128);
});
it('falls back to 128 for null', () => {
expect(exitCodeForSignal(null)).toBe(128);
});
});
describe('captureCommand: shell:false treats metacharacters as argv data', () => {
it('passes shell metacharacters in argv verbatim without shell expansion', async () => {
// Under shell:false, tokens like `;`, `|`, `$VAR` MUST reach the
// child as plain argv bytes. The child here echoes its argv
// length so we can prove the token was not split or expanded.
const metacharToken = '; echo PWNED | cat $HOME `id`';
const result = await captureCommand({
program: NODE,
args: [
'-e',
'process.stdout.write(String(process.argv.length) + ":" + process.argv.slice(-1)[0])',
metacharToken,
],
cwd: process.cwd(),
env: process.env,
stdinMode: 'none',
rawCaptureEnabled: true,
stdoutSampleBytes: 16384,
stderrSampleBytes: 16384,
timeoutMs: 5000,
killGraceMs: 1000,
});
expect(result.exitCode).toBe(0);
const decoded = Buffer.from(result.stdout.sample_base64!, 'base64').toString('utf8');
// The child must observe the literal metachar token as its last
// argv element; a shell expansion would have split it on `;` or
// `|`, dereferenced `$HOME`, or executed `id`. Under `node -e
// <script>`, the token is argv[1] (argv[0] is the node binary
// path), so argv.length is 2 and the trailing element is the
// verbatim metachar token.
expect(decoded.endsWith(`:${metacharToken}`)).toBe(true);
expect(decoded.startsWith('2:')).toBe(true);
}, 15_000);
});
describe('captureCommand: stream capture invariants', () => {
it('default mode hashes and counts stdout/stderr without a sample', async () => {
const result = await captureCommand({
program: NODE,
args: ['-e', 'process.stdout.write("hello"); process.stderr.write("warn");'],
cwd: process.cwd(),
env: process.env,
stdinMode: 'none',
rawCaptureEnabled: false,
stdoutSampleBytes: 16384,
stderrSampleBytes: 16384,
timeoutMs: 5000,
killGraceMs: 1000,
});
expect(result.exitCode).toBe(0);
expect(result.stdout.length).toBe(5);
expect(result.stdout.sha256).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(result.stdout.sample_base64).toBeUndefined();
expect(result.stderr.length).toBe(4);
expect(result.stderr.sha256).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(result.stderr.sample_base64).toBeUndefined();
}, 15_000);
it('raw mode emits a bounded sample when rawCaptureEnabled=true', async () => {
const result = await captureCommand({
program: NODE,
args: ['-e', 'process.stdout.write("hello world raw mode")'],
cwd: process.cwd(),
env: process.env,
stdinMode: 'none',
rawCaptureEnabled: true,
stdoutSampleBytes: 16384,
stderrSampleBytes: 16384,
timeoutMs: 5000,
killGraceMs: 1000,
});
expect(result.exitCode).toBe(0);
expect(result.stdout.sample_base64).toBeDefined();
const decoded = Buffer.from(result.stdout.sample_base64!, 'base64').toString('utf8');
expect(decoded).toBe('hello world raw mode');
}, 15_000);
it('truncates the sample buffer at the cap and sets truncated=true', async () => {
const result = await captureCommand({
program: NODE,
args: ['-e', 'process.stdout.write("A".repeat(200))'],
cwd: process.cwd(),
env: process.env,
stdinMode: 'none',
rawCaptureEnabled: true,
stdoutSampleBytes: 50,
stderrSampleBytes: 16384,
timeoutMs: 5000,
killGraceMs: 1000,
});
expect(result.stdout.length).toBe(200);
expect(result.stdout.truncated).toBe(true);
expect(result.stdout.sample_base64).toBeDefined();
const decoded = Buffer.from(result.stdout.sample_base64!, 'base64');
expect(decoded.length).toBe(50);
}, 15_000);
});
describe('captureCommand: stdin pump no-hang property', () => {
it('resolves when child exits even if parent stdin never ends (hashed mode)', async () => {
// Build a never-ending Readable that periodically emits chunks but
// never calls push(null). The child exits immediately. The wrapper
// must abort the stdin pump on child close and resolve.
const neverEnding = new Readable({
read() {
// Drip a small chunk, then schedule another. Never ends.
setTimeout(() => {
if (!this.destroyed) this.push(Buffer.from('x'));
}, 20);
},
});
const start = Date.now();
const result = await captureCommand({
program: NODE,
args: ['-e', 'process.exit(0)'],
cwd: process.cwd(),
env: process.env,
stdinMode: 'hashed',
rawCaptureEnabled: false,
stdoutSampleBytes: 16384,
stderrSampleBytes: 16384,
timeoutMs: 5000,
killGraceMs: 1000,
parentStdin: neverEnding,
});
const elapsed = Date.now() - start;
// Confirm the wrapper did NOT wait for the never-ending stream.
// 5000ms is the timeout; we should resolve well under that.
expect(elapsed).toBeLessThan(3_000);
expect(result.exitCode).toBe(0);
expect(result.stdin.mode).toBe('hashed');
// Cleanup the never-ending stream.
neverEnding.destroy();
}, 15_000);
it('hashed mode resolves when parent stays open and idle after child exits', async () => {
// Strict idle case: build a Readable that emits NOTHING. The child
// exits immediately. The pump must resolve on the abort signal,
// not wait for parent data that will never arrive.
const idleOpen = new Readable({
read() {
// never push, never end -- stays open and idle forever.
},
});
const start = Date.now();
const result = await captureCommand({
program: NODE,
args: ['-e', 'process.exit(0)'],
cwd: process.cwd(),
env: process.env,
stdinMode: 'hashed',
rawCaptureEnabled: false,
stdoutSampleBytes: 16384,
stderrSampleBytes: 16384,
timeoutMs: 5000,
killGraceMs: 1000,
parentStdin: idleOpen,
});
const elapsed = Date.now() - start;
// Wrapper must resolve well under the 5000ms timeout. If the pump
// hung on the idle stream it would only resolve after timeoutMs +
// killGraceMs, which is what this test guards against.
expect(elapsed).toBeLessThan(3_000);
expect(result.exitCode).toBe(0);
expect(result.stdin.mode).toBe('hashed');
expect(result.stdin.length).toBe(0);
expect(result.stdin.sha256).toMatch(/^sha256:[a-f0-9]{64}$/);
idleOpen.destroy();
}, 15_000);
it('mode=none does not read parent stdin and returns mode-only stdin_ref', async () => {
const neverEnding = new Readable({
read() {
setTimeout(() => {
if (!this.destroyed) this.push(Buffer.from('x'));
}, 20);
},
});
const result = await captureCommand({
program: NODE,
args: ['-e', 'process.exit(0)'],
cwd: process.cwd(),
env: process.env,
stdinMode: 'none',
rawCaptureEnabled: false,
stdoutSampleBytes: 16384,
stderrSampleBytes: 16384,
timeoutMs: 5000,
killGraceMs: 1000,
parentStdin: neverEnding,
});
expect(result.exitCode).toBe(0);
expect(result.stdin.mode).toBe('none');
expect(result.stdin.length).toBeUndefined();
expect(result.stdin.sha256).toBeUndefined();
neverEnding.destroy();
}, 15_000);
});
describe('captureCommand: stdin pump non-invasive abort', () => {
it('does not strip caller-owned data listeners from parent stdin on child close', async () => {
// Build a never-ending Readable that periodically emits chunks.
// Attach a caller-owned data listener BEFORE captureCommand sees
// the stream. After the child closes and the pump aborts, that
// listener must still be attached.
const neverEnding = new Readable({
read() {
setTimeout(() => {
if (!this.destroyed) this.push(Buffer.from('x'));
}, 20);
},
});
let callerListenerCalls = 0;
const callerListener = () => {
callerListenerCalls += 1;
};
neverEnding.on('data', callerListener);
const before = neverEnding.listenerCount('data');
const result = await captureCommand({
program: NODE,
args: ['-e', 'process.exit(0)'],
cwd: process.cwd(),
env: process.env,
stdinMode: 'hashed',
rawCaptureEnabled: false,
stdoutSampleBytes: 16384,
stderrSampleBytes: 16384,
timeoutMs: 5000,
killGraceMs: 1000,
parentStdin: neverEnding,
});
expect(result.exitCode).toBe(0);
// The caller-owned `data` listener must still be attached after
// the pump aborts. The pump may NOT call removeAllListeners on a
// stream it does not own.
const after = neverEnding.listenerCount('data');
expect(after).toBeGreaterThanOrEqual(before);
expect(neverEnding.listeners('data')).toContain(callerListener);
neverEnding.destroy();
// Avoid unused-binding warning for the caller listener counter;
// its existence proves the listener was wired up.
expect(callerListenerCalls).toBeGreaterThanOrEqual(0);
}, 15_000);
});
describe('captureCommand: timeout cascade', () => {
it('terminates a long-running child and emits the record with timed_out=true', async () => {
const result = await captureCommand({
program: NODE,
args: ['-e', 'setInterval(() => {}, 1000)'],
cwd: process.cwd(),
env: process.env,
stdinMode: 'none',
rawCaptureEnabled: false,
stdoutSampleBytes: 16384,
stderrSampleBytes: 16384,
timeoutMs: 200,
killGraceMs: 200,
});
expect(result.timedOut).toBe(true);
expect(['SIGTERM', 'SIGKILL']).toContain(result.terminationSignal);
// exitCode is either the synthetic 143/137 (signal mapping) or
// a normal code if the child raced ahead.
expect(typeof result.exitCode).toBe('number');
}, 15_000);
});