diff --git a/.github/actions/cargo-deny-advisories/check.test.ts b/.github/actions/cargo-deny-advisories/check.test.ts index 25eaf63..23b7004 100644 --- a/.github/actions/cargo-deny-advisories/check.test.ts +++ b/.github/actions/cargo-deny-advisories/check.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, test } from "bun:test"; -import { baseInputs, evaluate } from "./check"; +import { baseInputs, cargoDenyArgs, evaluate } from "./check"; const diagnostic = readFileSync( ".github/actions/cargo-deny-advisories/fixtures/advisory.jsonl", @@ -17,11 +17,40 @@ const NOW = new Date("2026-10-22T12:00:00Z"); const common = { cargoDeny: { exitCode: 1, output: diagnostic }, config: config(), - headLockfile: lockfile(["application", "1.0.0"], ["affected", "2.0.0"]), + headLockfile: lockfile(["application", "1.0.0"], ["unsafe-libyaml", "0.2.11"]), now: NOW, }; describe("cargo-deny advisories gate", () => { + test("places every global option before the advisories check", () => { + expect(cargoDenyArgs()).toEqual([ + "cargo", + "deny", + "--manifest-path", + "Cargo.toml", + "--config", + "deny.toml", + "--format", + "json", + "check", + "advisories", + ]); + const custom = cargoDenyArgs("workspace/Cargo.toml", "policy/deny.toml"); + expect(custom).toEqual([ + "cargo", + "deny", + "--manifest-path", + "workspace/Cargo.toml", + "--config", + "policy/deny.toml", + "--format", + "json", + "check", + "advisories", + ]); + expect(custom.slice(custom.indexOf("check") + 1)).toEqual(["advisories"]); + }); + test("reads base files from the checked-out directory", () => { const directory = mkdtempSync(join(tmpdir(), "cargo-deny-base-")); const lockfilePath = "nested/Cargo.lock"; @@ -86,27 +115,27 @@ describe("cargo-deny advisories gate", () => { baseLockfile: lockfile(["application", "1.0.0"]), changeEvent: true, }); - expect(result.errors.join("\n")).toContain("RUSTSEC-2026-0001"); + expect(result.errors.join("\n")).toContain("RUSTSEC-2024-0449"); }); test("warns for a pre-existing crate on a change event and fails on push", () => { const baseLockfile = common.headLockfile; const change = evaluate({ ...common, baseConfig: config(), baseLockfile, changeEvent: true }); expect(change.errors).toEqual([]); - expect(change.warnings.join("\n")).toContain("RUSTSEC-2026-0001"); + expect(change.warnings.join("\n")).toContain("RUSTSEC-2024-0449"); const push = evaluate({ ...common, changeEvent: false }); - expect(push.errors.join("\n")).toContain("RUSTSEC-2026-0001"); + expect(push.errors.join("\n")).toContain("RUSTSEC-2024-0449"); }); test("treats an affected crate version bump as new", () => { const result = evaluate({ ...common, baseConfig: config(), - baseLockfile: lockfile(["application", "1.0.0"], ["affected", "1.0.0"]), + baseLockfile: lockfile(["application", "1.0.0"], ["unsafe-libyaml", "0.2.10"]), changeEvent: true, }); - expect(result.errors.join("\n")).toContain("affected@2.0.0"); + expect(result.errors.join("\n")).toContain("unsafe-libyaml@0.2.11"); }); test("fails a change event when any advisory cannot be attributed", () => { @@ -160,7 +189,7 @@ describe("cargo-deny advisories gate", () => { cargoDeny: { exitCode: 1, output }, changeEvent: true, }); - expect(result.warnings.join("\n")).toContain("RUSTSEC-2026-0001"); + expect(result.warnings.join("\n")).toContain("RUSTSEC-2024-0449"); expect(result.errors).toContain( "advisory-db-fetch: failed to fetch the advisory database", ); @@ -186,7 +215,7 @@ describe("cargo-deny advisories gate", () => { changeEvent: true, }); expect(result.errors).toEqual([]); - expect(result.warnings.join("\n")).toContain("RUSTSEC-2026-0001"); + expect(result.warnings.join("\n")).toContain("RUSTSEC-2024-0449"); expect(result.warnings.join("\n")).not.toContain("advisory-db-stale"); }); @@ -208,6 +237,11 @@ describe("cargo-deny advisories gate", () => { baseLockfile: lockfile(["application", "1.0.0"], ["affected", "1.0.0"]), cargoDeny: { exitCode: 1, output }, changeEvent: true, + headLockfile: lockfile( + ["application", "1.0.0"], + ["affected", "1.0.0"], + ["affected", "2.0.0"], + ), }); expect(result.errors).toEqual([ "RUSTSEC-2026-0003 affects affected@2.0.0: Multiple versions", @@ -223,12 +257,14 @@ describe("cargo-deny advisories gate", () => { }); test("fails closed on a nonzero cargo-deny exit without diagnostics", () => { + const stderr = "error: unexpected argument '--config' found"; const result = evaluate({ ...common, - cargoDeny: { exitCode: 2, output: "database failed" }, + cargoDeny: { exitCode: 2, output: stderr, stderr }, changeEvent: false, }); - expect(result.errors.join("\n")).toContain("without a parseable"); + expect(result.errors.join("\n")).toContain("code 2"); + expect(result.errors.join("\n")).toContain(stderr); }); test("fails an ignore entry without an owner", () => { diff --git a/.github/actions/cargo-deny-advisories/check.ts b/.github/actions/cargo-deny-advisories/check.ts index d7e50d9..c856da9 100644 --- a/.github/actions/cargo-deny-advisories/check.ts +++ b/.github/actions/cargo-deny-advisories/check.ts @@ -5,7 +5,7 @@ type ErrorDiagnostic = | { kind: "advisory"; crate?: string; id: string; title: string } | { kind: "error"; code: string; message: string }; type Result = { errors: string[]; warnings: string[] }; -type CommandResult = { exitCode: number; output: string }; +type CommandResult = { exitCode: number; output: string; stderr?: string }; const fail = (message: string): never => { throw new Error(message); @@ -109,6 +109,21 @@ const waiverKey = ({ id, reason }: Waiver): string => `${id}\0${reason ?? ""}`; export const annotation = (level: "error" | "warning", message: string): string => `::${level}::${message.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A")}`; +const stderrTail = (stderr: string): string => + stderr + .split(/\r?\n/u) + .filter((line) => { + if (line.trim() === "") return false; + try { + JSON.parse(line); + return false; + } catch { + return true; + } + }) + .slice(-20) + .join("\n"); + export const evaluate = ({ baseConfig, baseLockfile, @@ -158,7 +173,10 @@ export const evaluate = ({ const diagnostics = parseErrorDiagnostics(cargoDeny.output); if (cargoDeny.exitCode !== 0 && diagnostics.length === 0) { - errors.push("cargo-deny exited nonzero without a parseable error advisory diagnostic"); + const detail = stderrTail(cargoDeny.stderr ?? cargoDeny.output); + errors.push( + `cargo-deny exited with code ${cargoDeny.exitCode} without a parseable error advisory diagnostic${detail === "" ? "" : `:\n${detail}`}`, + ); return { errors, warnings }; } @@ -229,6 +247,22 @@ export const baseInputs = ( return sha === "" ? {} : readBaseFiles(directory, lockfilePath, configPath); }; +export const cargoDenyArgs = ( + manifest = "Cargo.toml", + config = "deny.toml", +): string[] => [ + "cargo", + "deny", + "--manifest-path", + manifest, + "--config", + config, + "--format", + "json", + "check", + "advisories", +]; + const run = (): void => { const manifest = process.env.INPUT_MANIFEST_PATH ?? "Cargo.toml"; const configPath = process.env.INPUT_CONFIG ?? "deny.toml"; @@ -236,14 +270,15 @@ const run = (): void => { const baseSha = process.env.INPUT_BASE_SHA ?? ""; const baseDirectory = process.env.INPUT_BASE_DIRECTORY ?? ".cargo-deny-base"; const base = baseInputs(baseSha, baseDirectory, lockfilePath, configPath); - const result = Bun.spawnSync( - ["cargo", "deny", "--manifest-path", manifest, "--format", "json", "check", "--config", configPath, "advisories"], - { stdout: "pipe", stderr: "pipe" }, - ); - const output = `${result.stdout.toString()}\n${result.stderr.toString()}`; + const result = Bun.spawnSync(cargoDenyArgs(manifest, configPath), { + stdout: "pipe", + stderr: "pipe", + }); + const stderr = result.stderr.toString(); + const output = `${result.stdout.toString()}\n${stderr}`; const checked = evaluate({ ...base, - cargoDeny: { exitCode: result.exitCode, output }, + cargoDeny: { exitCode: result.exitCode, output, stderr }, changeEvent: baseSha !== "", config: readFileSync(configPath, "utf8"), headLockfile: readFileSync(lockfilePath, "utf8"), diff --git a/.github/actions/cargo-deny-advisories/fixtures/advisory.jsonl b/.github/actions/cargo-deny-advisories/fixtures/advisory.jsonl index 3f586b9..c75effe 100644 --- a/.github/actions/cargo-deny-advisories/fixtures/advisory.jsonl +++ b/.github/actions/cargo-deny-advisories/fixtures/advisory.jsonl @@ -1 +1,2 @@ -{"type":"diagnostic","fields":{"severity":"error","code":"vulnerability","advisory":{"id":"RUSTSEC-2026-0001","title":"Example vulnerability"},"graphs":[{"Krate":{"name":"affected","version":"2.0.0"},"parents":[]}]}} +loaded advisory database +{"type": "diagnostic", "fields": {"severity": "error", "code": "unmaintained", "message": "unsafe-libyaml is unmaintained", "advisory": {"id": "RUSTSEC-2024-0449", "title": "unsafe-libyaml is unmaintained"}, "graphs": [{"Krate": {"name": "unsafe-libyaml", "version": "0.2.11"}, "parents": []}]}}