diff --git a/.github/workflows/cf13-quality-gate-proof.yml b/.github/workflows/cf13-quality-gate-proof.yml new file mode 100644 index 00000000..a8bf0f75 --- /dev/null +++ b/.github/workflows/cf13-quality-gate-proof.yml @@ -0,0 +1,119 @@ +name: cf13-quality-gate-proof + +on: + pull_request: + paths: + - .github/workflows/cf13-quality-gate-proof.yml + - Cargo.toml + - Cargo.lock + - crates/commandf-pkg/** + - crates/commandf-cli/** + - specs/014-cf-13-baselines-suppression-quality-gates/** + - .specify/memory/constitution.md + - AGENTS.md + push: + branches: + - feat/cf13-quality-gate-cli + paths: + - .github/workflows/cf13-quality-gate-proof.yml + - Cargo.toml + - Cargo.lock + - crates/commandf-pkg/** + - crates/commandf-cli/** + - specs/014-cf-13-baselines-suppression-quality-gates/** + - .specify/memory/constitution.md + - AGENTS.md + workflow_dispatch: + +permissions: + contents: read + +env: + CF13_PROOF_CONTAINER: docker.io/library/rust@sha256:9146b0f62e1939989aa96fc8d89699a43c5635bf212819235a773e1a9e71a98f + CF13_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + +jobs: + deterministic-quality-gate: + runs-on: ubuntu-24.04 + container: + image: docker.io/library/rust@sha256:9146b0f62e1939989aa96fc8d89699a43c5635bf212819235a773e1a9e71a98f + timeout-minutes: 15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + ref: ${{ env.CF13_SOURCE_SHA }} + persist-credentials: false + + - name: Assert exact source checkout + run: | + set -euo pipefail + test "$(git -c safe.directory="$GITHUB_WORKSPACE" rev-parse HEAD)" = "$CF13_SOURCE_SHA" + + - name: Assert pinned execution toolchain + run: | + set -euo pipefail + rustc --version --verbose + cargo --version + test "$(rustc --version | awk '{print $2}')" = "1.97.1" + + - name: Prove CF-13 CLI contract and deterministic evidence + run: | + set -euo pipefail + cargo test --locked -p commandf --test gate_behavior_v1 --test gate_bounds --test gate_exit_contract + cargo test --locked -p commandf --test gate_determinism_proof -- --nocapture --test-threads=1 \ + | tee /tmp/cf13-quality-gate-proof.log + grep -oE 'CF13_(GATE|BASELINE_CANONICAL|SUPPRESSION_CANONICAL|BEFORE_ARCHIVE|AFTER_ARCHIVE)_SHA256=(sha256:)?[0-9a-f]{64}' \ + /tmp/cf13-quality-gate-proof.log \ + | sort \ + | tee /tmp/cf13-quality-gate-proof.env + grep -q '^CF13_GATE_SHA256=[0-9a-f]\{64\}$' /tmp/cf13-quality-gate-proof.env + grep -q '^CF13_BASELINE_CANONICAL_SHA256=sha256:[0-9a-f]\{64\}$' /tmp/cf13-quality-gate-proof.env + grep -q '^CF13_SUPPRESSION_CANONICAL_SHA256=sha256:[0-9a-f]\{64\}$' /tmp/cf13-quality-gate-proof.env + + - name: Record immutable repository evidence + run: | + set -euo pipefail + git_safe() { + git -c safe.directory="$GITHUB_WORKSPACE" "$@" + } + { + echo "CF13_SOURCE_SHA=$CF13_SOURCE_SHA" + echo "CF13_SOURCE_TREE=$(git_safe rev-parse 'HEAD^{tree}')" + echo "CF13_WORKFLOW_BLOB=$(git_safe rev-parse "$CF13_SOURCE_SHA:.github/workflows/cf13-quality-gate-proof.yml")" + echo "CF13_AGENTS_BLOB=$(git_safe rev-parse "$CF13_SOURCE_SHA:AGENTS.md")" + echo "CF13_CONSTITUTION_BLOB=$(git_safe rev-parse "$CF13_SOURCE_SHA:.specify/memory/constitution.md")" + echo "CF13_SPEC_BLOB=$(git_safe rev-parse "$CF13_SOURCE_SHA:specs/014-cf-13-baselines-suppression-quality-gates/spec.md")" + echo "CF13_PLAN_BLOB=$(git_safe rev-parse "$CF13_SOURCE_SHA:specs/014-cf-13-baselines-suppression-quality-gates/plan.md")" + echo "CF13_TASKS_BLOB=$(git_safe rev-parse "$CF13_SOURCE_SHA:specs/014-cf-13-baselines-suppression-quality-gates/tasks.md")" + echo "CF13_CARGO_LOCK_BLOB=$(git_safe rev-parse "$CF13_SOURCE_SHA:Cargo.lock")" + echo "CF13_CF04_COMPATIBILITY_BLOB=$(git_safe rev-parse "$CF13_SOURCE_SHA:crates/commandf-pkg/src/compatibility.rs")" + echo "CF13_CF05_CHECK_BLOB=$(git_safe rev-parse "$CF13_SOURCE_SHA:crates/commandf-pkg/src/check.rs")" + echo "CF13_GATE_LIBRARY_BLOB=$(git_safe rev-parse "$CF13_SOURCE_SHA:crates/commandf-pkg/src/gate.rs")" + echo "CF13_GATE_CLI_BLOB=$(git_safe rev-parse "$CF13_SOURCE_SHA:crates/commandf-cli/src/gate.rs")" + echo "CF13_PROOF_TEST_BLOB=$(git_safe rev-parse "$CF13_SOURCE_SHA:crates/commandf-cli/tests/gate_determinism_proof.rs")" + echo "CF13_WORKFLOW_SHA256=$(sha256sum .github/workflows/cf13-quality-gate-proof.yml | awk '{print $1}')" + echo "CF13_CARGO_LOCK_SHA256=$(sha256sum Cargo.lock | awk '{print $1}')" + echo "CF13_SPEC_SHA256=$(sha256sum specs/014-cf-13-baselines-suppression-quality-gates/spec.md | awk '{print $1}')" + echo "CF13_PLAN_SHA256=$(sha256sum specs/014-cf-13-baselines-suppression-quality-gates/plan.md | awk '{print $1}')" + echo "CF13_TASKS_SHA256=$(sha256sum specs/014-cf-13-baselines-suppression-quality-gates/tasks.md | awk '{print $1}')" + echo "CF13_PROOF_TEST_SHA256=$(sha256sum crates/commandf-cli/tests/gate_determinism_proof.rs | awk '{print $1}')" + echo "CF13_RUSTC=$(rustc --version)" + echo "CF13_CONTAINER=$CF13_PROOF_CONTAINER" + echo "CF13_CHECKOUT_ACTION=fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09" + echo "CF13_UPLOAD_ACTION=ea165f8d65b6e75b540449e92b4886f43607fa02" + } >> /tmp/cf13-quality-gate-proof.env + sort -o /tmp/cf13-quality-gate-proof.env /tmp/cf13-quality-gate-proof.env + + - name: Assert repository remains clean + run: | + set -euo pipefail + status="$(git -c safe.directory="$GITHUB_WORKSPACE" status --porcelain)" + test -z "$status" + + - name: Upload CF-13 deterministic evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: cf13-quality-gate-proof + path: /tmp/cf13-quality-gate-proof.env + if-no-files-found: error + retention-days: 3 diff --git a/crates/commandf-cli/src/gate.rs b/crates/commandf-cli/src/gate.rs new file mode 100644 index 00000000..d305f55c --- /dev/null +++ b/crates/commandf-cli/src/gate.rs @@ -0,0 +1,164 @@ +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Args, ValueEnum}; +use commandf_pkg::{ + classify_structural_diff, diff_package_archives, evaluate_compatibility_policy, + evaluate_quality_gate, CheckDirection, CheckFailOn, CheckPolicy, CheckReport, GateSuppressions, + Lockfile, PackageCache, PackageName, StructuralDiffReport, +}; + +use super::{read_bounded_file, select_locked_package, write_check_output}; + +const MAX_GATE_LOCKFILE_INPUT_BYTES: u64 = 16 * 1024 * 1024; +const MAX_GATE_ARCHIVE_INPUT_BYTES: u64 = 128 * 1024 * 1024; +const MAX_GATE_BASELINE_INPUT_BYTES: u64 = 64 * 1024 * 1024; +const MAX_GATE_SUPPRESSIONS_INPUT_BYTES: u64 = 64 * 1024 * 1024; + +#[derive(Args)] +pub(crate) struct GateArgs { + package: String, + #[arg(long)] + before_lock: PathBuf, + #[arg(long)] + before_cache: PathBuf, + #[arg(long)] + after_lock: PathBuf, + #[arg(long)] + after_cache: PathBuf, + #[arg(long, value_enum, default_value = "both")] + direction: GateDirectionArg, + #[arg(long, value_enum, default_value = "breaking")] + fail_on: GateFailOnArg, + #[arg(long)] + baseline: Option, + #[arg(long)] + suppressions: Option, + #[arg(long, value_enum, default_value = "json")] + format: GateOutputFormat, + #[arg(long)] + output: Option, +} + +#[derive(Clone, Copy, ValueEnum)] +enum GateOutputFormat { + Json, +} + +#[derive(Clone, Copy, ValueEnum)] +enum GateDirectionArg { + Both, + Producer, + Consumer, +} + +#[derive(Clone, Copy, ValueEnum)] +enum GateFailOnArg { + Breaking, + Risky, + None, +} + +impl From for CheckDirection { + fn from(value: GateDirectionArg) -> Self { + match value { + GateDirectionArg::Both => Self::Both, + GateDirectionArg::Producer => Self::Producer, + GateDirectionArg::Consumer => Self::Consumer, + } + } +} + +impl From for CheckFailOn { + fn from(value: GateFailOnArg) -> Self { + match value { + GateFailOnArg::Breaking => Self::Breaking, + GateFailOnArg::Risky => Self::Risky, + GateFailOnArg::None => Self::None, + } + } +} + +pub(crate) fn run(args: GateArgs) -> Result> { + let diff = build_gate_diff_report( + args.package, + args.before_lock, + args.before_cache, + args.after_lock, + args.after_cache, + )?; + let compatibility = classify_structural_diff(&diff)?; + let current = evaluate_compatibility_policy( + &compatibility, + CheckPolicy { + direction: args.direction.into(), + fail_on: args.fail_on.into(), + }, + )?; + + let baseline = args + .baseline + .as_deref() + .map(|path| { + let bytes = read_bounded_file(path, MAX_GATE_BASELINE_INPUT_BYTES)?; + Ok::<_, Box>(CheckReport::from_json_slice(&bytes)?) + }) + .transpose()?; + let suppressions = args + .suppressions + .as_deref() + .map(|path| { + let bytes = read_bounded_file(path, MAX_GATE_SUPPRESSIONS_INPUT_BYTES)?; + Ok::<_, Box>(GateSuppressions::from_json_slice(&bytes)?) + }) + .transpose()?; + + let report = evaluate_quality_gate(¤t, baseline.as_ref(), suppressions.as_ref())?; + let bytes = match args.format { + GateOutputFormat::Json => report.to_json_bytes()?, + }; + write_check_output(&bytes, args.output.as_deref())?; + + if report.decision.passed { + Ok(ExitCode::SUCCESS) + } else { + Ok(ExitCode::from(2)) + } +} + +fn build_gate_diff_report( + package: String, + before_lock: PathBuf, + before_cache: PathBuf, + after_lock: PathBuf, + after_cache: PathBuf, +) -> Result> { + let package_name = PackageName::parse(package)?; + let before_lockfile = Lockfile::from_slice(&read_bounded_file( + &before_lock, + MAX_GATE_LOCKFILE_INPUT_BYTES, + )?)?; + let after_lockfile = Lockfile::from_slice(&read_bounded_file( + &after_lock, + MAX_GATE_LOCKFILE_INPUT_BYTES, + )?)?; + let before_locked = select_locked_package(&before_lockfile, package_name.as_str())?; + let after_locked = select_locked_package(&after_lockfile, package_name.as_str())?; + + let before_cache = PackageCache::new(before_cache); + let after_cache = PackageCache::new(after_cache); + let before_bytes = + before_cache.read_verified_bounded(&before_locked.sha256, MAX_GATE_ARCHIVE_INPUT_BYTES)?; + let after_bytes = + after_cache.read_verified_bounded(&after_locked.sha256, MAX_GATE_ARCHIVE_INPUT_BYTES)?; + + Ok(diff_package_archives( + package_name.to_string(), + &before_locked.version, + &before_locked.sha256, + &before_bytes, + &after_locked.version, + &after_locked.sha256, + &after_bytes, + )?) +} diff --git a/crates/commandf-cli/src/main.rs b/crates/commandf-cli/src/main.rs index fc68b037..5f40270a 100644 --- a/crates/commandf-cli/src/main.rs +++ b/crates/commandf-cli/src/main.rs @@ -1,3 +1,4 @@ +mod gate; mod impact; mod oracle; @@ -115,6 +116,7 @@ enum Command { #[arg(long)] output: Option, }, + Gate(gate::GateArgs), Terminology { package: String, #[arg(long)] @@ -237,7 +239,11 @@ enum PkgCommand { } fn main() -> ExitCode { - let is_check = std::env::args_os().nth(1).as_deref() == Some(OsStr::new("check")); + let command = std::env::args_os().nth(1); + let normalize_usage_exit = matches!( + command.as_deref(), + Some(value) if value == OsStr::new("check") || value == OsStr::new("gate") + ); let cli = match Cli::try_parse() { Ok(cli) => cli, Err(error) => { @@ -246,7 +252,7 @@ fn main() -> ExitCode { if clap_exit == 0 { return ExitCode::SUCCESS; } - if is_check { + if normalize_usage_exit { return ExitCode::from(1); } return ExitCode::from(clap_exit as u8); @@ -426,6 +432,7 @@ fn run(cli: Cli) -> Result> { } return Ok(ExitCode::from(2)); } + Command::Gate(args) => return gate::run(args), Command::Terminology { package, before_lock, diff --git a/crates/commandf-cli/tests/gate_behavior_v1.rs b/crates/commandf-cli/tests/gate_behavior_v1.rs new file mode 100644 index 00000000..4d139c36 --- /dev/null +++ b/crates/commandf-cli/tests/gate_behavior_v1.rs @@ -0,0 +1,459 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use commandf_pkg::{ + finding_fingerprint_v1, CheckReport, FindingFingerprint, GateSuppression, GateSuppressions, + LockedPackage, Lockfile, PackageCache, QualityGateDisposition, QualityGateReport, +}; + +const BEFORE_HEX: &str = concat!( + "1f8b08000000000002ffed944d4fc3300c86fb5350cea31f63b452cf70e60037c4216bbd35d0a655924e43d3fe3beed66d6c", + "abc4013409789f1edc388df336b1ddc8ec4dce2968b6d67fb5b5f67e98908927938d654e6d1826f1e1bdf347517c137957a1", + "77015aeba4e1edbdffc94a68599148052d65d594e4f78920466241c6aa5af35ce4877ec89e9c1ad239e94c9115e96abdf6c0", + "2fa7bfeee0d1993673ada13b9a29ad1c5ffcf52e25bedb13beaaff241e9fd4ff6d128e51ff97a97f43b66e4d464fef4dd707", + "0612812b5fe58716c1c3d6943c2e9c6bd220d8a5496de64369141c969d7794bef9dcef3fe1db702d37172133a7169de74de9", + "6ef39d4cf6c8a97586e7453a93a5a591705be90f534b66217bc953693fffc35e6e51261ba9b3429941bdc7617232aa1fa422", + "ab75b7b5d2ae93aa65638b9a65ac049554117bd3e7d5f6ac8e8334d21567ce4a71c890ad5c762722d6a3f3b57e7f1e43210e", + "73a7917627c262b23aa78d7036eb177ed0b3010000000000000000000000000000000000803fc7075ec49c6300280000", +); + +const AFTER_HEX: &str = concat!( + "1f8b08000000000002ffed944d4fc3300c86fb5350cea31f636c52cf70e60037c4216bbd35d0a555924e4353ff3b6ed7ad6c", + "abc4013409789f1edc388df336b15dcae44d2e292877d67fb585f67e9890994e26ad654e6d18cea6fd7be38fa2e94de45d85", + "de05a8ac9386b7f7fe275ba1e58a442c682357654e7e97086224d664ac2a34cf457ee487ec49a9249d924e1459116febda03", + "bf9ceeba834767aac45586ee68a1b4727cf1d7fb94f86e4ff8aafe67d3f149fddfcec231eaff32f56fc8169549e8e9bd6cfa", + "c0402270e5abb46f113cac4ccee3ccb9320e827d9a146639944641bfecbca374cde7fef009df86abb8b9089938b56e3c6f4a", + "379bef65b247cead333c2fe285cc2d8d84db497f985b326bd9499e4bfbf91f0e72b37cd64a5d64ca0cea3d0e939251dd2016", + "49a19bad95768d542d4b9b152c632b28a715b1377edeeeceea3848295d76e65c290e19b2959be644443d3a5feb77e73114a2", + "9f6b23457da4fd89b098a448a915cea67ee1073d1b000000000000000000000000000000000000f8737c00934f6565002800", + "00", +); + +fn commandf() -> Command { + Command::new(env!("CARGO_BIN_EXE_commandf")) +} + +fn decode_hex(value: &str) -> Vec { + assert_eq!(value.len() % 2, 0); + value + .as_bytes() + .chunks_exact(2) + .map(|pair| (hex_digit(pair[0]) << 4) | hex_digit(pair[1])) + .collect() +} + +fn hex_digit(value: u8) -> u8 { + match value { + b'0'..=b'9' => value - b'0', + b'a'..=b'f' => value - b'a' + 10, + _ => panic!("invalid test hex digit"), + } +} + +fn unique_temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must be after the Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("commandf-{label}-{}-{nonce}", std::process::id())) +} + +fn write_locked_state(root: &Path, archive: &[u8], version: &str) -> (PathBuf, PathBuf) { + let cache_path = root.join("cache"); + let lock_path = root.join("commandf.lock"); + fs::create_dir_all(root).expect("create state root"); + let cache = PackageCache::new(&cache_path); + let digest = cache.put(archive).expect("cache synthetic archive"); + let lockfile = Lockfile::new( + vec![format!("example.package@{version}")], + vec![LockedPackage { + name: "example.package".to_owned(), + version: version.to_owned(), + sha256: digest, + source: "synthetic-test".to_owned(), + dependencies: BTreeMap::new(), + }], + ); + fs::write(&lock_path, lockfile.to_bytes().expect("serialize lock")).expect("write lock"); + (lock_path, cache_path) +} + +fn changed_states( + dir: &Path, + before_version: &str, + after_version: &str, +) -> (PathBuf, PathBuf, PathBuf, PathBuf) { + let before = decode_hex(BEFORE_HEX); + let after = decode_hex(AFTER_HEX); + let (before_lock, before_cache) = + write_locked_state(&dir.join("before"), &before, before_version); + let (after_lock, after_cache) = write_locked_state(&dir.join("after"), &after, after_version); + (before_lock, before_cache, after_lock, after_cache) +} + +fn run_command( + subcommand: &str, + before_lock: &Path, + before_cache: &Path, + after_lock: &Path, + after_cache: &Path, + extra: &[String], +) -> Output { + let mut command = commandf(); + command.args([ + subcommand, + "example.package", + "--before-lock", + before_lock.to_str().expect("UTF-8 path"), + "--before-cache", + before_cache.to_str().expect("UTF-8 path"), + "--after-lock", + after_lock.to_str().expect("UTF-8 path"), + "--after-cache", + after_cache.to_str().expect("UTF-8 path"), + ]); + command.args(extra); + command + .env("HTTP_PROXY", "http://127.0.0.1:9") + .env("HTTPS_PROXY", "http://127.0.0.1:9") + .env("NO_PROXY", "") + .output() + .expect("commandf must execute") +} + +fn current_check_report( + before_lock: &Path, + before_cache: &Path, + after_lock: &Path, + after_cache: &Path, +) -> CheckReport { + let output = run_command( + "check", + before_lock, + before_cache, + after_lock, + after_cache, + &[], + ); + assert_eq!(output.status.code(), Some(2)); + CheckReport::from_json_slice(&output.stdout).expect("valid check report") +} + +fn write_baseline( + path: &Path, + before_lock: &Path, + before_cache: &Path, + after_lock: &Path, + after_cache: &Path, +) { + let report = run_command( + "check", + before_lock, + before_cache, + after_lock, + after_cache, + &[], + ); + assert_eq!(report.status.code(), Some(2)); + fs::write(path, report.stdout).expect("write baseline report"); +} + +#[test] +fn gate_help_exposes_v1_contract() { + let output = commandf() + .args(["gate", "--help"]) + .output() + .expect("gate help must execute"); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 help"); + for flag in [ + "--before-lock", + "--before-cache", + "--after-lock", + "--after-cache", + "--direction", + "--fail-on", + "--baseline", + "--suppressions", + "--format", + "--output", + ] { + assert!(stdout.contains(flag), "missing {flag}"); + } +} + +#[test] +fn new_blocker_emits_complete_json_before_exit_two_and_replaces_output() { + let dir = unique_temp_dir("gate-new-blocker"); + let (before_lock, before_cache, after_lock, after_cache) = + changed_states(&dir, "1.0.0", "1.1.0"); + let output_path = dir.join("gate.json"); + fs::write(&output_path, b"stale-report").expect("write stale output"); + + let output = run_command( + "gate", + &before_lock, + &before_cache, + &after_lock, + &after_cache, + &[ + "--output".to_owned(), + output_path.to_str().expect("UTF-8 path").to_owned(), + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let bytes = fs::read(&output_path).expect("gate output exists"); + let report = QualityGateReport::from_json_slice(&bytes).expect("complete gate JSON"); + assert!(!report.decision.passed); + assert!(report.decision.blocking_findings > 0); + assert!(report + .findings + .iter() + .all(|finding| finding.disposition == QualityGateDisposition::New)); + assert!(!String::from_utf8_lossy(&bytes).contains("stale-report")); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn historical_baseline_allows_same_semantic_finding() { + let dir = unique_temp_dir("gate-baseline"); + let (base_before_lock, base_before_cache, base_after_lock, base_after_cache) = + changed_states(&dir.join("baseline-state"), "0.8.0", "0.9.0"); + let (before_lock, before_cache, after_lock, after_cache) = + changed_states(&dir.join("current-state"), "1.0.0", "1.1.0"); + let baseline_path = dir.join("baseline.json"); + write_baseline( + &baseline_path, + &base_before_lock, + &base_before_cache, + &base_after_lock, + &base_after_cache, + ); + + let output = run_command( + "gate", + &before_lock, + &before_cache, + &after_lock, + &after_cache, + &[ + "--baseline".to_owned(), + baseline_path.to_str().expect("UTF-8 path").to_owned(), + ], + ); + + assert_eq!(output.status.code(), Some(0)); + let report = QualityGateReport::from_json_slice(&output.stdout).expect("gate report"); + assert!(report.decision.passed); + assert!(report.decision.baseline_findings > 0); + assert_eq!(report.decision.blocking_findings, 0); + assert!(report + .findings + .iter() + .all(|finding| finding.disposition == QualityGateDisposition::Baseline)); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn exact_suppression_passes_and_stale_suppression_does_not_hide_blocker() { + let dir = unique_temp_dir("gate-suppression"); + let (before_lock, before_cache, after_lock, after_cache) = + changed_states(&dir, "1.0.0", "1.1.0"); + let current = current_check_report(&before_lock, &before_cache, &after_lock, &after_cache); + let exact = GateSuppressions { + schema: GateSuppressions::SCHEMA_V1, + suppressions: current + .compatibility + .findings + .iter() + .enumerate() + .map(|(index, finding)| GateSuppression { + finding_fingerprint: finding_fingerprint_v1( + ¤t.compatibility.ruleset, + finding, + ) + .expect("fingerprint"), + rationale: "approved interoperability exception".to_owned(), + reference: Some(format!("TEST-{}", index + 1)), + }) + .collect(), + }; + let exact_path = dir.join("exact-suppressions.json"); + fs::write( + &exact_path, + exact.to_json_bytes().expect("suppression JSON"), + ) + .expect("write exact suppression"); + + let exact_output = run_command( + "gate", + &before_lock, + &before_cache, + &after_lock, + &after_cache, + &[ + "--suppressions".to_owned(), + exact_path.to_str().expect("UTF-8 path").to_owned(), + ], + ); + assert_eq!(exact_output.status.code(), Some(0)); + let exact_report = + QualityGateReport::from_json_slice(&exact_output.stdout).expect("exact gate report"); + assert!(exact_report.decision.passed); + assert_eq!(exact_report.decision.blocking_findings, 0); + assert!(exact_report + .findings + .iter() + .all(|finding| finding.disposition == QualityGateDisposition::Suppressed)); + + let stale_path = dir.join("stale-suppressions.json"); + let stale = GateSuppressions { + schema: GateSuppressions::SCHEMA_V1, + suppressions: vec![GateSuppression { + finding_fingerprint: FindingFingerprint { + schema: FindingFingerprint::SCHEMA_V1, + digest: format!("sha256:{}", "f".repeat(64)), + }, + rationale: "stale exception".to_owned(), + reference: None, + }], + }; + fs::write(&stale_path, stale.to_json_bytes().expect("stale JSON")) + .expect("write stale suppression"); + let stale_output = run_command( + "gate", + &before_lock, + &before_cache, + &after_lock, + &after_cache, + &[ + "--suppressions".to_owned(), + stale_path.to_str().expect("UTF-8 path").to_owned(), + ], + ); + assert_eq!(stale_output.status.code(), Some(2)); + let stale_report = + QualityGateReport::from_json_slice(&stale_output.stdout).expect("stale gate report"); + assert!(!stale_report.decision.passed); + assert_eq!(stale_report.unused_suppressions.len(), 1); + assert!(stale_report + .findings + .iter() + .all(|finding| finding.disposition == QualityGateDisposition::New)); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn malformed_mismatched_and_version_incompatible_inputs_exit_one() { + let dir = unique_temp_dir("gate-invalid-input"); + let (before_lock, before_cache, after_lock, after_cache) = + changed_states(&dir, "1.0.0", "1.1.0"); + + let malformed_path = dir.join("malformed.json"); + fs::write(&malformed_path, b"{").expect("write malformed input"); + let malformed = run_command( + "gate", + &before_lock, + &before_cache, + &after_lock, + &after_cache, + &[ + "--suppressions".to_owned(), + malformed_path.to_str().expect("UTF-8 path").to_owned(), + ], + ); + assert_eq!(malformed.status.code(), Some(1)); + + let mut mismatched = + current_check_report(&before_lock, &before_cache, &after_lock, &after_cache); + mismatched.compatibility.package_name = "other.package".to_owned(); + let mismatched_path = dir.join("mismatched-baseline.json"); + fs::write( + &mismatched_path, + mismatched.to_json_bytes().expect("baseline JSON"), + ) + .expect("write mismatched baseline"); + let mismatch = run_command( + "gate", + &before_lock, + &before_cache, + &after_lock, + &after_cache, + &[ + "--baseline".to_owned(), + mismatched_path.to_str().expect("UTF-8 path").to_owned(), + ], + ); + assert_eq!(mismatch.status.code(), Some(1)); + + let current = current_check_report(&before_lock, &before_cache, &after_lock, &after_cache); + let current_fingerprint = finding_fingerprint_v1( + ¤t.compatibility.ruleset, + current.compatibility.findings.first().expect("finding"), + ) + .expect("fingerprint"); + let incompatible_path = dir.join("incompatible-suppressions.json"); + let incompatible = GateSuppressions { + schema: GateSuppressions::SCHEMA_V1, + suppressions: vec![GateSuppression { + finding_fingerprint: FindingFingerprint { + schema: 2, + digest: current_fingerprint.digest, + }, + rationale: "unsupported schema".to_owned(), + reference: None, + }], + }; + fs::write( + &incompatible_path, + incompatible.to_json_bytes().expect("incompatible JSON"), + ) + .expect("write incompatible suppressions"); + let incompatible_output = run_command( + "gate", + &before_lock, + &before_cache, + &after_lock, + &after_cache, + &[ + "--suppressions".to_owned(), + incompatible_path.to_str().expect("UTF-8 path").to_owned(), + ], + ); + assert_eq!(incompatible_output.status.code(), Some(1)); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn repeated_gate_runs_are_byte_identical() { + let dir = unique_temp_dir("gate-determinism"); + let (before_lock, before_cache, after_lock, after_cache) = + changed_states(&dir, "1.0.0", "1.1.0"); + let first = run_command( + "gate", + &before_lock, + &before_cache, + &after_lock, + &after_cache, + &[], + ); + let second = run_command( + "gate", + &before_lock, + &before_cache, + &after_lock, + &after_cache, + &[], + ); + + assert_eq!(first.status.code(), Some(2)); + assert_eq!(second.status.code(), Some(2)); + assert_eq!(first.stdout, second.stdout); + QualityGateReport::from_json_slice(&first.stdout).expect("deterministic report"); + let _ = fs::remove_dir_all(&dir); +} diff --git a/crates/commandf-cli/tests/gate_bounds.rs b/crates/commandf-cli/tests/gate_bounds.rs new file mode 100644 index 00000000..eae25fba --- /dev/null +++ b/crates/commandf-cli/tests/gate_bounds.rs @@ -0,0 +1,140 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use commandf_pkg::{LockedPackage, Lockfile, PackageCache}; + +const PROOF_ARCHIVE: &[u8] = include_bytes!("fixtures/proof.tgz"); +const GATE_LOCKFILE_LIMIT: u64 = 16 * 1024 * 1024; +const GATE_BASELINE_LIMIT: u64 = 64 * 1024 * 1024; +const GATE_SUPPRESSIONS_LIMIT: u64 = 64 * 1024 * 1024; + +fn commandf() -> Command { + Command::new(env!("CARGO_BIN_EXE_commandf")) +} + +fn unique_temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must be after the Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("commandf-{label}-{}-{nonce}", std::process::id())) +} + +fn write_valid_state(root: &Path) -> (PathBuf, PathBuf) { + fs::create_dir_all(root).expect("create state root"); + let cache_path = root.join("cache"); + let lock_path = root.join("commandf.lock"); + let cache = PackageCache::new(&cache_path); + let digest = cache.put(PROOF_ARCHIVE).expect("cache proof archive"); + let lockfile = Lockfile::new_v2( + vec!["acme.proof@1.0.0".to_owned()], + vec![LockedPackage { + name: "acme.proof".to_owned(), + version: "1.0.0".to_owned(), + sha256: digest, + source: "synthetic-gate-bounds".to_owned(), + dependencies: BTreeMap::new(), + }], + vec![], + ); + fs::write(&lock_path, lockfile.to_bytes().expect("serialize lockfile")) + .expect("write lockfile"); + (lock_path, cache_path) +} + +fn run_gate( + before_lock: &Path, + before_cache: &Path, + after_lock: &Path, + after_cache: &Path, + extra: &[String], +) -> Output { + let mut command = commandf(); + command.args([ + "gate", + "acme.proof", + "--before-lock", + before_lock.to_str().expect("UTF-8 path"), + "--before-cache", + before_cache.to_str().expect("UTF-8 path"), + "--after-lock", + after_lock.to_str().expect("UTF-8 path"), + "--after-cache", + after_cache.to_str().expect("UTF-8 path"), + ]); + command.args(extra); + command + .env("HTTP_PROXY", "http://127.0.0.1:9") + .env("HTTPS_PROXY", "http://127.0.0.1:9") + .env("NO_PROXY", "") + .output() + .expect("commandf gate must execute") +} + +fn create_sparse_file(path: &Path, bytes: u64) { + let file = fs::File::create(path).expect("create sparse input"); + file.set_len(bytes).expect("size sparse input"); +} + +#[test] +fn oversized_baseline_is_operational_exit_one() { + let root = unique_temp_dir("gate-oversized-baseline"); + let (lock, cache) = write_valid_state(&root.join("state")); + let baseline = root.join("oversized-baseline.json"); + create_sparse_file(&baseline, GATE_BASELINE_LIMIT + 1); + + let output = run_gate( + &lock, + &cache, + &lock, + &cache, + &[ + "--baseline".to_owned(), + baseline.to_str().expect("UTF-8 path").to_owned(), + ], + ); + + assert_eq!(output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&output.stderr).contains("exceeds")); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn oversized_suppressions_is_operational_exit_one() { + let root = unique_temp_dir("gate-oversized-suppressions"); + let (lock, cache) = write_valid_state(&root.join("state")); + let suppressions = root.join("oversized-suppressions.json"); + create_sparse_file(&suppressions, GATE_SUPPRESSIONS_LIMIT + 1); + + let output = run_gate( + &lock, + &cache, + &lock, + &cache, + &[ + "--suppressions".to_owned(), + suppressions.to_str().expect("UTF-8 path").to_owned(), + ], + ); + + assert_eq!(output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&output.stderr).contains("exceeds")); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn oversized_primary_lockfile_is_operational_exit_one() { + let root = unique_temp_dir("gate-oversized-lockfile"); + let (valid_lock, cache) = write_valid_state(&root.join("state")); + let oversized_lock = root.join("oversized.lock"); + create_sparse_file(&oversized_lock, GATE_LOCKFILE_LIMIT + 1); + + let output = run_gate(&oversized_lock, &cache, &valid_lock, &cache, &[]); + + assert_eq!(output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&output.stderr).contains("exceeds")); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/commandf-cli/tests/gate_determinism_proof.rs b/crates/commandf-cli/tests/gate_determinism_proof.rs new file mode 100644 index 00000000..3f1e8c47 --- /dev/null +++ b/crates/commandf-cli/tests/gate_determinism_proof.rs @@ -0,0 +1,214 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use commandf_pkg::{ + validate_quality_gate_report, GateSuppression, GateSuppressions, LockedPackage, Lockfile, + PackageCache, QualityGateDisposition, QualityGateReport, +}; + +const BEFORE_HEX: &str = concat!( + "1f8b08000000000002ffed944d4fc3300c86fb5350cea31f63b452cf70e60037c4216bbd35d0a655924e43d3fe3beed66d6c", + "abc4013409789f1edc388df336b1ddc8ec4dce2968b6d67fb5b5f67e98908927938d654e6d1826f1e1bdf347517c137957a1", + "77015aeba4e1edbdffc94a68599148052d65d594e4f78920466241c6aa5af35ce4877ec89e9c1ad239e94c9115e96abdf6c0", + "2fa7bfeee0d1993673ada13b9a29ad1c5ffcf52e25bedb13beaaff241e9fd4ff6d128e51ff97a97f43b66e4d464fef4dd707", + "0612812b5fe58716c1c3d6943c2e9c6bd220d8a5496de64369141c969d7794bef9dcef3fe1db702d37172133a7169de74de9", + "6ef39d4cf6c8a97586e7453a93a5a591705be90f534b66217bc953693fffc35e6e51261ba9b3429941bdc7617232aa1fa422", + "ab75b7b5d2ae93aa65638b9a65ac049554117bd3e7d5f6ac8e8334d21567ce4a71c890ad5c762722d6a3f3b57e7f1e43210e", + "73a7917627c262b23aa78d7036eb177ed0b3010000000000000000000000000000000000803fc7075ec49c6300280000", +); + +const AFTER_HEX: &str = concat!( + "1f8b08000000000002ffed944d4fc3300c86fb5350cea31f636c52cf70e60037c4216bbd35d0a555924e4353ff3b6ed7ad6c", + "abc4013409789f1edc388df336b15dcae44d2e292877d67fb585f67e9890994e26ad654e6d18cea6fd7be38fa2e94de45d85", + "de05a8ac9386b7f7fe275ba1e58a442c682357654e7e97086224d664ac2a34cf457ee487ec49a9249d924e1459116febda03", + "bf9ceeba834767aac45586ee68a1b4727cf1d7fb94f86e4ff8aafe67d3f149fddfcec231eaff32f56fc8169549e8e9bd6cfa", + "c0402270e5abb46f113cac4ccee3ccb9320e827d9a146639944641bfecbca374cde7fef009df86abb8b9089938b56e3c6f4a", + "379bef65b247cead333c2fe285cc2d8d84db497f985b326bd9499e4bfbf91f0e72b37cd64a5d64ca0cea3d0e939251dd2016", + "49a19bad95768d542d4b9b152c632b28a715b1377edeeeceea3848295d76e65c290e19b2959be644443d3a5feb77e73114a2", + "9f6b23457da4fd89b098a448a915cea67ee1073d1b000000000000000000000000000000000000f8737c00934f6565002800", + "00", +); + +#[test] +fn gate_cli_proof_is_deterministic_and_covers_new_baseline_suppression() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).unwrap(); + let current = write_changed_states(&root.join("current"), "1.0.0", "1.1.0"); + let historical = write_changed_states(&root.join("historical"), "0.8.0", "0.9.0"); + + let new_first = run_gate(¤t, &[]); + let new_second = run_gate(¤t, &[]); + assert_eq!(new_first.status.code(), Some(2)); + assert_eq!(new_second.status.code(), Some(2)); + assert_eq!(new_first.stdout, new_second.stdout); + let new_report = QualityGateReport::from_json_slice(&new_first.stdout).unwrap(); + validate_quality_gate_report(&new_report).unwrap(); + assert!(!new_report.decision.passed); + assert!(new_report + .findings + .iter() + .all(|finding| finding.disposition == QualityGateDisposition::New)); + + let baseline_path = root.join("baseline.json"); + let baseline_check = run_check(&historical); + assert_eq!(baseline_check.status.code(), Some(2)); + fs::write(&baseline_path, baseline_check.stdout).unwrap(); + let baseline = run_gate( + ¤t, + &[ + "--baseline".to_owned(), + baseline_path.to_str().unwrap().to_owned(), + ], + ); + assert_eq!(baseline.status.code(), Some(0)); + let baseline_report = QualityGateReport::from_json_slice(&baseline.stdout).unwrap(); + validate_quality_gate_report(&baseline_report).unwrap(); + assert!(baseline_report.decision.passed); + + let suppression_path = root.join("suppressions.json"); + let suppression = GateSuppressions { + schema: GateSuppressions::SCHEMA_V1, + suppressions: new_report + .findings + .iter() + .map(|finding| GateSuppression { + finding_fingerprint: finding.fingerprint.clone(), + rationale: "CF-13 deterministic proof suppression".to_owned(), + reference: Some("CF13-PROOF".to_owned()), + }) + .collect(), + }; + fs::write(&suppression_path, suppression.to_json_bytes().unwrap()).unwrap(); + let suppressed = run_gate( + ¤t, + &[ + "--suppressions".to_owned(), + suppression_path.to_str().unwrap().to_owned(), + ], + ); + assert_eq!(suppressed.status.code(), Some(0)); + let suppression_report = QualityGateReport::from_json_slice(&suppressed.stdout).unwrap(); + validate_quality_gate_report(&suppression_report).unwrap(); + assert!(suppression_report.decision.passed); + + println!( + "CF13_GATE_SHA256={}", + PackageCache::digest(&new_first.stdout) + ); + println!( + "CF13_BASELINE_CANONICAL_SHA256={}", + baseline_report.baseline.as_ref().unwrap().canonical_sha256 + ); + println!( + "CF13_SUPPRESSION_CANONICAL_SHA256={}", + suppression_report + .suppression_evidence + .as_ref() + .unwrap() + .canonical_sha256 + ); + println!( + "CF13_BEFORE_ARCHIVE_SHA256={}", + new_report.current.compatibility.before.archive_sha256 + ); + println!( + "CF13_AFTER_ARCHIVE_SHA256={}", + new_report.current.compatibility.after.archive_sha256 + ); + + let _ = fs::remove_dir_all(root); +} + +type State = (PathBuf, PathBuf, PathBuf, PathBuf); + +fn write_changed_states(root: &Path, before_version: &str, after_version: &str) -> State { + let before = decode_hex(BEFORE_HEX); + let after = decode_hex(AFTER_HEX); + let (before_lock, before_cache) = + write_locked_state(&root.join("before"), &before, before_version); + let (after_lock, after_cache) = write_locked_state(&root.join("after"), &after, after_version); + (before_lock, before_cache, after_lock, after_cache) +} + +fn write_locked_state(root: &Path, archive: &[u8], version: &str) -> (PathBuf, PathBuf) { + fs::create_dir_all(root).unwrap(); + let cache_path = root.join("cache"); + let lock_path = root.join("commandf.lock"); + let cache = PackageCache::new(&cache_path); + let sha256 = cache.put(archive).unwrap(); + let lockfile = Lockfile::new( + vec![format!("example.package@{version}")], + vec![LockedPackage { + name: "example.package".to_owned(), + version: version.to_owned(), + sha256, + source: "synthetic-cf13-proof".to_owned(), + dependencies: BTreeMap::new(), + }], + ); + fs::write(lock_path.clone(), lockfile.to_bytes().unwrap()).unwrap(); + (lock_path, cache_path) +} + +fn run_check(state: &State) -> Output { + run("check", state, &[]) +} + +fn run_gate(state: &State, extra: &[String]) -> Output { + run("gate", state, extra) +} + +fn run(subcommand: &str, state: &State, extra: &[String]) -> Output { + let (before_lock, before_cache, after_lock, after_cache) = state; + let mut command = Command::new(env!("CARGO_BIN_EXE_commandf")); + command.args([ + subcommand, + "example.package", + "--before-lock", + before_lock.to_str().unwrap(), + "--before-cache", + before_cache.to_str().unwrap(), + "--after-lock", + after_lock.to_str().unwrap(), + "--after-cache", + after_cache.to_str().unwrap(), + ]); + command.args(extra); + command + .env("HTTP_PROXY", "http://127.0.0.1:9") + .env("HTTPS_PROXY", "http://127.0.0.1:9") + .env("NO_PROXY", "") + .output() + .unwrap() +} + +fn decode_hex(value: &str) -> Vec { + assert_eq!(value.len() % 2, 0); + value + .as_bytes() + .chunks_exact(2) + .map(|pair| (hex_digit(pair[0]) << 4) | hex_digit(pair[1])) + .collect() +} + +fn hex_digit(value: u8) -> u8 { + match value { + b'0'..=b'9' => value - b'0', + b'a'..=b'f' => value - b'a' + 10, + _ => panic!("invalid hex digit"), + } +} + +fn unique_temp_dir() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "commandf-cf13-proof-{}-{nonce}", + std::process::id() + )) +} diff --git a/crates/commandf-cli/tests/gate_exit_contract.rs b/crates/commandf-cli/tests/gate_exit_contract.rs new file mode 100644 index 00000000..69cd1000 --- /dev/null +++ b/crates/commandf-cli/tests/gate_exit_contract.rs @@ -0,0 +1,51 @@ +use std::process::Command; + +fn commandf() -> Command { + Command::new(env!("CARGO_BIN_EXE_commandf")) +} + +#[test] +fn gate_usage_errors_are_operational_exit_one() { + let output = commandf() + .args([ + "gate", + "example.package", + "--before-lock", + "before.lock", + "--before-cache", + "before-cache", + "--after-lock", + "after.lock", + "--after-cache", + "after-cache", + "--fail-on", + "invalid-threshold", + ]) + .output() + .expect("commandf gate parse failure must execute"); + + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("invalid value"), "stderr: {stderr}"); + assert!(stderr.contains("invalid-threshold"), "stderr: {stderr}"); +} + +#[test] +fn gate_help_remains_success() { + let output = commandf() + .args(["gate", "--help"]) + .output() + .expect("commandf gate help must execute"); + + assert_eq!(output.status.code(), Some(0)); +} + +#[test] +fn unrelated_clap_usage_behavior_remains_exit_two() { + let output = commandf() + .args(["inspect"]) + .output() + .expect("commandf unrelated parse failure must execute"); + + assert_eq!(output.status.code(), Some(2)); +} diff --git a/crates/commandf-pkg/src/cache.rs b/crates/commandf-pkg/src/cache.rs index 325bfaf8..a6c34eba 100644 --- a/crates/commandf-pkg/src/cache.rs +++ b/crates/commandf-pkg/src/cache.rs @@ -1,5 +1,5 @@ use std::fs; -use std::io::Write; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; @@ -67,23 +67,52 @@ impl PackageCache { pub fn read_verified(&self, digest: &str) -> Result, PackageError> { validate_digest(digest)?; let path = self.object_path(digest); - let bytes = fs::read(&path).map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - PackageError::CacheMissing(digest.to_owned()) - } else { - PackageError::Io(error) - } - })?; - let found = Self::digest(&bytes); - if found != digest { - return Err(PackageError::CacheDigestMismatch { - path, - expected: digest.to_owned(), - found, - }); + let bytes = fs::read(&path).map_err(|error| map_cache_read_error(error, digest))?; + verify_cache_bytes(path, digest, bytes) + } + + pub fn read_verified_bounded( + &self, + digest: &str, + max_bytes: u64, + ) -> Result, PackageError> { + validate_digest(digest)?; + let path = self.object_path(digest); + let file = fs::File::open(&path).map_err(|error| map_cache_read_error(error, digest))?; + let read_limit = max_bytes.saturating_add(1); + let mut bytes = Vec::new(); + file.take(read_limit).read_to_end(&mut bytes)?; + if bytes.len() as u64 > max_bytes { + return Err(PackageError::InvalidRequest(format!( + "cache object exceeds the maximum supported size of {max_bytes} bytes" + ))); } - Ok(bytes) + verify_cache_bytes(path, digest, bytes) + } +} + +fn map_cache_read_error(error: std::io::Error, digest: &str) -> PackageError { + if error.kind() == std::io::ErrorKind::NotFound { + PackageError::CacheMissing(digest.to_owned()) + } else { + PackageError::Io(error) + } +} + +fn verify_cache_bytes( + path: PathBuf, + expected: &str, + bytes: Vec, +) -> Result, PackageError> { + let found = PackageCache::digest(&bytes); + if found != expected { + return Err(PackageError::CacheDigestMismatch { + path, + expected: expected.to_owned(), + found, + }); } + Ok(bytes) } fn validate_digest(digest: &str) -> Result<(), PackageError> { @@ -97,3 +126,32 @@ fn validate_digest(digest: &str) -> Result<(), PackageError> { Err(PackageError::InvalidDigest(digest.to_owned())) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounded_verified_read_rejects_oversized_cache_object() { + let directory = tempfile::tempdir().expect("temp directory"); + let cache = PackageCache::new(directory.path()); + let digest = cache.put(b"abcd").expect("cache object"); + + let error = cache + .read_verified_bounded(&digest, 3) + .expect_err("oversized cache object must fail closed"); + assert!(matches!(error, PackageError::InvalidRequest(_))); + } + + #[test] + fn bounded_verified_read_returns_the_bytes_it_verified() { + let directory = tempfile::tempdir().expect("temp directory"); + let cache = PackageCache::new(directory.path()); + let digest = cache.put(b"abcd").expect("cache object"); + + let bytes = cache + .read_verified_bounded(&digest, 4) + .expect("bounded verified bytes"); + assert_eq!(bytes, b"abcd"); + } +}