diff --git a/README.md b/README.md index 8ebe504..07c8d73 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index caa6ba8..544cf0d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ "import": "./dist/index.js" } }, + "bin": { + "cve-validate": "dist/cli.js" + }, "files": [ "dist", "src", @@ -37,12 +40,13 @@ "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": { diff --git a/scripts/test-cli.mjs b/scripts/test-cli.mjs new file mode 100644 index 0000000..41cdd91 --- /dev/null +++ b/scripts/test-cli.mjs @@ -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, //); +} + +function runStdinDash() { + const result = runCli(["-"], readFileSync(invalidRecord, "utf8")); + assert.equal(result.status, 1); + assert.match(result.stdout, //); +} + +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."); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..b1827ab --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,331 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises'; +import process from 'node:process'; + +import { Validate } from './core/validate.js'; +import { type Diagnostics } from './diagnostic/diagnostics.js'; +import { type JsonObject } from './utils/json.js'; + +/** Exit codes returned by the CLI. */ +const EXIT = { + /** Every input was valid. */ + SUCCESS: 0, + /** At least one input was invalid or could not be read. */ + VALIDATION_FAILED: 1, + /** The command was invoked incorrectly (bad option, no input). */ + USAGE: 2, +} as const; + +/** Supported input types, mapped to the Validate method that checks them. */ +const VALIDATORS = { + 'record': (validator: Validate, data: JsonObject) => validator.validateCveRecord(data), + 'published-cna': (validator: Validate, data: JsonObject) => validator.validatePublishedCveCnaContainer(data), + 'rejected-cna': (validator: Validate, data: JsonObject) => validator.validateRejectedCveCnaContainer(data), +} as const; + +type InputType = keyof typeof VALIDATORS; + +const INPUT_TYPES = Object.keys(VALIDATORS) as InputType[]; + +/** A single input's result, shaped for both human and JSON output. */ +type CliResult = { + readonly file: string; + readonly valid: boolean; + readonly error: string | null; + readonly message: string; + readonly details: Diagnostics['details'] | null; + readonly warnings: Diagnostics['warnings']; +}; + +type CliOptions = { + paths: string[]; + type: InputType; + json: boolean; + quiet: boolean; + help: boolean; + version: boolean; +}; + +/** Raised for invalid command-line usage; produces exit code 2. */ +class CliUsageError extends Error {} + +const USAGE = `Usage: cve-validate [options] + +Validate one or more CVE Records (or CNA Containers) against the CVE JSON +schema and CVE business rules. + +Arguments: + file Path to a JSON file to validate. Use "-" to read from + stdin. When no file is given and stdin is piped, stdin + is read. + +Options: + --type Input type: ${INPUT_TYPES.join(', ')} (default: record). + --json Output results as JSON instead of human-readable text. + -q, --quiet Do not print a line for each valid input. + -v, --version Print the library version and exit. + -h, --help Print this help and exit. + +Exit codes: + 0 all inputs valid + 1 at least one input invalid or unreadable + 2 usage error`; + +/** + * Parses CLI arguments into options. + * + * @param argv - Arguments after the node executable and script path. + * @returns Parsed options. + * @throws CliUsageError on an unknown option or invalid value. + */ +function parseArgs(argv: readonly string[]): CliOptions { + const options: CliOptions = { + paths: [], + type: 'record', + json: false, + quiet: false, + help: false, + version: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--') { + options.paths.push(...argv.slice(index + 1)); + break; + } + + switch (arg) { + case '-h': + case '--help': + options.help = true; + break; + case '-v': + case '--version': + options.version = true; + break; + case '--json': + options.json = true; + break; + case '-q': + case '--quiet': + options.quiet = true; + break; + case '--type': { + const value = argv[index + 1]; + index += 1; + options.type = parseType(value); + break; + } + default: + if (arg.startsWith('--type=')) { + options.type = parseType(arg.slice('--type='.length)); + } else if (arg !== '-' && arg.startsWith('-')) { + throw new CliUsageError(`Unknown option: ${arg}`); + } else { + options.paths.push(arg); + } + } + } + + return options; +} + +function parseType(value: string | undefined): InputType { + if (value !== undefined && (INPUT_TYPES as string[]).includes(value)) { + return value as InputType; + } + + throw new CliUsageError(`--type must be one of: ${INPUT_TYPES.join(', ')}`); +} + +/** Reads all of stdin as a UTF-8 string. */ +async function readStdin(): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + + return Buffer.concat(chunks).toString('utf8'); +} + +async function readJsonInput(path: string): Promise { + const raw = path === '-' ? await readStdin() : await readFile(path, 'utf8'); + return JSON.parse(raw) as JsonObject; +} + +/** Reads the library version from the package manifest. */ +async function readVersion(): Promise { + const manifestUrl = new URL('../package.json', import.meta.url); + const manifest = JSON.parse(await readFile(manifestUrl, 'utf8')) as { version?: string }; + return manifest.version ?? '0.0.0'; +} + +function displayName(path: string): string { + return path === '-' ? '' : path; +} + +/** Formats a diagnostics `details` payload into concise, one-per-line strings. */ +function formatDetails(details: Diagnostics['details']): string[] { + if (!details) { + return []; + } + + const errors = 'errors' in details ? details.errors : details; + + return errors.map((error) => { + if (typeof error === 'string') { + return error; + } + + if ('msg' in error) { + return `${error.param || '(root)'}: ${error.msg}`; + } + + return `${error.instancePath || '(root)'} ${error.message ?? ''}`.trim(); + }); +} + +/** + * Whether the environment can render the Unicode status glyphs. Falls back to + * ASCII on Windows legacy consoles that do not advertise UTF-8. + */ +function supportsUnicode(): boolean { + if (process.platform !== 'win32') { + return true; + } + + return Boolean(process.env.WT_SESSION) + || Boolean(process.env.CI) + || process.env.TERM_PROGRAM === 'vscode' + || /utf-?8/i.test(process.env.LANG ?? ''); +} + +const MARKS = supportsUnicode() + ? { valid: '✓', invalid: '✗', warning: '⚠', separator: '—' } + : { valid: 'PASS', invalid: 'FAIL', warning: 'WARN', separator: '-' }; + +function printHumanResults(results: readonly CliResult[], options: CliOptions): void { + let totalWarnings = 0; + + for (const result of results) { + const suppressed = result.valid && options.quiet; + + if (result.valid) { + if (!options.quiet) { + process.stdout.write(`${MARKS.valid} ${result.file} ${MARKS.separator} valid\n`); + } + } else { + process.stdout.write(`${MARKS.invalid} ${result.file} ${MARKS.separator} ${result.error}: ${result.message}\n`); + for (const line of formatDetails(result.details)) { + process.stdout.write(` ${line}\n`); + } + } + + if (!suppressed) { + for (const warning of result.warnings) { + totalWarnings += 1; + process.stdout.write(` ${MARKS.warning} ${warning.messageId}: ${warning.notificationMessage}\n`); + } + } + } + + const invalid = results.filter((result) => !result.valid).length; + const valid = results.length - invalid; + const warningSuffix = totalWarnings > 0 ? `, ${totalWarnings} warning(s)` : ''; + process.stdout.write(`\n${results.length} file(s): ${valid} valid, ${invalid} invalid${warningSuffix}\n`); +} + +/** + * Runs the CLI. + * + * @param argv - Arguments after the node executable and script path. + * @returns The process exit code. + */ +export async function run(argv: readonly string[]): Promise { + let options: CliOptions; + + try { + options = parseArgs(argv); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n\n${USAGE}\n`); + return EXIT.USAGE; + } + + if (options.help) { + process.stdout.write(`${USAGE}\n`); + return EXIT.SUCCESS; + } + + if (options.version) { + process.stdout.write(`${await readVersion()}\n`); + return EXIT.SUCCESS; + } + + if (options.paths.length === 0) { + if (process.stdin.isTTY) { + process.stderr.write(`No input files provided.\n\n${USAGE}\n`); + return EXIT.USAGE; + } + + // Piped input with no explicit path: read the record from stdin. + options.paths.push('-'); + } + + const validate = VALIDATORS[options.type]; + const validator = new Validate(); + const results: CliResult[] = []; + + for (const path of options.paths) { + try { + const data = await readJsonInput(path); + const diagnostics = await validate(validator, data); + results.push({ + file: displayName(path), + valid: diagnostics.valid, + error: diagnostics.error, + message: diagnostics.message, + details: diagnostics.details, + warnings: diagnostics.warnings, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + results.push({ + file: displayName(path), + valid: false, + error: 'INPUT_ERROR', + message, + details: null, + warnings: [], + }); + } + } + + if (options.json) { + process.stdout.write(`${JSON.stringify(results, null, 2)}\n`); + } else { + printHumanResults(results, options); + } + + return results.some((result) => !result.valid) ? EXIT.VALIDATION_FAILED : EXIT.SUCCESS; +} + +// Set process.exitCode rather than calling process.exit(): process.exit() +// terminates before asynchronous stdout writes to a pipe have drained, which +// truncates large output (e.g. --json over many files) at the OS pipe buffer. +// Letting the event loop empty naturally flushes stdout first, then exits with +// this code. +run(process.argv.slice(2)) + .then((code) => { + process.exitCode = code; + }) + .catch((error: unknown) => { + // Last-resort guard so any unexpected failure exits with a code instead of + // an unhandled rejection / stack trace. + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`cve-validate: ${message}\n`); + process.exitCode = EXIT.VALIDATION_FAILED; + });