Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion dist/cli.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/cli.js.map

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions dist/exit-codes.test.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Exit-code contract for the receipt path.
*
* README documents 0 VALID / 1 INVALID / 2 UNKNOWN_KEY / 3 MALFORMED /
* 4 NETWORK / 64 USAGE, and CI consumers are the one audience that reads the
* exit code rather than the text.
*
* The entry point used to call process.exit(code), which tears the process down
* immediately. On Windows, if a libuv async handle was mid-close — which it is
* on any path that did fs or network I/O — Node aborted with:
*
* Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76
*
* The verdict had already been printed, so output looked correct while the
* shell saw 127. Every documented code was wrong on those paths and nothing
* caught it, because the existing CLI tests exercise the certificate path with
* --offline and never hit the race.
*
* These tests exercise the RECEIPT path specifically, including one that reads
* a local file and still fetches the public key — the exact combination that
* aborted.
*/
export {};
//# sourceMappingURL=exit-codes.test.d.ts.map
1 change: 1 addition & 0 deletions dist/exit-codes.test.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 82 additions & 0 deletions dist/exit-codes.test.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions dist/exit-codes.test.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"prepare": "npm run build",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"test": "tsc -p tsconfig.json && node --test dist/canonicalize.test.js dist/verify.test.js dist/cli.test.js dist/receipt.test.js dist/receipt-vectors.test.js",
"test": "tsc -p tsconfig.json && node --test dist/canonicalize.test.js dist/verify.test.js dist/cli.test.js dist/receipt.test.js dist/receipt-vectors.test.js dist/exit-codes.test.js",
"fixtures": "node fixtures/generate.mjs",
"prepublishOnly": "npm run build && npm test"
},
Expand Down
19 changes: 18 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,5 +289,22 @@ async function readVersion(): Promise<string> {
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main(process.argv.slice(2)).then((code) => process.exit(code));
// Set exitCode and let the event loop drain, rather than process.exit(code).
//
// process.exit() tears the process down immediately. On Windows, if a libuv
// async handle is mid-close when that happens — which it is on any path that
// did fs or network I/O — Node aborts with:
//
// Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76
//
// The verdict has already been printed by then, so the output looks correct
// while the shell sees an abnormal exit (127) instead of the documented code.
// That silently breaks every CI consumer, which is the one audience that
// reads the exit code rather than the text.
//
// Assigning exitCode lets Node close its handles and exit normally with the
// code we asked for. Nothing here keeps the loop alive deliberately.
main(process.argv.slice(2)).then((code) => {
process.exitCode = code;
});
}
95 changes: 95 additions & 0 deletions src/exit-codes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Exit-code contract for the receipt path.
*
* README documents 0 VALID / 1 INVALID / 2 UNKNOWN_KEY / 3 MALFORMED /
* 4 NETWORK / 64 USAGE, and CI consumers are the one audience that reads the
* exit code rather than the text.
*
* The entry point used to call process.exit(code), which tears the process down
* immediately. On Windows, if a libuv async handle was mid-close — which it is
* on any path that did fs or network I/O — Node aborted with:
*
* Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76
*
* The verdict had already been printed, so output looked correct while the
* shell saw 127. Every documented code was wrong on those paths and nothing
* caught it, because the existing CLI tests exercise the certificate path with
* --offline and never hit the race.
*
* These tests exercise the RECEIPT path specifically, including one that reads
* a local file and still fetches the public key — the exact combination that
* aborted.
*/

import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const here = dirname(fileURLToPath(import.meta.url));
const cliPath = join(here, "cli.js");
const fixturesDir = join(here, "..", "fixtures");

interface Run { stdout: string; stderr: string; code: number; }

function run(args: string[]): Promise<Run> {
return new Promise((resolve) => {
const env = { ...process.env, NO_COLOR: "1" };
const child = spawn(process.execPath, [cliPath, ...args], { env });
let stdout = ""; let stderr = "";
child.stdout.on("data", (d) => (stdout += d.toString()));
child.stderr.on("data", (d) => (stderr += d.toString()));
child.on("close", (code) => resolve({ stdout, stderr, code: code ?? -1 }));
});
}

/** The process must exit cleanly, never abort. */
function assertNoAbort(r: Run, label: string) {
assert.ok(
!/(Assertion failed|UV_HANDLE_CLOSING)/.test(r.stderr + r.stdout),
`${label}: process aborted instead of exiting — ${r.stderr.trim()}`,
);
assert.notEqual(r.code, 127, `${label}: exit 127 means the process died abnormally`);
}

test("receipt VALID from a local file exits 0, not 127", async () => {
// Reads a file AND fetches the PEM — the combination that aborted.
const r = await run([join(fixturesDir, "valid-receipt.json"), "--type", "receipt"]);
assertNoAbort(r, "valid");
assert.match(r.stdout, /VALID/);
assert.equal(r.code, 0, `stderr: ${r.stderr}`);
});

test("receipt INVALID exits 1", async () => {
const r = await run([join(fixturesDir, "tampered-receipt.json"), "--type", "receipt"]);
assertNoAbort(r, "tampered");
assert.match(r.stdout, /INVALID/);
assert.equal(r.code, 1, `stderr: ${r.stderr}`);
});

test("receipt MALFORMED exits 3", async () => {
const r = await run([join(fixturesDir, "malformed-receipt.json"), "--type", "receipt"]);
assertNoAbort(r, "malformed");
assert.match(r.stdout, /MALFORMED/);
assert.equal(r.code, 3, `stderr: ${r.stderr}`);
});

test("--version exits 0 and prints a version", async () => {
const r = await run(["--version"]);
assertNoAbort(r, "version");
assert.equal(r.code, 0);
assert.match(r.stdout, /@certifieddata\/verify/);
});

test("--help exits 0", async () => {
const r = await run(["--help"]);
assertNoAbort(r, "help");
assert.equal(r.code, 0);
});

test("no arguments is a usage error, exit 64", async () => {
const r = await run([]);
assertNoAbort(r, "no args");
assert.equal(r.code, 64);
});
Loading