start-command 0.35.4 → 0.37.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.
- package/CHANGELOG.md +14 -0
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/bin/cli.js +48 -7
- package/src/lib/args-parser-queries.js +14 -0
- package/src/lib/args-parser.js +32 -1
- package/src/lib/atomic-write.js +34 -0
- package/src/lib/attached-diagnostics.js +90 -0
- package/src/lib/cgroup-memory.js +43 -11
- package/src/lib/command-builder.js +43 -0
- package/src/lib/cpu-penalty-monitor.js +226 -0
- package/src/lib/cpu-penalty.js +175 -0
- package/src/lib/detached-finalize.js +16 -0
- package/src/lib/docker-cleanup.js +35 -7
- package/src/lib/docker-command-handoff.js +75 -0
- package/src/lib/docker-labels.js +33 -0
- package/src/lib/docker-resource-limits.js +28 -0
- package/src/lib/docker-resource-options.js +180 -0
- package/src/lib/docker-runtime-args.js +23 -0
- package/src/lib/docker-snapshot-safety.js +164 -0
- package/src/lib/execution-recovery.js +153 -30
- package/src/lib/execution-resume-all.js +11 -0
- package/src/lib/execution-resume.js +314 -54
- package/src/lib/execution-store.js +25 -121
- package/src/lib/exit-evidence.js +123 -0
- package/src/lib/exit-reason.js +24 -6
- package/src/lib/failure-handler.js +15 -5
- package/src/lib/isolation.js +48 -7
- package/src/lib/launch-owner.js +25 -0
- package/src/lib/launch-persistence.js +54 -0
- package/src/lib/log-sanitizer.js +267 -0
- package/src/lib/log-uploader.js +38 -19
- package/src/lib/query-commands.js +18 -6
- package/src/lib/resume-resources.js +88 -0
- package/src/lib/shell-utils.js +67 -17
- package/src/lib/status-formatter.js +28 -0
- package/src/lib/store-lock.js +133 -0
- package/src/lib/usage.js +16 -1
- package/test/ci-workflow-invariants.js +15 -14
- package/test/docker-command-handoff.js +107 -0
- package/test/docker-runtime-options.js +10 -0
- package/test/docker-snapshot-safety.js +106 -0
- package/test/execution-resume.js +19 -11
- package/test/exit-reason.js +6 -1
- package/test/failure-handler.js +11 -0
- package/test/isolation.js +5 -4
- package/test/javascript-first-ci.js +216 -0
- package/test/log-sanitizer.js +306 -0
- package/test/regression-174.js +3 -1
- package/test/regression-176.js +7 -0
- package/test/regression-180.js +17 -10
- package/test/regression-182.js +7 -3
- package/test/regression-185.js +4 -9
- package/test/regression-187.js +11 -2
- package/test/regression-189.js +224 -0
- package/test/regression-190.js +111 -0
- package/test/regression-191.js +160 -0
- package/test/regression-192.js +66 -0
- package/test/regression-193.js +251 -0
- package/test/regression-194.js +182 -0
- package/test/regression-198.js +281 -0
- package/test/regression-199.js +79 -0
- package/test/regression-201.js +25 -0
- package/test/regression-202.js +92 -0
- package/test/regression-91.js +23 -20
- package/test/shared-golden.js +54 -0
- package/test/status-query.js +97 -15
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
const { describe, it } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { readdirSync, readFileSync } = require('node:fs');
|
|
4
|
+
const { resolve } = require('node:path');
|
|
5
|
+
const { YAML } = require('bun');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
const {
|
|
8
|
+
checkJavaScriptFirst,
|
|
9
|
+
} = require('../../scripts/check-javascript-first.mjs');
|
|
10
|
+
const {
|
|
11
|
+
checkFeatureChanges,
|
|
12
|
+
} = require('../../scripts/check-feature-parity.mjs');
|
|
13
|
+
|
|
14
|
+
const root = resolve(__dirname, '../..');
|
|
15
|
+
function workflows() {
|
|
16
|
+
return Object.fromEntries(
|
|
17
|
+
readdirSync(resolve(root, '.github/workflows'))
|
|
18
|
+
.filter((name) => name.endsWith('.yml'))
|
|
19
|
+
.map((name) => [
|
|
20
|
+
name,
|
|
21
|
+
YAML.parse(
|
|
22
|
+
readFileSync(resolve(root, '.github/workflows', name), 'utf8')
|
|
23
|
+
),
|
|
24
|
+
])
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('JavaScript first CI gate', () => {
|
|
29
|
+
it('validates every current workflow using parsed YAML', () => {
|
|
30
|
+
assert.deepEqual(checkJavaScriptFirst(workflows()), []);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
for (const [name, mutate] of [
|
|
34
|
+
[
|
|
35
|
+
'independent Rust PR trigger',
|
|
36
|
+
(files) => {
|
|
37
|
+
files['rust.yml'].on.pull_request = {};
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
'removed successful-stage condition',
|
|
42
|
+
(files) => {
|
|
43
|
+
files['js.yml'].jobs['rust-stage'].if = 'always()';
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
'an alternative that bypasses the successful stage',
|
|
48
|
+
(files) => {
|
|
49
|
+
files['js.yml'].jobs['rust-stage'].if =
|
|
50
|
+
"${{ !cancelled() && needs.pipeline-status.result == 'success' || true }}";
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
'Rust-only path filter',
|
|
55
|
+
(files) => {
|
|
56
|
+
files['js.yml'].on.pull_request.paths = ['js/**'];
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
[
|
|
60
|
+
'Rust security matrix bypass',
|
|
61
|
+
(files) => {
|
|
62
|
+
files['security.yml'].jobs.codeql.strategy.matrix.language.push('rust');
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
'direct Rust CodeQL bypass',
|
|
67
|
+
(files) => {
|
|
68
|
+
files['new.yml'] = {
|
|
69
|
+
jobs: {
|
|
70
|
+
scan: {
|
|
71
|
+
steps: [
|
|
72
|
+
{
|
|
73
|
+
uses: 'github/codeql-action/init@v4',
|
|
74
|
+
with: { languages: 'rust' },
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
[
|
|
83
|
+
'comma-delimited Rust CodeQL bypass',
|
|
84
|
+
(files) => {
|
|
85
|
+
files['new.yml'] = {
|
|
86
|
+
jobs: {
|
|
87
|
+
scan: {
|
|
88
|
+
steps: [
|
|
89
|
+
{
|
|
90
|
+
uses: 'github/codeql-action/init@v4',
|
|
91
|
+
with: { languages: 'javascript-typescript,rust' },
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
[
|
|
100
|
+
'Rust CodeQL matrix include bypass',
|
|
101
|
+
(files) => {
|
|
102
|
+
files['security.yml'].jobs.codeql.strategy.matrix = {
|
|
103
|
+
include: [{ language: 'rust' }],
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
[
|
|
108
|
+
'independent Rustup tooling',
|
|
109
|
+
(files) => {
|
|
110
|
+
files['new.yml'] = {
|
|
111
|
+
jobs: {
|
|
112
|
+
install: {
|
|
113
|
+
steps: [{ run: 'rustup toolchain install stable' }],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
[
|
|
120
|
+
'release key outside main environment',
|
|
121
|
+
(files) => {
|
|
122
|
+
delete files['js.yml'].jobs.release.environment;
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
[
|
|
126
|
+
'a new ungated Rust workflow',
|
|
127
|
+
(files) => {
|
|
128
|
+
files['new.yml'] = {
|
|
129
|
+
on: { push: {} },
|
|
130
|
+
jobs: { build: { steps: [{ run: 'cargo build' }] } },
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
[
|
|
135
|
+
'conditional parity',
|
|
136
|
+
(files) => {
|
|
137
|
+
files['js.yml'].jobs.parity.if = 'false';
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
]) {
|
|
141
|
+
it(`rejects ${name}`, () => {
|
|
142
|
+
const files = workflows();
|
|
143
|
+
mutate(files);
|
|
144
|
+
assert.ok(checkJavaScriptFirst(files).length > 0);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const result of ['failure', 'cancelled', 'skipped']) {
|
|
149
|
+
it(`rejects a ${result} required JavaScript check`, () => {
|
|
150
|
+
const check = spawnSync(
|
|
151
|
+
process.execPath,
|
|
152
|
+
[resolve(root, 'scripts/check-stage-status.mjs'), '--required', 'test'],
|
|
153
|
+
{
|
|
154
|
+
env: {
|
|
155
|
+
...process.env,
|
|
156
|
+
NEEDS_JSON: JSON.stringify({ test: { result } }),
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
assert.equal(check.status, 1);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
it('opens the gate only after all required checks succeed', () => {
|
|
165
|
+
const check = spawnSync(
|
|
166
|
+
process.execPath,
|
|
167
|
+
[resolve(root, 'scripts/check-stage-status.mjs'), '--required', 'test'],
|
|
168
|
+
{
|
|
169
|
+
env: {
|
|
170
|
+
...process.env,
|
|
171
|
+
NEEDS_JSON: JSON.stringify({
|
|
172
|
+
test: { result: 'success' },
|
|
173
|
+
release: { result: 'skipped' },
|
|
174
|
+
}),
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
assert.equal(check.status, 0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('rejects a change to only one implementation of a feature', () => {
|
|
182
|
+
const features = [
|
|
183
|
+
{
|
|
184
|
+
id: 'sample',
|
|
185
|
+
javascript: { implementation: ['js/source.js'], tests: ['js/test.js'] },
|
|
186
|
+
rust: { implementation: ['rust/source.rs'], tests: ['rust/test.rs'] },
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
assert.equal(checkFeatureChanges(features, ['rust/source.rs']).length, 1);
|
|
190
|
+
assert.equal(checkFeatureChanges(features, ['js/source.js']).length, 1);
|
|
191
|
+
assert.deepEqual(
|
|
192
|
+
checkFeatureChanges(features, ['js/source.js', 'rust/test.rs']),
|
|
193
|
+
[]
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('makes Rust callable only after the complete JavaScript stage', () => {
|
|
198
|
+
const files = workflows();
|
|
199
|
+
assert.deepEqual(Object.keys(files['rust.yml'].on), ['workflow_call']);
|
|
200
|
+
const call = files['js.yml'].jobs['rust-stage'];
|
|
201
|
+
assert.equal(call.uses, './.github/workflows/rust.yml');
|
|
202
|
+
assert.deepEqual(call.needs, ['pipeline-status']);
|
|
203
|
+
assert.match(call.if, /needs\.pipeline-status\.result == 'success'/);
|
|
204
|
+
assert.equal(call.concurrency['cancel-in-progress'], false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('always runs JavaScript and its behavioral parity gate on Rust-only changes', () => {
|
|
208
|
+
const js = workflows()['js.yml'];
|
|
209
|
+
for (const trigger of ['push', 'pull_request']) {
|
|
210
|
+
assert.equal(js.on[trigger].paths, undefined);
|
|
211
|
+
assert.equal(js.on[trigger]['paths-ignore'], undefined);
|
|
212
|
+
}
|
|
213
|
+
assert.ok(js.jobs.parity);
|
|
214
|
+
assert.equal(js.jobs.parity.if, undefined);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
const { describe, it } = require('node:test');
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { execFileSync } = require('child_process');
|
|
8
|
+
const {
|
|
9
|
+
sanitizeLogToTemp,
|
|
10
|
+
StreamingSanitizer,
|
|
11
|
+
} = require('../src/lib/log-sanitizer');
|
|
12
|
+
|
|
13
|
+
function credentials() {
|
|
14
|
+
return [
|
|
15
|
+
...'pousr'.split('').map((kind) => `gh${kind}_${'a'.repeat(36)}`),
|
|
16
|
+
`github_` + `pat_${'b'.repeat(82)}`,
|
|
17
|
+
`sk-${'c'.repeat(48)}`,
|
|
18
|
+
`sk-` + `proj-${'d'.repeat(90)}`,
|
|
19
|
+
`sk-` + `ant-api03-${'e'.repeat(90)}`,
|
|
20
|
+
`AK` + `IA${'F'.repeat(16)}`,
|
|
21
|
+
`AS` + `IA${'G'.repeat(16)}`,
|
|
22
|
+
'ey' + 'JhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature',
|
|
23
|
+
'Authorization: Bearer arbitrary-credential',
|
|
24
|
+
'Proxy-Authorization: Basic encoded-credential',
|
|
25
|
+
'https://user:password@example.test/repo.git',
|
|
26
|
+
'AWS_SECRET_ACCESS_KEY=arbitrary-aws-secret',
|
|
27
|
+
'custom-secret-value',
|
|
28
|
+
`${Buffer.from(' \n{"alg":"HS256"}').toString('base64url')}.${Buffer.from('{"sub":"x"}').toString('base64url')}.signature`,
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sanitize(input, blockSize) {
|
|
33
|
+
const sanitizer = new StreamingSanitizer({
|
|
34
|
+
START_TEST_SECRET: 'custom-secret-value',
|
|
35
|
+
});
|
|
36
|
+
const blocks = [];
|
|
37
|
+
for (let offset = 0; offset < input.length; offset += blockSize) {
|
|
38
|
+
blocks.push(sanitizer.push(input.subarray(offset, offset + blockSize)));
|
|
39
|
+
}
|
|
40
|
+
blocks.push(sanitizer.push(Buffer.alloc(0), true));
|
|
41
|
+
return Buffer.concat(blocks);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('log sanitization', () => {
|
|
45
|
+
it('redacts required formats and environment values at every chunk boundary', () => {
|
|
46
|
+
const secrets = credentials();
|
|
47
|
+
for (const size of [1, 7, 31, 256, 1024, 65536]) {
|
|
48
|
+
const input = Buffer.from(
|
|
49
|
+
`ordinary utf8: café 😀\n${' '.repeat(Math.max(0, size - 43))}${secrets.join('\n')}\n${'padding\n'.repeat(400)}finished\n`
|
|
50
|
+
);
|
|
51
|
+
const output = sanitize(input, size).toString();
|
|
52
|
+
for (const secret of secrets) {
|
|
53
|
+
assert.ok(!output.includes(secret));
|
|
54
|
+
}
|
|
55
|
+
for (const value of [
|
|
56
|
+
'arbitrary-credential',
|
|
57
|
+
'encoded-credential',
|
|
58
|
+
'password',
|
|
59
|
+
'arbitrary-aws-secret',
|
|
60
|
+
]) {
|
|
61
|
+
assert.ok(!output.includes(value));
|
|
62
|
+
}
|
|
63
|
+
assert.ok(output.startsWith('ordinary utf8: café 😀\n'));
|
|
64
|
+
assert.ok(output.endsWith('\nfinished\n'));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('bounds memory for long tokens and authorization values without releasing suffixes', () => {
|
|
69
|
+
for (const prefix of ['gh' + 'p_', 'Authorization: Bearer ']) {
|
|
70
|
+
const sanitizer = new StreamingSanitizer({});
|
|
71
|
+
let output = sanitizer.push(Buffer.from(prefix));
|
|
72
|
+
for (let i = 0; i < 128; i++) {
|
|
73
|
+
output = Buffer.concat([
|
|
74
|
+
output,
|
|
75
|
+
sanitizer.push(Buffer.alloc(16384, 97)),
|
|
76
|
+
]);
|
|
77
|
+
assert.ok(sanitizer.pendingBytes <= 65536);
|
|
78
|
+
}
|
|
79
|
+
output = Buffer.concat([
|
|
80
|
+
output,
|
|
81
|
+
sanitizer.push(Buffer.from('\nordinary\n'), true),
|
|
82
|
+
]);
|
|
83
|
+
assert.strictEqual(output.toString(), '[REDACTED]\nordinary\n');
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('retains full header continuation when an inner token reaches the same block boundary', () => {
|
|
88
|
+
const sanitizer = new StreamingSanitizer({});
|
|
89
|
+
const input = Buffer.from(
|
|
90
|
+
`Authorization: Bearer gh${'p_'}${'a'.repeat(131072)} trailing-private-value\nordinary\n`
|
|
91
|
+
);
|
|
92
|
+
const output = [];
|
|
93
|
+
for (let offset = 0; offset < input.length; offset += 65536) {
|
|
94
|
+
output.push(sanitizer.push(input.subarray(offset, offset + 65536)));
|
|
95
|
+
}
|
|
96
|
+
output.push(sanitizer.push(Buffer.alloc(0), true));
|
|
97
|
+
assert.strictEqual(
|
|
98
|
+
Buffer.concat(output).toString(),
|
|
99
|
+
'[REDACTED]\nordinary\n'
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('redacts AWS assignment values enclosed in either quote style', () => {
|
|
104
|
+
const sanitizer = new StreamingSanitizer({});
|
|
105
|
+
const output = sanitizer
|
|
106
|
+
.push(
|
|
107
|
+
Buffer.from(
|
|
108
|
+
`AWS_SECRET_ACCESS_KEY="quoted-aws-secret"\nAWS_SESSION_TOKEN='quoted-session-value'\n`
|
|
109
|
+
),
|
|
110
|
+
true
|
|
111
|
+
)
|
|
112
|
+
.toString();
|
|
113
|
+
assert.ok(!output.includes('quoted-aws-secret'));
|
|
114
|
+
assert.ok(!output.includes('quoted-session-value'));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('redacts multiline and short known secrets, preserving unrelated bytes', () => {
|
|
118
|
+
const sanitizer = new StreamingSanitizer({
|
|
119
|
+
GH_TOKEN: 'abc\nxyz',
|
|
120
|
+
TEST_PASSWORD: '!',
|
|
121
|
+
});
|
|
122
|
+
const output = sanitizer
|
|
123
|
+
.push(Buffer.from('safe abc\nxyz value !'), true)
|
|
124
|
+
.toString();
|
|
125
|
+
assert.strictEqual(output, 'safe [REDACTED] value [REDACTED]');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('merges overlapping environment secrets and preserves ordinary domain names', () => {
|
|
129
|
+
const sanitizer = new StreamingSanitizer({
|
|
130
|
+
GH_TOKEN: 'abcde',
|
|
131
|
+
TEST_PASSWORD: 'cdefg',
|
|
132
|
+
GITHUB_PAT: 'opaque-known-pat',
|
|
133
|
+
});
|
|
134
|
+
assert.strictEqual(
|
|
135
|
+
sanitizer
|
|
136
|
+
.push(
|
|
137
|
+
Buffer.from('abcdefg opaque-known-pat example.test.invalid'),
|
|
138
|
+
true
|
|
139
|
+
)
|
|
140
|
+
.toString(),
|
|
141
|
+
'[REDACTED] [REDACTED] example.test.invalid'
|
|
142
|
+
);
|
|
143
|
+
const short = new StreamingSanitizer({ GH_TOKEN: 'a' });
|
|
144
|
+
assert.strictEqual(
|
|
145
|
+
short.push(Buffer.alloc(65536, 97), true).toString(),
|
|
146
|
+
'[REDACTED]'
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('creates a private sanitized copy and removes it without changing the source', () => {
|
|
151
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-test-'));
|
|
152
|
+
const source = path.join(dir, 'source.log');
|
|
153
|
+
const input = `gh` + `p_${'x'.repeat(36)}`;
|
|
154
|
+
fs.writeFileSync(source, input);
|
|
155
|
+
try {
|
|
156
|
+
const copy = sanitizeLogToTemp(source, { env: {} });
|
|
157
|
+
assert.notStrictEqual(copy.path, source);
|
|
158
|
+
assert.strictEqual(fs.readFileSync(copy.path, 'utf8'), '[REDACTED]');
|
|
159
|
+
if (process.platform !== 'win32') {
|
|
160
|
+
assert.strictEqual(fs.statSync(copy.path).mode & 0o777, 0o600);
|
|
161
|
+
assert.strictEqual(
|
|
162
|
+
fs.statSync(path.dirname(copy.path)).mode & 0o777,
|
|
163
|
+
0o700
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
assert.strictEqual(fs.readFileSync(source, 'utf8'), input);
|
|
167
|
+
copy.cleanup();
|
|
168
|
+
assert.ok(!fs.existsSync(copy.path));
|
|
169
|
+
} finally {
|
|
170
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('enforces private permissions at creation even with a permissive umask', () => {
|
|
175
|
+
if (process.platform === 'win32') {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-creation-'));
|
|
179
|
+
const source = path.join(dir, 'source.log');
|
|
180
|
+
fs.writeFileSync(source, 'ordinary output');
|
|
181
|
+
const originalOpen = fs.openSync;
|
|
182
|
+
const originalUmask = process.umask(0);
|
|
183
|
+
let creationChecked = false;
|
|
184
|
+
let copy;
|
|
185
|
+
fs.openSync = (...args) => {
|
|
186
|
+
if (args[1] !== 'wx') {
|
|
187
|
+
return originalOpen(...args);
|
|
188
|
+
}
|
|
189
|
+
assert.strictEqual(
|
|
190
|
+
fs.statSync(path.dirname(args[0])).mode & 0o777,
|
|
191
|
+
0o700
|
|
192
|
+
);
|
|
193
|
+
const descriptor = originalOpen(...args);
|
|
194
|
+
assert.strictEqual(fs.fstatSync(descriptor).mode & 0o777, 0o600);
|
|
195
|
+
creationChecked = true;
|
|
196
|
+
return descriptor;
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
copy = sanitizeLogToTemp(source, { env: {} });
|
|
200
|
+
assert.ok(creationChecked);
|
|
201
|
+
assert.strictEqual(fs.readFileSync(copy.path, 'utf8'), 'ordinary output');
|
|
202
|
+
} finally {
|
|
203
|
+
fs.openSync = originalOpen;
|
|
204
|
+
process.umask(originalUmask);
|
|
205
|
+
copy?.cleanup();
|
|
206
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('fails closed on invalid source and excessive environment secret sizes', () => {
|
|
211
|
+
assert.throws(
|
|
212
|
+
() => sanitizeLogToTemp('/does-not-exist', { env: {} }),
|
|
213
|
+
/sanitization failed/i
|
|
214
|
+
);
|
|
215
|
+
assert.throws(
|
|
216
|
+
() => new StreamingSanitizer({ GH_TOKEN: 'a'.repeat(65537) }),
|
|
217
|
+
/too large/i
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('blocks both upload paths on read or write failure and does not expose exception text', () => {
|
|
222
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-errors-'));
|
|
223
|
+
const source = path.join(dir, 'source.log');
|
|
224
|
+
fs.writeFileSync(source, 'ordinary log');
|
|
225
|
+
const { uploadLogPath } = require('../src/lib/log-uploader');
|
|
226
|
+
const { uploadLog } = require('../src/lib/failure-handler');
|
|
227
|
+
const messages = [];
|
|
228
|
+
const originalLog = console.log;
|
|
229
|
+
console.log = (message) => messages.push(message);
|
|
230
|
+
try {
|
|
231
|
+
for (const method of ['readSync', 'writeSync', 'fsyncSync']) {
|
|
232
|
+
const original = fs[method];
|
|
233
|
+
fs[method] = () => {
|
|
234
|
+
throw new Error('sensitive error details');
|
|
235
|
+
};
|
|
236
|
+
try {
|
|
237
|
+
assert.strictEqual(uploadLogPath(source).success, false);
|
|
238
|
+
assert.strictEqual(uploadLog(source), null);
|
|
239
|
+
} finally {
|
|
240
|
+
fs[method] = original;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
assert.ok(
|
|
244
|
+
messages.every(
|
|
245
|
+
(message) => !message.includes('sensitive error details')
|
|
246
|
+
)
|
|
247
|
+
);
|
|
248
|
+
} finally {
|
|
249
|
+
console.log = originalLog;
|
|
250
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('sanitizes manual and automatic uploads, explicitly requests private visibility, and cleans copies', () => {
|
|
255
|
+
if (process.platform === 'win32') {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-sanitize-test-'));
|
|
259
|
+
const log = path.join(dir, 'source.log');
|
|
260
|
+
fs.writeFileSync(log, `gh` + `p_${'x'.repeat(36)}`);
|
|
261
|
+
fs.writeFileSync(
|
|
262
|
+
path.join(dir, 'gh-upload-log'),
|
|
263
|
+
'#!/bin/sh\nprintf "%s\\n" "$@" > "$CAPTURE_ARGS"\ncat "$1" > "$CAPTURE_LOG"\nls -ld "$1" | cut -c1-10 > "$CAPTURE_MODE"\necho https://gist.github.com/test/id\n',
|
|
264
|
+
{ mode: 0o755 }
|
|
265
|
+
);
|
|
266
|
+
try {
|
|
267
|
+
for (const kind of ['manual', 'automatic', 'optout']) {
|
|
268
|
+
const script =
|
|
269
|
+
kind === 'automatic'
|
|
270
|
+
? `require(${JSON.stringify(require.resolve('../src/lib/failure-handler'))}).uploadLog(process.env.SOURCE_LOG)`
|
|
271
|
+
: `require(${JSON.stringify(require.resolve('../src/lib/log-uploader'))}).uploadLogPath(process.env.SOURCE_LOG, { noSanitize: ${kind === 'optout'} })`;
|
|
272
|
+
execFileSync(process.execPath, ['-e', script], {
|
|
273
|
+
env: {
|
|
274
|
+
...process.env,
|
|
275
|
+
PATH: `${dir}${path.delimiter}${process.env.PATH}`,
|
|
276
|
+
SOURCE_LOG: log,
|
|
277
|
+
CAPTURE_ARGS: path.join(dir, 'args'),
|
|
278
|
+
CAPTURE_LOG: path.join(dir, 'uploaded'),
|
|
279
|
+
CAPTURE_MODE: path.join(dir, 'mode'),
|
|
280
|
+
GH_UPLOAD_PUBLIC: 'true',
|
|
281
|
+
},
|
|
282
|
+
stdio: 'pipe',
|
|
283
|
+
});
|
|
284
|
+
const args = fs
|
|
285
|
+
.readFileSync(path.join(dir, 'args'), 'utf8')
|
|
286
|
+
.trim()
|
|
287
|
+
.split('\n');
|
|
288
|
+
assert.strictEqual(args[1], '--private');
|
|
289
|
+
assert.strictEqual(
|
|
290
|
+
fs.readFileSync(path.join(dir, 'uploaded'), 'utf8'),
|
|
291
|
+
kind === 'optout' ? fs.readFileSync(log, 'utf8') : '[REDACTED]'
|
|
292
|
+
);
|
|
293
|
+
if (kind !== 'optout') {
|
|
294
|
+
assert.notStrictEqual(args[0], log);
|
|
295
|
+
assert.ok(!fs.existsSync(args[0]));
|
|
296
|
+
assert.strictEqual(
|
|
297
|
+
fs.readFileSync(path.join(dir, 'mode'), 'utf8').trim(),
|
|
298
|
+
'-rw-------'
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} finally {
|
|
303
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
});
|
package/test/regression-174.js
CHANGED
|
@@ -100,9 +100,11 @@ function runWatcher({ policy, running, state, withLog = true }) {
|
|
|
100
100
|
[
|
|
101
101
|
'#!/bin/sh',
|
|
102
102
|
`dir='${dir}'`,
|
|
103
|
+
'[ "$START_COMMAND_CGROUP_SAMPLING" = 1 ] && exit 1',
|
|
103
104
|
'case "$1" in',
|
|
104
105
|
' inspect)',
|
|
105
106
|
' case "$3" in',
|
|
107
|
+
' *HostConfig*) exit 1 ;;',
|
|
106
108
|
' *State.Running*)',
|
|
107
109
|
' answer=$(head -n 1 "$dir/running")',
|
|
108
110
|
' if [ "$(wc -l < "$dir/running")" -gt 1 ]; then',
|
|
@@ -173,7 +175,7 @@ describe('issue #174: docker logs -f returning is not the container exiting', ()
|
|
|
173
175
|
});
|
|
174
176
|
|
|
175
177
|
expect(run.status).toBe(0);
|
|
176
|
-
expect(run.calls).toContain('
|
|
178
|
+
expect(run.calls).toContain('inspect-running false');
|
|
177
179
|
expect(run.calls.indexOf('wait')).toBeGreaterThan(
|
|
178
180
|
run.calls.indexOf('logs')
|
|
179
181
|
);
|
package/test/regression-176.js
CHANGED
|
@@ -127,6 +127,12 @@ function fakeRunner(hostConfig, failures = {}) {
|
|
|
127
127
|
if (args[0] === 'inspect' && args.includes('{{json .HostConfig}}')) {
|
|
128
128
|
return ok(`${JSON.stringify(hostConfig)}\n`);
|
|
129
129
|
}
|
|
130
|
+
if (args.includes('--size')) {
|
|
131
|
+
return ok('1024');
|
|
132
|
+
}
|
|
133
|
+
if (args[0] === 'info') {
|
|
134
|
+
return ok(JSON.stringify({ DockerRootDir: os.tmpdir() }));
|
|
135
|
+
}
|
|
130
136
|
return ok('cid\n');
|
|
131
137
|
};
|
|
132
138
|
runner.calls = calls;
|
|
@@ -300,6 +306,7 @@ describe('issue #176: snapshot resume keeps the resource limits', () => {
|
|
|
300
306
|
command: 'npm run build',
|
|
301
307
|
probe: stoppedProbe,
|
|
302
308
|
runner,
|
|
309
|
+
snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
|
|
303
310
|
startWatcher: (...args) => watchers.push(args),
|
|
304
311
|
});
|
|
305
312
|
expect(result.success).toBe(true);
|
package/test/regression-180.js
CHANGED
|
@@ -70,20 +70,20 @@ describe('issue #180: the sticky OOMKilled flag is not an exit reason', () => {
|
|
|
70
70
|
{
|
|
71
71
|
exitCode: 137,
|
|
72
72
|
oomKilled: true,
|
|
73
|
-
reason:
|
|
74
|
-
memory:
|
|
73
|
+
reason: 'signal (SIGKILL; cause unknown)',
|
|
74
|
+
memory: false,
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
exitCode: 137,
|
|
78
78
|
oomKilled: false,
|
|
79
|
-
reason: 'signal (SIGKILL)',
|
|
79
|
+
reason: 'signal (SIGKILL; cause unknown)',
|
|
80
80
|
memory: false,
|
|
81
81
|
},
|
|
82
82
|
{
|
|
83
83
|
exitCode: -1,
|
|
84
84
|
oomKilled: true,
|
|
85
|
-
reason:
|
|
86
|
-
memory:
|
|
85
|
+
reason: null,
|
|
86
|
+
memory: false,
|
|
87
87
|
},
|
|
88
88
|
{
|
|
89
89
|
exitCode: 139,
|
|
@@ -104,10 +104,17 @@ describe('issue #180: the sticky OOMKilled flag is not an exit reason', () => {
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
it('
|
|
108
|
-
expect(isOomKillOfCommand({ exitCode: 137, oomKilled: true })).toBe(
|
|
109
|
-
expect(
|
|
110
|
-
|
|
107
|
+
it('requires fresh attribution in addition to SIGKILL or an unknown exit', () => {
|
|
108
|
+
expect(isOomKillOfCommand({ exitCode: 137, oomKilled: true })).toBe(false);
|
|
109
|
+
expect(
|
|
110
|
+
isOomKillOfCommand({
|
|
111
|
+
exitCode: 137,
|
|
112
|
+
oomKilled: true,
|
|
113
|
+
exitEvidence: { mainOom: true },
|
|
114
|
+
})
|
|
115
|
+
).toBe(true);
|
|
116
|
+
expect(isOomKillOfCommand({ exitCode: null, oomKilled: true })).toBe(false);
|
|
117
|
+
expect(isOomKillOfCommand({ exitCode: -1, oomKilled: true })).toBe(false);
|
|
111
118
|
expect(isOomKillOfCommand({ exitCode: 0, oomKilled: true })).toBe(false);
|
|
112
119
|
expect(isOomKillOfCommand({ exitCode: 1, oomKilled: true })).toBe(false);
|
|
113
120
|
expect(isOomKillOfCommand({ exitCode: 137, oomKilled: false })).toBe(false);
|
|
@@ -191,6 +198,6 @@ describe('issue #180: the sticky OOMKilled flag is not an exit reason', () => {
|
|
|
191
198
|
oomKilled: true,
|
|
192
199
|
logPath: null,
|
|
193
200
|
});
|
|
194
|
-
expect(main).toContain('Docker reports it was OOM-killed.');
|
|
201
|
+
expect(main).toContain('Docker reports a process in it was OOM-killed.');
|
|
195
202
|
});
|
|
196
203
|
});
|
package/test/regression-182.js
CHANGED
|
@@ -285,9 +285,13 @@ describe('issue #182: the watcher samples the cgroup while the container runs',
|
|
|
285
285
|
timeout: 20000,
|
|
286
286
|
});
|
|
287
287
|
expect(result.status).toBe(0);
|
|
288
|
-
expect(
|
|
289
|
-
|
|
290
|
-
|
|
288
|
+
expect(
|
|
289
|
+
fs
|
|
290
|
+
.readFileSync(path.join(host.dir, 'sample'), 'utf8')
|
|
291
|
+
.split(' ')
|
|
292
|
+
.slice(0, 4)
|
|
293
|
+
.join(' ')
|
|
294
|
+
).toBe(OOM_SAMPLE);
|
|
291
295
|
expect(fs.readFileSync(logPath, 'utf8')).toBe(
|
|
292
296
|
`${formatCgroupMemoryLogLine(OOM_SAMPLE)}\n`
|
|
293
297
|
);
|
package/test/regression-185.js
CHANGED
|
@@ -97,15 +97,10 @@ describe('issue #185: counter-only OOM scope is unknown', () => {
|
|
|
97
97
|
}
|
|
98
98
|
for (const exitCode of [137, null, -1]) {
|
|
99
99
|
const input = { exitCode, oomKilled: false, cgroupMemory };
|
|
100
|
-
expect(resolveExitReason(input)).toBe(CGROUP_OOM_EXIT_REASON);
|
|
101
|
-
expect(resolveMemoryExhaustion(input)).
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
: {
|
|
105
|
-
memoryExhausted: true,
|
|
106
|
-
memoryExhaustedReason: 'cgroup memory.events reported oom_kill=3',
|
|
107
|
-
}
|
|
108
|
-
);
|
|
100
|
+
expect(resolveExitReason(input)).not.toBe(CGROUP_OOM_EXIT_REASON);
|
|
101
|
+
expect(resolveMemoryExhaustion(input)).toBeNull();
|
|
102
|
+
const attributed = { ...input, exitEvidence: { mainOom: true } };
|
|
103
|
+
expect(resolveExitReason(attributed)).toBe(CGROUP_OOM_EXIT_REASON);
|
|
109
104
|
}
|
|
110
105
|
});
|
|
111
106
|
});
|
package/test/regression-187.js
CHANGED
|
@@ -76,7 +76,15 @@ function fixture() {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
const stopped = () => ({ alive: false, state: SessionState.STOPPED });
|
|
79
|
-
const accepted = () => ({
|
|
79
|
+
const accepted = (_bin, args = []) => ({
|
|
80
|
+
success: true,
|
|
81
|
+
stdout: args.includes('--size')
|
|
82
|
+
? '1024'
|
|
83
|
+
: args[0] === 'info'
|
|
84
|
+
? JSON.stringify({ DockerRootDir: os.tmpdir() })
|
|
85
|
+
: 'container-id\n',
|
|
86
|
+
status: 0,
|
|
87
|
+
});
|
|
80
88
|
|
|
81
89
|
describe('issue #187: explicit resume attempts', () => {
|
|
82
90
|
it('reattaches the current attempt without enabling kill recovery for snapshot commands', async () => {
|
|
@@ -368,7 +376,8 @@ esac
|
|
|
368
376
|
probe: () => ({ alive: false, state: SessionState.MISSING }),
|
|
369
377
|
runIsolated: (backend, command, options) => {
|
|
370
378
|
expect(options.deferCompletionWatcher).toBe(true);
|
|
371
|
-
expect(store.get(record.uuid).status).toBe('
|
|
379
|
+
expect(store.get(record.uuid).status).toBe('executing');
|
|
380
|
+
expect(store.get(record.uuid).options.launchPending).toBe(true);
|
|
372
381
|
return { success: true, containerId: 'replacement-container' };
|
|
373
382
|
},
|
|
374
383
|
startWatcher: () => {
|