Skip to content

Commit e29a658

Browse files
committed
test: improve CI and package verification
1 parent 60de8eb commit e29a658

9 files changed

Lines changed: 416 additions & 200 deletions

File tree

‎.github/workflows/node-ci.yml‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,6 @@ jobs:
321321
sudo apt-get update
322322
sudo apt-get install --yes ripgrep
323323
- name: Test
324-
timeout-minutes: 10
325324
working-directory: sdk/typescript
326325
env:
327326
TEMP: ${{ runner.temp }}
@@ -589,7 +588,6 @@ jobs:
589588
shell: pwsh
590589
run: ./sdk/typescript/scripts/prepare-windows-test-root.ps1
591590
- name: Test shard ${{ matrix.shard }}
592-
timeout-minutes: 10
593591
env:
594592
TEMP: ${{ steps.windows-temp.outputs.path }}
595593
TMP: ${{ steps.windows-temp.outputs.path }}

‎.github/workflows/test-quality.yml‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,45 @@ jobs:
3232
if: ${{ !cancelled() && (inputs.native-artifacts-ready || needs.native.result == 'success') }}
3333
name: ${{ matrix.os }} / ${{ matrix.mode }}
3434
runs-on: ${{ matrix.os }}
35-
timeout-minutes: ${{ matrix.os == 'windows-latest' && (matrix.mode == 'baseline' || matrix.mode == 'isolated') && 45 || 30 }}
35+
timeout-minutes: ${{ matrix.os == 'windows-latest' && (matrix.mode == 'baseline' && 60 || matrix.mode == 'parallel' && 45) || 30 }}
3636
env:
3737
CODEX_SECURITY_PROPERTY_SEED: ${{ github.event_name == 'pull_request' && 1 || github.run_number }}
3838
strategy:
3939
fail-fast: false
4040
matrix:
4141
os: [ubuntu-latest, windows-latest]
4242
mode: [baseline, isolated, parallel]
43+
exclude:
44+
- os: windows-latest
45+
mode: isolated
4346
include:
4447
- mode: baseline
4548
args: ""
4649
- mode: isolated
4750
args: --isolate
4851
- mode: parallel
4952
args: --parallel=2
53+
- os: windows-latest
54+
mode: isolated-1
55+
args: --isolate --shard=1/7
56+
- os: windows-latest
57+
mode: isolated-2
58+
args: --isolate --shard=2/7
59+
- os: windows-latest
60+
mode: isolated-3
61+
args: --isolate --shard=3/7
62+
- os: windows-latest
63+
mode: isolated-4
64+
args: --isolate --shard=4/7
65+
- os: windows-latest
66+
mode: isolated-5
67+
args: --isolate --shard=5/7
68+
- os: windows-latest
69+
mode: isolated-6
70+
args: --isolate --shard=6/7
71+
- os: windows-latest
72+
mode: isolated-7
73+
args: --isolate --shard=7/7
5074
- os: windows-latest
5175
mode: shard-1
5276
args: --shard=1/7
@@ -104,6 +128,7 @@ jobs:
104128
run: ./sdk/typescript/scripts/prepare-windows-test-root.ps1
105129
- name: Test runner mode
106130
env:
131+
CODEX_SECURITY_TEST_TIMEOUT_MS: ${{ runner.os == 'Windows' && '120000' || '30000' }}
107132
TEMP: ${{ steps.windows-temp.outputs.path || runner.temp }}
108133
TMP: ${{ steps.windows-temp.outputs.path || runner.temp }}
109134
TMPDIR: ${{ steps.windows-temp.outputs.path || runner.temp }}
@@ -152,7 +177,7 @@ jobs:
152177
comparison_status=0
153178
for os in ubuntu-latest windows-latest; do
154179
for mode in isolated parallel; do
155-
node sdk/typescript/scripts/compare-test-reports.mjs "reports/runner-$os-baseline.xml" "reports/runner-$os-$mode.xml" >> "$GITHUB_STEP_SUMMARY" || comparison_status=1
180+
node sdk/typescript/scripts/compare-test-reports.mjs "reports/runner-$os-baseline.xml" "reports/runner-$os-$mode*.xml" >> "$GITHUB_STEP_SUMMARY" || comparison_status=1
156181
done
157182
done
158183
node sdk/typescript/scripts/compare-test-reports.mjs reports/runner-windows-latest-baseline.xml 'reports/runner-windows-latest-shard-*.xml' >> "$GITHUB_STEP_SUMMARY" || comparison_status=1

‎sdk/typescript/scripts/check-package.mjs‎

Lines changed: 21 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@ import { spawnSync } from "node:child_process";
22
import { createHash } from "node:crypto";
33
import { readFileSync } from "node:fs";
44
import { fileURLToPath } from "node:url";
5-
import { brotliDecompressSync, gunzipSync } from "node:zlib";
5+
import { gunzipSync } from "node:zlib";
6+
import {
7+
assertPublicPackageContents,
8+
MAX_EXPANDED_ASSET_BYTES,
9+
} from "./package-public-content.mjs";
610
import { assertExpectedGitHead } from "./package-provenance.mjs";
711
import { packageSmokeTimeouts } from "./package-smoke-timeouts.mjs";
8-
import { regularTarListingLines } from "./package-tar-listing.mjs";
12+
import {
13+
readTarContents,
14+
regularTarListingLines,
15+
} from "./package-tar-listing.mjs";
916

1017
const PACKAGE_SMOKE_PROCESS_TIMEOUT_MS =
1118
packageSmokeTimeouts().processTimeoutMs;
@@ -25,7 +32,6 @@ if (archive === undefined || args.length > 2) {
2532
);
2633
}
2734

28-
const MAX_EXPANDED_ASSET_BYTES = 32 * 1024 * 1024;
2935
const archiveBytes = gunzipSync(readFileSync(archive), {
3036
maxOutputLength: MAX_EXPANDED_ASSET_BYTES,
3137
});
@@ -47,54 +53,6 @@ function tar(args, encoding = "buffer") {
4753
return result.stdout;
4854
}
4955

50-
let offset = 0;
51-
const archiveFiles = new Map();
52-
for (; offset + 512 <= archiveBytes.byteLength;) {
53-
const header = archiveBytes.subarray(offset, offset + 512);
54-
if (header.every((byte) => byte === 0)) {
55-
offset += 512;
56-
continue;
57-
}
58-
const name = header.subarray(0, 100).toString("utf8").split("\0", 1)[0];
59-
const prefix = header.subarray(345, 500).toString("utf8").split("\0", 1)[0];
60-
const path = prefix === "" ? name : `${prefix}/${name}`;
61-
const sizeField = header
62-
.subarray(124, 136)
63-
.toString("ascii")
64-
.split("\0", 1)[0]
65-
.trim();
66-
if (!/^[0-7]*$/u.test(sizeField)) {
67-
throw new Error("npm tarball contains an invalid tar entry.");
68-
}
69-
if (path.endsWith("/") && header[156] !== 0x35) {
70-
throw new Error("npm tarball contains an invalid tar entry.");
71-
}
72-
const size = Number.parseInt(sizeField || "0", 8);
73-
const contentsStart = offset + 512;
74-
const nextOffset = contentsStart + Math.ceil(size / 512) * 512;
75-
if (nextOffset > archiveBytes.byteLength) {
76-
throw new Error("npm tarball contains an invalid tar entry.");
77-
}
78-
if (header[156] === 0 || header[156] === 0x30) {
79-
archiveFiles.set(
80-
path,
81-
archiveBytes.subarray(contentsStart, contentsStart + size),
82-
);
83-
}
84-
offset = nextOffset;
85-
}
86-
if (archiveBytes.subarray(offset).some((byte) => byte !== 0)) {
87-
throw new Error("npm tarball contains trailing tar data.");
88-
}
89-
90-
function archiveFile(path) {
91-
const contents = archiveFiles.get(path);
92-
if (contents === undefined) {
93-
throw new Error("npm tarball contains an invalid tar entry: " + path + ".");
94-
}
95-
return contents;
96-
}
97-
9856
const entries = tar(["-tzf", archive], "utf8").split(/\r?\n/u).filter(Boolean);
9957
const files = new Set(entries);
10058
if (files.size !== entries.length) {
@@ -303,13 +261,17 @@ for (const file of files) {
303261

304262
const listing = tar(["-tvzf", archive], "utf8");
305263
const listingLines = regularTarListingLines(listing);
306-
if (
307-
listingLines.length !== entries.length ||
308-
listingLines.some(
309-
(line, index) => line.startsWith("d") !== entries[index].endsWith("/"),
310-
)
311-
) {
312-
throw new Error("npm tarball contains an invalid tar entry.");
264+
const { files: archiveFiles, metadata: archiveMetadata } = readTarContents(
265+
archiveBytes,
266+
entries,
267+
listingLines,
268+
);
269+
function archiveFile(path) {
270+
const contents = archiveFiles.get(path);
271+
if (contents === undefined) {
272+
throw new Error("npm tarball contains an invalid tar entry: " + path + ".");
273+
}
274+
return contents;
313275
}
314276
for (const [path, name] of [
315277
["package/bin/codex-security.mjs", "CLI"],
@@ -335,40 +297,6 @@ assertExpectedGitHead(
335297
process.env.CODEX_SECURITY_EXPECTED_GIT_HEAD,
336298
);
337299

338-
const internalMarker =
339-
/(?:internal\.api\.openai\.org|gateway\.[a-z0-9.-]*internal|\.openai\.org|openai\.firewall\.socket\.dev|socket\x2dfirewall\x2dregistry|openai\.(?:enterprise\.)?slack\.com|app\.slack\.com\/client|(?:app\.notion\.com\/p|notion\.so)\/openai|linear\.app\/openai|(?:github\.com[:/]|api\.github\.com\/repos\/|raw\.githubusercontent\.com\/)openai\/openai(?:\.git)?(?:[^a-z0-9_-]|$)|LicenseRef\x2dProprietary|\/Users\/|\/home\/dev-user|flow\.apps\.openai\.org|(?:^|[^a-z0-9_-])go\/[a-z0-9_-]+)/iu;
340-
341-
const payloads = [archiveBytes.toString("utf8")];
342-
const compressedFiles = [...files].filter((file) => /\.br$/iu.test(file));
343-
const compressedParts = new Map();
344-
for (const file of files) {
345-
const match = /^(.*\.br)\.part-([0-9]+)$/iu.exec(file);
346-
if (match === null) continue;
347-
const [, name, part] = match;
348-
const parts = compressedParts.get(name) ?? [];
349-
parts.push({ file, part: Number(part) });
350-
compressedParts.set(name, parts);
351-
}
352-
353-
function brotliPayload(bytes, file) {
354-
const result = brotliDecompressSync(bytes, {
355-
info: true,
356-
maxOutputLength: MAX_EXPANDED_ASSET_BYTES,
357-
});
358-
if (result.engine.bytesWritten !== bytes.length) {
359-
throw new Error(`npm tarball contains trailing Brotli data: ${file}.`);
360-
}
361-
return result.buffer;
362-
}
363-
364-
for (const file of compressedFiles) {
365-
payloads.push(brotliPayload(archiveFile(file), file).toString("utf8"));
366-
}
367-
for (const parts of compressedParts.values()) {
368-
parts.sort((left, right) => left.part - right.part);
369-
const bytes = Buffer.concat(parts.map(({ file }) => archiveFile(file)));
370-
payloads.push(brotliPayload(bytes, parts[0].file).toString("utf8"));
371-
}
372300
for (const file of files) {
373301
if (/\.png$/iu.test(file)) {
374302
const digest = createHash("sha256").update(archiveFile(file)).digest("hex");
@@ -378,11 +306,7 @@ for (const file of files) {
378306
}
379307
}
380308

381-
for (const contents of payloads) {
382-
if (internalMarker.test(contents)) {
383-
throw new Error("npm tarball contains an internal reference.");
384-
}
385-
}
309+
assertPublicPackageContents(archiveFiles, archiveMetadata);
386310

387311
if (args.length === 1) {
388312
const smoke = spawnSync(
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { brotliDecompressSync } from "node:zlib";
2+
3+
export const MAX_EXPANDED_ASSET_BYTES = 32 * 1024 * 1024;
4+
5+
const internalMarker =
6+
/(?:internal\.api\.openai\.org|gateway\.[a-z0-9.-]*internal|\.openai\.org|openai\.firewall\.socket\.dev|socket\x2dfirewall\x2dregistry|openai\.(?:enterprise\.)?slack\.com|app\.slack\.com\/client|(?:app\.notion\.com\/p|notion\.so)\/openai|linear\.app\/openai|(?:github\.com[:/]|api\.github\.com\/repos\/|raw\.githubusercontent\.com\/)openai\/openai(?:\.git)?(?:[^a-z0-9_-]|$)|LicenseRef\x2dProprietary|\/Users\/|\/home\/dev-user|flow\.apps\.openai\.org|(?:^|[^a-z0-9_-])go\/[a-z0-9_-]+)/iu;
7+
8+
export function assertPublicPackageContents(
9+
archiveFiles,
10+
archiveMetadata = Buffer.alloc(0),
11+
) {
12+
assertPublicText(archiveMetadata.toString("utf8"));
13+
const compressedParts = new Map();
14+
for (const [path, bytes] of archiveFiles) {
15+
assertPublicText(path);
16+
const part = /^(.*\.br)\.part-([0-9]+)$/iu.exec(path);
17+
if (part !== null) {
18+
const [, name, index] = part;
19+
const parts = compressedParts.get(name) ?? [];
20+
parts.push({ path, index: Number(index), bytes });
21+
compressedParts.set(name, parts);
22+
} else if (/\.br$/iu.test(path)) {
23+
assertPublicBrotli(bytes, path);
24+
} else {
25+
assertPublicText(bytes.toString("utf8"));
26+
}
27+
}
28+
for (const parts of compressedParts.values()) {
29+
parts.sort((left, right) => left.index - right.index);
30+
assertPublicBrotli(
31+
Buffer.concat(parts.map(({ bytes }) => bytes)),
32+
parts[0].path,
33+
);
34+
}
35+
}
36+
37+
function assertPublicBrotli(bytes, path) {
38+
const result = brotliDecompressSync(bytes, {
39+
info: true,
40+
maxOutputLength: MAX_EXPANDED_ASSET_BYTES,
41+
});
42+
if (result.engine.bytesWritten !== bytes.length) {
43+
throw new Error(`npm tarball contains trailing Brotli data: ${path}.`);
44+
}
45+
assertPublicText(result.buffer.toString("utf8"));
46+
}
47+
48+
function assertPublicText(contents) {
49+
if (internalMarker.test(contents)) {
50+
throw new Error("npm tarball contains an internal reference.");
51+
}
52+
}

‎sdk/typescript/scripts/package-tar-listing.mjs‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,69 @@ export function regularTarListingLines(listing) {
77
}
88
return lines;
99
}
10+
11+
export function readTarContents(archiveBytes, entries, listingLines) {
12+
if (
13+
listingLines.length !== entries.length ||
14+
listingLines.some(
15+
(line, index) => line.startsWith("d") !== entries[index].endsWith("/"),
16+
)
17+
) {
18+
throw new Error("npm tarball contains an invalid tar entry.");
19+
}
20+
let offset = 0;
21+
const payloads = [];
22+
const metadata = [];
23+
for (; offset + 512 <= archiveBytes.byteLength;) {
24+
const header = archiveBytes.subarray(offset, offset + 512);
25+
if (header.every((byte) => byte === 0)) {
26+
metadata.push(header);
27+
offset += 512;
28+
continue;
29+
}
30+
const name = header.subarray(0, 100).toString("utf8").split("\0", 1)[0];
31+
const prefix = header.subarray(345, 500).toString("utf8").split("\0", 1)[0];
32+
const path = prefix === "" ? name : `${prefix}/${name}`;
33+
const sizeField = header
34+
.subarray(124, 136)
35+
.toString("ascii")
36+
.split("\0", 1)[0]
37+
.trim();
38+
if (!/^[0-7]*$/u.test(sizeField)) {
39+
throw new Error("npm tarball contains an invalid tar entry.");
40+
}
41+
if (path.endsWith("/") && header[156] !== 0x35) {
42+
throw new Error("npm tarball contains an invalid tar entry.");
43+
}
44+
const size = Number.parseInt(sizeField || "0", 8);
45+
const contentsStart = offset + 512;
46+
const nextOffset = contentsStart + Math.ceil(size / 512) * 512;
47+
if (nextOffset > archiveBytes.byteLength) {
48+
throw new Error("npm tarball contains an invalid tar entry.");
49+
}
50+
if (header[156] === 0 || header[156] === 0x30) {
51+
payloads.push(archiveBytes.subarray(contentsStart, contentsStart + size));
52+
metadata.push(
53+
header,
54+
archiveBytes.subarray(contentsStart + size, nextOffset),
55+
);
56+
} else {
57+
metadata.push(archiveBytes.subarray(offset, nextOffset));
58+
}
59+
offset = nextOffset;
60+
}
61+
if (archiveBytes.subarray(offset).some((byte) => byte !== 0)) {
62+
throw new Error("npm tarball contains trailing tar data.");
63+
}
64+
const regularPaths = entries.filter((_, index) =>
65+
listingLines[index].startsWith("-"),
66+
);
67+
if (regularPaths.length !== payloads.length) {
68+
throw new Error("npm tarball contains an invalid tar entry.");
69+
}
70+
// Tar resolves PAX and GNU long names; raw headers may reuse fallback names.
71+
const files = new Map(
72+
regularPaths.map((path, index) => [path, payloads[index]]),
73+
);
74+
return { files, metadata: Buffer.concat(metadata) };
75+
}

0 commit comments

Comments
 (0)