diff --git a/.github/workflows/cf12-impact-proof.yml b/.github/workflows/cf12-impact-proof.yml new file mode 100644 index 00000000..aac7e1e4 --- /dev/null +++ b/.github/workflows/cf12-impact-proof.yml @@ -0,0 +1,70 @@ +name: cf12-impact-proof + +on: + pull_request: + paths: + - .github/workflows/cf12-impact-proof.yml + - Cargo.toml + - Cargo.lock + - crates/commandf-pkg/** + - crates/commandf-cli/** + - specs/013-cf-12-impact/** + push: + branches: + - impl/cf12-impact-cli + paths: + - .github/workflows/cf12-impact-proof.yml + - Cargo.toml + - Cargo.lock + - crates/commandf-pkg/** + - crates/commandf-cli/** + - specs/013-cf-12-impact/** + workflow_dispatch: + +permissions: + contents: read + +env: + CF12_PROOF_CONTAINER: docker.io/library/rust@sha256:9146b0f62e1939989aa96fc8d89699a43c5635bf212819235a773e1a9e71a98f + +jobs: + deterministic-impact-cli: + runs-on: ubuntu-24.04 + container: + image: docker.io/library/rust@sha256:9146b0f62e1939989aa96fc8d89699a43c5635bf212819235a773e1a9e71a98f + timeout-minutes: 15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + persist-credentials: false + + - 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 byte-identical commandf impact output + run: | + set -euo pipefail + cargo test --locked -p commandf --test impact_determinism_proof -- --nocapture \ + | tee /tmp/cf12-impact-proof.log + grep -oE 'CF12_IMPACT_SHA256=[0-9a-f]{64}' /tmp/cf12-impact-proof.log \ + | tail -n 1 \ + | tee /tmp/cf12-impact.sha256 + test -s /tmp/cf12-impact.sha256 + + - name: Assert repository remains clean + run: | + set -euo pipefail + status="$(git -c safe.directory="$GITHUB_WORKSPACE" status --porcelain)" + test -z "$status" + + - name: Upload impact determinism evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: cf12-impact-proof + path: /tmp/cf12-impact.sha256 + if-no-files-found: error + retention-days: 3 diff --git a/crates/commandf-cli/src/impact.rs b/crates/commandf-cli/src/impact.rs new file mode 100644 index 00000000..c0a6d2b2 --- /dev/null +++ b/crates/commandf-cli/src/impact.rs @@ -0,0 +1,90 @@ +use std::fs; +use std::io; +use std::path::PathBuf; + +use commandf_pkg::{ + build_context_graph, build_impact_report, diff_package_archives, LockedPackage, Lockfile, + PackageCache, PackageName, +}; + +pub fn run( + package: String, + before_lock: PathBuf, + before_cache: PathBuf, + after_lock: PathBuf, + after_cache: PathBuf, +) -> Result, Box> { + let package_name = PackageName::parse(package)?; + let before_lockfile = Lockfile::from_slice(&fs::read(before_lock)?)?; + let after_lockfile = Lockfile::from_slice(&fs::read(after_lock)?)?; + require_lock_v2(&before_lockfile, "before")?; + require_lock_v2(&after_lockfile, "after")?; + 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); + before_cache.verify(&before_locked.sha256)?; + after_cache.verify(&after_locked.sha256)?; + + let before_bytes = read_locked_archive(&before_cache, before_locked)?; + let after_bytes = read_locked_archive(&after_cache, after_locked)?; + let diff = diff_package_archives( + package_name.to_string(), + &before_locked.version, + &before_locked.sha256, + &before_bytes, + &after_locked.version, + &after_locked.sha256, + &after_bytes, + )?; + let before_graph = build_context_graph(&before_lockfile, &before_cache)?; + let after_graph = build_context_graph(&after_lockfile, &after_cache)?; + let report = build_impact_report(&diff, &before_graph, &after_graph)?; + Ok(report.to_json_bytes()?) +} + +fn require_lock_v2(lockfile: &Lockfile, side: &'static str) -> io::Result<()> { + if lockfile.schema == Lockfile::SCHEMA_V2 { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "impact requires commandf.lock schema 2 on {side}; found schema {}", + lockfile.schema + ), + )) +} + +fn read_locked_archive(cache: &PackageCache, locked: &LockedPackage) -> io::Result> { + fs::read( + cache + .root() + .join("sha256") + .join(format!("{}.tgz", locked.sha256)), + ) +} + +fn select_locked_package<'a>( + lockfile: &'a Lockfile, + package_name: &str, +) -> Result<&'a LockedPackage, io::Error> { + let mut matches = lockfile + .packages + .iter() + .filter(|candidate| candidate.name == package_name); + let selected = matches.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("package {package_name} is not present in the lockfile"), + ) + })?; + if matches.next().is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("package {package_name} appears more than once in the lockfile"), + )); + } + Ok(selected) +} diff --git a/crates/commandf-cli/src/main.rs b/crates/commandf-cli/src/main.rs index eba618ae..fc68b037 100644 --- a/crates/commandf-cli/src/main.rs +++ b/crates/commandf-cli/src/main.rs @@ -1,3 +1,4 @@ +mod impact; mod oracle; use std::ffi::OsStr; @@ -69,6 +70,19 @@ enum Command { #[arg(long, value_enum, default_value = "json")] format: OutputFormat, }, + Impact { + 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 = "json")] + format: OutputFormat, + }, Classify { package: String, #[arg(long)] @@ -353,6 +367,19 @@ fn run(cli: Cli) -> Result> { OutputFormat::Json => io::stdout().write_all(&report.to_json_bytes()?)?, } } + Command::Impact { + package, + before_lock, + before_cache, + after_lock, + after_cache, + format, + } => { + let bytes = impact::run(package, before_lock, before_cache, after_lock, after_cache)?; + match format { + OutputFormat::Json => io::stdout().write_all(&bytes)?, + } + } Command::Classify { package, before_lock, diff --git a/crates/commandf-cli/tests/impact_behavior.rs b/crates/commandf-cli/tests/impact_behavior.rs new file mode 100644 index 00000000..1c73ff12 --- /dev/null +++ b/crates/commandf-cli/tests/impact_behavior.rs @@ -0,0 +1,238 @@ +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, ResolvedDependency}; + +const PARENT_A_ARCHIVE: &[u8] = include_bytes!("fixtures/parent-a.tgz"); +const PARENT_B_ARCHIVE: &[u8] = include_bytes!("fixtures/parent-b.tgz"); +const SHARED_V1_ARCHIVE: &[u8] = include_bytes!("fixtures/shared-v1.tgz"); +const SHARED_V2_ARCHIVE: &[u8] = include_bytes!("fixtures/shared-v2.tgz"); + +fn commandf() -> Command { + Command::new(env!("CARGO_BIN_EXE_commandf")) +} + +#[test] +fn impact_help_exposes_pinned_before_after_inputs() { + let output = commandf() + .args(["impact", "--help"]) + .output() + .expect("commandf impact help must execute"); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 help"); + for expected in [ + "", + "--before-lock", + "--before-cache", + "--after-lock", + "--after-cache", + "--format", + ] { + assert!(stdout.contains(expected), "missing {expected}"); + } +} + +#[test] +fn impact_is_byte_identical_and_reports_dependency_evidence_without_severity() { + let root = unique_temp_dir("success"); + let (before_lock, before_cache, after_lock, after_cache) = write_impact_state(&root); + + let first = run_impact(&before_lock, &before_cache, &after_lock, &after_cache); + let second = run_impact(&before_lock, &before_cache, &after_lock, &after_cache); + assert_success(&first); + assert_success(&second); + assert_eq!(first.stdout, second.stdout); + + let json = String::from_utf8(first.stdout).expect("UTF-8 impact JSON"); + for expected in [ + "\"schema\": 1", + "\"package_name\": \"acme.subject\"", + "\"before_evidence\"", + "\"after_evidence\"", + "\"seeds\"", + "\"artifact_impacts\"", + "\"package_impacts\"", + "\"unresolved_boundaries\"", + "\"coverage\"", + ] { + assert!(json.contains(expected), "missing {expected}"); + } + for forbidden in ["\"breaking\"", "\"risky\"", "\"additive\""] { + assert!( + !json.contains(forbidden), + "impact invented severity: {forbidden}" + ); + } + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn impact_rejects_schema_v1_and_corrupt_cache_without_stdout() { + let schema_root = unique_temp_dir("schema-v1"); + let (before_lock, before_cache, after_lock, after_cache) = write_impact_state(&schema_root); + fs::write( + &before_lock, + Lockfile::new(Vec::new(), Vec::new()).to_bytes().unwrap(), + ) + .unwrap(); + let output = run_impact(&before_lock, &before_cache, &after_lock, &after_cache); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("requires commandf.lock schema 2")); + let _ = fs::remove_dir_all(schema_root); + + let corrupt_root = unique_temp_dir("corrupt"); + let (before_lock, before_cache, after_lock, after_cache) = write_impact_state(&corrupt_root); + let lock = Lockfile::from_slice(&fs::read(&before_lock).unwrap()).unwrap(); + let subject = lock + .packages + .iter() + .find(|package| package.name == "acme.subject") + .unwrap(); + fs::write( + before_cache + .join("sha256") + .join(format!("{}.tgz", subject.sha256)), + b"corrupted", + ) + .unwrap(); + let output = run_impact(&before_lock, &before_cache, &after_lock, &after_cache); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + let _ = fs::remove_dir_all(corrupt_root); +} + +fn write_impact_state(root: &Path) -> (PathBuf, PathBuf, PathBuf, PathBuf) { + fs::create_dir_all(root).unwrap(); + let before_cache_path = root.join("before-cache"); + let after_cache_path = root.join("after-cache"); + let before_lock_path = root.join("before.lock"); + let after_lock_path = root.join("after.lock"); + let before_cache = PackageCache::new(&before_cache_path); + let after_cache = PackageCache::new(&after_cache_path); + + let before_subject_sha = before_cache.put(PARENT_A_ARCHIVE).unwrap(); + let before_shared_sha = before_cache.put(SHARED_V1_ARCHIVE).unwrap(); + let after_subject_sha = after_cache.put(PARENT_B_ARCHIVE).unwrap(); + let after_shared_sha = after_cache.put(SHARED_V2_ARCHIVE).unwrap(); + + let mut before_dependencies = BTreeMap::new(); + before_dependencies.insert("acme.shared".to_owned(), "1.0.0".to_owned()); + let mut after_dependencies = BTreeMap::new(); + after_dependencies.insert("acme.shared".to_owned(), "2.0.0".to_owned()); + + let before = Lockfile::new_v2( + vec!["acme.subject@1.0.0".to_owned()], + vec![ + locked_package( + "acme.subject", + "1.0.0", + &before_subject_sha, + before_dependencies, + ), + locked_package("acme.shared", "1.0.0", &before_shared_sha, BTreeMap::new()), + ], + vec![ResolvedDependency { + from_name: "acme.subject".to_owned(), + from_version: "1.0.0".to_owned(), + to_name: "acme.shared".to_owned(), + to_version: "1.0.0".to_owned(), + declared_constraint: "1.0.0".to_owned(), + }], + ); + let after = Lockfile::new_v2( + vec!["acme.subject@2.0.0".to_owned()], + vec![ + locked_package( + "acme.subject", + "2.0.0", + &after_subject_sha, + after_dependencies, + ), + locked_package("acme.shared", "2.0.0", &after_shared_sha, BTreeMap::new()), + ], + vec![ResolvedDependency { + from_name: "acme.subject".to_owned(), + from_version: "2.0.0".to_owned(), + to_name: "acme.shared".to_owned(), + to_version: "2.0.0".to_owned(), + declared_constraint: "2.0.0".to_owned(), + }], + ); + fs::write(&before_lock_path, before.to_bytes().unwrap()).unwrap(); + fs::write(&after_lock_path, after.to_bytes().unwrap()).unwrap(); + + ( + before_lock_path, + before_cache_path, + after_lock_path, + after_cache_path, + ) +} + +fn run_impact( + before_lock: &Path, + before_cache: &Path, + after_lock: &Path, + after_cache: &Path, +) -> Output { + commandf() + .args([ + "impact", + "acme.subject", + "--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(), + "--format", + "json", + ]) + .env("HTTP_PROXY", "http://127.0.0.1:9") + .env("HTTPS_PROXY", "http://127.0.0.1:9") + .env("NO_PROXY", "") + .output() + .expect("commandf impact must execute") +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stderr.is_empty()); +} + +fn unique_temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "commandf-impact-{label}-{}-{nonce}", + std::process::id() + )) +} + +fn locked_package( + name: &str, + version: &str, + sha256: &str, + dependencies: BTreeMap, +) -> LockedPackage { + LockedPackage { + name: name.to_owned(), + version: version.to_owned(), + sha256: sha256.to_owned(), + source: "synthetic-impact-test".to_owned(), + dependencies, + } +} diff --git a/crates/commandf-cli/tests/impact_determinism_proof.rs b/crates/commandf-cli/tests/impact_determinism_proof.rs new file mode 100644 index 00000000..a86eae88 --- /dev/null +++ b/crates/commandf-cli/tests/impact_determinism_proof.rs @@ -0,0 +1,156 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use commandf_pkg::{LockedPackage, Lockfile, PackageCache, ResolvedDependency}; + +const PARENT_A_ARCHIVE: &[u8] = include_bytes!("fixtures/parent-a.tgz"); +const PARENT_B_ARCHIVE: &[u8] = include_bytes!("fixtures/parent-b.tgz"); +const SHARED_V1_ARCHIVE: &[u8] = include_bytes!("fixtures/shared-v1.tgz"); +const SHARED_V2_ARCHIVE: &[u8] = include_bytes!("fixtures/shared-v2.tgz"); + +#[test] +fn impact_cli_output_is_byte_identical_and_reports_sha256() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).unwrap(); + let (before_lock, before_cache, after_lock, after_cache) = write_state(&root); + + let first = run_impact(&before_lock, &before_cache, &after_lock, &after_cache); + let second = run_impact(&before_lock, &before_cache, &after_lock, &after_cache); + assert!( + first.status.success(), + "{}", + String::from_utf8_lossy(&first.stderr) + ); + assert!( + second.status.success(), + "{}", + String::from_utf8_lossy(&second.stderr) + ); + assert_eq!(first.stdout, second.stdout); + println!("CF12_IMPACT_SHA256={}", PackageCache::digest(&first.stdout)); + let _ = fs::remove_dir_all(root); +} + +fn write_state(root: &Path) -> (PathBuf, PathBuf, PathBuf, PathBuf) { + let before_cache_path = root.join("before-cache"); + let after_cache_path = root.join("after-cache"); + let before_lock_path = root.join("before.lock"); + let after_lock_path = root.join("after.lock"); + let before_cache = PackageCache::new(&before_cache_path); + let after_cache = PackageCache::new(&after_cache_path); + + let before_subject_sha = before_cache.put(PARENT_A_ARCHIVE).unwrap(); + let before_shared_sha = before_cache.put(SHARED_V1_ARCHIVE).unwrap(); + let after_subject_sha = after_cache.put(PARENT_B_ARCHIVE).unwrap(); + let after_shared_sha = after_cache.put(SHARED_V2_ARCHIVE).unwrap(); + + let mut before_dependencies = BTreeMap::new(); + before_dependencies.insert("acme.shared".to_owned(), "1.0.0".to_owned()); + let mut after_dependencies = BTreeMap::new(); + after_dependencies.insert("acme.shared".to_owned(), "2.0.0".to_owned()); + + let before = Lockfile::new_v2( + vec!["acme.subject@1.0.0".to_owned()], + vec![ + locked_package( + "acme.subject", + "1.0.0", + &before_subject_sha, + before_dependencies, + ), + locked_package("acme.shared", "1.0.0", &before_shared_sha, BTreeMap::new()), + ], + vec![ResolvedDependency { + from_name: "acme.subject".to_owned(), + from_version: "1.0.0".to_owned(), + to_name: "acme.shared".to_owned(), + to_version: "1.0.0".to_owned(), + declared_constraint: "1.0.0".to_owned(), + }], + ); + let after = Lockfile::new_v2( + vec!["acme.subject@2.0.0".to_owned()], + vec![ + locked_package( + "acme.subject", + "2.0.0", + &after_subject_sha, + after_dependencies, + ), + locked_package("acme.shared", "2.0.0", &after_shared_sha, BTreeMap::new()), + ], + vec![ResolvedDependency { + from_name: "acme.subject".to_owned(), + from_version: "2.0.0".to_owned(), + to_name: "acme.shared".to_owned(), + to_version: "2.0.0".to_owned(), + declared_constraint: "2.0.0".to_owned(), + }], + ); + fs::write(&before_lock_path, before.to_bytes().unwrap()).unwrap(); + fs::write(&after_lock_path, after.to_bytes().unwrap()).unwrap(); + + ( + before_lock_path, + before_cache_path, + after_lock_path, + after_cache_path, + ) +} + +fn run_impact( + before_lock: &Path, + before_cache: &Path, + after_lock: &Path, + after_cache: &Path, +) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_commandf")) + .args([ + "impact", + "acme.subject", + "--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(), + "--format", + "json", + ]) + .env("HTTP_PROXY", "http://127.0.0.1:9") + .env("HTTPS_PROXY", "http://127.0.0.1:9") + .env("NO_PROXY", "") + .output() + .unwrap() +} + +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-impact-proof-{}-{nonce}", + std::process::id() + )) +} + +fn locked_package( + name: &str, + version: &str, + sha256: &str, + dependencies: BTreeMap, +) -> LockedPackage { + LockedPackage { + name: name.to_owned(), + version: version.to_owned(), + sha256: sha256.to_owned(), + source: "synthetic-impact-proof".to_owned(), + dependencies, + } +}