rpki/src/verification_only.rs

1041 lines
38 KiB
Rust

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<usize>,
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<usize>,
cache: ValidationCacheContract,
) -> Result<Self, String> {
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, String> {
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<PathBuf>,
source_state_root: impl Into<PathBuf>,
output_dir: impl Into<PathBuf>,
) -> 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<Arc<RocksStore>>,
}
#[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<String>,
pub verification_hash_hex: Option<String>,
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<String>,
pub states: Vec<VerificationCcrStateComparison>,
}
#[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<ValidationContract, String> {
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<CurrentStateBinding, String> {
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<crate::cir::CanonicalInputRepresentation, String> {
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<TalInputSpec> {
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<String> {
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<PreparedVerification, String> {
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<VerificationCcrComparison, String> {
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::<Vec<_>>();
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<Vec<u8>, String> {
Err(format!(
"network access is disabled in verification-only mode: {uri}"
))
}
}
pub fn sha256_file(path: &Path) -> Result<String, String> {
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<String, String> {
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<String, String> {
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<u8> {
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());
}
}