use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use time::format_description::well_known::Rfc3339; use crate::parallel::types::TalInputSpec; use crate::policy::Policy; use crate::storage::{RepositoryBlobVerificationSummary, RocksStore}; pub const VALIDATION_CONTRACT_SCHEMA_VERSION: u32 = 1; pub const CURRENT_STATE_BINDING_SCHEMA_VERSION: u32 = 1; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ValidationCacheContract { pub publication_point: bool, pub roa: bool, pub child_certificate: bool, pub transport_prefetch: bool, } impl ValidationCacheContract { pub fn any_validation_cache_enabled(&self) -> bool { self.publication_point || self.roa || self.child_certificate } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ValidationContract { pub schema_version: u32, pub validation_time: String, pub binary_sha256: String, pub policy: Policy, pub max_ca_depth: usize, pub max_instances: Option, pub cache: ValidationCacheContract, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CurrentStateBinding { pub schema_version: u32, pub current_run_id: String, pub source_cir_sha256: String, pub source_ccr_sha256: String, pub validation_contract_sha256: String, pub updated_at_rfc3339_utc: String, } impl CurrentStateBinding { pub fn validate(&self) -> Result<(), String> { if self.schema_version != CURRENT_STATE_BINDING_SCHEMA_VERSION { return Err(format!( "unsupported current state binding schemaVersion: {}", self.schema_version )); } if self.current_run_id.trim().is_empty() { return Err("current state binding currentRunId must not be empty".to_string()); } validate_sha256_hex( "current state binding sourceCirSha256", &self.source_cir_sha256, )?; validate_sha256_hex( "current state binding sourceCcrSha256", &self.source_ccr_sha256, )?; validate_sha256_hex( "current state binding validationContractSha256", &self.validation_contract_sha256, )?; time::OffsetDateTime::parse(&self.updated_at_rfc3339_utc, &Rfc3339).map_err(|error| { format!("invalid current state binding updatedAtRfc3339Utc: {error}") })?; Ok(()) } } impl ValidationContract { pub fn for_current_binary( validation_time: time::OffsetDateTime, policy: Policy, max_ca_depth: usize, max_instances: Option, cache: ValidationCacheContract, ) -> Result { Ok(Self { schema_version: VALIDATION_CONTRACT_SCHEMA_VERSION, validation_time: format_validation_time(validation_time)?, binary_sha256: current_binary_sha256()?, policy, max_ca_depth, max_instances, cache, }) } pub fn validation_time(&self) -> Result { time::OffsetDateTime::parse(&self.validation_time, &Rfc3339) .map(|value| value.to_offset(time::UtcOffset::UTC)) .map_err(|error| format!("invalid validation contract validationTime: {error}")) } pub fn validate(&self) -> Result<(), String> { if self.schema_version != VALIDATION_CONTRACT_SCHEMA_VERSION { return Err(format!( "unsupported validation contract schemaVersion: {}", self.schema_version )); } self.validation_time()?; validate_sha256_hex("validation contract binarySha256", &self.binary_sha256)?; if self.max_ca_depth == 0 { return Err("validation contract maxCaDepth must be greater than zero".to_string()); } Ok(()) } pub fn require_current_binary(&self) -> Result<(), String> { let current = current_binary_sha256()?; if current != self.binary_sha256 { return Err(format!( "verification-only binary differs from source run: source={}, current={current}", self.binary_sha256 )); } Ok(()) } } #[derive(Clone, Debug)] pub struct VerificationArtifacts { pub source_run_dir: PathBuf, pub source_state_root: PathBuf, pub output_dir: PathBuf, pub source_cir: PathBuf, pub source_ccr: PathBuf, pub source_contract: PathBuf, pub source_work_db: PathBuf, pub source_repo_bytes_db: PathBuf, pub scratch_work_db: PathBuf, pub report_json: PathBuf, pub result_cir: PathBuf, pub result_ccr: PathBuf, pub vrps_csv: PathBuf, pub vaps_csv: PathBuf, pub compare_dir: PathBuf, pub source_binding: PathBuf, pub source_run_summary: PathBuf, pub result_contract: PathBuf, pub verification_meta: PathBuf, } impl VerificationArtifacts { pub fn new( source_run_dir: impl Into, source_state_root: impl Into, output_dir: impl Into, ) -> Self { let source_run_dir = source_run_dir.into(); let source_state_root = source_state_root.into(); let output_dir = output_dir.into(); Self { source_cir: source_run_dir.join("input.cir"), source_ccr: source_run_dir.join("result.ccr"), source_contract: source_run_dir.join("validation-contract.json"), source_work_db: source_state_root.join("db/work-db"), source_repo_bytes_db: source_state_root.join("db/repo-bytes.db"), scratch_work_db: output_dir.join("scratch/work-db"), report_json: output_dir.join("report.json"), result_cir: output_dir.join("input.cir"), result_ccr: output_dir.join("result.ccr"), vrps_csv: output_dir.join("vrps.csv"), vaps_csv: output_dir.join("vaps.csv"), compare_dir: output_dir.join("compare"), source_binding: source_state_root.join("meta/current-state-binding.json"), source_run_summary: source_run_dir.join("run-summary.json"), result_contract: output_dir.join("validation-contract.json"), verification_meta: output_dir.join("verification-meta.json"), source_run_dir, source_state_root, output_dir, } } } pub struct PreparedVerification { pub artifacts: VerificationArtifacts, pub cir: crate::cir::CanonicalInputRepresentation, pub contract: ValidationContract, pub blob_summary: RepositoryBlobVerificationSummary, pub store: Option>, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VerificationCcrStateComparison { pub name: String, pub source_present: bool, pub verification_present: bool, pub source_hash_hex: Option, pub verification_hash_hex: Option, pub matches: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VerificationCcrComparison { pub state_digest_match: bool, pub source_version: u32, pub verification_version: u32, pub source_hash_algorithm_oid: String, pub verification_hash_algorithm_oid: String, pub mismatched_states: Vec, pub states: Vec, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VerificationRunMeta { pub schema_version: u32, pub status: String, pub source_run_id: String, pub validation_time: String, pub wall_ms: u64, pub repository_objects_checked: u64, pub repository_bytes_checked: u64, pub state_digest_match: bool, pub scratch_retained: bool, } pub fn read_validation_contract(path: &Path) -> Result { let bytes = fs::read(path).map_err(|error| { format!( "read validation contract failed: {}: {error}", path.display() ) })?; let contract: ValidationContract = serde_json::from_slice(&bytes).map_err(|error| { format!( "decode validation contract failed: {}: {error}", path.display() ) })?; contract.validate()?; Ok(contract) } pub fn read_current_state_binding(path: &Path) -> Result { let bytes = fs::read(path).map_err(|error| { format!( "read current state binding failed: {}: {error}", path.display() ) })?; let binding: CurrentStateBinding = serde_json::from_slice(&bytes).map_err(|error| { format!( "decode current state binding failed: {}: {error}", path.display() ) })?; binding.validate()?; Ok(binding) } pub fn write_validation_contract(path: &Path, contract: &ValidationContract) -> Result<(), String> { contract.validate()?; let bytes = serde_json::to_vec_pretty(contract) .map_err(|error| format!("encode validation contract failed: {error}"))?; atomic_write(path, &bytes) } pub fn read_cir(path: &Path) -> Result { let bytes = fs::read(path) .map_err(|error| format!("read source CIR failed: {}: {error}", path.display()))?; crate::cir::decode_cir(&bytes) .map_err(|error| format!("decode source CIR failed: {}: {error}", path.display())) } pub fn tal_inputs_from_cir(cir: &crate::cir::CanonicalInputRepresentation) -> Vec { cir.trust_anchors .iter() .map(|trust_anchor| { TalInputSpec::from_ta_der( trust_anchor.tal_uri.clone(), trust_anchor.tal_bytes.clone(), trust_anchor.ta_certificate_der.clone(), ) }) .collect() } pub fn cir_tal_uris(cir: &crate::cir::CanonicalInputRepresentation) -> Vec { cir.trust_anchors .iter() .map(|trust_anchor| trust_anchor.tal_uri.clone()) .collect() } pub fn prepare_verification( source_run_dir: &Path, source_state_root: &Path, output_dir: &Path, ) -> Result { if std::env::var("RPKI_STATE_EXECUTION_LOCK_HELD").as_deref() != Ok("1") { return Err( "verification-only requires the state execution lock; use scripts/verification/run_verification_only.sh" .to_string(), ); } let artifacts = VerificationArtifacts::new(source_run_dir, source_state_root, output_dir); validate_output_isolated(&artifacts)?; validate_source_run_success(&artifacts.source_run_summary)?; let binding = read_current_state_binding(&artifacts.source_binding)?; let source_run_id = artifacts .source_run_dir .file_name() .and_then(|value| value.to_str()) .ok_or_else(|| "source run directory must have a UTF-8 run id basename".to_string())?; if binding.current_run_id != source_run_id { return Err(format!( "source run is not bound to the current state: requested={source_run_id}, current={}", binding.current_run_id )); } verify_bound_file( "source CIR", &artifacts.source_cir, &binding.source_cir_sha256, )?; verify_bound_file( "source CCR", &artifacts.source_ccr, &binding.source_ccr_sha256, )?; verify_bound_file( "validation contract", &artifacts.source_contract, &binding.validation_contract_sha256, )?; let cir = read_cir(&artifacts.source_cir)?; let contract = read_validation_contract(&artifacts.source_contract)?; if cir.validation_time.to_offset(time::UtcOffset::UTC) != contract.validation_time()? { return Err(format!( "source CIR validation time differs from validation contract: cir={}, contract={}", format_validation_time(cir.validation_time)?, contract.validation_time )); } contract.require_current_binary()?; if !contract.cache.any_validation_cache_enabled() { return Err( "source run did not enable PP, ROA, or child-certificate validation cache".to_string(), ); } if !artifacts.source_work_db.is_dir() { return Err(format!( "source work-db is missing: {}", artifacts.source_work_db.display() )); } if !artifacts.source_repo_bytes_db.is_dir() { return Err(format!( "source repo-bytes.db is missing: {}", artifacts.source_repo_bytes_db.display() )); } fs::create_dir_all(&artifacts.output_dir).map_err(|error| { format!( "create verification output directory failed: {}: {error}", artifacts.output_dir.display() ) })?; RocksStore::create_read_only_checkpoint(&artifacts.source_work_db, &artifacts.scratch_work_db) .map_err(|error| format!("create frozen work-db checkpoint failed: {error}"))?; let store = Arc::new( RocksStore::open_with_external_repo_bytes_read_only( &artifacts.scratch_work_db, &artifacts.source_repo_bytes_db, ) .map_err(|error| format!("open frozen verification store failed: {error}"))?, ); let blob_summary = store .verify_current_repository_blobs(1024) .map_err(|error| format!("verify frozen repository blobs failed: {error}"))?; write_validation_contract(&artifacts.result_contract, &contract)?; Ok(PreparedVerification { artifacts, cir, contract, blob_summary, store: Some(store), }) } pub fn compare_ccr_files( source_path: &Path, verification_path: &Path, ) -> Result { let source = fs::read(source_path) .map_err(|error| format!("read source CCR failed: {}: {error}", source_path.display()))?; let verification = fs::read(verification_path).map_err(|error| { format!( "read verification CCR failed: {}: {error}", verification_path.display() ) })?; let comparison = crate::ccr::compare_state_digests(&source, &verification) .map_err(|error| format!("compare CCR state digests failed: {error}"))?; let states = comparison .states .iter() .map(|state| VerificationCcrStateComparison { name: state.name.to_string(), source_present: state.ours_present, verification_present: state.peer_present, source_hash_hex: state.ours_hash_hex.clone(), verification_hash_hex: state.peer_hash_hex.clone(), matches: state.matches, }) .collect::>(); Ok(VerificationCcrComparison { state_digest_match: comparison.matches(), source_version: comparison.ours.version, verification_version: comparison.peer.version, source_hash_algorithm_oid: comparison.ours.hash_alg_oid.clone(), verification_hash_algorithm_oid: comparison.peer.hash_alg_oid.clone(), mismatched_states: comparison .mismatched_state_names() .into_iter() .map(str::to_string) .collect(), states, }) } pub fn write_ccr_comparison( json_path: &Path, markdown_path: Option<&Path>, comparison: &VerificationCcrComparison, ) -> Result<(), String> { let json = serde_json::to_vec_pretty(comparison) .map_err(|error| format!("encode CCR comparison failed: {error}"))?; atomic_write(json_path, &json)?; if let Some(markdown_path) = markdown_path { let mut markdown = String::from( "# Verification-only CCR Comparison\n\n| State | Source hash | Verification hash | Match |\n|---|---|---|---|\n", ); for state in &comparison.states { markdown.push_str(&format!( "| {} | {} | {} | {} |\n", state.name, state.source_hash_hex.as_deref().unwrap_or("absent"), state.verification_hash_hex.as_deref().unwrap_or("absent"), if state.matches { "yes" } else { "no" } )); } markdown.push_str(&format!( "\nOverall state digest match: **{}**\n", if comparison.state_digest_match { "yes" } else { "no" } )); atomic_write(markdown_path, markdown.as_bytes())?; } Ok(()) } pub fn write_verification_meta(path: &Path, meta: &VerificationRunMeta) -> Result<(), String> { let bytes = serde_json::to_vec_pretty(meta) .map_err(|error| format!("encode verification metadata failed: {error}"))?; atomic_write(path, &bytes) } #[derive(Clone)] pub struct NetworkDisabledHttpFetcher; impl crate::sync::rrdp::Fetcher for NetworkDisabledHttpFetcher { fn fetch(&self, uri: &str) -> Result, String> { Err(format!( "network access is disabled in verification-only mode: {uri}" )) } } pub fn sha256_file(path: &Path) -> Result { let bytes = fs::read(path) .map_err(|error| format!("read file for SHA-256 failed: {}: {error}", path.display()))?; Ok(hex::encode(Sha256::digest(bytes))) } pub fn current_binary_sha256() -> Result { let path = std::env::current_exe() .map_err(|error| format!("resolve current executable failed: {error}"))?; sha256_file(&path) } pub fn format_validation_time(value: time::OffsetDateTime) -> Result { value .to_offset(time::UtcOffset::UTC) .format(&Rfc3339) .map_err(|error| format!("format validation time failed: {error}")) } fn validate_sha256_hex(label: &str, value: &str) -> Result<(), String> { if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err(format!( "{label} must be a 64-character hexadecimal SHA-256" )); } Ok(()) } fn validate_source_run_success(path: &Path) -> Result<(), String> { let bytes = fs::read(path).map_err(|error| { format!( "read source run summary failed: {}: {error}", path.display() ) })?; let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| { format!( "decode source run summary failed: {}: {error}", path.display() ) })?; if value.get("status").and_then(serde_json::Value::as_str) != Some("success") { return Err(format!("source run is not successful: {}", path.display())); } Ok(()) } fn validate_output_isolated(artifacts: &VerificationArtifacts) -> Result<(), String> { if artifacts.output_dir.exists() { let mut entries = fs::read_dir(&artifacts.output_dir).map_err(|error| { format!( "read verification output directory failed: {}: {error}", artifacts.output_dir.display() ) })?; if entries.next().is_some() { return Err(format!( "verification output directory must be absent or empty: {}", artifacts.output_dir.display() )); } } if artifacts.output_dir.starts_with(&artifacts.source_run_dir) || artifacts.source_run_dir.starts_with(&artifacts.output_dir) { return Err("verification output must be independent from the source run directory".into()); } if let Some(run_root) = artifacts.source_state_root.parent() { if artifacts.output_dir.starts_with(run_root.join("runs")) { return Err("verification output must not use the normal runs directory".into()); } } Ok(()) } fn verify_bound_file(label: &str, path: &Path, expected_sha256: &str) -> Result<(), String> { let actual = sha256_file(path)?; if actual != expected_sha256 { return Err(format!( "{label} differs from current state binding: path={}, expected={}, actual={actual}", path.display(), expected_sha256 )); } Ok(()) } fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("create directory failed: {}: {error}", parent.display()))?; } let tmp = path.with_extension(format!( "{}.tmp", path.extension() .and_then(|value| value.to_str()) .unwrap_or("json") )); let mut file = fs::File::create(&tmp) .map_err(|error| format!("create temporary file failed: {}: {error}", tmp.display()))?; file.write_all(bytes) .map_err(|error| format!("write temporary file failed: {}: {error}", tmp.display()))?; file.write_all(b"\n") .map_err(|error| format!("write temporary file failed: {}: {error}", tmp.display()))?; file.sync_all() .map_err(|error| format!("sync temporary file failed: {}: {error}", tmp.display()))?; fs::rename(&tmp, path).map_err(|error| { format!( "rename temporary file failed: {} -> {}: {error}", tmp.display(), path.display() ) }) } #[cfg(test)] mod tests { use super::*; use crate::ccr::{ CcrContentInfo, CcrDigestAlgorithm, RpkiCanonicalCacheRepresentation, build_roa_payload_state, encode_content_info, }; use crate::cir::{CanonicalInputRepresentation, CirTrustAnchor, encode_cir}; use crate::data_model::roa::{IpPrefix, RoaAfi}; use crate::sync::rrdp::Fetcher; use crate::validation::objects::Vrp; use std::sync::Mutex; static ENV_LOCK: Mutex<()> = Mutex::new(()); struct SourceFixture { _temp: tempfile::TempDir, source_run: PathBuf, state_root: PathBuf, output: PathBuf, } fn sample_contract() -> ValidationContract { ValidationContract { schema_version: VALIDATION_CONTRACT_SCHEMA_VERSION, validation_time: "2026-07-16T00:00:00Z".to_string(), binary_sha256: "ab".repeat(32), policy: Policy::default(), max_ca_depth: 32, max_instances: None, cache: ValidationCacheContract { publication_point: true, roa: true, child_certificate: true, transport_prefetch: false, }, } } fn sample_cir() -> CanonicalInputRepresentation { let ta_der = b"verification-only-test-ta".to_vec(); CanonicalInputRepresentation::new_v4( time::OffsetDateTime::parse( "2026-07-16T00:00:00Z", &time::format_description::well_known::Rfc3339, ) .expect("validation time"), Vec::new(), Vec::new(), vec![CirTrustAnchor { ta_rsync_uri: "rsync://example.test/ta.cer".to_string(), tal_uri: "https://example.test/ta.tal".to_string(), tal_bytes: b"rsync://example.test/ta.cer\n\nAQID\n".to_vec(), ta_certificate_sha256: crate::cir::sha256(&ta_der), ta_certificate_der: ta_der, }], Vec::new(), Vec::new(), ) } fn sample_ccr(asn: u32) -> Vec { let vrps = build_roa_payload_state(&[Vrp { asn, prefix: IpPrefix { afi: RoaAfi::Ipv4, prefix_len: 24, addr: [192, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], }, max_length: 24, }]) .expect("build VRP state"); encode_content_info(&CcrContentInfo::new(RpkiCanonicalCacheRepresentation { version: 0, hash_alg: CcrDigestAlgorithm::Sha256, produced_at: time::OffsetDateTime::UNIX_EPOCH, mfts: None, vrps: Some(vrps), vaps: None, tas: None, rks: None, })) .expect("encode CCR") } fn refresh_binding(fixture: &SourceFixture) { let binding = CurrentStateBinding { schema_version: CURRENT_STATE_BINDING_SCHEMA_VERSION, current_run_id: "run_0001".to_string(), source_cir_sha256: sha256_file(&fixture.source_run.join("input.cir")) .expect("hash CIR"), source_ccr_sha256: sha256_file(&fixture.source_run.join("result.ccr")) .expect("hash CCR"), validation_contract_sha256: sha256_file( &fixture.source_run.join("validation-contract.json"), ) .expect("hash contract"), updated_at_rfc3339_utc: "2026-07-16T00:01:00Z".to_string(), }; fs::create_dir_all(fixture.state_root.join("meta")).expect("create meta"); fs::write( fixture.state_root.join("meta/current-state-binding.json"), serde_json::to_vec_pretty(&binding).expect("encode binding"), ) .expect("write binding"); } fn source_fixture() -> SourceFixture { let temp = tempfile::tempdir().expect("tempdir"); let source_run = temp.path().join("runs/run_0001"); let state_root = temp.path().join("state"); let output = temp.path().join("verification/run_0001"); fs::create_dir_all(&source_run).expect("create source run"); fs::create_dir_all(state_root.join("db")).expect("create state db root"); let store = RocksStore::open_with_external_repo_bytes( &state_root.join("db/work-db"), &state_root.join("db/repo-bytes.db"), ) .expect("create source databases"); drop(store); fs::write( source_run.join("input.cir"), encode_cir(&sample_cir()).expect("encode CIR"), ) .expect("write CIR"); fs::write(source_run.join("result.ccr"), sample_ccr(64496)).expect("write CCR"); fs::write( source_run.join("run-summary.json"), br#"{"status":"success"}"#, ) .expect("write summary"); let contract = ValidationContract::for_current_binary( sample_cir().validation_time, Policy::default(), 32, None, ValidationCacheContract { publication_point: true, roa: false, child_certificate: false, transport_prefetch: true, }, ) .expect("contract"); write_validation_contract(&source_run.join("validation-contract.json"), &contract) .expect("write contract"); let fixture = SourceFixture { _temp: temp, source_run, state_root, output, }; refresh_binding(&fixture); fixture } fn prepare_error(fixture: &SourceFixture) -> String { match prepare_verification(&fixture.source_run, &fixture.state_root, &fixture.output) { Ok(_) => panic!("verification preparation unexpectedly succeeded"), Err(error) => error, } } #[test] fn validation_contract_roundtrips() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("validation-contract.json"); let expected = sample_contract(); write_validation_contract(&path, &expected).expect("write contract"); assert_eq!( read_validation_contract(&path).expect("read contract"), expected ); } #[test] fn validation_contract_rejects_invalid_time_and_hash() { let mut contract = sample_contract(); contract.validation_time = "not-a-time".to_string(); assert!(contract.validate().is_err()); contract.validation_time = "2026-07-16T00:00:00Z".to_string(); contract.binary_sha256 = "bad".to_string(); assert!(contract.validate().is_err()); } #[test] fn cache_contract_distinguishes_prefetch_from_validation_caches() { let cache = ValidationCacheContract { publication_point: false, roa: false, child_certificate: false, transport_prefetch: true, }; assert!(!cache.any_validation_cache_enabled()); } #[test] fn validation_contract_and_binding_validation_cover_rejection_paths() { let time = time::OffsetDateTime::parse( "2026-07-16T00:00:00Z", &time::format_description::well_known::Rfc3339, ) .expect("time"); let contract = ValidationContract::for_current_binary( time, Policy::default(), 32, Some(10), sample_contract().cache, ) .expect("current contract"); assert_eq!(contract.validation_time().expect("contract time"), time); contract.validate().expect("valid contract"); contract.require_current_binary().expect("same binary"); let mut invalid = contract.clone(); invalid.schema_version += 1; assert!(invalid.validate().unwrap_err().contains("schemaVersion")); invalid = contract.clone(); invalid.max_ca_depth = 0; assert!(invalid.validate().unwrap_err().contains("maxCaDepth")); invalid = contract.clone(); invalid.binary_sha256 = "00".repeat(32); assert!(invalid.require_current_binary().is_err()); let valid_binding = CurrentStateBinding { schema_version: CURRENT_STATE_BINDING_SCHEMA_VERSION, current_run_id: "run_0001".to_string(), source_cir_sha256: "11".repeat(32), source_ccr_sha256: "22".repeat(32), validation_contract_sha256: "33".repeat(32), updated_at_rfc3339_utc: "2026-07-16T00:00:00Z".to_string(), }; valid_binding.validate().expect("valid binding"); for mutate in 0..6 { let mut binding = valid_binding.clone(); match mutate { 0 => binding.schema_version += 1, 1 => binding.current_run_id.clear(), 2 => binding.source_cir_sha256 = "bad".into(), 3 => binding.source_ccr_sha256 = "bad".into(), 4 => binding.validation_contract_sha256 = "bad".into(), _ => binding.updated_at_rfc3339_utc = "bad".into(), } assert!(binding.validate().is_err()); } } #[test] fn artifact_helpers_read_write_compare_and_disable_network() { let temp = tempfile::tempdir().expect("tempdir"); let source = temp.path().join("runs/run_0001"); let state = temp.path().join("state"); let output = temp.path().join("verification/run_0001"); let artifacts = VerificationArtifacts::new(&source, &state, &output); assert_eq!(artifacts.source_cir, source.join("input.cir")); assert_eq!( artifacts.source_binding, state.join("meta/current-state-binding.json") ); assert_eq!(artifacts.result_ccr, output.join("result.ccr")); fs::create_dir_all(&source).expect("create source"); let cir = sample_cir(); fs::write(&artifacts.source_cir, encode_cir(&cir).expect("encode CIR")).expect("write CIR"); let decoded = read_cir(&artifacts.source_cir).expect("read CIR"); assert_eq!(cir_tal_uris(&decoded), vec!["https://example.test/ta.tal"]); let tal_inputs = tal_inputs_from_cir(&decoded); assert_eq!(tal_inputs.len(), 1); let first = temp.path().join("first.ccr"); let second = temp.path().join("second.ccr"); fs::write(&first, sample_ccr(64496)).expect("first CCR"); fs::write(&second, sample_ccr(64496)).expect("second CCR"); let equal = compare_ccr_files(&first, &second).expect("equal compare"); assert!(equal.state_digest_match); let json = temp.path().join("compare/result.json"); let markdown = temp.path().join("compare/result.md"); write_ccr_comparison(&json, Some(&markdown), &equal).expect("write compare"); assert!( fs::read_to_string(json) .expect("json") .contains("stateDigestMatch") ); assert!( fs::read_to_string(markdown) .expect("md") .contains("Overall state") ); fs::write(&second, sample_ccr(64497)).expect("different CCR"); let unequal = compare_ccr_files(&first, &second).expect("unequal compare"); assert!(!unequal.state_digest_match); assert_eq!(unequal.mismatched_states, vec!["vrps"]); let meta = VerificationRunMeta { schema_version: 1, status: "success".to_string(), source_run_id: "run_0001".to_string(), validation_time: "2026-07-16T00:00:00Z".to_string(), wall_ms: 12, repository_objects_checked: 3, repository_bytes_checked: 4, state_digest_match: true, scratch_retained: false, }; let meta_path = temp.path().join("meta/result.json"); write_verification_meta(&meta_path, &meta).expect("write meta"); assert!( fs::read_to_string(meta_path) .expect("meta") .contains("run_0001") ); assert!( NetworkDisabledHttpFetcher .fetch("https://example.test") .is_err() ); assert_eq!(sha256_file(&first).expect("hash").len(), 64); assert!(read_cir(&first).is_err()); } #[test] fn verification_preflight_rejects_stale_inputs_then_prepares_frozen_store() { let _guard = ENV_LOCK.lock().expect("env lock"); let fixture = source_fixture(); unsafe { std::env::remove_var("RPKI_STATE_EXECUTION_LOCK_HELD") }; assert!(prepare_error(&fixture).contains("state execution lock")); unsafe { std::env::set_var("RPKI_STATE_EXECUTION_LOCK_HELD", "1") }; fs::create_dir_all(&fixture.output).expect("output"); fs::write(fixture.output.join("occupied"), b"x").expect("occupied"); assert!(prepare_error(&fixture).contains("absent or empty")); fs::remove_dir_all(&fixture.output).expect("remove output"); fs::write( fixture.source_run.join("run-summary.json"), br#"{"status":"failed"}"#, ) .expect("failed summary"); assert!(prepare_error(&fixture).contains("not successful")); fs::write( fixture.source_run.join("run-summary.json"), br#"{"status":"success"}"#, ) .expect("success summary"); let binding_path = fixture.state_root.join("meta/current-state-binding.json"); let mut binding = read_current_state_binding(&binding_path).expect("binding"); binding.current_run_id = "run_0002".to_string(); fs::write( &binding_path, serde_json::to_vec_pretty(&binding).expect("binding JSON"), ) .expect("write binding"); assert!(prepare_error(&fixture).contains("not bound")); refresh_binding(&fixture); fs::write(fixture.source_run.join("input.cir"), b"tampered").expect("tamper CIR"); assert!(prepare_error(&fixture).contains("differs from current state binding")); fs::write( fixture.source_run.join("input.cir"), encode_cir(&sample_cir()).expect("encode CIR"), ) .expect("restore CIR"); refresh_binding(&fixture); let mut contract = read_validation_contract(&fixture.source_run.join("validation-contract.json")) .expect("contract"); contract.cache.publication_point = false; contract.cache.transport_prefetch = true; write_validation_contract( &fixture.source_run.join("validation-contract.json"), &contract, ) .expect("write no-cache contract"); refresh_binding(&fixture); assert!(prepare_error(&fixture).contains("did not enable")); contract.cache.roa = true; write_validation_contract( &fixture.source_run.join("validation-contract.json"), &contract, ) .expect("restore cache contract"); refresh_binding(&fixture); let mut prepared = prepare_verification(&fixture.source_run, &fixture.state_root, &fixture.output) .expect("prepare verification"); assert_eq!(prepared.blob_summary.current_objects, 0); assert_eq!(prepared.blob_summary.bytes_verified, 0); assert_eq!(prepared.contract.validation_time, "2026-07-16T00:00:00Z"); assert_eq!(cir_tal_uris(&prepared.cir).len(), 1); assert!(prepared.artifacts.scratch_work_db.is_dir()); assert!(prepared.artifacts.result_contract.is_file()); drop(prepared.store.take()); unsafe { std::env::remove_var("RPKI_STATE_EXECUTION_LOCK_HELD") }; } #[test] fn private_validation_helpers_report_bad_files_and_paths() { let temp = tempfile::tempdir().expect("tempdir"); let summary = temp.path().join("summary.json"); fs::write(&summary, b"not-json").expect("summary"); assert!(validate_source_run_success(&summary).is_err()); assert!(validate_source_run_success(&temp.path().join("missing")).is_err()); let file = temp.path().join("value"); fs::write(&file, b"value").expect("file"); assert!(verify_bound_file("value", &file, &"00".repeat(32)).is_err()); assert!(verify_bound_file("missing", &temp.path().join("missing"), "").is_err()); let binding = temp.path().join("binding.json"); fs::write(&binding, b"{}").expect("binding"); assert!(read_current_state_binding(&binding).is_err()); assert!(read_validation_contract(&binding).is_err()); let source = temp.path().join("normal/runs/run_0001"); let state = temp.path().join("normal/state"); let nested_output = source.join("verification"); let nested = VerificationArtifacts::new(&source, &state, &nested_output); assert!(validate_output_isolated(&nested).is_err()); let normal_output = temp.path().join("normal/runs/run_verify"); let normal = VerificationArtifacts::new(&source, &state, &normal_output); assert!(validate_output_isolated(&normal).is_err()); } }