Skip to content
Open
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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,42 @@ npm install -D tsx
npx tsx main.ts
```

## CLI

Installing the package provides a `cve-validate` command for validating CVE
Records and CNA Containers from the shell or a CI pipeline.

Validate one or more files:

```sh
npx cve-validate record.json
npx cve-validate record1.json record2.json
```

Read from stdin (use `-` explicitly, or pipe with no arguments):

```sh
cat record.json | cve-validate
```

Validate a CNA Container instead of a full CVE Record:

```sh
cve-validate --type published-cna published-container.json
cve-validate --type rejected-cna rejected-container.json
```

Emit machine-readable JSON for scripting:

```sh
cve-validate --json record.json
```

Any registered warnings are printed beneath each result (and included in the
`--json` output). The command exits `0` when every input is valid, `1` when any
input is invalid or unreadable, and `2` on a usage error. Run
`cve-validate --help` for the full list of options.

## Validation Methods

```ts
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,23 @@
"import": "./dist/index.js"
}
},
"bin": {
"cve-validate": "dist/cli.js"
},
"files": [
"dist",
"src",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsup src/index.ts --format esm --dts --clean",
"build": "tsup src/index.ts src/cli.ts --format esm --dts --clean",
"dev": "tsx watch main.js",
"typecheck": "tsc --noEmit",
"prepare": "npm run build",
"test": "npm run typecheck",
"test:local": "npm run build && node scripts/test-local.mjs",
"test:cli": "npm run build && node scripts/test-cli.mjs",
"warning:stub": "npm run build && node scripts/create-stub-warning.mjs"
},
"devDependencies": {
Expand Down
248 changes: 248 additions & 0 deletions scripts/test-cli.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

const fromRoot = (relativePath) => fileURLToPath(new URL(relativePath, import.meta.url));

const cli = fromRoot("../dist/cli.js");
const validRecord = fromRoot("../scripts/test-data/cve-record-valid.json");
const invalidRecord = fromRoot("../scripts/test-data/cve-record-missing-cna-container.json");
const validPublishedContainer = fromRoot(
"../scripts/test-data/cna-container/published-cna-container-valid.json",
);
const validRejectedContainer = fromRoot(
"../scripts/test-data/cna-container/rejected-cna-container-valid.json",
);
const manifest = JSON.parse(readFileSync(fromRoot("../package.json"), "utf8"));
const packageVersion = manifest.version;

function runCli(args, input) {
return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", input });
}

// Runs the CLI and consumes stdout SLOWLY, pausing on each chunk. This creates
// pipe backpressure so the OS pipe buffer fills, exposing any premature exit
// that would truncate buffered output before it drains.
function runCliSlowPipe(args) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [cli, ...args], { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";

child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
child.stdout.pause();
setTimeout(() => child.stdout.resume(), 5);
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (status) => resolve({ status, stdout, stderr }));
});
}

function runValidFile() {
const result = runCli([validRecord]);
assert.equal(result.status, 0);
assert.equal(result.stderr, "", "success must not write to stderr");
assert.match(result.stdout, /valid/);
assert.match(result.stdout, /1 file\(s\): 1 valid, 0 invalid/);
}

function runInvalidFile() {
const result = runCli([invalidRecord]);
assert.equal(result.status, 1);
assert.equal(result.stderr, "");
assert.match(result.stdout, /FAILED_JSON_SCHEMA_VALIDATION/);
// A per-error detail line must be printed under the failing file.
assert.match(result.stdout, /must have required property 'cna'/);
assert.match(result.stdout, /1 file\(s\): 0 valid, 1 invalid/);
}

function runMixedFiles() {
const result = runCli([validRecord, invalidRecord]);
assert.equal(result.status, 1);
assert.match(result.stdout, /2 file\(s\): 1 valid, 1 invalid/);
}

function runWarningsInHumanOutput() {
// The default registry ships a STUB_WARNING; it must be surfaced (not only in
// --json) and reflected in the summary count.
const result = runCli([validRecord]);
assert.equal(result.status, 0);
assert.match(result.stdout, /STUB_WARNING/);
assert.match(result.stdout, /\d+ warning\(s\)/);
}

function runJsonOutput() {
const result = runCli(["--json", validRecord, invalidRecord]);
assert.equal(result.status, 1);
assert.equal(result.stderr, "", "--json must not pollute stderr");
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.length, 2);
assert.equal(parsed[0].valid, true);
assert.equal(parsed[0].error, null);
// Warnings must be present and populated, not merely an array.
assert.ok(parsed[0].warnings.length >= 1);
assert.equal(parsed[0].warnings[0].messageId, "STUB_WARNING");
assert.equal(parsed[1].valid, false);
assert.equal(parsed[1].error, "FAILED_JSON_SCHEMA_VALIDATION");
}

function runStdin() {
const result = runCli([], readFileSync(validRecord, "utf8"));
assert.equal(result.status, 0);
assert.match(result.stdout, /<stdin>/);
}

function runStdinDash() {
const result = runCli(["-"], readFileSync(invalidRecord, "utf8"));
assert.equal(result.status, 1);
assert.match(result.stdout, /<stdin>/);
}

function runQuiet() {
const result = runCli(["--quiet", validRecord]);
assert.equal(result.status, 0);
// No per-file "… — valid" line in quiet mode. Match the marker directly so the
// assertion does not accidentally match the "invalid" count in the summary.
assert.doesNotMatch(result.stdout, /— valid/);
assert.match(result.stdout, /1 valid, 0 invalid/);
}

function runShortFlags() {
const quiet = runCli(["-q", validRecord]);
assert.equal(quiet.status, 0);
assert.doesNotMatch(quiet.stdout, /— valid/);

const help = runCli(["-h"]);
assert.equal(help.status, 0);
assert.match(help.stdout, /Usage: cve-validate/);

const version = runCli(["-v"]);
assert.equal(version.status, 0);
assert.equal(version.stdout.trim(), packageVersion);
}

function runVersion() {
const result = runCli(["--version"]);
assert.equal(result.status, 0);
assert.equal(result.stderr, "");
assert.equal(result.stdout.trim(), packageVersion);
// Non-vacuous shape check (meaningful once version advances past 0.0.0).
assert.match(result.stdout.trim(), /^\d+\.\d+\.\d+/);
}

function runHelp() {
const result = runCli(["--help"]);
assert.equal(result.status, 0);
assert.equal(result.stderr, "");
assert.match(result.stdout, /Usage: cve-validate/);
}

function runUnknownOption() {
const result = runCli(["--bogus"]);
assert.equal(result.status, 2);
assert.equal(result.stdout, "", "usage errors must not write to stdout");
assert.match(result.stderr, /Unknown option: --bogus/);
}

function runBadType() {
const result = runCli(["--type", "nope", validRecord]);
assert.equal(result.status, 2);
assert.equal(result.stdout, "");
assert.match(result.stderr, /--type must be one of/);
}

function runPublishedContainerType() {
const result = runCli(["--type", "published-cna", validPublishedContainer]);
assert.equal(result.status, 0);
assert.match(result.stdout, /1 valid, 0 invalid/);
}

function runTypeEqualsForm() {
const result = runCli(["--type=published-cna", validPublishedContainer]);
assert.equal(result.status, 0);
assert.match(result.stdout, /1 valid, 0 invalid/);
}

function runRejectedContainerType() {
const result = runCli(["--type", "rejected-cna", validRejectedContainer]);
assert.equal(result.status, 0);
assert.match(result.stdout, /1 valid, 0 invalid/);
}

function runEndOfOptions() {
// After '--', a leading-dash argument is treated as a path, not an option.
const result = runCli(["--", "--version"]);
assert.equal(result.status, 1);
assert.match(result.stdout, /INPUT_ERROR/);
assert.match(result.stdout, /--version/);
}

function runMissingFile() {
const result = runCli(["does-not-exist.json"]);
assert.equal(result.status, 1);
assert.match(result.stdout, /INPUT_ERROR/);
}

function runMalformedJsonStdin() {
const result = runCli(["-"], "{ not valid json");
assert.equal(result.status, 1);
assert.match(result.stdout, /INPUT_ERROR/);
}

async function runNoPipeTruncation() {
// Produce output far larger than the ~64KB OS pipe buffer, consumed by a slow
// reader. The whole payload must arrive intact; process.exit() would truncate
// it and make the --json output unparseable.
const count = 500;
const args = ["--json", ...Array.from({ length: count }, () => invalidRecord)];
const result = await runCliSlowPipe(args);

assert.equal(result.status, 1);
assert.ok(result.stdout.length > 65536, "test must generate more than one pipe buffer of output");

let parsed;
assert.doesNotThrow(() => {
parsed = JSON.parse(result.stdout);
}, "piped --json output must be complete and parseable (no truncation on exit)");
assert.equal(parsed.length, count, "no results may be dropped over a slow pipe");
}

function runPackagingGuard() {
// The published bin resolves its schema/registry JSON assets from src/ at
// runtime, so src/ (and dist/) must stay in the published file set.
assert.ok(Array.isArray(manifest.files));
assert.ok(manifest.files.includes("src"), '"files" must include src for the bin to resolve assets');
assert.ok(manifest.files.includes("dist"), '"files" must include dist for the bin');
assert.equal(manifest.bin["cve-validate"], "dist/cli.js");
}

runValidFile();
runInvalidFile();
runMixedFiles();
runWarningsInHumanOutput();
runJsonOutput();
runStdin();
runStdinDash();
runQuiet();
runShortFlags();
runVersion();
runHelp();
runUnknownOption();
runBadType();
runPublishedContainerType();
runTypeEqualsForm();
runRejectedContainerType();
runEndOfOptions();
runMissingFile();
runMalformedJsonStdin();
await runNoPipeTruncation();
runPackagingGuard();

console.log("CLI tests passed.");
Loading