1041 lines
38 KiB
Rust
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());
|
|
}
|
|
}
|