diff --git a/scripts/manual_sync/README.md b/scripts/manual_sync/README.md index cceed1f..d4167b8 100644 --- a/scripts/manual_sync/README.md +++ b/scripts/manual_sync/README.md @@ -24,7 +24,7 @@ They are meant for **hands-on validation / acceptance runs**, not for CI. - run meta JSON (includes durations + download_stats) - short summary Markdown (includes durations + download_stats) - RocksDB key statistics (`db_stats --exact`) - - RRDP repo state dump (`rrdp_state_dump`) + - RRDP legacy session/serial dump (`rrdp_state_dump --view legacy-state`) ### `delta_sync.sh` @@ -33,7 +33,7 @@ They are meant for **hands-on validation / acceptance runs**, not for CI. - Produces the same artifacts as `full_sync.sh` - Additionally generates a Markdown **delta analysis** report by comparing: - base vs delta report JSON - - base vs delta `rrdp_state_dump` TSV + - base vs delta `rrdp_state_dump --view legacy-state` TSV - and includes a **duration comparison** (base vs delta) if the base meta JSON is available - delta meta JSON includes download_stats copied from delta report JSON @@ -47,6 +47,9 @@ The `rpki` binary writes an audit report JSON with: These are useful for diagnosing why a run is slow (e.g. RRDP snapshot vs delta vs rsync fallback). +The standalone `rrdp_state_dump` tool also supports `source`, `members`, `owners`, and `all` views. +The manual sync scripts intentionally call `--view legacy-state` so delta analysis keeps using a stable session/serial TSV format. + ## Meta fields (meta.json) The scripts generate `*_meta.json` next to `*_report.json` and include: diff --git a/scripts/manual_sync/delta_sync.sh b/scripts/manual_sync/delta_sync.sh index 4b76399..75b2ef6 100755 --- a/scripts/manual_sync/delta_sync.sh +++ b/scripts/manual_sync/delta_sync.sh @@ -16,7 +16,7 @@ set -euo pipefail # - *_delta_report_*.json audit report # - *_delta_run_*.log stdout/stderr log (includes summary) # - *_delta_db_stats_*.txt db_stats --exact output -# - *_delta_rrdp_state_*.tsv rrdp_state_dump output +# - *_delta_rrdp_state_*.tsv rrdp_state_dump --view legacy-state output # - *_delta_analysis_*.md base vs delta comparison report ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -113,10 +113,10 @@ cargo run --release --bin db_stats -- --db "$DELTA_DB_DIR" --exact 2>&1 | tee "$ db_stats_end_s="$(date +%s)" db_stats_duration_s="$((db_stats_end_s - db_stats_start_s))" -echo "== rrdp_state_dump ==" >&2 +echo "== rrdp_state_dump (legacy-state) ==" >&2 state_start_s="$(date +%s)" -cargo run --release --bin rrdp_state_dump -- --db "$BASE_DB_DIR" >"$BASE_RRDP_STATE_TSV" -cargo run --release --bin rrdp_state_dump -- --db "$DELTA_DB_DIR" >"$DELTA_RRDP_STATE_TSV" +cargo run --release --bin rrdp_state_dump -- --db "$BASE_DB_DIR" --view legacy-state >"$BASE_RRDP_STATE_TSV" +cargo run --release --bin rrdp_state_dump -- --db "$DELTA_DB_DIR" --view legacy-state >"$DELTA_RRDP_STATE_TSV" state_end_s="$(date +%s)" state_duration_s="$((state_end_s - state_start_s))" @@ -174,12 +174,18 @@ def load_optional_json(path_s: str): return load_json(p) def parse_rrdp_state_tsv(p: Path): - # format: "\t\t" + # format from `rrdp_state_dump --view legacy-state`: + # [legacy-state] + # notify_uri serial session_id + # out = {} for line in p.read_text(encoding="utf-8").splitlines(): - if not line.strip(): + line = line.strip() + if not line or line.startswith("["): continue - parts = line.split("\t") + if line == "notify_uri serial session_id": + continue + parts = line.split(" ") if len(parts) != 3: raise SystemExit(f"invalid rrdp_state_dump line in {p}: {line!r}") uri, serial, session = parts @@ -339,7 +345,7 @@ delta_repo_sync_failed = count_repo_sync_failed(delta) def cache_reason_counts(rep: dict) -> Counter: c = Counter() for pp in rep.get("publication_points", []): - if pp.get("source") != "fetch_cache_pp": + if pp.get("source") != "vcir_current_instance": continue # Use warning messages as "reason". If missing, emit a fallback bucket. ws = pp.get("warnings", []) @@ -378,11 +384,33 @@ added_v = delta_v - base_v removed_v = base_v - delta_v def fmt_db_stats(db: dict) -> str: - keys = ["raw_objects","rrdp_object_index","fetch_cache_pp","rrdp_state","total"] + ordered = [ + "mode", + "repository_view", + "raw_by_hash", + "vcir", + "audit_rule_index", + "rrdp_source", + "rrdp_source_member", + "rrdp_uri_owner", + "rrdp_state", + "raw_objects", + "rrdp_object_index", + "group_current_repository_view", + "group_current_validation_state", + "group_current_rrdp_state", + "group_legacy_compatibility", + "total", + "sst_files", + ] out = [] - for k in keys: + seen = set() + for k in ordered: if k in db: out.append(f"- `{k}={db[k]}`") + seen.add(k) + for k in sorted(set(db) - seen): + out.append(f"- `{k}={db[k]}`") return "\n".join(out) if out else "_(missing db_stats keys)_" lines = [] @@ -459,7 +487,7 @@ lines.append(f"- `updated_pp={updated_pp}`\n") lines.append(f"- `unchanged_pp={unchanged_pp}`\n\n") lines.append("> 注:`new_pp/missing_pp/updated_pp` 会混入“遍历范围变化”的影响(例如 validation_time 不同、或 base 中存在更多失败 PP)。\n\n") -lines.append("## fail fetch / cache 使用情况\n\n") +lines.append("## fail fetch / VCIR 当前实例缓存复用情况\n\n") lines.append(f"- repo sync failed(启发式:warning contains 'repo sync failed'/'rrdp fetch failed'/'rsync fetch failed')\n") lines.append(f" - base:`{base_repo_sync_failed}`\n") lines.append(f" - delta:`{delta_repo_sync_failed}`\n\n") @@ -470,7 +498,7 @@ lines.append(f" - delta:`{dict(delta_sources)}`\n\n") def render_cache_reasons(title: str, c: Counter) -> str: if not c: - return f"{title}:`0`(未使用 fetch_cache_pp)\n\n" + return f"{title}:`0`(未使用 VCIR 当前实例缓存复用)\n\n" lines = [] total = sum(c.values()) lines.append(f"{title}:`{total}`\n\n") @@ -480,8 +508,8 @@ def render_cache_reasons(title: str, c: Counter) -> str: lines.append("\n") return "".join(lines) -lines.append(render_cache_reasons("- base `source=fetch_cache_pp`", base_cache_reasons)) -lines.append(render_cache_reasons("- delta `source=fetch_cache_pp`", delta_cache_reasons)) +lines.append(render_cache_reasons("- base `source=vcir_current_instance`", base_cache_reasons)) +lines.append(render_cache_reasons("- delta `source=vcir_current_instance`", delta_cache_reasons)) lines.append("## 文件变更统计(按对象类型)\n\n") lines.append("按 `ObjectAuditEntry.sha256_hex` 对比(同一 rsync URI 前后 hash 变化记为 `~changed`):\n\n") diff --git a/scripts/manual_sync/full_sync.sh b/scripts/manual_sync/full_sync.sh index a12c00b..43bf67f 100755 --- a/scripts/manual_sync/full_sync.sh +++ b/scripts/manual_sync/full_sync.sh @@ -8,7 +8,7 @@ set -euo pipefail # - *_report_*.json audit report # - *_run_*.log stdout/stderr log (includes summary) # - *_db_stats_*.txt db_stats --exact output -# - *_rrdp_state_*.tsv rrdp_state_dump output +# - *_rrdp_state_*.tsv rrdp_state_dump --view legacy-state output ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT_DIR" @@ -71,9 +71,9 @@ cargo run --release --bin db_stats -- --db "$DB_DIR" --exact 2>&1 | tee "$DB_STA db_stats_end_s="$(date +%s)" db_stats_duration_s="$((db_stats_end_s - db_stats_start_s))" -echo "== rrdp_state_dump ==" >&2 +echo "== rrdp_state_dump (legacy-state) ==" >&2 state_start_s="$(date +%s)" -cargo run --release --bin rrdp_state_dump -- --db "$DB_DIR" >"$RRDP_STATE_TSV" +cargo run --release --bin rrdp_state_dump -- --db "$DB_DIR" --view legacy-state >"$RRDP_STATE_TSV" state_end_s="$(date +%s)" state_duration_s="$((state_end_s - state_start_s))" diff --git a/src/audit_downloads.rs b/src/audit_downloads.rs index 5860947..41f7412 100644 --- a/src/audit_downloads.rs +++ b/src/audit_downloads.rs @@ -39,14 +39,17 @@ impl DownloadLogHandle { } .to_string(); - let st = out.by_kind.entry(kind_key).or_insert_with(|| AuditDownloadKindStats { - ok_total: 0, - fail_total: 0, - duration_ms_total: 0, - bytes_total: None, - objects_count_total: None, - objects_bytes_total: None, - }); + let st = out + .by_kind + .entry(kind_key) + .or_insert_with(|| AuditDownloadKindStats { + ok_total: 0, + fail_total: 0, + duration_ms_total: 0, + bytes_total: None, + objects_count_total: None, + objects_bytes_total: None, + }); if e.success { st.ok_total = st.ok_total.saturating_add(1); } else { @@ -165,4 +168,3 @@ fn duration_to_ms(d: Duration) -> u64 { let ms = d.as_millis(); ms.min(u128::from(u64::MAX)) as u64 } - diff --git a/src/audit_trace.rs b/src/audit_trace.rs new file mode 100644 index 0000000..8ab5f64 --- /dev/null +++ b/src/audit_trace.rs @@ -0,0 +1,581 @@ +use crate::storage::{ + AuditRuleIndexEntry, AuditRuleKind, RawByHashEntry, RocksStore, ValidatedCaInstanceResult, + VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, VcirLocalOutput, + VcirOutputType, +}; +use serde::Serialize; +use std::collections::HashSet; + +#[derive(Debug, thiserror::Error)] +pub enum AuditTraceError { + #[error("storage error: {0}")] + Storage(#[from] crate::storage::StorageError), + + #[error("audit rule index points to missing VCIR: {manifest_rsync_uri}")] + MissingVcir { manifest_rsync_uri: String }, + + #[error( + "audit rule index points to missing local output: rule_hash={rule_hash}, output_id={output_id}, manifest={manifest_rsync_uri}" + )] + MissingLocalOutput { + rule_hash: String, + output_id: String, + manifest_rsync_uri: String, + }, + + #[error("detected VCIR parent cycle at {manifest_rsync_uri}")] + ParentCycle { manifest_rsync_uri: String }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AuditTraceRawRef { + pub sha256_hex: String, + pub raw_present: bool, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub origin_uris: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub object_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub byte_len: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AuditTraceArtifact { + pub artifact_role: VcirArtifactRole, + pub artifact_kind: VcirArtifactKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub object_type: Option, + pub validation_status: VcirArtifactValidationStatus, + pub raw: AuditTraceRawRef, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AuditTraceChainNode { + pub manifest_rsync_uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_manifest_rsync_uri: Option, + pub tal_id: String, + pub ca_subject_name: String, + pub ca_ski: String, + pub issuer_ski: String, + pub current_manifest_rsync_uri: String, + pub current_crl_rsync_uri: String, + pub last_successful_validation_time_rfc3339_utc: String, + pub local_output_count: usize, + pub child_count: usize, + pub related_artifacts: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AuditTraceResolvedOutput { + pub output_id: String, + pub output_type: VcirOutputType, + pub rule_hash: String, + pub source_object_uri: String, + pub source_object_type: String, + pub source_object_hash: String, + pub source_ee_cert_hash: String, + pub item_effective_until_rfc3339_utc: String, + pub payload_json: String, + pub validation_path_hint: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AuditRuleTrace { + pub rule: AuditRuleIndexEntry, + pub resolved_output: AuditTraceResolvedOutput, + pub source_object_raw: AuditTraceRawRef, + pub source_ee_cert_raw: AuditTraceRawRef, + pub chain_leaf_to_root: Vec, +} + +pub fn trace_rule_to_root( + store: &RocksStore, + kind: AuditRuleKind, + rule_hash: &str, +) -> Result, AuditTraceError> { + let Some(rule) = store.get_audit_rule_index_entry(kind, rule_hash)? else { + return Ok(None); + }; + + let Some(leaf_vcir) = store.get_vcir(&rule.manifest_rsync_uri)? else { + return Err(AuditTraceError::MissingVcir { + manifest_rsync_uri: rule.manifest_rsync_uri.clone(), + }); + }; + + let Some(local_output) = leaf_vcir + .local_outputs + .iter() + .find(|output| output.output_id == rule.output_id && output.rule_hash == rule.rule_hash) + .or_else(|| { + leaf_vcir + .local_outputs + .iter() + .find(|output| output.rule_hash == rule.rule_hash) + }) + .cloned() + else { + return Err(AuditTraceError::MissingLocalOutput { + rule_hash: rule.rule_hash.clone(), + output_id: rule.output_id.clone(), + manifest_rsync_uri: rule.manifest_rsync_uri.clone(), + }); + }; + + let chain = trace_vcir_chain_to_root(store, &leaf_vcir.manifest_rsync_uri)? + .expect("leaf VCIR already loaded must exist"); + + Ok(Some(AuditRuleTrace { + rule, + resolved_output: resolved_output_from_local(&local_output), + source_object_raw: resolve_raw_ref(store, &local_output.source_object_hash)?, + source_ee_cert_raw: resolve_raw_ref(store, &local_output.source_ee_cert_hash)?, + chain_leaf_to_root: chain, + })) +} + +pub fn trace_vcir_chain_to_root( + store: &RocksStore, + manifest_rsync_uri: &str, +) -> Result>, AuditTraceError> { + let Some(mut current) = store.get_vcir(manifest_rsync_uri)? else { + return Ok(None); + }; + + let mut seen = HashSet::new(); + let mut chain = Vec::new(); + loop { + if !seen.insert(current.manifest_rsync_uri.clone()) { + return Err(AuditTraceError::ParentCycle { + manifest_rsync_uri: current.manifest_rsync_uri, + }); + } + let parent = current.parent_manifest_rsync_uri.clone(); + chain.push(trace_chain_node(store, ¤t)?); + let Some(parent_manifest_rsync_uri) = parent else { + break; + }; + let Some(parent_vcir) = store.get_vcir(&parent_manifest_rsync_uri)? else { + return Err(AuditTraceError::MissingVcir { + manifest_rsync_uri: parent_manifest_rsync_uri, + }); + }; + current = parent_vcir; + } + + Ok(Some(chain)) +} + +fn trace_chain_node( + store: &RocksStore, + vcir: &ValidatedCaInstanceResult, +) -> Result { + let mut related_artifacts = Vec::with_capacity(vcir.related_artifacts.len()); + for artifact in &vcir.related_artifacts { + related_artifacts.push(AuditTraceArtifact { + artifact_role: artifact.artifact_role, + artifact_kind: artifact.artifact_kind, + uri: artifact.uri.clone(), + object_type: artifact.object_type.clone(), + validation_status: artifact.validation_status, + raw: resolve_raw_ref(store, &artifact.sha256)?, + }); + } + + Ok(AuditTraceChainNode { + manifest_rsync_uri: vcir.manifest_rsync_uri.clone(), + parent_manifest_rsync_uri: vcir.parent_manifest_rsync_uri.clone(), + tal_id: vcir.tal_id.clone(), + ca_subject_name: vcir.ca_subject_name.clone(), + ca_ski: vcir.ca_ski.clone(), + issuer_ski: vcir.issuer_ski.clone(), + current_manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(), + current_crl_rsync_uri: vcir.current_crl_rsync_uri.clone(), + last_successful_validation_time_rfc3339_utc: vcir + .last_successful_validation_time + .rfc3339_utc + .clone(), + local_output_count: vcir.local_outputs.len(), + child_count: vcir.child_entries.len(), + related_artifacts, + }) +} + +fn resolved_output_from_local(local: &VcirLocalOutput) -> AuditTraceResolvedOutput { + AuditTraceResolvedOutput { + output_id: local.output_id.clone(), + output_type: local.output_type, + rule_hash: local.rule_hash.clone(), + source_object_uri: local.source_object_uri.clone(), + source_object_type: local.source_object_type.clone(), + source_object_hash: local.source_object_hash.clone(), + source_ee_cert_hash: local.source_ee_cert_hash.clone(), + item_effective_until_rfc3339_utc: local.item_effective_until.rfc3339_utc.clone(), + payload_json: local.payload_json.clone(), + validation_path_hint: local.validation_path_hint.clone(), + } +} + +fn resolve_raw_ref( + store: &RocksStore, + sha256_hex: &str, +) -> Result { + let raw = store.get_raw_by_hash_entry(sha256_hex)?; + Ok(raw_ref_from_entry(sha256_hex, raw.as_ref())) +} + +fn raw_ref_from_entry(sha256_hex: &str, entry: Option<&RawByHashEntry>) -> AuditTraceRawRef { + match entry { + Some(entry) => AuditTraceRawRef { + sha256_hex: sha256_hex.to_string(), + raw_present: true, + origin_uris: entry.origin_uris.clone(), + object_type: entry.object_type.clone(), + byte_len: Some(entry.bytes.len()), + }, + None => AuditTraceRawRef { + sha256_hex: sha256_hex.to_string(), + raw_present: false, + origin_uris: Vec::new(), + object_type: None, + byte_len: None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::sha256_hex; + use crate::storage::{ + PackTime, ValidatedManifestMeta, VcirAuditSummary, VcirChildEntry, VcirInstanceGate, + VcirRelatedArtifact, VcirSummary, + }; + + fn sample_vcir( + manifest_rsync_uri: &str, + parent_manifest_rsync_uri: Option<&str>, + tal_id: &str, + local_output: Option, + related_artifacts: Vec, + ) -> ValidatedCaInstanceResult { + let now = time::OffsetDateTime::now_utc(); + let next = PackTime::from_utc_offset_datetime(now + time::Duration::hours(1)); + let local_outputs: Vec = local_output.into_iter().collect(); + ValidatedCaInstanceResult { + manifest_rsync_uri: manifest_rsync_uri.to_string(), + parent_manifest_rsync_uri: parent_manifest_rsync_uri.map(str::to_string), + tal_id: tal_id.to_string(), + ca_subject_name: format!("CN={manifest_rsync_uri}"), + ca_ski: "11".repeat(20), + issuer_ski: "22".repeat(20), + last_successful_validation_time: PackTime::from_utc_offset_datetime(now), + current_manifest_rsync_uri: manifest_rsync_uri.to_string(), + current_crl_rsync_uri: manifest_rsync_uri.replace(".mft", ".crl"), + validated_manifest_meta: ValidatedManifestMeta { + validated_manifest_number: vec![1], + validated_manifest_this_update: PackTime::from_utc_offset_datetime(now), + validated_manifest_next_update: next.clone(), + }, + instance_gate: VcirInstanceGate { + manifest_next_update: next.clone(), + current_crl_next_update: next.clone(), + self_ca_not_after: PackTime::from_utc_offset_datetime( + now + time::Duration::hours(2), + ), + instance_effective_until: next, + }, + child_entries: vec![VcirChildEntry { + child_manifest_rsync_uri: "rsync://example.test/child/child.mft".to_string(), + child_cert_rsync_uri: "rsync://example.test/parent/child.cer".to_string(), + child_cert_hash: sha256_hex(b"child-cert"), + child_ski: "33".repeat(20), + child_rsync_base_uri: "rsync://example.test/child/".to_string(), + child_publication_point_rsync_uri: "rsync://example.test/child/".to_string(), + child_rrdp_notification_uri: Some( + "https://example.test/child/notify.xml".to_string(), + ), + child_effective_ip_resources: None, + child_effective_as_resources: None, + accepted_at_validation_time: PackTime::from_utc_offset_datetime(now), + }], + summary: VcirSummary { + local_vrp_count: local_outputs + .iter() + .filter(|output| output.output_type == VcirOutputType::Vrp) + .count() as u32, + local_aspa_count: local_outputs + .iter() + .filter(|output| output.output_type == VcirOutputType::Aspa) + .count() as u32, + child_count: 1, + accepted_object_count: related_artifacts.len() as u32, + rejected_object_count: 0, + }, + local_outputs, + related_artifacts, + audit_summary: VcirAuditSummary { + failed_fetch_eligible: true, + last_failed_fetch_reason: None, + warning_count: 0, + audit_flags: Vec::new(), + }, + } + } + + fn sample_local_output(manifest_rsync_uri: &str) -> VcirLocalOutput { + let now = time::OffsetDateTime::now_utc(); + VcirLocalOutput { + output_id: sha256_hex(b"vrp-output"), + output_type: VcirOutputType::Vrp, + item_effective_until: PackTime::from_utc_offset_datetime( + now + time::Duration::minutes(30), + ), + source_object_uri: "rsync://example.test/leaf/a.roa".to_string(), + source_object_type: "roa".to_string(), + source_object_hash: sha256_hex(b"roa-raw"), + source_ee_cert_hash: sha256_hex(b"roa-ee"), + payload_json: + serde_json::json!({"asn": 64496, "prefix": "203.0.113.0/24", "max_length": 24}) + .to_string(), + rule_hash: sha256_hex(b"roa-rule"), + validation_path_hint: vec![ + manifest_rsync_uri.to_string(), + "rsync://example.test/leaf/a.roa".to_string(), + sha256_hex(b"roa-raw"), + ], + } + } + + fn sample_artifacts(manifest_rsync_uri: &str, roa_hash: &str) -> Vec { + vec![ + VcirRelatedArtifact { + artifact_role: VcirArtifactRole::Manifest, + artifact_kind: VcirArtifactKind::Mft, + uri: Some(manifest_rsync_uri.to_string()), + sha256: sha256_hex(manifest_rsync_uri.as_bytes()), + object_type: Some("mft".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + VcirRelatedArtifact { + artifact_role: VcirArtifactRole::CurrentCrl, + artifact_kind: VcirArtifactKind::Crl, + uri: Some(manifest_rsync_uri.replace(".mft", ".crl")), + sha256: sha256_hex(format!("{}-crl", manifest_rsync_uri).as_bytes()), + object_type: Some("crl".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + VcirRelatedArtifact { + artifact_role: VcirArtifactRole::SignedObject, + artifact_kind: VcirArtifactKind::Roa, + uri: Some("rsync://example.test/leaf/a.roa".to_string()), + sha256: roa_hash.to_string(), + object_type: Some("roa".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + ] + } + + fn put_raw(store: &RocksStore, bytes: &[u8], uri: &str, object_type: &str) { + let mut entry = RawByHashEntry::from_bytes(sha256_hex(bytes), bytes.to_vec()); + entry.origin_uris.push(uri.to_string()); + entry.object_type = Some(object_type.to_string()); + entry.encoding = Some("der".to_string()); + store + .put_raw_by_hash_entry(&entry) + .expect("put raw evidence"); + } + + #[test] + fn trace_rule_to_root_returns_leaf_to_root_chain_and_evidence_refs() { + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + + let root_manifest = "rsync://example.test/root/root.mft"; + let leaf_manifest = "rsync://example.test/leaf/leaf.mft"; + let local = sample_local_output(leaf_manifest); + let leaf_vcir = sample_vcir( + leaf_manifest, + Some(root_manifest), + "test-tal", + Some(local.clone()), + sample_artifacts(leaf_manifest, &local.source_object_hash), + ); + let root_vcir = sample_vcir( + root_manifest, + None, + "test-tal", + None, + sample_artifacts(root_manifest, &sha256_hex(b"root-object")), + ); + store.put_vcir(&leaf_vcir).expect("put leaf vcir"); + store.put_vcir(&root_vcir).expect("put root vcir"); + + let rule_entry = AuditRuleIndexEntry { + kind: AuditRuleKind::Roa, + rule_hash: local.rule_hash.clone(), + manifest_rsync_uri: leaf_manifest.to_string(), + source_object_uri: local.source_object_uri.clone(), + source_object_hash: local.source_object_hash.clone(), + output_id: local.output_id.clone(), + item_effective_until: local.item_effective_until.clone(), + }; + store + .put_audit_rule_index_entry(&rule_entry) + .expect("put rule index"); + + put_raw(&store, leaf_manifest.as_bytes(), leaf_manifest, "mft"); + put_raw( + &store, + format!("{}-crl", leaf_manifest).as_bytes(), + &leaf_manifest.replace(".mft", ".crl"), + "crl", + ); + put_raw(&store, b"roa-raw", &local.source_object_uri, "roa"); + put_raw(&store, b"roa-ee", "rsync://example.test/leaf/a.ee", "cer"); + put_raw(&store, root_manifest.as_bytes(), root_manifest, "mft"); + put_raw( + &store, + format!("{}-crl", root_manifest).as_bytes(), + &root_manifest.replace(".mft", ".crl"), + "crl", + ); + + let trace = trace_rule_to_root(&store, AuditRuleKind::Roa, &local.rule_hash) + .expect("trace rule") + .expect("trace exists"); + + assert_eq!(trace.rule, rule_entry); + assert_eq!(trace.resolved_output.output_id, local.output_id); + assert_eq!(trace.chain_leaf_to_root.len(), 2); + assert_eq!( + trace.chain_leaf_to_root[0].manifest_rsync_uri, + leaf_manifest + ); + assert_eq!( + trace.chain_leaf_to_root[1].manifest_rsync_uri, + root_manifest + ); + assert_eq!( + trace.chain_leaf_to_root[0] + .parent_manifest_rsync_uri + .as_deref(), + Some(root_manifest) + ); + assert!(trace.source_object_raw.raw_present); + assert!(trace.source_ee_cert_raw.raw_present); + assert!( + trace.chain_leaf_to_root[0] + .related_artifacts + .iter() + .any(|artifact| { + artifact.uri.as_deref() == Some(leaf_manifest) && artifact.raw.raw_present + }) + ); + } + + #[test] + fn trace_rule_to_root_returns_none_for_missing_rule_index() { + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + + assert!( + trace_rule_to_root(&store, AuditRuleKind::Roa, &sha256_hex(b"missing")) + .expect("missing trace ok") + .is_none() + ); + } + + #[test] + fn trace_rule_to_root_errors_when_index_points_to_missing_vcir() { + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + let rule_hash = sha256_hex(b"missing-vcir-rule"); + store + .put_audit_rule_index_entry(&AuditRuleIndexEntry { + kind: AuditRuleKind::Roa, + rule_hash: rule_hash.clone(), + manifest_rsync_uri: "rsync://example.test/missing.mft".to_string(), + source_object_uri: "rsync://example.test/missing.roa".to_string(), + source_object_hash: sha256_hex(b"missing-source"), + output_id: sha256_hex(b"missing-output"), + item_effective_until: PackTime::from_utc_offset_datetime( + time::OffsetDateTime::now_utc() + time::Duration::minutes(1), + ), + }) + .expect("put rule index"); + + let err = trace_rule_to_root(&store, AuditRuleKind::Roa, &rule_hash).unwrap_err(); + assert!(matches!( + err, + AuditTraceError::MissingVcir { manifest_rsync_uri } + if manifest_rsync_uri == "rsync://example.test/missing.mft" + )); + } + + #[test] + fn trace_rule_to_root_errors_when_vcir_local_output_is_missing() { + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + let manifest = "rsync://example.test/leaf/leaf.mft"; + let vcir = sample_vcir( + manifest, + None, + "test-tal", + None, + sample_artifacts(manifest, &sha256_hex(b"leaf-object")), + ); + store.put_vcir(&vcir).expect("put vcir"); + let rule_hash = sha256_hex(b"missing-output-rule"); + store + .put_audit_rule_index_entry(&AuditRuleIndexEntry { + kind: AuditRuleKind::Roa, + rule_hash: rule_hash.clone(), + manifest_rsync_uri: manifest.to_string(), + source_object_uri: "rsync://example.test/leaf/a.roa".to_string(), + source_object_hash: sha256_hex(b"leaf-object"), + output_id: sha256_hex(b"missing-output"), + item_effective_until: PackTime::from_utc_offset_datetime( + time::OffsetDateTime::now_utc() + time::Duration::minutes(1), + ), + }) + .expect("put rule index"); + + let err = trace_rule_to_root(&store, AuditRuleKind::Roa, &rule_hash).unwrap_err(); + assert!(matches!(err, AuditTraceError::MissingLocalOutput { .. })); + } + + #[test] + fn trace_vcir_chain_to_root_detects_parent_cycle() { + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + let a_manifest = "rsync://example.test/a.mft"; + let b_manifest = "rsync://example.test/b.mft"; + let a_vcir = sample_vcir( + a_manifest, + Some(b_manifest), + "test-tal", + None, + sample_artifacts(a_manifest, &sha256_hex(b"a-object")), + ); + let b_vcir = sample_vcir( + b_manifest, + Some(a_manifest), + "test-tal", + None, + sample_artifacts(b_manifest, &sha256_hex(b"b-object")), + ); + store.put_vcir(&a_vcir).expect("put a"); + store.put_vcir(&b_vcir).expect("put b"); + + let err = trace_vcir_chain_to_root(&store, a_manifest).unwrap_err(); + assert!(matches!( + err, + AuditTraceError::ParentCycle { manifest_rsync_uri } + if manifest_rsync_uri == a_manifest + )); + } +} diff --git a/src/bin/db_stats.rs b/src/bin/db_stats.rs index 815ef60..d71443c 100644 --- a/src/bin/db_stats.rs +++ b/src/bin/db_stats.rs @@ -1,21 +1,36 @@ +use std::collections::BTreeMap; use std::path::PathBuf; -use rocksdb::{ColumnFamilyDescriptor, DB, IteratorMode, Options}; +use rocksdb::{DB, IteratorMode, Options}; +use rpki::storage::{ + ALL_COLUMN_FAMILY_NAMES, CF_AUDIT_RULE_INDEX, CF_RAW_BY_HASH, CF_RAW_OBJECTS, + CF_REPOSITORY_VIEW, CF_RRDP_OBJECT_INDEX, CF_RRDP_SOURCE, CF_RRDP_SOURCE_MEMBER, CF_RRDP_STATE, + CF_RRDP_URI_OWNER, CF_VCIR, column_family_descriptors, +}; -const CF_RAW_OBJECTS: &str = "raw_objects"; -const CF_FETCH_CACHE_PP: &str = "fetch_cache_pp"; -const CF_RRDP_STATE: &str = "rrdp_state"; -const CF_RRDP_OBJECT_INDEX: &str = "rrdp_object_index"; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DbStatsMode { + Estimate, + Exact, +} -fn enable_blobdb_if_supported(opts: &mut Options) { - // Keep this in sync with `rpki::storage`: - // blob files are CF-level options; readers should open CFs with blob enabled too. - #[allow(dead_code)] - fn _set(opts: &mut Options) { - opts.set_enable_blob_files(true); - opts.set_min_blob_size(1024); +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum CfGroup { + CurrentRepositoryView, + CurrentValidationState, + CurrentRrdpState, + LegacyCompatibility, +} + +impl CfGroup { + fn as_str(self) -> &'static str { + match self { + Self::CurrentRepositoryView => "current_repository_view", + Self::CurrentValidationState => "current_validation_state", + Self::CurrentRrdpState => "current_rrdp_state", + Self::LegacyCompatibility => "legacy_compatibility", + } } - _set(opts); } fn usage() -> String { @@ -29,21 +44,16 @@ Options: --db RocksDB directory --exact Iterate to count keys (slower; default uses RocksDB estimates) --help Show this help + +Output groups: + - current_repository_view: repository_view + raw_by_hash + - current_validation_state: vcir + audit_rule_index + - current_rrdp_state: rrdp_state + rrdp_source + rrdp_source_member + rrdp_uri_owner + - legacy_compatibility: raw_objects + rrdp_object_index " ) } -fn cf_descriptors() -> Vec { - let mut cf_opts = Options::default(); - enable_blobdb_if_supported(&mut cf_opts); - vec![ - ColumnFamilyDescriptor::new(CF_RAW_OBJECTS, cf_opts.clone()), - ColumnFamilyDescriptor::new(CF_FETCH_CACHE_PP, cf_opts.clone()), - ColumnFamilyDescriptor::new(CF_RRDP_STATE, cf_opts.clone()), - ColumnFamilyDescriptor::new(CF_RRDP_OBJECT_INDEX, cf_opts), - ] -} - fn estimate_keys(db: &DB, cf_name: &str) -> Result, Box> { let cf = db .cf_handle(cf_name) @@ -64,6 +74,35 @@ fn exact_keys(db: &DB, cf_name: &str) -> Result> Ok(count) } +fn cf_group(cf_name: &str) -> CfGroup { + match cf_name { + CF_REPOSITORY_VIEW | CF_RAW_BY_HASH => CfGroup::CurrentRepositoryView, + CF_VCIR | CF_AUDIT_RULE_INDEX => CfGroup::CurrentValidationState, + CF_RRDP_STATE | CF_RRDP_SOURCE | CF_RRDP_SOURCE_MEMBER | CF_RRDP_URI_OWNER => { + CfGroup::CurrentRrdpState + } + CF_RAW_OBJECTS | CF_RRDP_OBJECT_INDEX => CfGroup::LegacyCompatibility, + _ => CfGroup::LegacyCompatibility, + } +} + +fn summarize_counts<'a>( + counts: impl IntoIterator, +) -> BTreeMap { + let mut grouped = BTreeMap::new(); + for (cf_name, count) in counts { + *grouped.entry(cf_group(cf_name)).or_insert(0) += count; + } + grouped +} + +fn mode_label(mode: DbStatsMode) -> &'static str { + match mode { + DbStatsMode::Estimate => "estimate", + DbStatsMode::Exact => "exact", + } +} + fn main() -> Result<(), Box> { let argv: Vec = std::env::args().collect(); if argv.iter().any(|a| a == "--help" || a == "-h") { @@ -72,7 +111,7 @@ fn main() -> Result<(), Box> { } let mut db_path: Option = None; - let mut exact = false; + let mut mode = DbStatsMode::Estimate; let mut i = 1usize; while i < argv.len() { match argv[i].as_str() { @@ -81,7 +120,7 @@ fn main() -> Result<(), Box> { let v = argv.get(i).ok_or("--db requires a value")?; db_path = Some(PathBuf::from(v)); } - "--exact" => exact = true, + "--exact" => mode = DbStatsMode::Exact, other => return Err(format!("unknown argument: {other}\n\n{}", usage()).into()), } i += 1; @@ -93,33 +132,75 @@ fn main() -> Result<(), Box> { opts.create_if_missing(false); opts.create_missing_column_families(false); - let db = DB::open_cf_descriptors(&opts, &db_path, cf_descriptors())?; - - let cf_names = [ - CF_RAW_OBJECTS, - CF_FETCH_CACHE_PP, - CF_RRDP_STATE, - CF_RRDP_OBJECT_INDEX, - ]; + let db = DB::open_cf_descriptors(&opts, &db_path, column_family_descriptors())?; println!("db={}", db_path.display()); - println!("mode={}", if exact { "exact" } else { "estimate" }); + println!("mode={}", mode_label(mode)); + let mut per_cf = Vec::with_capacity(ALL_COLUMN_FAMILY_NAMES.len()); let mut total: u64 = 0; - for name in cf_names { - let n = if exact { - exact_keys(&db, name)? - } else { - estimate_keys(&db, name)?.unwrap_or(0) + for &name in ALL_COLUMN_FAMILY_NAMES { + let n = match mode { + DbStatsMode::Exact => exact_keys(&db, name)?, + DbStatsMode::Estimate => estimate_keys(&db, name)?.unwrap_or(0), }; total = total.saturating_add(n); + per_cf.push((name, n)); println!("{name}={n}"); } println!("total={total}"); - // Also print # of SST files (useful sanity signal). + for (group, count) in summarize_counts(per_cf.iter().copied()) { + println!("group_{}={count}", group.as_str()); + } + let live = db.live_files()?; println!("sst_files={}", live.len()); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cf_group_classifies_current_and_legacy_keyspaces() { + assert_eq!(cf_group(CF_REPOSITORY_VIEW), CfGroup::CurrentRepositoryView); + assert_eq!(cf_group(CF_RAW_BY_HASH), CfGroup::CurrentRepositoryView); + assert_eq!(cf_group(CF_VCIR), CfGroup::CurrentValidationState); + assert_eq!( + cf_group(CF_AUDIT_RULE_INDEX), + CfGroup::CurrentValidationState + ); + assert_eq!(cf_group(CF_RRDP_SOURCE), CfGroup::CurrentRrdpState); + assert_eq!(cf_group(CF_RRDP_URI_OWNER), CfGroup::CurrentRrdpState); + assert_eq!(cf_group(CF_RAW_OBJECTS), CfGroup::LegacyCompatibility); + } + + #[test] + fn summarize_counts_accumulates_by_group() { + let grouped = summarize_counts([ + (CF_REPOSITORY_VIEW, 5), + (CF_RAW_BY_HASH, 7), + (CF_VCIR, 11), + (CF_AUDIT_RULE_INDEX, 13), + (CF_RRDP_STATE, 17), + (CF_RRDP_SOURCE_MEMBER, 19), + (CF_RRDP_OBJECT_INDEX, 29), + ]); + + assert_eq!(grouped.get(&CfGroup::CurrentRepositoryView), Some(&12)); + assert_eq!(grouped.get(&CfGroup::CurrentValidationState), Some(&24)); + assert_eq!(grouped.get(&CfGroup::CurrentRrdpState), Some(&36)); + assert_eq!(grouped.get(&CfGroup::LegacyCompatibility), Some(&29)); + } + + #[test] + fn usage_mentions_grouped_output_and_exact_mode() { + let text = usage(); + assert!(text.contains("--exact"), "{text}"); + assert!(text.contains("current_validation_state"), "{text}"); + assert!(text.contains("legacy_compatibility"), "{text}"); + } +} diff --git a/src/bin/rrdp_state_dump.rs b/src/bin/rrdp_state_dump.rs index b7312f5..651cf96 100644 --- a/src/bin/rrdp_state_dump.rs +++ b/src/bin/rrdp_state_dump.rs @@ -1,16 +1,40 @@ use std::path::PathBuf; -use rocksdb::{ColumnFamilyDescriptor, DB, IteratorMode, Options}; +use rocksdb::{DB, IteratorMode, Options}; +use rpki::storage::{ + CF_RRDP_SOURCE, CF_RRDP_SOURCE_MEMBER, CF_RRDP_STATE, CF_RRDP_URI_OWNER, + RrdpSourceMemberRecord, RrdpSourceRecord, RrdpUriOwnerRecord, column_family_descriptors, +}; +use rpki::sync::rrdp::RrdpState; -fn enable_blobdb_if_supported(opts: &mut Options) { - // Keep this in sync with `rpki::storage`: - // blob files are CF-level options; readers should open CFs with blob enabled too. - #[allow(dead_code)] - fn _set(opts: &mut Options) { - opts.set_enable_blob_files(true); - opts.set_min_blob_size(1024); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DumpView { + LegacyState, + Source, + Members, + Owners, + All, +} + +impl DumpView { + fn parse(value: &str) -> Result { + match value { + "legacy-state" => Ok(Self::LegacyState), + "source" => Ok(Self::Source), + "members" => Ok(Self::Members), + "owners" => Ok(Self::Owners), + "all" => Ok(Self::All), + other => Err(format!( + "invalid --view: {other} (expected one of: legacy-state, source, members, owners, all)" + )), + } } - _set(opts); +} + +#[derive(Debug)] +struct DumpArgs { + db_path: PathBuf, + view: DumpView, } fn usage() -> String { @@ -18,23 +42,23 @@ fn usage() -> String { format!( "\ Usage: - {bin} --db + {bin} --db [--view ] Options: --db RocksDB directory + --view Dump one RRDP view; default is all --help Show this help " ) } -fn main() -> Result<(), Box> { - let argv: Vec = std::env::args().collect(); +fn parse_args(argv: &[String]) -> Result { if argv.iter().any(|a| a == "--help" || a == "-h") { - print!("{}", usage()); - return Ok(()); + return Err(usage()); } let mut db_path: Option = None; + let mut view = DumpView::All; let mut i = 1usize; while i < argv.len() { match argv[i].as_str() { @@ -43,43 +67,280 @@ fn main() -> Result<(), Box> { let v = argv.get(i).ok_or("--db requires a value")?; db_path = Some(PathBuf::from(v)); } - other => return Err(format!("unknown argument: {other}\n\n{}", usage()).into()), + "--view" => { + i += 1; + let v = argv.get(i).ok_or("--view requires a value")?; + view = DumpView::parse(v)?; + } + other => return Err(format!("unknown argument: {other}\n\n{}", usage())), } i += 1; } - let db_path = db_path.ok_or_else(|| format!("--db is required\n\n{}", usage()))?; + Ok(DumpArgs { + db_path: db_path.ok_or_else(|| format!("--db is required\n\n{}", usage()))?, + view, + }) +} +fn open_db(path: &std::path::Path) -> Result> { let mut opts = Options::default(); opts.create_if_missing(false); opts.create_missing_column_families(false); + Ok(DB::open_cf_descriptors( + &opts, + path, + column_family_descriptors(), + )?) +} - // Open only the column families we need. - let mut cf_opts = Options::default(); - enable_blobdb_if_supported(&mut cf_opts); - let cfs = vec![ - ColumnFamilyDescriptor::new("raw_objects", cf_opts.clone()), - ColumnFamilyDescriptor::new("fetch_cache_pp", cf_opts.clone()), - ColumnFamilyDescriptor::new("rrdp_state", cf_opts.clone()), - ColumnFamilyDescriptor::new("rrdp_object_index", cf_opts), - ]; - let db = DB::open_cf_descriptors(&opts, &db_path, cfs)?; +fn collect_legacy_state(db: &DB) -> Result, Box> { let cf = db - .cf_handle("rrdp_state") + .cf_handle(CF_RRDP_STATE) .ok_or("missing column family: rrdp_state")?; - - let mut out: Vec<(String, u64, String)> = Vec::new(); + let mut out = Vec::new(); for res in db.iterator_cf(cf, IteratorMode::Start) { let (k, v) = res?; - let k = String::from_utf8_lossy(&k).to_string(); - let st = rpki::sync::rrdp::RrdpState::decode(&v) - .map_err(|e| format!("decode rrdp_state failed for {k}: {e}"))?; - out.push((k, st.serial, st.session_id)); + let notify_uri = String::from_utf8_lossy(&k).to_string(); + let state = RrdpState::decode(&v) + .map_err(|e| format!("decode rrdp_state failed for {notify_uri}: {e}"))?; + out.push((notify_uri, state)); } - out.sort_by(|a, b| a.0.cmp(&b.0)); - for (k, serial, session) in out { - println!("{k}\t{serial}\t{session}"); + Ok(out) +} + +fn collect_source_records(db: &DB) -> Result, Box> { + let cf = db + .cf_handle(CF_RRDP_SOURCE) + .ok_or("missing column family: rrdp_source")?; + let mut out = Vec::new(); + for res in db.iterator_cf(cf, IteratorMode::Start) { + let (_k, v) = res?; + let record: RrdpSourceRecord = serde_cbor::from_slice(&v)?; + record.validate_internal()?; + out.push(record); + } + out.sort_by(|a, b| a.notify_uri.cmp(&b.notify_uri)); + Ok(out) +} + +fn collect_source_member_records( + db: &DB, +) -> Result, Box> { + let cf = db + .cf_handle(CF_RRDP_SOURCE_MEMBER) + .ok_or("missing column family: rrdp_source_member")?; + let mut out = Vec::new(); + for res in db.iterator_cf(cf, IteratorMode::Start) { + let (_k, v) = res?; + let record: RrdpSourceMemberRecord = serde_cbor::from_slice(&v)?; + record.validate_internal()?; + out.push(record); + } + out.sort_by(|a, b| { + a.notify_uri + .cmp(&b.notify_uri) + .then(a.rsync_uri.cmp(&b.rsync_uri)) + }); + Ok(out) +} + +fn collect_uri_owner_records( + db: &DB, +) -> Result, Box> { + let cf = db + .cf_handle(CF_RRDP_URI_OWNER) + .ok_or("missing column family: rrdp_uri_owner")?; + let mut out = Vec::new(); + for res in db.iterator_cf(cf, IteratorMode::Start) { + let (_k, v) = res?; + let record: RrdpUriOwnerRecord = serde_cbor::from_slice(&v)?; + record.validate_internal()?; + out.push(record); + } + out.sort_by(|a, b| a.rsync_uri.cmp(&b.rsync_uri)); + Ok(out) +} + +fn print_legacy_state(entries: &[(String, RrdpState)]) { + println!("[legacy-state]"); + println!("notify_uri\tserial\tsession_id"); + for (notify_uri, state) in entries { + println!("{notify_uri}\t{}\t{}", state.serial, state.session_id); + } +} + +fn print_source_records(entries: &[RrdpSourceRecord]) { + println!("[source]"); + println!("notify_uri\tlast_serial\tlast_session_id\tsync_state\tlast_snapshot_uri\tlast_error"); + for record in entries { + println!( + "{}\t{}\t{}\t{:?}\t{}\t{}", + record.notify_uri, + record + .last_serial + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".to_string()), + record.last_session_id.as_deref().unwrap_or("-"), + record.sync_state, + record.last_snapshot_uri.as_deref().unwrap_or("-"), + record.last_error.as_deref().unwrap_or("-"), + ); + } +} + +fn print_source_member_records(entries: &[RrdpSourceMemberRecord]) { + println!("[members]"); + println!("notify_uri\trsync_uri\tpresent\thash\tsession_id\tserial"); + for record in entries { + println!( + "{}\t{}\t{}\t{}\t{}\t{}", + record.notify_uri, + record.rsync_uri, + record.present, + record.current_hash.as_deref().unwrap_or("-"), + record.last_confirmed_session_id, + record.last_confirmed_serial, + ); + } +} + +fn print_uri_owner_records(entries: &[RrdpUriOwnerRecord]) { + println!("[owners]"); + println!("rsync_uri\tnotify_uri\towner_state\thash\tsession_id\tserial"); + for record in entries { + println!( + "{}\t{}\t{:?}\t{}\t{}\t{}", + record.rsync_uri, + record.notify_uri, + record.owner_state, + record.current_hash.as_deref().unwrap_or("-"), + record.last_confirmed_session_id, + record.last_confirmed_serial, + ); + } +} + +fn main() -> Result<(), Box> { + let argv: Vec = std::env::args().collect(); + let args = parse_args(&argv).map_err(|e| -> Box { e.into() })?; + let db = open_db(&args.db_path)?; + + match args.view { + DumpView::LegacyState => print_legacy_state(&collect_legacy_state(&db)?), + DumpView::Source => print_source_records(&collect_source_records(&db)?), + DumpView::Members => print_source_member_records(&collect_source_member_records(&db)?), + DumpView::Owners => print_uri_owner_records(&collect_uri_owner_records(&db)?), + DumpView::All => { + print_legacy_state(&collect_legacy_state(&db)?); + print_source_records(&collect_source_records(&db)?); + print_source_member_records(&collect_source_member_records(&db)?); + print_uri_owner_records(&collect_uri_owner_records(&db)?); + } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use rpki::storage::{PackTime, RocksStore, RrdpSourceSyncState, RrdpUriOwnerState}; + + #[test] + fn parse_args_accepts_view_and_db() { + let args = parse_args(&[ + "rrdp_state_dump".to_string(), + "--db".to_string(), + "db".to_string(), + "--view".to_string(), + "owners".to_string(), + ]) + .expect("parse args"); + assert_eq!(args.db_path, PathBuf::from("db")); + assert_eq!(args.view, DumpView::Owners); + } + + #[test] + fn parse_args_rejects_invalid_view() { + let err = parse_args(&[ + "rrdp_state_dump".to_string(), + "--db".to_string(), + "db".to_string(), + "--view".to_string(), + "nope".to_string(), + ]) + .unwrap_err(); + assert!(err.contains("invalid --view"), "{err}"); + } + + #[test] + fn collect_rrdp_views_reads_legacy_and_current_records() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(dir.path()).expect("open store"); + let legacy_state = RrdpState { + session_id: "session-1".to_string(), + serial: 42, + }; + store + .put_rrdp_state( + "https://example.test/notify.xml", + &legacy_state.encode().expect("encode legacy state"), + ) + .expect("put legacy state"); + store + .put_rrdp_source_record(&RrdpSourceRecord { + notify_uri: "https://example.test/notify.xml".to_string(), + last_session_id: Some("session-1".to_string()), + last_serial: Some(42), + first_seen_at: PackTime::from_utc_offset_datetime(time::OffsetDateTime::now_utc()), + last_seen_at: PackTime::from_utc_offset_datetime(time::OffsetDateTime::now_utc()), + last_sync_at: None, + sync_state: RrdpSourceSyncState::DeltaReady, + last_snapshot_uri: Some("https://example.test/snapshot.xml".to_string()), + last_snapshot_hash: None, + last_error: None, + }) + .expect("put source record"); + store + .put_rrdp_source_member_record(&RrdpSourceMemberRecord { + notify_uri: "https://example.test/notify.xml".to_string(), + rsync_uri: "rsync://example.test/repo/a.roa".to_string(), + current_hash: Some("11".repeat(32)), + object_type: Some("roa".to_string()), + present: true, + last_confirmed_session_id: "session-1".to_string(), + last_confirmed_serial: 42, + last_changed_at: PackTime::from_utc_offset_datetime(time::OffsetDateTime::now_utc()), + }) + .expect("put member record"); + store + .put_rrdp_uri_owner_record(&RrdpUriOwnerRecord { + rsync_uri: "rsync://example.test/repo/a.roa".to_string(), + notify_uri: "https://example.test/notify.xml".to_string(), + current_hash: Some("11".repeat(32)), + last_confirmed_session_id: "session-1".to_string(), + last_confirmed_serial: 42, + last_changed_at: PackTime::from_utc_offset_datetime(time::OffsetDateTime::now_utc()), + owner_state: RrdpUriOwnerState::Active, + }) + .expect("put owner record"); + drop(store); + + let db = open_db(dir.path()).expect("open db"); + let legacy = collect_legacy_state(&db).expect("legacy dump"); + let sources = collect_source_records(&db).expect("source dump"); + let members = collect_source_member_records(&db).expect("members dump"); + let owners = collect_uri_owner_records(&db).expect("owners dump"); + + assert_eq!(legacy.len(), 1); + assert_eq!(legacy[0].0, "https://example.test/notify.xml"); + assert_eq!(legacy[0].1.serial, 42); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].sync_state, RrdpSourceSyncState::DeltaReady); + assert_eq!(members.len(), 1); + assert_eq!(members[0].rsync_uri, "rsync://example.test/repo/a.roa"); + assert_eq!(owners.len(), 1); + assert_eq!(owners[0].owner_state, RrdpUriOwnerState::Active); + } +} diff --git a/src/cli.rs b/src/cli.rs index a2d39f7..7ad1a37 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -37,7 +37,6 @@ pub struct CliArgs { pub max_instances: Option, pub validation_time: Option, - pub revalidate_only: bool, pub analyze: bool, pub profile_cpu: bool, } @@ -66,7 +65,6 @@ Options: --max-depth Max CA instance depth (0 = root only) --max-instances Max number of CA instances to process --validation-time Validation time in RFC3339 (default: now UTC) - --revalidate-only Skip RRDP/rsync sync; re-validate from existing DB cache --analyze Write timing analysis JSON under target/live/analyze// --profile-cpu (Requires build feature 'profile') Write CPU flamegraph under analyze dir @@ -91,7 +89,6 @@ pub fn parse_args(argv: &[String]) -> Result { let mut max_depth: Option = None; let mut max_instances: Option = None; let mut validation_time: Option = None; - let mut revalidate_only: bool = false; let mut analyze: bool = false; let mut profile_cpu: bool = false; @@ -178,9 +175,6 @@ pub fn parse_args(argv: &[String]) -> Result { .map_err(|e| format!("invalid --validation-time (RFC3339 expected): {e}"))?; validation_time = Some(t); } - "--revalidate-only" => { - revalidate_only = true; - } "--analyze" => { analyze = true; } @@ -222,7 +216,6 @@ pub fn parse_args(argv: &[String]) -> Result { max_depth, max_instances, validation_time, - revalidate_only, analyze, profile_cpu, }) @@ -356,7 +349,6 @@ pub fn run(argv: &[String]) -> Result<(), String> { let config = TreeRunConfig { max_depth: args.max_depth, max_instances: args.max_instances, - revalidate_only: args.revalidate_only, }; use time::format_description::well_known::Rfc3339; @@ -804,6 +796,20 @@ mod tests { assert!(args.validation_time.is_some()); } + #[test] + fn parse_rejects_removed_revalidate_only_flag() { + let argv = vec![ + "rpki".to_string(), + "--db".to_string(), + "db".to_string(), + "--tal-url".to_string(), + "https://example.test/x.tal".to_string(), + "--revalidate-only".to_string(), + ]; + let err = parse_args(&argv).unwrap_err(); + assert!(err.contains("unknown argument: --revalidate-only"), "{err}"); + } + #[test] fn read_policy_accepts_valid_toml() { let dir = tempfile::tempdir().expect("tmpdir"); diff --git a/src/data_model/rc.rs b/src/data_model/rc.rs index fadef2f..bf7f609 100644 --- a/src/data_model/rc.rs +++ b/src/data_model/rc.rs @@ -5,6 +5,8 @@ use x509_parser::asn1_rs::{Class as Asn1Class, Tag as Asn1Tag}; use x509_parser::extensions::ParsedExtension; use x509_parser::prelude::{FromDer, X509Certificate, X509Extension, X509Version}; +use serde::{Deserialize, Serialize}; + use crate::data_model::common::{ Asn1TimeUtc, InvalidTimeEncodingError, UtcTime, X509NameDer, asn1_time_to_model, }; @@ -183,7 +185,7 @@ pub struct AccessDescription { pub access_location: String, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum Afi { Ipv4, Ipv6, @@ -205,7 +207,7 @@ impl Afi { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct IpResourceSet { pub families: Vec, } @@ -240,7 +242,7 @@ pub enum IpResourceSetDecodeError { InvalidEncoding, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct IpAddressFamily { pub afi: Afi, pub choice: IpAddressChoice, @@ -261,32 +263,32 @@ impl IpAddressFamily { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum IpAddressChoice { Inherit, AddressesOrRanges(Vec), } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum IpAddressOrRange { Prefix(IpPrefix), Range(IpAddressRange), } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct IpAddressRange { pub min: Vec, pub max: Vec, } -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct IpPrefix { pub afi: Afi, pub prefix_len: u16, pub addr: Vec, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AsResourceSet { pub asnum: Option, pub rdi: Option, @@ -330,7 +332,7 @@ pub enum AsResourceSetDecodeError { InvalidEncoding, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum AsIdentifierChoice { Inherit, AsIdsOrRanges(Vec), @@ -347,7 +349,7 @@ impl AsIdentifierChoice { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum AsIdOrRange { Id(u32), Range { min: u32, max: u32 }, diff --git a/src/fetch/rsync_system.rs b/src/fetch/rsync_system.rs index d9f8de0..7bcbf33 100644 --- a/src/fetch/rsync_system.rs +++ b/src/fetch/rsync_system.rs @@ -52,12 +52,8 @@ impl SystemRsyncFetcher { return Ok(None); }; - std::fs::create_dir_all(root).map_err(|e| { - format!( - "create rsync mirror root failed: {}: {e}", - root.display() - ) - })?; + std::fs::create_dir_all(root) + .map_err(|e| format!("create rsync mirror root failed: {}: {e}", root.display()))?; let hash = hex::encode(sha2::Sha256::digest(normalized_rsync_base_uri.as_bytes())); let dir = root.join(hash); @@ -107,7 +103,8 @@ impl RsyncFetcher for SystemRsyncFetcher { .mirror_dst_dir(&base) .map_err(|e| RsyncFetchError::Fetch(e.to_string()))? { - self.run_rsync(&base, &dst).map_err(RsyncFetchError::Fetch)?; + self.run_rsync(&base, &dst) + .map_err(RsyncFetchError::Fetch)?; let mut out = Vec::new(); walk_dir_collect(&dst, &dst, &base, &mut out).map_err(RsyncFetchError::Fetch)?; return Ok(out); @@ -249,4 +246,74 @@ mod tests { assert!(e.contains("rsync failed:"), "{e}"); assert!(e.contains("status="), "{e}"); } + + #[test] + fn mirror_dst_dir_reports_root_creation_error() { + let temp = tempfile::tempdir().expect("tempdir"); + let root_file = temp.path().join("mirror-root-file"); + std::fs::write(&root_file, b"not a directory").expect("write root file"); + + let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig { + rsync_bin: PathBuf::from("rsync"), + timeout: Duration::from_secs(1), + extra_args: Vec::new(), + mirror_root: Some(root_file.clone()), + }); + + let err = fetcher + .mirror_dst_dir("rsync://example.net/repo/") + .expect_err("file mirror root must fail"); + assert!(err.contains("create rsync mirror root failed"), "{err}"); + assert!(err.contains(&root_file.display().to_string()), "{err}"); + } + + #[cfg(unix)] + #[test] + fn mirror_dst_dir_reports_directory_creation_error_inside_root() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("mirror"); + std::fs::create_dir_all(&root).expect("mkdir root"); + let mut perms = std::fs::metadata(&root).expect("metadata").permissions(); + perms.set_mode(0o555); + std::fs::set_permissions(&root, perms).expect("chmod root readonly"); + + let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig { + rsync_bin: PathBuf::from("rsync"), + timeout: Duration::from_secs(1), + extra_args: Vec::new(), + mirror_root: Some(root.clone()), + }); + + let err = fetcher + .mirror_dst_dir("rsync://example.net/repo/") + .expect_err("readonly mirror root must fail"); + assert!( + err.contains("create rsync mirror directory failed"), + "{err}" + ); + + let mut perms = std::fs::metadata(&root).expect("metadata").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&root, perms).expect("restore perms"); + } + + #[cfg(unix)] + #[test] + fn walk_dir_collect_ignores_non_file_entries() { + use std::os::unix::net::UnixListener; + + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path(); + std::fs::write(root.join("a.cer"), b"x").expect("write file"); + let socket_path = root.join("skip.sock"); + let _listener = UnixListener::bind(&socket_path).expect("bind socket"); + + let mut out: Vec<(String, Vec)> = Vec::new(); + walk_dir_collect(root, root, "rsync://example.net/repo/", &mut out).expect("walk"); + + assert_eq!(out.len(), 1); + assert_eq!(out[0].0, "rsync://example.net/repo/a.cer"); + } } diff --git a/src/lib.rs b/src/lib.rs index bd6c599..44238fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,8 @@ pub mod audit; #[cfg(feature = "full")] pub mod audit_downloads; #[cfg(feature = "full")] +pub mod audit_trace; +#[cfg(feature = "full")] pub mod cli; #[cfg(feature = "full")] pub mod fetch; diff --git a/src/policy.rs b/src/policy.rs index db7c756..54efa9a 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -16,13 +16,13 @@ impl Default for SyncPreference { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CaFailedFetchPolicy { - UseFetchCachePp, + ReuseCurrentInstanceVcir, StopAllOutput, } impl Default for CaFailedFetchPolicy { fn default() -> Self { - Self::UseFetchCachePp + Self::ReuseCurrentInstanceVcir } } diff --git a/src/storage.rs b/src/storage.rs index ee2bad2..b5fd2b3 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,31 +1,61 @@ +use std::collections::HashSet; use std::path::Path; use rocksdb::{ ColumnFamily, ColumnFamilyDescriptor, DB, DBCompressionType, Direction, IteratorMode, Options, WriteBatch, }; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use sha2::Digest; -use std::collections::HashSet; -const CF_RAW_OBJECTS: &str = "raw_objects"; -const CF_FETCH_CACHE_PP: &str = "fetch_cache_pp"; -const CF_RRDP_STATE: &str = "rrdp_state"; -const CF_RRDP_OBJECT_INDEX: &str = "rrdp_object_index"; +use crate::data_model::rc::{AsResourceSet, IpResourceSet}; + +pub const CF_RAW_OBJECTS: &str = "raw_objects"; +pub const CF_RRDP_STATE: &str = "rrdp_state"; +pub const CF_RRDP_OBJECT_INDEX: &str = "rrdp_object_index"; +pub const CF_REPOSITORY_VIEW: &str = "repository_view"; +pub const CF_RAW_BY_HASH: &str = "raw_by_hash"; +pub const CF_VCIR: &str = "vcir"; +pub const CF_AUDIT_RULE_INDEX: &str = "audit_rule_index"; +pub const CF_RRDP_SOURCE: &str = "rrdp_source"; +pub const CF_RRDP_SOURCE_MEMBER: &str = "rrdp_source_member"; +pub const CF_RRDP_URI_OWNER: &str = "rrdp_uri_owner"; + +pub const ALL_COLUMN_FAMILY_NAMES: &[&str] = &[ + CF_RAW_OBJECTS, + CF_RRDP_STATE, + CF_RRDP_OBJECT_INDEX, + CF_REPOSITORY_VIEW, + CF_RAW_BY_HASH, + CF_VCIR, + CF_AUDIT_RULE_INDEX, + CF_RRDP_SOURCE, + CF_RRDP_SOURCE_MEMBER, + CF_RRDP_URI_OWNER, +]; const RRDP_OBJECT_INDEX_PREFIX: &[u8] = b"rrdp_obj:"; +const REPOSITORY_VIEW_KEY_PREFIX: &str = "repo_view:"; +const RAW_BY_HASH_KEY_PREFIX: &str = "rawbyhash:"; +const VCIR_KEY_PREFIX: &str = "vcir:"; +const AUDIT_ROA_RULE_KEY_PREFIX: &str = "audit:roa_rule:"; +const AUDIT_ASPA_RULE_KEY_PREFIX: &str = "audit:aspa_rule:"; +const RRDP_SOURCE_KEY_PREFIX: &str = "rrdp_source:"; +const RRDP_SOURCE_MEMBER_KEY_PREFIX: &str = "rrdp_source_member:"; +const RRDP_URI_OWNER_KEY_PREFIX: &str = "rrdp_uri_owner:"; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FetchCachePpKey(String); +fn cf_opts() -> Options { + let mut opts = Options::default(); + opts.set_compression_type(DBCompressionType::Lz4); + enable_blobdb_if_supported(&mut opts); + opts +} -impl FetchCachePpKey { - pub fn from_manifest_rsync_uri(manifest_rsync_uri: &str) -> Self { - Self(format!("fetch_cache_pp:{manifest_rsync_uri}")) - } - - pub fn as_str(&self) -> &str { - &self.0 - } +pub fn column_family_descriptors() -> Vec { + ALL_COLUMN_FAMILY_NAMES + .iter() + .map(|name| ColumnFamilyDescriptor::new(*name, cf_opts())) + .collect() } #[derive(Debug, thiserror::Error)] @@ -36,8 +66,17 @@ pub enum StorageError { #[error("missing column family: {0}")] MissingColumnFamily(&'static str), - #[error("fetch_cache_pp pack error: {0}")] - Pack(#[from] PackDecodeError), + #[error("cbor codec error for {entity}: {detail}")] + Codec { + entity: &'static str, + detail: String, + }, + + #[error("invalid {entity}: {detail}")] + InvalidData { + entity: &'static str, + detail: String, + }, } pub type StorageResult = Result; @@ -47,7 +86,7 @@ pub struct RocksStore { } pub mod pack { - pub use super::{FetchCachePpPack, PackDecodeError, PackFile, PackTime}; + pub use super::{PackFile, PackTime}; } #[derive(Clone, Debug, PartialEq, Eq)] @@ -56,36 +95,669 @@ pub enum RrdpDeltaOp { Delete { rsync_uri: String }, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RepositoryViewState { + Present, + Withdrawn, + Replaced, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RepositoryViewEntry { + pub rsync_uri: String, + pub current_hash: Option, + pub repository_source: Option, + pub object_type: Option, + pub state: RepositoryViewState, +} + +impl RepositoryViewEntry { + pub fn validate_internal(&self) -> StorageResult<()> { + validate_non_empty("repository_view.rsync_uri", &self.rsync_uri)?; + if let Some(source) = &self.repository_source { + validate_non_empty("repository_view.repository_source", source)?; + } + match self.state { + RepositoryViewState::Present | RepositoryViewState::Replaced => { + let hash = self + .current_hash + .as_deref() + .ok_or(StorageError::InvalidData { + entity: "repository_view", + detail: "current_hash is required when state is present or replaced" + .to_string(), + })?; + validate_sha256_hex("repository_view.current_hash", hash)?; + } + RepositoryViewState::Withdrawn => { + if let Some(hash) = &self.current_hash { + validate_sha256_hex("repository_view.current_hash", hash)?; + } + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RawByHashEntry { + pub sha256_hex: String, + pub bytes: Vec, + pub origin_uris: Vec, + pub object_type: Option, + pub encoding: Option, +} + +impl RawByHashEntry { + pub fn from_bytes(sha256_hex: impl Into, bytes: Vec) -> Self { + Self { + sha256_hex: sha256_hex.into(), + bytes, + origin_uris: Vec::new(), + object_type: None, + encoding: None, + } + } + + pub fn validate_internal(&self) -> StorageResult<()> { + validate_sha256_hex("raw_by_hash.sha256_hex", &self.sha256_hex)?; + if self.bytes.is_empty() { + return Err(StorageError::InvalidData { + entity: "raw_by_hash", + detail: "bytes must not be empty".to_string(), + }); + } + let computed = hex::encode(compute_sha256_32(&self.bytes)); + if computed != self.sha256_hex.to_ascii_lowercase() { + return Err(StorageError::InvalidData { + entity: "raw_by_hash", + detail: "sha256_hex does not match bytes".to_string(), + }); + } + let mut seen = HashSet::with_capacity(self.origin_uris.len()); + for uri in &self.origin_uris { + validate_non_empty("raw_by_hash.origin_uris[]", uri)?; + if !seen.insert(uri.as_str()) { + return Err(StorageError::InvalidData { + entity: "raw_by_hash", + detail: format!("duplicate origin URI: {uri}"), + }); + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ValidatedManifestMeta { + pub validated_manifest_number: Vec, + pub validated_manifest_this_update: PackTime, + pub validated_manifest_next_update: PackTime, +} + +impl ValidatedManifestMeta { + pub fn validate_internal(&self) -> StorageResult<()> { + validate_manifest_number_be( + "validated_manifest_meta.validated_manifest_number", + &self.validated_manifest_number, + )?; + let this_update = parse_time( + "validated_manifest_meta.validated_manifest_this_update", + &self.validated_manifest_this_update, + )?; + let next_update = parse_time( + "validated_manifest_meta.validated_manifest_next_update", + &self.validated_manifest_next_update, + )?; + if next_update < this_update { + return Err(StorageError::InvalidData { + entity: "validated_manifest_meta", + detail: "validated_manifest_next_update must be >= validated_manifest_this_update" + .to_string(), + }); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VcirInstanceGate { + pub manifest_next_update: PackTime, + pub current_crl_next_update: PackTime, + pub self_ca_not_after: PackTime, + pub instance_effective_until: PackTime, +} + +impl VcirInstanceGate { + pub fn validate_internal(&self) -> StorageResult<()> { + let manifest_next_update = parse_time( + "vcir.instance_gate.manifest_next_update", + &self.manifest_next_update, + )?; + let current_crl_next_update = parse_time( + "vcir.instance_gate.current_crl_next_update", + &self.current_crl_next_update, + )?; + let self_ca_not_after = parse_time( + "vcir.instance_gate.self_ca_not_after", + &self.self_ca_not_after, + )?; + let instance_effective_until = parse_time( + "vcir.instance_gate.instance_effective_until", + &self.instance_effective_until, + )?; + let expected = manifest_next_update + .min(current_crl_next_update) + .min(self_ca_not_after); + if instance_effective_until != expected { + return Err(StorageError::InvalidData { + entity: "vcir.instance_gate", + detail: "instance_effective_until must equal min(manifest_next_update, current_crl_next_update, self_ca_not_after)".to_string(), + }); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VcirChildEntry { + pub child_manifest_rsync_uri: String, + pub child_cert_rsync_uri: String, + pub child_cert_hash: String, + pub child_ski: String, + pub child_rsync_base_uri: String, + pub child_publication_point_rsync_uri: String, + pub child_rrdp_notification_uri: Option, + pub child_effective_ip_resources: Option, + pub child_effective_as_resources: Option, + pub accepted_at_validation_time: PackTime, +} + +impl VcirChildEntry { + pub fn validate_internal(&self) -> StorageResult<()> { + validate_non_empty( + "vcir.child_entries[].child_manifest_rsync_uri", + &self.child_manifest_rsync_uri, + )?; + validate_non_empty( + "vcir.child_entries[].child_cert_rsync_uri", + &self.child_cert_rsync_uri, + )?; + validate_sha256_hex( + "vcir.child_entries[].child_cert_hash", + &self.child_cert_hash, + )?; + validate_non_empty("vcir.child_entries[].child_ski", &self.child_ski)?; + validate_non_empty( + "vcir.child_entries[].child_rsync_base_uri", + &self.child_rsync_base_uri, + )?; + validate_non_empty( + "vcir.child_entries[].child_publication_point_rsync_uri", + &self.child_publication_point_rsync_uri, + )?; + if let Some(uri) = &self.child_rrdp_notification_uri { + validate_non_empty("vcir.child_entries[].child_rrdp_notification_uri", uri)?; + } + parse_time( + "vcir.child_entries[].accepted_at_validation_time", + &self.accepted_at_validation_time, + )?; + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VcirOutputType { + Vrp, + Aspa, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VcirLocalOutput { + pub output_id: String, + pub output_type: VcirOutputType, + pub item_effective_until: PackTime, + pub source_object_uri: String, + pub source_object_type: String, + pub source_object_hash: String, + pub source_ee_cert_hash: String, + pub payload_json: String, + pub rule_hash: String, + pub validation_path_hint: Vec, +} + +impl VcirLocalOutput { + pub fn validate_internal(&self) -> StorageResult<()> { + validate_non_empty("vcir.local_outputs[].output_id", &self.output_id)?; + parse_time( + "vcir.local_outputs[].item_effective_until", + &self.item_effective_until, + )?; + validate_non_empty( + "vcir.local_outputs[].source_object_uri", + &self.source_object_uri, + )?; + validate_non_empty( + "vcir.local_outputs[].source_object_type", + &self.source_object_type, + )?; + validate_sha256_hex( + "vcir.local_outputs[].source_object_hash", + &self.source_object_hash, + )?; + validate_sha256_hex( + "vcir.local_outputs[].source_ee_cert_hash", + &self.source_ee_cert_hash, + )?; + validate_sha256_hex("vcir.local_outputs[].rule_hash", &self.rule_hash)?; + validate_non_empty("vcir.local_outputs[].payload_json", &self.payload_json)?; + for hint in &self.validation_path_hint { + validate_non_empty("vcir.local_outputs[].validation_path_hint[]", hint)?; + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VcirArtifactRole { + Manifest, + CurrentCrl, + ChildCaCert, + SignedObject, + EeCert, + IssuerCert, + Tal, + TrustAnchorCert, + Other, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VcirArtifactKind { + Cer, + Crl, + Mft, + Roa, + Aspa, + Gbr, + Tal, + Other, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VcirArtifactValidationStatus { + Accepted, + Rejected, + WarningOnly, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VcirRelatedArtifact { + pub artifact_role: VcirArtifactRole, + pub artifact_kind: VcirArtifactKind, + pub uri: Option, + pub sha256: String, + pub object_type: Option, + pub validation_status: VcirArtifactValidationStatus, +} + +impl VcirRelatedArtifact { + pub fn validate_internal(&self) -> StorageResult<()> { + if let Some(uri) = &self.uri { + validate_non_empty("vcir.related_artifacts[].uri", uri)?; + } + validate_sha256_hex("vcir.related_artifacts[].sha256", &self.sha256)?; + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VcirSummary { + pub local_vrp_count: u32, + pub local_aspa_count: u32, + pub child_count: u32, + pub accepted_object_count: u32, + pub rejected_object_count: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VcirAuditSummary { + pub failed_fetch_eligible: bool, + pub last_failed_fetch_reason: Option, + pub warning_count: u32, + pub audit_flags: Vec, +} + +impl VcirAuditSummary { + pub fn validate_internal(&self) -> StorageResult<()> { + if let Some(reason) = &self.last_failed_fetch_reason { + validate_non_empty("vcir.audit_summary.last_failed_fetch_reason", reason)?; + } + for flag in &self.audit_flags { + validate_non_empty("vcir.audit_summary.audit_flags[]", flag)?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ValidatedCaInstanceResult { + pub manifest_rsync_uri: String, + pub parent_manifest_rsync_uri: Option, + pub tal_id: String, + pub ca_subject_name: String, + pub ca_ski: String, + pub issuer_ski: String, + pub last_successful_validation_time: PackTime, + pub current_manifest_rsync_uri: String, + pub current_crl_rsync_uri: String, + pub validated_manifest_meta: ValidatedManifestMeta, + pub instance_gate: VcirInstanceGate, + pub child_entries: Vec, + pub local_outputs: Vec, + pub related_artifacts: Vec, + pub summary: VcirSummary, + pub audit_summary: VcirAuditSummary, +} + +impl ValidatedCaInstanceResult { + pub fn validate_internal(&self) -> StorageResult<()> { + validate_non_empty("vcir.manifest_rsync_uri", &self.manifest_rsync_uri)?; + if let Some(parent_manifest_rsync_uri) = &self.parent_manifest_rsync_uri { + validate_non_empty("vcir.parent_manifest_rsync_uri", parent_manifest_rsync_uri)?; + } + validate_non_empty("vcir.tal_id", &self.tal_id)?; + validate_non_empty("vcir.ca_subject_name", &self.ca_subject_name)?; + validate_non_empty("vcir.ca_ski", &self.ca_ski)?; + validate_non_empty("vcir.issuer_ski", &self.issuer_ski)?; + parse_time( + "vcir.last_successful_validation_time", + &self.last_successful_validation_time, + )?; + validate_non_empty( + "vcir.current_manifest_rsync_uri", + &self.current_manifest_rsync_uri, + )?; + validate_non_empty("vcir.current_crl_rsync_uri", &self.current_crl_rsync_uri)?; + self.validated_manifest_meta.validate_internal()?; + self.instance_gate.validate_internal()?; + + let expected_manifest_next = self + .validated_manifest_meta + .validated_manifest_next_update + .parse() + .map_err(|detail| StorageError::InvalidData { + entity: "vcir", + detail: format!( + "validated_manifest_meta.validated_manifest_next_update invalid: {detail}" + ), + })?; + let instance_manifest_next = + self.instance_gate + .manifest_next_update + .parse() + .map_err(|detail| StorageError::InvalidData { + entity: "vcir", + detail: format!("instance_gate.manifest_next_update invalid: {detail}"), + })?; + if expected_manifest_next != instance_manifest_next { + return Err(StorageError::InvalidData { + entity: "vcir", + detail: "instance_gate.manifest_next_update must equal validated_manifest_meta.validated_manifest_next_update".to_string(), + }); + } + + let mut child_manifests = HashSet::with_capacity(self.child_entries.len()); + for child in &self.child_entries { + child.validate_internal()?; + if !child_manifests.insert(child.child_manifest_rsync_uri.as_str()) { + return Err(StorageError::InvalidData { + entity: "vcir", + detail: format!( + "duplicate child_manifest_rsync_uri: {}", + child.child_manifest_rsync_uri + ), + }); + } + } + + let mut output_ids = HashSet::with_capacity(self.local_outputs.len()); + let mut vrp_count = 0u32; + let mut aspa_count = 0u32; + for output in &self.local_outputs { + output.validate_internal()?; + if !output_ids.insert(output.output_id.as_str()) { + return Err(StorageError::InvalidData { + entity: "vcir", + detail: format!("duplicate output_id: {}", output.output_id), + }); + } + match output.output_type { + VcirOutputType::Vrp => vrp_count += 1, + VcirOutputType::Aspa => aspa_count += 1, + } + } + if self.summary.local_vrp_count != vrp_count { + return Err(StorageError::InvalidData { + entity: "vcir.summary", + detail: format!( + "local_vrp_count={} does not match local_outputs count {}", + self.summary.local_vrp_count, vrp_count + ), + }); + } + if self.summary.local_aspa_count != aspa_count { + return Err(StorageError::InvalidData { + entity: "vcir.summary", + detail: format!( + "local_aspa_count={} does not match local_outputs count {}", + self.summary.local_aspa_count, aspa_count + ), + }); + } + if self.summary.child_count != self.child_entries.len() as u32 { + return Err(StorageError::InvalidData { + entity: "vcir.summary", + detail: format!( + "child_count={} does not match child_entries length {}", + self.summary.child_count, + self.child_entries.len() + ), + }); + } + + for artifact in &self.related_artifacts { + artifact.validate_internal()?; + } + self.audit_summary.validate_internal()?; + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditRuleKind { + Roa, + Aspa, +} + +impl AuditRuleKind { + fn key_prefix(self) -> &'static str { + match self { + Self::Roa => AUDIT_ROA_RULE_KEY_PREFIX, + Self::Aspa => AUDIT_ASPA_RULE_KEY_PREFIX, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditRuleIndexEntry { + pub kind: AuditRuleKind, + pub rule_hash: String, + pub manifest_rsync_uri: String, + pub source_object_uri: String, + pub source_object_hash: String, + pub output_id: String, + pub item_effective_until: PackTime, +} + +impl AuditRuleIndexEntry { + pub fn validate_internal(&self) -> StorageResult<()> { + validate_sha256_hex("audit_rule_index.rule_hash", &self.rule_hash)?; + validate_non_empty( + "audit_rule_index.manifest_rsync_uri", + &self.manifest_rsync_uri, + )?; + validate_non_empty( + "audit_rule_index.source_object_uri", + &self.source_object_uri, + )?; + validate_sha256_hex( + "audit_rule_index.source_object_hash", + &self.source_object_hash, + )?; + validate_non_empty("audit_rule_index.output_id", &self.output_id)?; + parse_time( + "audit_rule_index.item_effective_until", + &self.item_effective_until, + )?; + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RrdpSourceSyncState { + Empty, + SnapshotOnly, + DeltaReady, + Error, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RrdpSourceRecord { + pub notify_uri: String, + pub last_session_id: Option, + pub last_serial: Option, + pub first_seen_at: PackTime, + pub last_seen_at: PackTime, + pub last_sync_at: Option, + pub sync_state: RrdpSourceSyncState, + pub last_snapshot_uri: Option, + pub last_snapshot_hash: Option, + pub last_error: Option, +} + +impl RrdpSourceRecord { + pub fn validate_internal(&self) -> StorageResult<()> { + validate_non_empty("rrdp_source.notify_uri", &self.notify_uri)?; + if let Some(session_id) = &self.last_session_id { + validate_non_empty("rrdp_source.last_session_id", session_id)?; + } + parse_time("rrdp_source.first_seen_at", &self.first_seen_at)?; + parse_time("rrdp_source.last_seen_at", &self.last_seen_at)?; + if let Some(last_sync_at) = &self.last_sync_at { + parse_time("rrdp_source.last_sync_at", last_sync_at)?; + } + if let Some(last_snapshot_uri) = &self.last_snapshot_uri { + validate_non_empty("rrdp_source.last_snapshot_uri", last_snapshot_uri)?; + } + if let Some(last_snapshot_hash) = &self.last_snapshot_hash { + validate_sha256_hex("rrdp_source.last_snapshot_hash", last_snapshot_hash)?; + } + if let Some(last_error) = &self.last_error { + validate_non_empty("rrdp_source.last_error", last_error)?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RrdpSourceMemberRecord { + pub notify_uri: String, + pub rsync_uri: String, + pub current_hash: Option, + pub object_type: Option, + pub present: bool, + pub last_confirmed_session_id: String, + pub last_confirmed_serial: u64, + pub last_changed_at: PackTime, +} + +impl RrdpSourceMemberRecord { + pub fn validate_internal(&self) -> StorageResult<()> { + validate_non_empty("rrdp_source_member.notify_uri", &self.notify_uri)?; + validate_non_empty("rrdp_source_member.rsync_uri", &self.rsync_uri)?; + validate_non_empty( + "rrdp_source_member.last_confirmed_session_id", + &self.last_confirmed_session_id, + )?; + if self.present { + let hash = self + .current_hash + .as_deref() + .ok_or(StorageError::InvalidData { + entity: "rrdp_source_member", + detail: "current_hash is required when present=true".to_string(), + })?; + validate_sha256_hex("rrdp_source_member.current_hash", hash)?; + } else if let Some(hash) = &self.current_hash { + validate_sha256_hex("rrdp_source_member.current_hash", hash)?; + } + parse_time("rrdp_source_member.last_changed_at", &self.last_changed_at)?; + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RrdpUriOwnerState { + Active, + Conflict, + Withdrawn, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RrdpUriOwnerRecord { + pub rsync_uri: String, + pub notify_uri: String, + pub current_hash: Option, + pub last_confirmed_session_id: String, + pub last_confirmed_serial: u64, + pub last_changed_at: PackTime, + pub owner_state: RrdpUriOwnerState, +} + +impl RrdpUriOwnerRecord { + pub fn validate_internal(&self) -> StorageResult<()> { + validate_non_empty("rrdp_uri_owner.rsync_uri", &self.rsync_uri)?; + validate_non_empty("rrdp_uri_owner.notify_uri", &self.notify_uri)?; + validate_non_empty( + "rrdp_uri_owner.last_confirmed_session_id", + &self.last_confirmed_session_id, + )?; + if let Some(hash) = &self.current_hash { + validate_sha256_hex("rrdp_uri_owner.current_hash", hash)?; + } + parse_time("rrdp_uri_owner.last_changed_at", &self.last_changed_at)?; + Ok(()) + } +} + impl RocksStore { pub fn open(path: &Path) -> StorageResult { let mut base_opts = Options::default(); base_opts.create_if_missing(true); base_opts.create_missing_column_families(true); - - // Prefer conservative compression; may be overridden later. base_opts.set_compression_type(DBCompressionType::Lz4); - - // Blob files / BlobDB enablement. - // - // IMPORTANT: `enable_blob_files` is a *column family option* in RocksDB. Setting it only - // on the DB's base options is not sufficient; every CF that stores values must enable it. enable_blobdb_if_supported(&mut base_opts); - fn cf_opts() -> Options { - let mut opts = Options::default(); - opts.set_compression_type(DBCompressionType::Lz4); - enable_blobdb_if_supported(&mut opts); - opts - } - - let cfs = vec![ - ColumnFamilyDescriptor::new(CF_RAW_OBJECTS, cf_opts()), - ColumnFamilyDescriptor::new(CF_FETCH_CACHE_PP, cf_opts()), - ColumnFamilyDescriptor::new(CF_RRDP_STATE, cf_opts()), - ColumnFamilyDescriptor::new(CF_RRDP_OBJECT_INDEX, cf_opts()), - ]; - - let db = DB::open_cf_descriptors(&base_opts, path, cfs) + let db = DB::open_cf_descriptors(&base_opts, path, column_family_descriptors()) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(Self { db }) @@ -105,10 +777,6 @@ impl RocksStore { Ok(()) } - /// Store multiple raw objects in a single RocksDB write batch. - /// - /// This is primarily used by rsync-based publication point sync to reduce write amplification - /// (many small `put_cf` calls) into one `DB::write()` call. pub fn put_raw_batch(&self, objects: Vec<(String, Vec)>) -> StorageResult { if objects.is_empty() { return Ok(0); @@ -140,23 +808,6 @@ impl RocksStore { Ok(()) } - pub fn put_fetch_cache_pp(&self, key: &FetchCachePpKey, bytes: &[u8]) -> StorageResult<()> { - let cf = self.cf(CF_FETCH_CACHE_PP)?; - self.db - .put_cf(cf, key.as_str().as_bytes(), bytes) - .map_err(|e| StorageError::RocksDb(e.to_string()))?; - Ok(()) - } - - pub fn get_fetch_cache_pp(&self, key: &FetchCachePpKey) -> StorageResult>> { - let cf = self.cf(CF_FETCH_CACHE_PP)?; - let v = self - .db - .get_cf(cf, key.as_str().as_bytes()) - .map_err(|e| StorageError::RocksDb(e.to_string()))?; - Ok(v) - } - pub fn put_rrdp_state(&self, notification_uri: &str, bytes: &[u8]) -> StorageResult<()> { let cf = self.cf(CF_RRDP_STATE)?; self.db @@ -269,14 +920,6 @@ impl RocksStore { Ok(keys.len()) } - /// Apply an RRDP snapshot as a complete repository state for this `notification_uri`. - /// - /// This updates: - /// - `raw_objects` (publish all objects, delete objects that were present in the previous - /// snapshot state but absent from this snapshot) - /// - `rrdp_object_index` membership (used to scope deletes to this RRDP repository) - /// - /// RFC 8182 §3.5.2.1: snapshots reflect the complete and current repository contents. pub fn apply_rrdp_snapshot( &self, notification_uri: &str, @@ -344,6 +987,295 @@ impl RocksStore { Ok(ops.len()) } + pub fn put_repository_view_entry(&self, entry: &RepositoryViewEntry) -> StorageResult<()> { + entry.validate_internal()?; + let cf = self.cf(CF_REPOSITORY_VIEW)?; + let key = repository_view_key(&entry.rsync_uri); + let value = encode_cbor(entry, "repository_view")?; + self.db + .put_cf(cf, key.as_bytes(), value) + .map_err(|e| StorageError::RocksDb(e.to_string()))?; + Ok(()) + } + + pub fn get_repository_view_entry( + &self, + rsync_uri: &str, + ) -> StorageResult> { + let cf = self.cf(CF_REPOSITORY_VIEW)?; + let key = repository_view_key(rsync_uri); + let Some(bytes) = self + .db + .get_cf(cf, key.as_bytes()) + .map_err(|e| StorageError::RocksDb(e.to_string()))? + else { + return Ok(None); + }; + let entry = decode_cbor::(&bytes, "repository_view")?; + entry.validate_internal()?; + Ok(Some(entry)) + } + + pub fn delete_repository_view_entry(&self, rsync_uri: &str) -> StorageResult<()> { + let cf = self.cf(CF_REPOSITORY_VIEW)?; + let key = repository_view_key(rsync_uri); + self.db + .delete_cf(cf, key.as_bytes()) + .map_err(|e| StorageError::RocksDb(e.to_string()))?; + Ok(()) + } + + pub fn list_repository_view_entries_with_prefix( + &self, + rsync_uri_prefix: &str, + ) -> StorageResult> { + let cf = self.cf(CF_REPOSITORY_VIEW)?; + let prefix = repository_view_prefix(rsync_uri_prefix); + let mode = IteratorMode::From(prefix.as_bytes(), Direction::Forward); + self.db + .iterator_cf(cf, mode) + .take_while(|res| match res { + Ok((key, _)) => key.starts_with(prefix.as_bytes()), + Err(_) => false, + }) + .map(|res| { + let (_key, value) = res.map_err(|e| StorageError::RocksDb(e.to_string()))?; + let entry = decode_cbor::(&value, "repository_view")?; + entry.validate_internal()?; + Ok(entry) + }) + .collect() + } + + pub fn put_raw_by_hash_entry(&self, entry: &RawByHashEntry) -> StorageResult<()> { + entry.validate_internal()?; + let cf = self.cf(CF_RAW_BY_HASH)?; + let key = raw_by_hash_key(&entry.sha256_hex); + let value = encode_cbor(entry, "raw_by_hash")?; + self.db + .put_cf(cf, key.as_bytes(), value) + .map_err(|e| StorageError::RocksDb(e.to_string()))?; + Ok(()) + } + + pub fn get_raw_by_hash_entry(&self, sha256_hex: &str) -> StorageResult> { + let cf = self.cf(CF_RAW_BY_HASH)?; + let key = raw_by_hash_key(sha256_hex); + let Some(bytes) = self + .db + .get_cf(cf, key.as_bytes()) + .map_err(|e| StorageError::RocksDb(e.to_string()))? + else { + return Ok(None); + }; + let entry = decode_cbor::(&bytes, "raw_by_hash")?; + entry.validate_internal()?; + Ok(Some(entry)) + } + + pub fn put_vcir(&self, vcir: &ValidatedCaInstanceResult) -> StorageResult<()> { + vcir.validate_internal()?; + let cf = self.cf(CF_VCIR)?; + let key = vcir_key(&vcir.manifest_rsync_uri); + let value = encode_cbor(vcir, "vcir")?; + self.db + .put_cf(cf, key.as_bytes(), value) + .map_err(|e| StorageError::RocksDb(e.to_string()))?; + Ok(()) + } + + pub fn get_vcir( + &self, + manifest_rsync_uri: &str, + ) -> StorageResult> { + let cf = self.cf(CF_VCIR)?; + let key = vcir_key(manifest_rsync_uri); + let Some(bytes) = self + .db + .get_cf(cf, key.as_bytes()) + .map_err(|e| StorageError::RocksDb(e.to_string()))? + else { + return Ok(None); + }; + let vcir = decode_cbor::(&bytes, "vcir")?; + vcir.validate_internal()?; + Ok(Some(vcir)) + } + + pub fn delete_vcir(&self, manifest_rsync_uri: &str) -> StorageResult<()> { + let cf = self.cf(CF_VCIR)?; + let key = vcir_key(manifest_rsync_uri); + self.db + .delete_cf(cf, key.as_bytes()) + .map_err(|e| StorageError::RocksDb(e.to_string()))?; + Ok(()) + } + + pub fn put_audit_rule_index_entry(&self, entry: &AuditRuleIndexEntry) -> StorageResult<()> { + entry.validate_internal()?; + let cf = self.cf(CF_AUDIT_RULE_INDEX)?; + let key = audit_rule_key(entry.kind, &entry.rule_hash); + let value = encode_cbor(entry, "audit_rule_index")?; + self.db + .put_cf(cf, key.as_bytes(), value) + .map_err(|e| StorageError::RocksDb(e.to_string()))?; + Ok(()) + } + + pub fn get_audit_rule_index_entry( + &self, + kind: AuditRuleKind, + rule_hash: &str, + ) -> StorageResult> { + let cf = self.cf(CF_AUDIT_RULE_INDEX)?; + let key = audit_rule_key(kind, rule_hash); + let Some(bytes) = self + .db + .get_cf(cf, key.as_bytes()) + .map_err(|e| StorageError::RocksDb(e.to_string()))? + else { + return Ok(None); + }; + let entry = decode_cbor::(&bytes, "audit_rule_index")?; + entry.validate_internal()?; + Ok(Some(entry)) + } + + pub fn delete_audit_rule_index_entry( + &self, + kind: AuditRuleKind, + rule_hash: &str, + ) -> StorageResult<()> { + let cf = self.cf(CF_AUDIT_RULE_INDEX)?; + let key = audit_rule_key(kind, rule_hash); + self.db + .delete_cf(cf, key.as_bytes()) + .map_err(|e| StorageError::RocksDb(e.to_string()))?; + Ok(()) + } + + pub fn put_rrdp_source_record(&self, record: &RrdpSourceRecord) -> StorageResult<()> { + record.validate_internal()?; + let cf = self.cf(CF_RRDP_SOURCE)?; + let key = rrdp_source_key(&record.notify_uri); + let value = encode_cbor(record, "rrdp_source")?; + self.db + .put_cf(cf, key.as_bytes(), value) + .map_err(|e| StorageError::RocksDb(e.to_string()))?; + Ok(()) + } + + pub fn get_rrdp_source_record( + &self, + notify_uri: &str, + ) -> StorageResult> { + let cf = self.cf(CF_RRDP_SOURCE)?; + let key = rrdp_source_key(notify_uri); + let Some(bytes) = self + .db + .get_cf(cf, key.as_bytes()) + .map_err(|e| StorageError::RocksDb(e.to_string()))? + else { + return Ok(None); + }; + let record = decode_cbor::(&bytes, "rrdp_source")?; + record.validate_internal()?; + Ok(Some(record)) + } + + pub fn put_rrdp_source_member_record( + &self, + record: &RrdpSourceMemberRecord, + ) -> StorageResult<()> { + record.validate_internal()?; + let cf = self.cf(CF_RRDP_SOURCE_MEMBER)?; + let key = rrdp_source_member_key(&record.notify_uri, &record.rsync_uri); + let value = encode_cbor(record, "rrdp_source_member")?; + self.db + .put_cf(cf, key.as_bytes(), value) + .map_err(|e| StorageError::RocksDb(e.to_string()))?; + Ok(()) + } + + pub fn get_rrdp_source_member_record( + &self, + notify_uri: &str, + rsync_uri: &str, + ) -> StorageResult> { + let cf = self.cf(CF_RRDP_SOURCE_MEMBER)?; + let key = rrdp_source_member_key(notify_uri, rsync_uri); + let Some(bytes) = self + .db + .get_cf(cf, key.as_bytes()) + .map_err(|e| StorageError::RocksDb(e.to_string()))? + else { + return Ok(None); + }; + let record = decode_cbor::(&bytes, "rrdp_source_member")?; + record.validate_internal()?; + Ok(Some(record)) + } + + pub fn list_rrdp_source_member_records( + &self, + notify_uri: &str, + ) -> StorageResult> { + let cf = self.cf(CF_RRDP_SOURCE_MEMBER)?; + let prefix = rrdp_source_member_prefix(notify_uri); + let mode = IteratorMode::From(prefix.as_bytes(), Direction::Forward); + self.db + .iterator_cf(cf, mode) + .take_while(|res| match res { + Ok((key, _)) => key.starts_with(prefix.as_bytes()), + Err(_) => false, + }) + .map(|res| { + let (_key, value) = res.map_err(|e| StorageError::RocksDb(e.to_string()))?; + let record = decode_cbor::(&value, "rrdp_source_member")?; + record.validate_internal()?; + Ok(record) + }) + .collect() + } + + pub fn put_rrdp_uri_owner_record(&self, record: &RrdpUriOwnerRecord) -> StorageResult<()> { + record.validate_internal()?; + let cf = self.cf(CF_RRDP_URI_OWNER)?; + let key = rrdp_uri_owner_key(&record.rsync_uri); + let value = encode_cbor(record, "rrdp_uri_owner")?; + self.db + .put_cf(cf, key.as_bytes(), value) + .map_err(|e| StorageError::RocksDb(e.to_string()))?; + Ok(()) + } + + pub fn get_rrdp_uri_owner_record( + &self, + rsync_uri: &str, + ) -> StorageResult> { + let cf = self.cf(CF_RRDP_URI_OWNER)?; + let key = rrdp_uri_owner_key(rsync_uri); + let Some(bytes) = self + .db + .get_cf(cf, key.as_bytes()) + .map_err(|e| StorageError::RocksDb(e.to_string()))? + else { + return Ok(None); + }; + let record = decode_cbor::(&bytes, "rrdp_uri_owner")?; + record.validate_internal()?; + Ok(Some(record)) + } + + pub fn delete_rrdp_uri_owner_record(&self, rsync_uri: &str) -> StorageResult<()> { + let cf = self.cf(CF_RRDP_URI_OWNER)?; + let key = rrdp_uri_owner_key(rsync_uri); + self.db + .delete_cf(cf, key.as_bytes()) + .map_err(|e| StorageError::RocksDb(e.to_string()))?; + Ok(()) + } + #[allow(dead_code)] pub fn raw_iter_prefix<'a>( &'a self, @@ -371,15 +1303,7 @@ impl RocksStore { } #[allow(dead_code)] - pub fn fetch_cache_pp_iter_all<'a>( - &'a self, - ) -> StorageResult, Box<[u8]>)> + 'a> { - let cf = self.cf(CF_FETCH_CACHE_PP)?; - let mode = IteratorMode::Start; - Ok(self.db.iterator_cf(cf, mode).filter_map(|res| res.ok())) - } - #[allow(dead_code)] pub fn write_batch(&self, batch: WriteBatch) -> StorageResult<()> { self.db .write(batch) @@ -388,145 +1312,118 @@ impl RocksStore { } } +fn repository_view_key(rsync_uri: &str) -> String { + format!("{REPOSITORY_VIEW_KEY_PREFIX}{rsync_uri}") +} + +fn repository_view_prefix(rsync_uri_prefix: &str) -> String { + format!("{REPOSITORY_VIEW_KEY_PREFIX}{rsync_uri_prefix}") +} + +fn raw_by_hash_key(sha256_hex: &str) -> String { + format!("{RAW_BY_HASH_KEY_PREFIX}{sha256_hex}") +} + +fn vcir_key(manifest_rsync_uri: &str) -> String { + format!("{VCIR_KEY_PREFIX}{manifest_rsync_uri}") +} + +fn audit_rule_key(kind: AuditRuleKind, rule_hash: &str) -> String { + format!("{}{rule_hash}", kind.key_prefix()) +} + +fn rrdp_source_key(notify_uri: &str) -> String { + format!("{RRDP_SOURCE_KEY_PREFIX}{notify_uri}") +} + +fn rrdp_source_member_key(notify_uri: &str, rsync_uri: &str) -> String { + format!("{RRDP_SOURCE_MEMBER_KEY_PREFIX}{notify_uri}:{rsync_uri}") +} + +fn rrdp_source_member_prefix(notify_uri: &str) -> String { + format!("{RRDP_SOURCE_MEMBER_KEY_PREFIX}{notify_uri}:") +} + +fn rrdp_uri_owner_key(rsync_uri: &str) -> String { + format!("{RRDP_URI_OWNER_KEY_PREFIX}{rsync_uri}") +} + +fn encode_cbor(value: &T, entity: &'static str) -> StorageResult> { + serde_cbor::to_vec(value).map_err(|e| StorageError::Codec { + entity, + detail: e.to_string(), + }) +} + +fn decode_cbor(bytes: &[u8], entity: &'static str) -> StorageResult { + serde_cbor::from_slice(bytes).map_err(|e| StorageError::Codec { + entity, + detail: e.to_string(), + }) +} + +fn validate_non_empty(field: &'static str, value: &str) -> StorageResult<()> { + if value.is_empty() { + return Err(StorageError::InvalidData { + entity: field, + detail: "must not be empty".to_string(), + }); + } + Ok(()) +} + +fn validate_sha256_hex(field: &'static str, value: &str) -> StorageResult<()> { + if value.len() != 64 || !value.as_bytes().iter().all(u8::is_ascii_hexdigit) { + return Err(StorageError::InvalidData { + entity: field, + detail: "must be a 64-character lowercase or uppercase SHA-256 hex string".to_string(), + }); + } + Ok(()) +} + +fn validate_manifest_number_be(field: &'static str, value: &[u8]) -> StorageResult<()> { + if value.is_empty() { + return Err(StorageError::InvalidData { + entity: field, + detail: "must not be empty".to_string(), + }); + } + if value.len() > 20 { + return Err(StorageError::InvalidData { + entity: field, + detail: "must be at most 20 octets".to_string(), + }); + } + if value.len() > 1 && value[0] == 0 { + return Err(StorageError::InvalidData { + entity: field, + detail: "must be minimal big-endian without leading zeros".to_string(), + }); + } + Ok(()) +} + +fn parse_time(field: &'static str, value: &PackTime) -> StorageResult { + value.parse().map_err(|detail| StorageError::InvalidData { + entity: field, + detail, + }) +} + fn enable_blobdb_if_supported(opts: &mut Options) { - // Rust bindings may or may not expose BlobDB options depending on RocksDB build. - // We enable them in a best-effort way using method detection via trait bounds is - // not possible in stable Rust; so we keep this minimal. - // - // If the crate exposes `set_enable_blob_files`, use it; otherwise do nothing. #[allow(unused_mut)] let mut _enabled = false; #[allow(dead_code)] fn _set(opts: &mut Options) { - // If this method exists, this compiles and enables BlobDB. opts.set_enable_blob_files(true); opts.set_min_blob_size(1024); } - // Call the helper in a way that fails to compile if methods don't exist. - // To keep compilation stable, this file is expected to compile with a rocksdb - // crate version that exposes these setters. If not, remove the call and use - // plain RocksDB (still compatible with later enabling). _set(opts); } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct FetchCachePpPack { - pub format_version: u32, - - pub manifest_rsync_uri: String, - pub publication_point_rsync_uri: String, - - /// Manifest manifestNumber value (RFC 9286 §4.2.1). - /// - /// Minimal big-endian bytes. For zero, this is `[0]`. - pub manifest_number_be: Vec, - - pub this_update: PackTime, - pub next_update: PackTime, - - pub verified_at: PackTime, - - pub manifest_bytes: Vec, - - /// Objects listed in the Manifest fileList (RFC 9286 §4.2.1). - /// - /// Note: the manifest object itself is *not* listed in fileList; it is stored separately - /// in `manifest_bytes`. - pub files: Vec, -} - -impl FetchCachePpPack { - pub const FORMAT_VERSION_V1: u32 = 1; - - pub fn encode(&self) -> StorageResult> { - serde_cbor::to_vec(self).map_err(|e| PackDecodeError::Encode(e.to_string()).into()) - } - - pub fn decode(bytes: &[u8]) -> StorageResult { - let pack: Self = - serde_cbor::from_slice(bytes).map_err(|e| PackDecodeError::Decode(e.to_string()))?; - pack.validate_internal()?; - Ok(pack) - } - - pub fn validate_internal(&self) -> StorageResult<()> { - if self.format_version != Self::FORMAT_VERSION_V1 { - return Err(PackDecodeError::UnsupportedFormatVersion(self.format_version).into()); - } - - if self.manifest_rsync_uri.is_empty() { - return Err(PackDecodeError::MissingField("manifest_rsync_uri").into()); - } - if self.publication_point_rsync_uri.is_empty() { - return Err(PackDecodeError::MissingField("publication_point_rsync_uri").into()); - } - if self.manifest_number_be.is_empty() { - return Err(PackDecodeError::MissingField("manifest_number_be").into()); - } - if self.manifest_number_be.len() > 20 { - return Err(PackDecodeError::InvalidField { - field: "manifest_number_be", - detail: "must be at most 20 octets (RFC 9286 §4.2.1)", - } - .into()); - } - if self.manifest_number_be.len() > 1 && self.manifest_number_be[0] == 0 { - return Err(PackDecodeError::InvalidField { - field: "manifest_number_be", - detail: "must be minimal big-endian (no leading zeros)", - } - .into()); - } - - self.this_update - .parse() - .map_err(|e| PackDecodeError::InvalidTimeField { - field: "this_update", - detail: e, - })?; - self.next_update - .parse() - .map_err(|e| PackDecodeError::InvalidTimeField { - field: "next_update", - detail: e, - })?; - self.verified_at - .parse() - .map_err(|e| PackDecodeError::InvalidTimeField { - field: "verified_at", - detail: e, - })?; - - if self.manifest_bytes.is_empty() { - return Err(PackDecodeError::MissingManifestBytes.into()); - } - - let mut seen: HashSet<&str> = HashSet::with_capacity(self.files.len()); - for file in &self.files { - if file.rsync_uri.is_empty() { - return Err(PackDecodeError::MissingField("files[].rsync_uri").into()); - } - if !seen.insert(file.rsync_uri.as_str()) { - return Err(PackDecodeError::DuplicateFileRsyncUri(file.rsync_uri.clone()).into()); - } - if file.bytes.is_empty() { - return Err(PackDecodeError::EmptyFileBytes(file.rsync_uri.clone()).into()); - } - let computed = file.compute_sha256(); - if computed != file.sha256 { - return Err(PackDecodeError::FileHashMismatch { - rsync_uri: file.rsync_uri.clone(), - } - .into()); - } - } - - Ok(()) - } -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PackFile { pub rsync_uri: String, @@ -555,7 +1452,6 @@ impl PackFile { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PackTime { - /// RFC 3339 timestamp in UTC, e.g. "2026-02-06T00:00:00Z". pub rfc3339_utc: String, } @@ -573,42 +1469,6 @@ impl PackTime { } } -#[derive(Debug, thiserror::Error)] -pub enum PackDecodeError { - #[error("encode fetch_cache_pp pack failed: {0}")] - Encode(String), - - #[error("decode fetch_cache_pp pack failed: {0}")] - Decode(String), - - #[error("unsupported pack format_version: {0}")] - UnsupportedFormatVersion(u32), - - #[error("missing required field: {0}")] - MissingField(&'static str), - - #[error("invalid field {field}: {detail}")] - InvalidField { - field: &'static str, - detail: &'static str, - }, - - #[error("missing manifest_bytes in fetch_cache_pp pack")] - MissingManifestBytes, - - #[error("duplicate file rsync uri in fetch_cache_pp pack: {0}")] - DuplicateFileRsyncUri(String), - - #[error("empty file bytes in fetch_cache_pp pack: {0}")] - EmptyFileBytes(String), - - #[error("file hash mismatch in fetch_cache_pp pack: {rsync_uri}")] - FileHashMismatch { rsync_uri: String }, - - #[error("invalid time field {field}: {detail}")] - InvalidTimeField { field: &'static str, detail: String }, -} - fn compute_sha256_32(bytes: &[u8]) -> [u8; 32] { let digest = sha2::Sha256::digest(bytes); let mut out = [0u8; 32]; @@ -620,6 +1480,205 @@ fn compute_sha256_32(bytes: &[u8]) -> [u8; 32] { mod tests { use super::*; + fn pack_time(hour: i64) -> PackTime { + PackTime::from_utc_offset_datetime( + time::OffsetDateTime::UNIX_EPOCH + time::Duration::hours(hour), + ) + } + + fn sha256_hex(input: &[u8]) -> String { + hex::encode(compute_sha256_32(input)) + } + + fn sample_repository_view_entry(rsync_uri: &str, bytes: &[u8]) -> RepositoryViewEntry { + RepositoryViewEntry { + rsync_uri: rsync_uri.to_string(), + current_hash: Some(sha256_hex(bytes)), + repository_source: Some("https://rrdp.example.test/notification.xml".to_string()), + object_type: Some("cer".to_string()), + state: RepositoryViewState::Present, + } + } + + fn sample_raw_by_hash_entry(bytes: Vec) -> RawByHashEntry { + RawByHashEntry { + sha256_hex: sha256_hex(&bytes), + bytes, + origin_uris: vec!["rsync://example.test/repo/object.cer".to_string()], + object_type: Some("cer".to_string()), + encoding: Some("der".to_string()), + } + } + + fn sample_vcir(manifest_rsync_uri: &str) -> ValidatedCaInstanceResult { + let roa_bytes = b"roa-object".to_vec(); + let ee_bytes = b"ee-cert".to_vec(); + let child_bytes = b"child-cert".to_vec(); + ValidatedCaInstanceResult { + manifest_rsync_uri: manifest_rsync_uri.to_string(), + parent_manifest_rsync_uri: Some( + "rsync://example.test/repo/parent/parent.mft".to_string(), + ), + tal_id: "apnic".to_string(), + ca_subject_name: "CN=Example CA".to_string(), + ca_ski: "00112233445566778899aabbccddeeff00112233".to_string(), + issuer_ski: "ffeeddccbbaa99887766554433221100ffeeddcc".to_string(), + last_successful_validation_time: pack_time(0), + current_manifest_rsync_uri: manifest_rsync_uri.to_string(), + current_crl_rsync_uri: "rsync://example.test/repo/current.crl".to_string(), + validated_manifest_meta: ValidatedManifestMeta { + validated_manifest_number: vec![3], + validated_manifest_this_update: pack_time(0), + validated_manifest_next_update: pack_time(24), + }, + instance_gate: VcirInstanceGate { + manifest_next_update: pack_time(24), + current_crl_next_update: pack_time(12), + self_ca_not_after: pack_time(48), + instance_effective_until: pack_time(12), + }, + child_entries: vec![VcirChildEntry { + child_manifest_rsync_uri: "rsync://example.test/repo/child/child.mft".to_string(), + child_cert_rsync_uri: "rsync://example.test/repo/child/child.cer".to_string(), + child_cert_hash: sha256_hex(&child_bytes), + child_ski: "1234567890abcdef1234567890abcdef12345678".to_string(), + child_rsync_base_uri: "rsync://example.test/repo/child/".to_string(), + child_publication_point_rsync_uri: "rsync://example.test/repo/child/".to_string(), + child_rrdp_notification_uri: Some( + "https://example.test/child-notify.xml".to_string(), + ), + child_effective_ip_resources: None, + child_effective_as_resources: None, + accepted_at_validation_time: pack_time(0), + }], + local_outputs: vec![ + VcirLocalOutput { + output_id: "vrp-1".to_string(), + output_type: VcirOutputType::Vrp, + item_effective_until: pack_time(12), + source_object_uri: "rsync://example.test/repo/object.roa".to_string(), + source_object_type: "roa".to_string(), + source_object_hash: sha256_hex(&roa_bytes), + source_ee_cert_hash: sha256_hex(&ee_bytes), + payload_json: r#"{"asn":64496,"prefix":"203.0.113.0/24"}"#.to_string(), + rule_hash: sha256_hex(b"vrp-rule-1"), + validation_path_hint: vec![manifest_rsync_uri.to_string()], + }, + VcirLocalOutput { + output_id: "aspa-1".to_string(), + output_type: VcirOutputType::Aspa, + item_effective_until: pack_time(10), + source_object_uri: "rsync://example.test/repo/object.asa".to_string(), + source_object_type: "aspa".to_string(), + source_object_hash: sha256_hex(b"aspa-object"), + source_ee_cert_hash: sha256_hex(b"aspa-ee-cert"), + payload_json: r#"{"customer_as":64496,"providers":[64497]}"#.to_string(), + rule_hash: sha256_hex(b"aspa-rule-1"), + validation_path_hint: vec![manifest_rsync_uri.to_string()], + }, + ], + related_artifacts: vec![ + VcirRelatedArtifact { + artifact_role: VcirArtifactRole::Manifest, + artifact_kind: VcirArtifactKind::Mft, + uri: Some(manifest_rsync_uri.to_string()), + sha256: sha256_hex(b"manifest-object"), + object_type: Some("mft".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + VcirRelatedArtifact { + artifact_role: VcirArtifactRole::CurrentCrl, + artifact_kind: VcirArtifactKind::Crl, + uri: Some("rsync://example.test/repo/current.crl".to_string()), + sha256: sha256_hex(b"current-crl"), + object_type: Some("crl".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + ], + summary: VcirSummary { + local_vrp_count: 1, + local_aspa_count: 1, + child_count: 1, + accepted_object_count: 4, + rejected_object_count: 0, + }, + audit_summary: VcirAuditSummary { + failed_fetch_eligible: true, + last_failed_fetch_reason: None, + warning_count: 0, + audit_flags: vec!["validated-fresh".to_string()], + }, + } + } + + fn sample_audit_rule_entry(kind: AuditRuleKind) -> AuditRuleIndexEntry { + AuditRuleIndexEntry { + kind, + rule_hash: sha256_hex(match kind { + AuditRuleKind::Roa => b"roa-index-rule", + AuditRuleKind::Aspa => b"aspa-index-rule", + }), + manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(), + source_object_uri: match kind { + AuditRuleKind::Roa => "rsync://example.test/repo/object.roa".to_string(), + AuditRuleKind::Aspa => "rsync://example.test/repo/object.asa".to_string(), + }, + source_object_hash: sha256_hex(match kind { + AuditRuleKind::Roa => b"roa-object", + AuditRuleKind::Aspa => b"aspa-object", + }), + output_id: match kind { + AuditRuleKind::Roa => "vrp-1".to_string(), + AuditRuleKind::Aspa => "aspa-1".to_string(), + }, + item_effective_until: pack_time(12), + } + } + + fn sample_rrdp_source_record(notify_uri: &str) -> RrdpSourceRecord { + RrdpSourceRecord { + notify_uri: notify_uri.to_string(), + last_session_id: Some("session-1".to_string()), + last_serial: Some(42), + first_seen_at: pack_time(0), + last_seen_at: pack_time(1), + last_sync_at: Some(pack_time(1)), + sync_state: RrdpSourceSyncState::DeltaReady, + last_snapshot_uri: Some("https://rrdp.example.test/snapshot.xml".to_string()), + last_snapshot_hash: Some(sha256_hex(b"snapshot-bytes")), + last_error: None, + } + } + + fn sample_rrdp_source_member_record( + notify_uri: &str, + rsync_uri: &str, + serial: u64, + ) -> RrdpSourceMemberRecord { + RrdpSourceMemberRecord { + notify_uri: notify_uri.to_string(), + rsync_uri: rsync_uri.to_string(), + current_hash: Some(sha256_hex(rsync_uri.as_bytes())), + object_type: Some("cer".to_string()), + present: true, + last_confirmed_session_id: "session-1".to_string(), + last_confirmed_serial: serial, + last_changed_at: pack_time(serial as i64), + } + } + + fn sample_rrdp_uri_owner_record(notify_uri: &str, rsync_uri: &str) -> RrdpUriOwnerRecord { + RrdpUriOwnerRecord { + rsync_uri: rsync_uri.to_string(), + notify_uri: notify_uri.to_string(), + current_hash: Some(sha256_hex(rsync_uri.as_bytes())), + last_confirmed_session_id: "session-1".to_string(), + last_confirmed_serial: 7, + last_changed_at: pack_time(7), + owner_state: RrdpUriOwnerState::Active, + } + } + #[test] fn rrdp_object_index_and_snapshot_delta_helpers_work_end_to_end() { let td = tempfile::tempdir().expect("tempdir"); @@ -629,7 +1688,6 @@ mod tests { let u1 = "rsync://rpki.example.test/repo/obj1.cer".to_string(); let u2 = "rsync://rpki.example.test/repo/obj2.mft".to_string(); - // Empty clear is a fast no-op. assert_eq!( store .rrdp_object_index_clear(notification_uri) @@ -637,7 +1695,6 @@ mod tests { 0 ); - // Snapshot publishes two objects. let published_v1 = vec![(u1.clone(), vec![1u8, 2, 3]), (u2.clone(), vec![9u8, 8, 7])]; let n = store .apply_rrdp_snapshot(notification_uri, &published_v1) @@ -670,7 +1727,6 @@ mod tests { listed.sort(); assert_eq!(listed, vec![u1.clone(), u2.clone()]); - // Snapshot v2 removes u1 and updates u2. let published_v2 = vec![(u2.clone(), vec![0u8, 1, 2, 3])]; store .apply_rrdp_snapshot(notification_uri, &published_v2) @@ -681,7 +1737,6 @@ mod tests { Some(vec![0u8, 1, 2, 3]) ); - // Delta can upsert and delete, and uses the index to scope membership. let u3 = "rsync://rpki.example.test/repo/obj3.crl".to_string(); let ops = vec![ RrdpDeltaOp::Upsert { @@ -702,7 +1757,6 @@ mod tests { Some(vec![4u8, 5, 6]) ); - // Prefix iterators yield only matching keys. let prefix = b"rsync://rpki.example.test/repo/"; let mut got: Vec = store .raw_iter_prefix(prefix) @@ -719,7 +1773,6 @@ mod tests { .collect(); assert!(all.contains(&u3)); - // Clearing removes all index entries for this RRDP repository. let cleared = store .rrdp_object_index_clear(notification_uri) .expect("clear"); @@ -731,24 +1784,6 @@ mod tests { ); } - fn minimal_valid_pack() -> FetchCachePpPack { - let now = time::OffsetDateTime::now_utc(); - FetchCachePpPack { - format_version: FetchCachePpPack::FORMAT_VERSION_V1, - manifest_rsync_uri: "rsync://example.test/repo/pp/manifest.mft".to_string(), - publication_point_rsync_uri: "rsync://example.test/repo/pp/".to_string(), - manifest_number_be: vec![1], - this_update: PackTime::from_utc_offset_datetime(now), - next_update: PackTime::from_utc_offset_datetime(now + time::Duration::hours(1)), - verified_at: PackTime::from_utc_offset_datetime(now), - manifest_bytes: vec![0x01], - files: vec![PackFile::from_bytes_compute_sha256( - "rsync://example.test/repo/pp/a.roa", - vec![1u8, 2, 3], - )], - } - } - #[test] fn apply_rrdp_delta_empty_ops_is_noop() { let td = tempfile::tempdir().expect("tempdir"); @@ -760,47 +1795,371 @@ mod tests { } #[test] - fn fetch_cache_pp_pack_rejects_invalid_time_fields_and_duplicates() { - // Invalid `next_update`. - let mut p = minimal_valid_pack(); - p.next_update.rfc3339_utc = "not-a-time".to_string(); - let e = p.validate_internal().unwrap_err().to_string(); - assert!(e.contains("invalid time field next_update"), "{e}"); - - // Invalid `verified_at`. - let mut p = minimal_valid_pack(); - p.verified_at.rfc3339_utc = "also-not-a-time".to_string(); - let e = p.validate_internal().unwrap_err().to_string(); - assert!(e.contains("invalid time field verified_at"), "{e}"); - - // Duplicate file rsync URI. - let mut p = minimal_valid_pack(); - let f = p.files[0].clone(); - p.files.push(f); - let e = p.validate_internal().unwrap_err().to_string(); - assert!(e.contains("duplicate file rsync uri"), "{e}"); - } - - #[test] - fn fetch_cache_pp_iter_all_lists_keys() { + fn repository_view_and_raw_by_hash_roundtrip() { let td = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(td.path()).expect("open rocksdb"); - let key = - FetchCachePpKey::from_manifest_rsync_uri("rsync://example.test/repo/pp/manifest.mft"); - let bytes = minimal_valid_pack().encode().expect("encode pack"); + let entry1 = sample_repository_view_entry("rsync://example.test/repo/a.cer", b"object-a"); + let entry2 = + sample_repository_view_entry("rsync://example.test/repo/sub/b.roa", b"object-b"); store - .put_fetch_cache_pp(&key, &bytes) - .expect("put fetch_cache_pp"); + .put_repository_view_entry(&entry1) + .expect("put repository view entry1"); + store + .put_repository_view_entry(&entry2) + .expect("put repository view entry2"); - let keys: Vec = store - .fetch_cache_pp_iter_all() - .expect("iter all") - .map(|(k, _v)| String::from_utf8(k.to_vec()).expect("utf8 key")) - .collect(); + let got1 = store + .get_repository_view_entry(&entry1.rsync_uri) + .expect("get repository view entry1") + .expect("entry1 exists"); + assert_eq!(got1, entry1); + + let got_prefix = store + .list_repository_view_entries_with_prefix("rsync://example.test/repo/sub/") + .expect("list repository view prefix"); + assert_eq!(got_prefix, vec![entry2.clone()]); + + store + .delete_repository_view_entry(&entry1.rsync_uri) + .expect("delete repository view entry1"); assert!( - keys.iter().any(|k| k == key.as_str()), - "missing key in iterator" + store + .get_repository_view_entry(&entry1.rsync_uri) + .expect("get deleted repository view entry1") + .is_none() + ); + + let raw = sample_raw_by_hash_entry(b"raw-der-object".to_vec()); + store + .put_raw_by_hash_entry(&raw) + .expect("put raw_by_hash entry"); + let got_raw = store + .get_raw_by_hash_entry(&raw.sha256_hex) + .expect("get raw_by_hash entry") + .expect("raw entry exists"); + assert_eq!(got_raw, raw); + } + + #[test] + fn repository_view_and_raw_by_hash_validation_errors_are_reported() { + let td = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(td.path()).expect("open rocksdb"); + + let invalid_view = RepositoryViewEntry { + rsync_uri: "rsync://example.test/repo/withdrawn.cer".to_string(), + current_hash: None, + repository_source: None, + object_type: None, + state: RepositoryViewState::Present, + }; + let err = store + .put_repository_view_entry(&invalid_view) + .expect_err("missing current_hash must fail"); + assert!(err.to_string().contains("current_hash is required")); + + let invalid_raw = RawByHashEntry { + sha256_hex: sha256_hex(b"expected"), + bytes: b"actual".to_vec(), + origin_uris: vec!["rsync://example.test/repo/object.cer".to_string()], + object_type: None, + encoding: None, + }; + let err = store + .put_raw_by_hash_entry(&invalid_raw) + .expect_err("mismatched raw_by_hash entry must fail"); + assert!(err.to_string().contains("does not match bytes")); + } + + #[test] + fn vcir_roundtrip_and_validation_failures_are_reported() { + let td = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(td.path()).expect("open rocksdb"); + + let vcir = sample_vcir("rsync://example.test/repo/current.mft"); + store.put_vcir(&vcir).expect("put vcir"); + let got = store + .get_vcir(&vcir.manifest_rsync_uri) + .expect("get vcir") + .expect("vcir exists"); + assert_eq!(got, vcir); + + let mut invalid = sample_vcir("rsync://example.test/repo/invalid.mft"); + invalid.summary.local_vrp_count = 9; + let err = store + .put_vcir(&invalid) + .expect_err("invalid vcir must fail"); + assert!(err.to_string().contains("local_vrp_count=9")); + + let mut invalid = sample_vcir("rsync://example.test/repo/invalid-2.mft"); + invalid.instance_gate.instance_effective_until = pack_time(11); + let err = store + .put_vcir(&invalid) + .expect_err("invalid instance gate must fail"); + assert!(err.to_string().contains("instance_effective_until")); + + store + .delete_vcir(&vcir.manifest_rsync_uri) + .expect("delete vcir"); + assert!( + store + .get_vcir(&vcir.manifest_rsync_uri) + .expect("get deleted vcir") + .is_none() ); } + + #[test] + fn audit_rule_index_roundtrip_for_roa_and_aspa() { + let td = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(td.path()).expect("open rocksdb"); + + let roa = sample_audit_rule_entry(AuditRuleKind::Roa); + let aspa = sample_audit_rule_entry(AuditRuleKind::Aspa); + store + .put_audit_rule_index_entry(&roa) + .expect("put roa audit rule entry"); + store + .put_audit_rule_index_entry(&aspa) + .expect("put aspa audit rule entry"); + + let got_roa = store + .get_audit_rule_index_entry(AuditRuleKind::Roa, &roa.rule_hash) + .expect("get roa audit rule entry") + .expect("roa entry exists"); + let got_aspa = store + .get_audit_rule_index_entry(AuditRuleKind::Aspa, &aspa.rule_hash) + .expect("get aspa audit rule entry") + .expect("aspa entry exists"); + assert_eq!(got_roa, roa); + assert_eq!(got_aspa, aspa); + + store + .delete_audit_rule_index_entry(AuditRuleKind::Roa, &roa.rule_hash) + .expect("delete roa audit rule entry"); + assert!( + store + .get_audit_rule_index_entry(AuditRuleKind::Roa, &roa.rule_hash) + .expect("get deleted roa audit rule entry") + .is_none() + ); + + let mut invalid = sample_audit_rule_entry(AuditRuleKind::Roa); + invalid.rule_hash = "bad".to_string(); + let err = store + .put_audit_rule_index_entry(&invalid) + .expect_err("invalid audit rule hash must fail"); + assert!(err.to_string().contains("64-character")); + } + + #[test] + fn storage_helpers_cover_optional_validation_paths() { + let td = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(td.path()).expect("open rocksdb"); + + let withdrawn = RepositoryViewEntry { + rsync_uri: "rsync://example.test/repo/withdrawn.cer".to_string(), + current_hash: Some(sha256_hex(b"withdrawn")), + repository_source: Some("https://rrdp.example.test/notification.xml".to_string()), + object_type: Some("cer".to_string()), + state: RepositoryViewState::Withdrawn, + }; + withdrawn + .validate_internal() + .expect("withdrawn repository view validates"); + + let raw = RawByHashEntry::from_bytes(sha256_hex(b"helper-bytes"), b"helper-bytes".to_vec()); + raw.validate_internal() + .expect("raw_by_hash helper validates"); + + let empty_raw = RawByHashEntry { + sha256_hex: sha256_hex(b"x"), + bytes: Vec::new(), + origin_uris: Vec::new(), + object_type: None, + encoding: None, + }; + let err = empty_raw + .validate_internal() + .expect_err("empty raw bytes must fail"); + assert!(err.to_string().contains("bytes must not be empty")); + + let duplicate_origin_raw = RawByHashEntry { + sha256_hex: sha256_hex(b"dup-origin"), + bytes: b"dup-origin".to_vec(), + origin_uris: vec![ + "rsync://example.test/repo/object.cer".to_string(), + "rsync://example.test/repo/object.cer".to_string(), + ], + object_type: Some("cer".to_string()), + encoding: Some("der".to_string()), + }; + let err = duplicate_origin_raw + .validate_internal() + .expect_err("duplicate origin URI must fail"); + assert!(err.to_string().contains("duplicate origin URI")); + + store + .put_raw_batch(vec![( + "rsync://example.test/repo/raw.cer".to_string(), + vec![1, 2, 3], + )]) + .expect("put_raw_batch stores entries"); + assert_eq!( + store + .get_raw("rsync://example.test/repo/raw.cer") + .expect("get raw"), + Some(vec![1, 2, 3]) + ); + store + .delete_raw("rsync://example.test/repo/raw.cer") + .expect("delete raw entry"); + assert!( + store + .get_raw("rsync://example.test/repo/raw.cer") + .expect("get deleted raw") + .is_none() + ); + + store + .put_rrdp_state("https://rrdp.example.test/notification.xml", b"state") + .expect("put rrdp state"); + assert_eq!( + store + .get_rrdp_state("https://rrdp.example.test/notification.xml") + .expect("get rrdp state"), + Some(b"state".to_vec()) + ); + store + .delete_rrdp_state("https://rrdp.example.test/notification.xml") + .expect("delete rrdp state"); + assert!( + store + .get_rrdp_state("https://rrdp.example.test/notification.xml") + .expect("get deleted rrdp state") + .is_none() + ); + } + + #[test] + fn rrdp_source_optional_fields_and_owner_without_hash_validate() { + let source = RrdpSourceRecord { + notify_uri: "https://rrdp.example.test/notification.xml".to_string(), + last_session_id: None, + last_serial: None, + first_seen_at: pack_time(0), + last_seen_at: pack_time(1), + last_sync_at: None, + sync_state: RrdpSourceSyncState::Empty, + last_snapshot_uri: None, + last_snapshot_hash: None, + last_error: Some("network timeout".to_string()), + }; + source + .validate_internal() + .expect("source with optional fields validates"); + + let owner = RrdpUriOwnerRecord { + rsync_uri: "rsync://example.test/repo/object.cer".to_string(), + notify_uri: "https://rrdp.example.test/notification.xml".to_string(), + current_hash: None, + last_confirmed_session_id: "session-1".to_string(), + last_confirmed_serial: 5, + last_changed_at: pack_time(5), + owner_state: RrdpUriOwnerState::Withdrawn, + }; + owner + .validate_internal() + .expect("owner without hash validates when withdrawn"); + } + + #[test] + fn rrdp_source_binding_records_roundtrip_and_prefix_iteration() { + let td = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(td.path()).expect("open rocksdb"); + + let notify_uri = "https://rrdp.example.test/notification.xml"; + let source = sample_rrdp_source_record(notify_uri); + store + .put_rrdp_source_record(&source) + .expect("put rrdp source record"); + let got_source = store + .get_rrdp_source_record(notify_uri) + .expect("get rrdp source record") + .expect("rrdp source exists"); + assert_eq!(got_source, source); + + let member1 = + sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/a.cer", 1); + let member2 = + sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/b.roa", 2); + let other_member = sample_rrdp_source_member_record( + "https://other.example.test/notification.xml", + "rsync://other.example.test/repo/c.cer", + 3, + ); + store + .put_rrdp_source_member_record(&member1) + .expect("put member1"); + store + .put_rrdp_source_member_record(&member2) + .expect("put member2"); + store + .put_rrdp_source_member_record(&other_member) + .expect("put other member"); + + let mut members = store + .list_rrdp_source_member_records(notify_uri) + .expect("list rrdp source members"); + members.sort_by(|a, b| a.rsync_uri.cmp(&b.rsync_uri)); + assert_eq!(members, vec![member1.clone(), member2.clone()]); + + let got_member = store + .get_rrdp_source_member_record(notify_uri, &member1.rsync_uri) + .expect("get member1") + .expect("member1 exists"); + assert_eq!(got_member, member1); + + let owner = sample_rrdp_uri_owner_record(notify_uri, &member1.rsync_uri); + store + .put_rrdp_uri_owner_record(&owner) + .expect("put uri owner record"); + let got_owner = store + .get_rrdp_uri_owner_record(&member1.rsync_uri) + .expect("get uri owner record") + .expect("uri owner exists"); + assert_eq!(got_owner, owner); + store + .delete_rrdp_uri_owner_record(&member1.rsync_uri) + .expect("delete uri owner record"); + assert!( + store + .get_rrdp_uri_owner_record(&member1.rsync_uri) + .expect("get deleted uri owner") + .is_none() + ); + + let mut invalid_source = + sample_rrdp_source_record("https://invalid.example/notification.xml"); + invalid_source.last_snapshot_hash = Some("bad".to_string()); + let err = store + .put_rrdp_source_record(&invalid_source) + .expect_err("invalid source hash must fail"); + assert!(err.to_string().contains("last_snapshot_hash")); + + let invalid_member = RrdpSourceMemberRecord { + notify_uri: notify_uri.to_string(), + rsync_uri: "rsync://example.test/repo/deleted.cer".to_string(), + current_hash: None, + object_type: None, + present: true, + last_confirmed_session_id: "session-1".to_string(), + last_confirmed_serial: 10, + last_changed_at: pack_time(10), + }; + let err = store + .put_rrdp_source_member_record(&invalid_member) + .expect_err("present member without hash must fail"); + assert!(err.to_string().contains("current_hash is required")); + } } diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 107aaa2..7d88f66 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -1,2 +1,3 @@ pub mod repo; pub mod rrdp; +pub(crate) mod store_projection; diff --git a/src/sync/repo.rs b/src/sync/repo.rs index c3f5c44..2078cb8 100644 --- a/src/sync/repo.rs +++ b/src/sync/repo.rs @@ -1,12 +1,16 @@ use crate::analysis::timing::TimingHandle; +use crate::audit::AuditDownloadKind; use crate::audit_downloads::DownloadLogHandle; -use crate::audit::{AuditDownloadKind}; use crate::fetch::rsync::{RsyncFetchError, RsyncFetcher}; use crate::policy::{Policy, SyncPreference}; use crate::report::{RfcRef, Warning}; use crate::storage::RocksStore; use crate::sync::rrdp::sync_from_notification_with_timing_and_download_log; use crate::sync::rrdp::{Fetcher as HttpFetcher, RrdpSyncError}; +use crate::sync::store_projection::{ + put_repository_view_present, put_repository_view_withdrawn, upsert_raw_by_hash_evidence, +}; +use std::collections::HashSet; use std::thread; use std::time::Duration; @@ -143,9 +147,8 @@ fn try_rrdp_sync( let _total = timing .as_ref() .map(|t| t.span_phase("rrdp_fetch_notification_total")); - let mut dl_span = download_log.map(|dl| { - dl.span_download(AuditDownloadKind::RrdpNotification, notification_uri) - }); + let mut dl_span = download_log + .map(|dl| dl.span_download(AuditDownloadKind::RrdpNotification, notification_uri)); match http_fetcher.fetch(notification_uri) { Ok(v) => { if let Some(t) = timing.as_ref() { @@ -307,12 +310,43 @@ fn rsync_sync_into_raw_objects( t.record_count("rsync_objects_bytes_total", bytes_total); } drop(_p); + + let existing_view = store + .list_repository_view_entries_with_prefix(rsync_base_uri) + .map_err(|e| RepoSyncError::Storage(e.to_string()))?; + let new_set: HashSet<&str> = objects.iter().map(|(uri, _)| uri.as_str()).collect(); + let _w = timing .as_ref() .map(|t| t.span_phase("rsync_write_raw_objects_total")); store - .put_raw_batch(objects) - .map_err(|e| RepoSyncError::Storage(e.to_string())) + .put_raw_batch(objects.clone()) + .map_err(|e| RepoSyncError::Storage(e.to_string()))?; + drop(_w); + + let _proj = timing + .as_ref() + .map(|t| t.span_phase("rsync_write_repository_view_total")); + for entry in existing_view { + if !new_set.contains(entry.rsync_uri.as_str()) { + put_repository_view_withdrawn( + store, + rsync_base_uri, + &entry.rsync_uri, + entry.current_hash, + ) + .map_err(RepoSyncError::Storage)?; + } + } + + for (uri, bytes) in &objects { + let current_hash = + upsert_raw_by_hash_evidence(store, uri, bytes).map_err(RepoSyncError::Storage)?; + put_repository_view_present(store, rsync_base_uri, uri, ¤t_hash) + .map_err(RepoSyncError::Storage)?; + } + + Ok(objects.len()) } #[cfg(test)] @@ -458,6 +492,29 @@ mod tests { Some(b"cer".to_vec()) ); + let view = store + .get_repository_view_entry("rsync://example.test/repo/a.mft") + .expect("get repository view") + .expect("repository view entry present"); + assert_eq!( + view.current_hash.as_deref(), + Some(hex::encode(sha2::Sha256::digest(b"mft")).as_str()) + ); + assert_eq!( + view.repository_source.as_deref(), + Some("rsync://example.test/repo/") + ); + + let raw = store + .get_raw_by_hash_entry(hex::encode(sha2::Sha256::digest(b"roa")).as_str()) + .expect("get raw_by_hash") + .expect("raw_by_hash entry present"); + assert!( + raw.origin_uris + .iter() + .any(|uri| uri == "rsync://example.test/repo/sub/b.roa") + ); + let timing_path = temp.path().join("timing.json"); timing.write_json(&timing_path, 5).expect("write json"); let v: serde_json::Value = @@ -478,6 +535,71 @@ mod tests { ); } + #[test] + fn rsync_second_sync_marks_missing_repository_view_entries_withdrawn() { + let temp = tempfile::tempdir().expect("tempdir"); + + let repo_dir = temp.path().join("repo"); + std::fs::create_dir_all(repo_dir.join("sub")).expect("mkdir"); + std::fs::write(repo_dir.join("a.mft"), b"mft-v1").expect("write a"); + std::fs::write(repo_dir.join("sub").join("b.roa"), b"roa-v1").expect("write b"); + + let store_dir = temp.path().join("db"); + let store = RocksStore::open(&store_dir).expect("open rocksdb"); + let policy = Policy { + sync_preference: SyncPreference::RsyncOnly, + ..Policy::default() + }; + let http = DummyHttpFetcher; + let rsync = LocalDirRsyncFetcher::new(&repo_dir); + + sync_publication_point( + &store, + &policy, + None, + "rsync://example.test/repo/", + &http, + &rsync, + None, + None, + ) + .expect("first sync ok"); + + std::fs::remove_file(repo_dir.join("sub").join("b.roa")).expect("remove b"); + std::fs::write(repo_dir.join("c.crl"), b"crl-v2").expect("write c"); + + sync_publication_point( + &store, + &policy, + None, + "rsync://example.test/repo/", + &http, + &rsync, + None, + None, + ) + .expect("second sync ok"); + + let withdrawn = store + .get_repository_view_entry("rsync://example.test/repo/sub/b.roa") + .expect("get withdrawn repo view") + .expect("withdrawn entry exists"); + assert_eq!( + withdrawn.state, + crate::storage::RepositoryViewState::Withdrawn + ); + assert_eq!( + withdrawn.repository_source.as_deref(), + Some("rsync://example.test/repo/") + ); + + let added = store + .get_repository_view_entry("rsync://example.test/repo/c.crl") + .expect("get added repo view") + .expect("added entry exists"); + assert_eq!(added.state, crate::storage::RepositoryViewState::Present); + } + #[test] fn rrdp_retry_succeeds_without_rsync_when_notification_fetch_is_transient() { let temp = tempfile::tempdir().expect("tempdir"); @@ -602,7 +724,9 @@ mod tests { Some(1) ); assert_eq!( - counts.get("repo_sync_rrdp_ok_total").and_then(|v| v.as_u64()), + counts + .get("repo_sync_rrdp_ok_total") + .and_then(|v| v.as_u64()), Some(1) ); } @@ -681,7 +805,11 @@ mod tests { ); let events = download_log.snapshot_events(); - assert_eq!(events.len(), 3, "expected notification + snapshot + rsync fallback"); + assert_eq!( + events.len(), + 3, + "expected notification + snapshot + rsync fallback" + ); assert_eq!(events[0].kind, AuditDownloadKind::RrdpNotification); assert!(events[0].success); assert_eq!(events[1].kind, AuditDownloadKind::RrdpSnapshot); diff --git a/src/sync/rrdp.rs b/src/sync/rrdp.rs index 79e77b6..5628267 100644 --- a/src/sync/rrdp.rs +++ b/src/sync/rrdp.rs @@ -1,8 +1,13 @@ use crate::analysis::timing::TimingHandle; use crate::audit::AuditDownloadKind; use crate::audit_downloads::DownloadLogHandle; -use crate::storage::RocksStore; -use crate::storage::RrdpDeltaOp; +use crate::storage::{RocksStore, RrdpDeltaOp, RrdpSourceSyncState}; +use crate::sync::store_projection::{ + compute_sha256_hex, current_rrdp_owner_is, ensure_rrdp_uri_can_be_owned_by, + put_repository_view_present, put_repository_view_withdrawn, put_rrdp_source_member_present, + put_rrdp_source_member_withdrawn, put_rrdp_uri_owner_active, put_rrdp_uri_owner_withdrawn, + update_rrdp_source_record_on_success, upsert_raw_by_hash_evidence, +}; use base64::Engine; use serde::{Deserialize, Serialize}; use sha2::Digest; @@ -490,7 +495,9 @@ fn sync_from_notification_snapshot_inner( let _parse_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "parse_notification_snapshot")); - let _parse_total = timing.as_ref().map(|t| t.span_phase("rrdp_parse_notification_total")); + let _parse_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_parse_notification_total")); let notif = parse_notification_snapshot(notification_xml)?; drop(_parse_step); drop(_parse_total); @@ -498,7 +505,9 @@ fn sync_from_notification_snapshot_inner( let _fetch_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "fetch_snapshot")); - let _fetch_total = timing.as_ref().map(|t| t.span_phase("rrdp_fetch_snapshot_total")); + let _fetch_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_fetch_snapshot_total")); let mut dl_span = download_log .map(|dl| dl.span_download(AuditDownloadKind::RrdpSnapshot, ¬if.snapshot_uri)); let snapshot_xml = match fetcher.fetch(¬if.snapshot_uri) { @@ -529,7 +538,9 @@ fn sync_from_notification_snapshot_inner( let _hash_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "hash_snapshot")); - let _hash_total = timing.as_ref().map(|t| t.span_phase("rrdp_hash_snapshot_total")); + let _hash_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_hash_snapshot_total")); let computed = sha2::Sha256::digest(&snapshot_xml); if computed.as_slice() != notif.snapshot_hash_sha256.as_slice() { return Err(RrdpError::SnapshotHashMismatch.into()); @@ -540,7 +551,9 @@ fn sync_from_notification_snapshot_inner( let _apply_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "apply_snapshot")); - let _apply_total = timing.as_ref().map(|t| t.span_phase("rrdp_apply_snapshot_total")); + let _apply_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_apply_snapshot_total")); let published = apply_snapshot( store, notification_uri, @@ -557,7 +570,9 @@ fn sync_from_notification_snapshot_inner( let _write_state_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "write_state")); - let _write_state_total = timing.as_ref().map(|t| t.span_phase("rrdp_write_state_total")); + let _write_state_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_write_state_total")); let state = RrdpState { session_id: notif.session_id.to_string(), serial: notif.serial, @@ -566,6 +581,16 @@ fn sync_from_notification_snapshot_inner( store .put_rrdp_state(notification_uri, &bytes) .map_err(|e| RrdpSyncError::Storage(e.to_string()))?; + update_rrdp_source_record_on_success( + store, + notification_uri, + notif.session_id.to_string().as_str(), + notif.serial, + RrdpSourceSyncState::SnapshotOnly, + Some(¬if.snapshot_uri), + Some(&hex::encode(notif.snapshot_hash_sha256)), + ) + .map_err(RrdpSyncError::Storage)?; drop(_write_state_step); drop(_write_state_total); @@ -578,7 +603,14 @@ pub fn sync_from_notification( notification_xml: &[u8], fetcher: &dyn Fetcher, ) -> RrdpSyncResult { - sync_from_notification_inner(store, notification_uri, notification_xml, fetcher, None, None) + sync_from_notification_inner( + store, + notification_uri, + notification_xml, + fetcher, + None, + None, + ) } pub fn sync_from_notification_with_timing( @@ -588,7 +620,14 @@ pub fn sync_from_notification_with_timing( fetcher: &dyn Fetcher, timing: Option<&TimingHandle>, ) -> RrdpSyncResult { - sync_from_notification_inner(store, notification_uri, notification_xml, fetcher, timing, None) + sync_from_notification_inner( + store, + notification_uri, + notification_xml, + fetcher, + timing, + None, + ) } pub fn sync_from_notification_with_timing_and_download_log( @@ -620,7 +659,9 @@ fn sync_from_notification_inner( let _parse_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "parse_notification")); - let _parse_total = timing.as_ref().map(|t| t.span_phase("rrdp_parse_notification_total")); + let _parse_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_parse_notification_total")); let notif = parse_notification(notification_xml)?; drop(_parse_step); drop(_parse_total); @@ -634,7 +675,9 @@ fn sync_from_notification_inner( let _read_state_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "read_state")); - let _read_state_total = timing.as_ref().map(|t| t.span_phase("rrdp_read_state_total")); + let _read_state_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_read_state_total")); let state = store .get_rrdp_state(notification_uri) .map_err(|e| RrdpSyncError::Storage(e.to_string()))? @@ -676,8 +719,9 @@ fn sync_from_notification_inner( let _fetch_d_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "fetch_deltas")); - let _fetch_d_total = - timing.as_ref().map(|t| t.span_phase("rrdp_fetch_deltas_total")); + let _fetch_d_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_fetch_deltas_total")); let mut fetched: Vec<(u64, [u8; 32], Vec)> = Vec::with_capacity((want_last - want_first + 1) as usize); let mut fetch_ok = true; @@ -727,8 +771,9 @@ fn sync_from_notification_inner( let _apply_d_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "apply_deltas")); - let _apply_d_total = - timing.as_ref().map(|t| t.span_phase("rrdp_apply_deltas_total")); + let _apply_d_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_apply_deltas_total")); let mut applied_total = 0usize; let mut ok = true; for (serial, expected_hash, bytes) in &fetched { @@ -754,8 +799,9 @@ fn sync_from_notification_inner( let _write_state_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "write_state")); - let _write_state_total = - timing.as_ref().map(|t| t.span_phase("rrdp_write_state_total")); + let _write_state_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_write_state_total")); let new_state = RrdpState { session_id: notif.session_id.to_string(), serial: notif.serial, @@ -764,6 +810,16 @@ fn sync_from_notification_inner( store .put_rrdp_state(notification_uri, &bytes) .map_err(|e| RrdpSyncError::Storage(e.to_string()))?; + update_rrdp_source_record_on_success( + store, + notification_uri, + notif.session_id.to_string().as_str(), + notif.serial, + RrdpSourceSyncState::DeltaReady, + Some(¬if.snapshot_uri), + Some(&hex::encode(notif.snapshot_hash_sha256)), + ) + .map_err(RrdpSyncError::Storage)?; drop(_write_state_step); drop(_write_state_total); if let Some(t) = timing.as_ref() { @@ -780,7 +836,9 @@ fn sync_from_notification_inner( let _fetch_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "fetch_snapshot")); - let _fetch_total = timing.as_ref().map(|t| t.span_phase("rrdp_fetch_snapshot_total")); + let _fetch_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_fetch_snapshot_total")); let mut dl_span = download_log .map(|dl| dl.span_download(AuditDownloadKind::RrdpSnapshot, ¬if.snapshot_uri)); let snapshot_xml = match fetcher.fetch(¬if.snapshot_uri) { @@ -811,7 +869,9 @@ fn sync_from_notification_inner( let _hash_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "hash_snapshot")); - let _hash_total = timing.as_ref().map(|t| t.span_phase("rrdp_hash_snapshot_total")); + let _hash_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_hash_snapshot_total")); let computed = sha2::Sha256::digest(&snapshot_xml); if computed.as_slice() != notif.snapshot_hash_sha256.as_slice() { return Err(RrdpError::SnapshotHashMismatch.into()); @@ -822,7 +882,9 @@ fn sync_from_notification_inner( let _apply_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "apply_snapshot")); - let _apply_total = timing.as_ref().map(|t| t.span_phase("rrdp_apply_snapshot_total")); + let _apply_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_apply_snapshot_total")); let published = apply_snapshot( store, notification_uri, @@ -839,7 +901,9 @@ fn sync_from_notification_inner( let _write_state_step = timing .as_ref() .map(|t| t.span_rrdp_repo_step(notification_uri, "write_state")); - let _write_state_total = timing.as_ref().map(|t| t.span_phase("rrdp_write_state_total")); + let _write_state_total = timing + .as_ref() + .map(|t| t.span_phase("rrdp_write_state_total")); let new_state = RrdpState { session_id: notif.session_id.to_string(), serial: notif.serial, @@ -848,6 +912,16 @@ fn sync_from_notification_inner( store .put_rrdp_state(notification_uri, &bytes) .map_err(|e| RrdpSyncError::Storage(e.to_string()))?; + update_rrdp_source_record_on_success( + store, + notification_uri, + notif.session_id.to_string().as_str(), + notif.serial, + RrdpSourceSyncState::SnapshotOnly, + Some(¬if.snapshot_uri), + Some(&hex::encode(notif.snapshot_hash_sha256)), + ) + .map_err(RrdpSyncError::Storage)?; drop(_write_state_step); drop(_write_state_total); @@ -883,7 +957,20 @@ fn apply_delta( .into()); } + enum DeltaProjectionEffect { + Upsert { + rsync_uri: String, + bytes: Vec, + }, + Delete { + rsync_uri: String, + previous_hash: String, + }, + } + + let session_id = expected_session_id.to_string(); let mut ops: Vec = Vec::with_capacity(delta.elements.len()); + let mut projection: Vec = Vec::with_capacity(delta.elements.len()); for e in delta.elements { match e { DeltaElement::Publish { @@ -897,6 +984,8 @@ fn apply_delta( if !is_member { return Err(RrdpError::DeltaTargetNotFromRepository { rsync_uri: uri }.into()); } + ensure_rrdp_uri_can_be_owned_by(store, notification_uri, uri.as_str()) + .map_err(RrdpSyncError::Storage)?; let old_bytes = store .get_raw(uri.as_str()) .map_err(|e| RrdpSyncError::Storage(e.to_string()))? @@ -909,6 +998,10 @@ fn apply_delta( } ops.push(RrdpDeltaOp::Upsert { + rsync_uri: uri.clone(), + bytes: bytes.clone(), + }); + projection.push(DeltaProjectionEffect::Upsert { rsync_uri: uri, bytes, }); @@ -926,7 +1019,13 @@ fn apply_delta( RrdpError::DeltaPublishWithoutHashForExisting { rsync_uri: uri }.into(), ); } + ensure_rrdp_uri_can_be_owned_by(store, notification_uri, uri.as_str()) + .map_err(RrdpSyncError::Storage)?; ops.push(RrdpDeltaOp::Upsert { + rsync_uri: uri.clone(), + bytes: bytes.clone(), + }); + projection.push(DeltaProjectionEffect::Upsert { rsync_uri: uri, bytes, }); @@ -938,6 +1037,8 @@ fn apply_delta( if !is_member { return Err(RrdpError::DeltaTargetNotFromRepository { rsync_uri: uri }.into()); } + ensure_rrdp_uri_can_be_owned_by(store, notification_uri, uri.as_str()) + .map_err(RrdpSyncError::Storage)?; let old_bytes = store .get_raw(uri.as_str()) .map_err(|e| RrdpSyncError::Storage(e.to_string()))? @@ -948,14 +1049,86 @@ fn apply_delta( if old_computed.as_slice() != hash_sha256.as_slice() { return Err(RrdpError::DeltaTargetHashMismatch { rsync_uri: uri }.into()); } - ops.push(RrdpDeltaOp::Delete { rsync_uri: uri }); + let previous_hash = hex::encode(old_computed); + ops.push(RrdpDeltaOp::Delete { + rsync_uri: uri.clone(), + }); + projection.push(DeltaProjectionEffect::Delete { + rsync_uri: uri, + previous_hash, + }); } } } store .apply_rrdp_delta(notification_uri, ops.as_slice()) - .map_err(|e| RrdpSyncError::Storage(e.to_string())) + .map_err(|e| RrdpSyncError::Storage(e.to_string()))?; + + for effect in projection { + match effect { + DeltaProjectionEffect::Upsert { rsync_uri, bytes } => { + let current_hash = upsert_raw_by_hash_evidence(store, &rsync_uri, &bytes) + .map_err(RrdpSyncError::Storage)?; + put_repository_view_present(store, notification_uri, &rsync_uri, ¤t_hash) + .map_err(RrdpSyncError::Storage)?; + put_rrdp_source_member_present( + store, + notification_uri, + &session_id, + expected_serial, + &rsync_uri, + ¤t_hash, + ) + .map_err(RrdpSyncError::Storage)?; + put_rrdp_uri_owner_active( + store, + notification_uri, + &session_id, + expected_serial, + &rsync_uri, + ¤t_hash, + ) + .map_err(RrdpSyncError::Storage)?; + } + DeltaProjectionEffect::Delete { + rsync_uri, + previous_hash, + } => { + put_rrdp_source_member_withdrawn( + store, + notification_uri, + &session_id, + expected_serial, + &rsync_uri, + Some(previous_hash.clone()), + ) + .map_err(RrdpSyncError::Storage)?; + if current_rrdp_owner_is(store, notification_uri, &rsync_uri) + .map_err(RrdpSyncError::Storage)? + { + put_repository_view_withdrawn( + store, + notification_uri, + &rsync_uri, + Some(previous_hash.clone()), + ) + .map_err(RrdpSyncError::Storage)?; + put_rrdp_uri_owner_withdrawn( + store, + notification_uri, + &session_id, + expected_serial, + &rsync_uri, + Some(previous_hash), + ) + .map_err(RrdpSyncError::Storage)?; + } + } + } + } + + Ok(ops.len()) } fn apply_snapshot( @@ -1004,12 +1177,92 @@ fn apply_snapshot( .decode(content_b64.as_bytes()) .map_err(|e| RrdpError::PublishBase64(e.to_string()))?; + ensure_rrdp_uri_can_be_owned_by(store, notification_uri, uri) + .map_err(RrdpSyncError::Storage)?; published.push((uri.to_string(), bytes)); } + let previous_members: Vec = store + .rrdp_object_index_iter(notification_uri) + .map_err(|e| RrdpSyncError::Storage(e.to_string()))? + .collect(); + let new_set: std::collections::HashSet<&str> = + published.iter().map(|(uri, _)| uri.as_str()).collect(); + let mut withdrawn: Vec<(String, Option)> = Vec::new(); + for old_uri in &previous_members { + if new_set.contains(old_uri.as_str()) { + continue; + } + let previous_hash = store + .get_repository_view_entry(old_uri) + .map_err(|e| RrdpSyncError::Storage(e.to_string()))? + .and_then(|entry| entry.current_hash) + .or_else(|| { + store + .get_raw(old_uri) + .ok() + .flatten() + .map(|bytes| compute_sha256_hex(&bytes)) + }); + withdrawn.push((old_uri.clone(), previous_hash)); + } + store .apply_rrdp_snapshot(notification_uri, published.as_slice()) - .map_err(|e| RrdpSyncError::Storage(e.to_string())) + .map_err(|e| RrdpSyncError::Storage(e.to_string()))?; + + let session_id = expected_session_id.to_string(); + for (uri, bytes) in &published { + let current_hash = + upsert_raw_by_hash_evidence(store, uri, bytes).map_err(RrdpSyncError::Storage)?; + put_repository_view_present(store, notification_uri, uri, ¤t_hash) + .map_err(RrdpSyncError::Storage)?; + put_rrdp_source_member_present( + store, + notification_uri, + &session_id, + expected_serial, + uri, + ¤t_hash, + ) + .map_err(RrdpSyncError::Storage)?; + put_rrdp_uri_owner_active( + store, + notification_uri, + &session_id, + expected_serial, + uri, + ¤t_hash, + ) + .map_err(RrdpSyncError::Storage)?; + } + + for (uri, previous_hash) in withdrawn { + put_rrdp_source_member_withdrawn( + store, + notification_uri, + &session_id, + expected_serial, + &uri, + previous_hash.clone(), + ) + .map_err(RrdpSyncError::Storage)?; + if current_rrdp_owner_is(store, notification_uri, &uri).map_err(RrdpSyncError::Storage)? { + put_repository_view_withdrawn(store, notification_uri, &uri, previous_hash.clone()) + .map_err(RrdpSyncError::Storage)?; + put_rrdp_uri_owner_withdrawn( + store, + notification_uri, + &session_id, + expected_serial, + &uri, + previous_hash, + ) + .map_err(RrdpSyncError::Storage)?; + } + } + + Ok(published.len()) } fn parse_rrdp_xml(xml: &[u8]) -> Result, RrdpError> { @@ -1477,6 +1730,34 @@ mod tests { .expect("contains"), "c added to rrdp repo index" ); + + let a_view = store + .get_repository_view_entry("rsync://example.net/repo/a.mft") + .expect("get a view") + .expect("a view exists"); + assert_eq!(a_view.state, crate::storage::RepositoryViewState::Withdrawn); + let b_view = store + .get_repository_view_entry("rsync://example.net/repo/b.roa") + .expect("get b view") + .expect("b view exists"); + assert_eq!(b_view.state, crate::storage::RepositoryViewState::Present); + assert_eq!( + b_view.current_hash.as_deref(), + Some(hex::encode(sha2::Sha256::digest(b"b2")).as_str()) + ); + let c_owner = store + .get_rrdp_uri_owner_record("rsync://example.net/repo/c.crl") + .expect("get c owner") + .expect("c owner exists"); + assert_eq!( + c_owner.owner_state, + crate::storage::RrdpUriOwnerState::Active + ); + let a_member = store + .get_rrdp_source_member_record(notif_uri, "rsync://example.net/repo/a.mft") + .expect("get a member") + .expect("a member exists"); + assert!(!a_member.present); } #[test] @@ -1695,6 +1976,41 @@ mod tests { )); } + #[test] + fn sync_from_notification_snapshot_rejects_cross_source_owner_conflict() { + let tmp = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(tmp.path()).expect("open rocksdb"); + + let sid_a = "550e8400-e29b-41d4-a716-446655440000"; + let sid_b = "550e8400-e29b-41d4-a716-446655440001"; + let uri = "rsync://example.net/repo/a.mft"; + + let notif_a_uri = "https://example.net/a/notification.xml"; + let snapshot_a_uri = "https://example.net/a/snapshot.xml"; + let snapshot_a = snapshot_xml(sid_a, 1, &[(uri, b"a1")]); + let snapshot_a_hash = hex::encode(sha2::Sha256::digest(&snapshot_a)); + let notif_a = notification_xml(sid_a, 1, snapshot_a_uri, &snapshot_a_hash); + let fetcher_a = MapFetcher { + map: HashMap::from([(snapshot_a_uri.to_string(), snapshot_a)]), + }; + sync_from_notification_snapshot(&store, notif_a_uri, ¬if_a, &fetcher_a) + .expect("seed source a"); + + let notif_b_uri = "https://example.net/b/notification.xml"; + let snapshot_b_uri = "https://example.net/b/snapshot.xml"; + let snapshot_b = snapshot_xml(sid_b, 1, &[(uri, b"b1")]); + let snapshot_b_hash = hex::encode(sha2::Sha256::digest(&snapshot_b)); + let notif_b = notification_xml(sid_b, 1, snapshot_b_uri, &snapshot_b_hash); + let fetcher_b = MapFetcher { + map: HashMap::from([(snapshot_b_uri.to_string(), snapshot_b)]), + }; + + let err = sync_from_notification_snapshot(&store, notif_b_uri, ¬if_b, &fetcher_b) + .expect_err("cross-source overwrite must fail"); + assert!(matches!(err, RrdpSyncError::Storage(_))); + assert!(err.to_string().contains("owner conflict"), "{err}"); + } + #[test] fn sync_from_notification_snapshot_applies_snapshot_and_stores_state() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -1743,6 +2059,46 @@ mod tests { let state = RrdpState::decode(&state_bytes).expect("decode state"); assert_eq!(state.session_id, sid); assert_eq!(state.serial, serial); + + let source = store + .get_rrdp_source_record(notif_uri) + .expect("get rrdp source") + .expect("rrdp source exists"); + assert_eq!(source.last_session_id.as_deref(), Some(sid)); + assert_eq!(source.last_serial, Some(serial)); + assert_eq!( + source.sync_state, + crate::storage::RrdpSourceSyncState::SnapshotOnly + ); + + let view = store + .get_repository_view_entry("rsync://example.net/repo/a.mft") + .expect("get repository view") + .expect("repository view exists"); + assert_eq!(view.state, crate::storage::RepositoryViewState::Present); + assert_eq!(view.repository_source.as_deref(), Some(notif_uri)); + + let raw = store + .get_raw_by_hash_entry(hex::encode(sha2::Sha256::digest(b"mft-bytes")).as_str()) + .expect("get raw_by_hash") + .expect("raw_by_hash exists"); + assert!( + raw.origin_uris + .iter() + .any(|uri| uri == "rsync://example.net/repo/a.mft") + ); + + let member = store + .get_rrdp_source_member_record(notif_uri, "rsync://example.net/repo/a.mft") + .expect("get member") + .expect("member exists"); + assert!(member.present); + let owner = store + .get_rrdp_uri_owner_record("rsync://example.net/repo/a.mft") + .expect("get owner") + .expect("owner exists"); + assert_eq!(owner.notify_uri, notif_uri); + assert_eq!(owner.owner_state, crate::storage::RrdpUriOwnerState::Active); } #[test] diff --git a/src/sync/store_projection.rs b/src/sync/store_projection.rs new file mode 100644 index 0000000..bb898a8 --- /dev/null +++ b/src/sync/store_projection.rs @@ -0,0 +1,262 @@ +use std::collections::BTreeSet; + +use crate::storage::{ + PackTime, RawByHashEntry, RepositoryViewEntry, RepositoryViewState, RocksStore, + RrdpSourceMemberRecord, RrdpSourceRecord, RrdpSourceSyncState, RrdpUriOwnerRecord, + RrdpUriOwnerState, +}; +use sha2::Digest; + +pub fn infer_object_type_from_uri(uri: &str) -> Option { + let ext = uri.rsplit('.').next()?; + let ext = ext.to_ascii_lowercase(); + match ext.as_str() { + "cer" | "crl" | "mft" | "roa" | "asa" | "gbr" | "tal" | "xml" => Some(ext), + _ => None, + } +} + +pub fn upsert_raw_by_hash_evidence( + store: &RocksStore, + rsync_uri: &str, + bytes: &[u8], +) -> Result { + let sha256_hex = compute_sha256_hex(bytes); + let mut entry = match store + .get_raw_by_hash_entry(&sha256_hex) + .map_err(|e| e.to_string())? + { + Some(existing) => existing, + None => RawByHashEntry::from_bytes(sha256_hex.clone(), bytes.to_vec()), + }; + + if entry.bytes != bytes { + return Err(format!( + "raw_by_hash collision for {rsync_uri}: same sha256 maps to different bytes" + )); + } + + let mut origins: BTreeSet = entry.origin_uris.into_iter().collect(); + origins.insert(rsync_uri.to_string()); + entry.origin_uris = origins.into_iter().collect(); + if entry.object_type.is_none() { + entry.object_type = infer_object_type_from_uri(rsync_uri); + } + + store + .put_raw_by_hash_entry(&entry) + .map_err(|e| e.to_string())?; + Ok(sha256_hex) +} + +pub fn put_repository_view_present( + store: &RocksStore, + repository_source: &str, + rsync_uri: &str, + current_hash: &str, +) -> Result<(), String> { + let entry = RepositoryViewEntry { + rsync_uri: rsync_uri.to_string(), + current_hash: Some(current_hash.to_string()), + repository_source: Some(repository_source.to_string()), + object_type: infer_object_type_from_uri(rsync_uri), + state: RepositoryViewState::Present, + }; + store + .put_repository_view_entry(&entry) + .map_err(|e| e.to_string()) +} + +pub fn put_repository_view_withdrawn( + store: &RocksStore, + repository_source: &str, + rsync_uri: &str, + current_hash: Option, +) -> Result<(), String> { + let entry = RepositoryViewEntry { + rsync_uri: rsync_uri.to_string(), + current_hash, + repository_source: Some(repository_source.to_string()), + object_type: infer_object_type_from_uri(rsync_uri), + state: RepositoryViewState::Withdrawn, + }; + store + .put_repository_view_entry(&entry) + .map_err(|e| e.to_string()) +} + +pub fn ensure_rrdp_uri_can_be_owned_by( + store: &RocksStore, + notification_uri: &str, + rsync_uri: &str, +) -> Result<(), String> { + let Some(owner) = store + .get_rrdp_uri_owner_record(rsync_uri) + .map_err(|e| e.to_string())? + else { + return Ok(()); + }; + + if owner.notify_uri != notification_uri && owner.owner_state == RrdpUriOwnerState::Active { + return Err(format!( + "RRDP source owner conflict for {rsync_uri}: current owner {} but incoming source {}", + owner.notify_uri, notification_uri + )); + } + Ok(()) +} + +pub fn current_rrdp_owner_is( + store: &RocksStore, + notification_uri: &str, + rsync_uri: &str, +) -> Result { + Ok(matches!( + store + .get_rrdp_uri_owner_record(rsync_uri) + .map_err(|e| e.to_string())?, + Some(owner) + if owner.notify_uri == notification_uri && owner.owner_state == RrdpUriOwnerState::Active + )) +} + +pub fn put_rrdp_source_member_present( + store: &RocksStore, + notification_uri: &str, + session_id: &str, + serial: u64, + rsync_uri: &str, + current_hash: &str, +) -> Result<(), String> { + let record = RrdpSourceMemberRecord { + notify_uri: notification_uri.to_string(), + rsync_uri: rsync_uri.to_string(), + current_hash: Some(current_hash.to_string()), + object_type: infer_object_type_from_uri(rsync_uri), + present: true, + last_confirmed_session_id: session_id.to_string(), + last_confirmed_serial: serial, + last_changed_at: now_pack_time(), + }; + store + .put_rrdp_source_member_record(&record) + .map_err(|e| e.to_string()) +} + +pub fn put_rrdp_source_member_withdrawn( + store: &RocksStore, + notification_uri: &str, + session_id: &str, + serial: u64, + rsync_uri: &str, + current_hash: Option, +) -> Result<(), String> { + let record = RrdpSourceMemberRecord { + notify_uri: notification_uri.to_string(), + rsync_uri: rsync_uri.to_string(), + current_hash, + object_type: infer_object_type_from_uri(rsync_uri), + present: false, + last_confirmed_session_id: session_id.to_string(), + last_confirmed_serial: serial, + last_changed_at: now_pack_time(), + }; + store + .put_rrdp_source_member_record(&record) + .map_err(|e| e.to_string()) +} + +pub fn put_rrdp_uri_owner_active( + store: &RocksStore, + notification_uri: &str, + session_id: &str, + serial: u64, + rsync_uri: &str, + current_hash: &str, +) -> Result<(), String> { + let record = RrdpUriOwnerRecord { + rsync_uri: rsync_uri.to_string(), + notify_uri: notification_uri.to_string(), + current_hash: Some(current_hash.to_string()), + last_confirmed_session_id: session_id.to_string(), + last_confirmed_serial: serial, + last_changed_at: now_pack_time(), + owner_state: RrdpUriOwnerState::Active, + }; + store + .put_rrdp_uri_owner_record(&record) + .map_err(|e| e.to_string()) +} + +pub fn put_rrdp_uri_owner_withdrawn( + store: &RocksStore, + notification_uri: &str, + session_id: &str, + serial: u64, + rsync_uri: &str, + current_hash: Option, +) -> Result<(), String> { + let record = RrdpUriOwnerRecord { + rsync_uri: rsync_uri.to_string(), + notify_uri: notification_uri.to_string(), + current_hash, + last_confirmed_session_id: session_id.to_string(), + last_confirmed_serial: serial, + last_changed_at: now_pack_time(), + owner_state: RrdpUriOwnerState::Withdrawn, + }; + store + .put_rrdp_uri_owner_record(&record) + .map_err(|e| e.to_string()) +} + +pub fn update_rrdp_source_record_on_success( + store: &RocksStore, + notification_uri: &str, + session_id: &str, + serial: u64, + sync_state: RrdpSourceSyncState, + last_snapshot_uri: Option<&str>, + last_snapshot_hash_hex: Option<&str>, +) -> Result<(), String> { + let now = now_pack_time(); + let mut record = match store + .get_rrdp_source_record(notification_uri) + .map_err(|e| e.to_string())? + { + Some(existing) => existing, + None => RrdpSourceRecord { + notify_uri: notification_uri.to_string(), + last_session_id: None, + last_serial: None, + first_seen_at: now.clone(), + last_seen_at: now.clone(), + last_sync_at: None, + sync_state, + last_snapshot_uri: None, + last_snapshot_hash: None, + last_error: None, + }, + }; + + record.last_session_id = Some(session_id.to_string()); + record.last_serial = Some(serial); + record.last_seen_at = now.clone(); + record.last_sync_at = Some(now); + record.sync_state = sync_state; + record.last_snapshot_uri = last_snapshot_uri.map(str::to_string); + record.last_snapshot_hash = last_snapshot_hash_hex.map(str::to_string); + record.last_error = None; + + store + .put_rrdp_source_record(&record) + .map_err(|e| e.to_string()) +} + +pub fn compute_sha256_hex(bytes: &[u8]) -> String { + hex::encode(sha2::Sha256::digest(bytes)) +} + +pub fn now_pack_time() -> PackTime { + PackTime::from_utc_offset_datetime(time::OffsetDateTime::now_utc()) +} diff --git a/src/validation/manifest.rs b/src/validation/manifest.rs index db07d45..1c4085b 100644 --- a/src/validation/manifest.rs +++ b/src/validation/manifest.rs @@ -2,25 +2,134 @@ use crate::data_model::manifest::{ManifestDecodeError, ManifestObject, ManifestV use crate::data_model::signed_object::SignedObjectVerifyError; use crate::policy::{CaFailedFetchPolicy, Policy}; use crate::report::{RfcRef, Warning}; -use crate::storage::{FetchCachePpKey, FetchCachePpPack, RocksStore, StorageError}; +use crate::storage::{PackFile, PackTime, RocksStore, StorageError, VcirArtifactRole}; use crate::validation::cert_path::{CertPathError, validate_ee_cert_path}; +use crate::validation::publication_point::PublicationPointSnapshot; use sha2::Digest; use std::cmp::Ordering; -use std::collections::HashMap; +use std::collections::HashSet; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PublicationPointSource { Fresh, - FetchCachePp, + VcirCurrentInstance, + FailedFetchNoCache, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct PublicationPointResult { pub source: PublicationPointSource, - pub pack: FetchCachePpPack, + pub snapshot: PublicationPointSnapshot, pub warnings: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FreshValidatedPublicationPoint { + pub manifest_rsync_uri: String, + pub publication_point_rsync_uri: String, + pub manifest_number_be: Vec, + pub this_update: PackTime, + pub next_update: PackTime, + pub verified_at: PackTime, + pub manifest_bytes: Vec, + pub files: Vec, +} + +pub trait PublicationPointData { + fn manifest_rsync_uri(&self) -> &str; + fn publication_point_rsync_uri(&self) -> &str; + fn manifest_number_be(&self) -> &[u8]; + fn this_update(&self) -> &PackTime; + fn next_update(&self) -> &PackTime; + fn verified_at(&self) -> &PackTime; + fn manifest_bytes(&self) -> &[u8]; + fn files(&self) -> &[PackFile]; +} + +impl FreshValidatedPublicationPoint { + pub fn to_publication_point_snapshot(&self) -> PublicationPointSnapshot { + PublicationPointSnapshot { + format_version: PublicationPointSnapshot::FORMAT_VERSION_V1, + manifest_rsync_uri: self.manifest_rsync_uri.clone(), + publication_point_rsync_uri: self.publication_point_rsync_uri.clone(), + manifest_number_be: self.manifest_number_be.clone(), + this_update: self.this_update.clone(), + next_update: self.next_update.clone(), + verified_at: self.verified_at.clone(), + manifest_bytes: self.manifest_bytes.clone(), + files: self.files.clone(), + } + } +} + +impl PublicationPointData for FreshValidatedPublicationPoint { + fn manifest_rsync_uri(&self) -> &str { + &self.manifest_rsync_uri + } + + fn publication_point_rsync_uri(&self) -> &str { + &self.publication_point_rsync_uri + } + + fn manifest_number_be(&self) -> &[u8] { + self.manifest_number_be.as_slice() + } + + fn this_update(&self) -> &PackTime { + &self.this_update + } + + fn next_update(&self) -> &PackTime { + &self.next_update + } + + fn verified_at(&self) -> &PackTime { + &self.verified_at + } + + fn manifest_bytes(&self) -> &[u8] { + self.manifest_bytes.as_slice() + } + + fn files(&self) -> &[PackFile] { + self.files.as_slice() + } +} + +impl PublicationPointData for PublicationPointSnapshot { + fn manifest_rsync_uri(&self) -> &str { + &self.manifest_rsync_uri + } + + fn publication_point_rsync_uri(&self) -> &str { + &self.publication_point_rsync_uri + } + + fn manifest_number_be(&self) -> &[u8] { + self.manifest_number_be.as_slice() + } + + fn this_update(&self) -> &PackTime { + &self.this_update + } + + fn next_update(&self) -> &PackTime { + &self.next_update + } + + fn verified_at(&self) -> &PackTime { + &self.verified_at + } + + fn manifest_bytes(&self) -> &[u8] { + self.manifest_bytes.as_slice() + } + + fn files(&self) -> &[PackFile] { + self.files.as_slice() + } +} + #[derive(Debug, thiserror::Error)] pub enum ManifestFreshError { #[error("repo sync failed: {detail} (RFC 8182 §3.4.5; RFC 9286 §6.6)")] @@ -104,23 +213,40 @@ pub enum ManifestFreshError { } #[derive(Debug, thiserror::Error)] -pub enum ManifestCachedError { - #[error("fetch_cache_pp entry missing: {0} (RFC 9286 §6.6)")] - MissingFetchCachePp(String), - - #[error("fetch_cache_pp pack invalid: {0}")] - InvalidPack(#[from] StorageError), - - #[error("cached manifest revalidation failed: {0}")] - CachedManifestFresh(#[from] ManifestFreshError), +pub enum ManifestReuseError { + #[error("latest current-instance VCIR missing: {0} (RFC 9286 §6.6)")] + MissingCurrentInstanceVcir(String), #[error( - "cached fetch_cache_pp missing file referenced by manifest: {rsync_uri} (RFC 9286 §6.4; RFC 9286 §6.6)" + "latest current-instance VCIR is not marked failed-fetch eligible: {0} (RFC 9286 §6.6)" )] - CachedMissingFile { rsync_uri: String }, + IneligibleCurrentInstanceVcir(String), - #[error("cached fetch_cache_pp file hash mismatch: {rsync_uri} (RFC 9286 §6.5; RFC 9286 §6.6)")] - CachedHashMismatch { rsync_uri: String }, + #[error( + "latest current-instance VCIR instance_gate expired: manifest={manifest_rsync_uri} effective_until={effective_until_rfc3339_utc} validation_time={validation_time_rfc3339_utc} (RFC 9286 §6.6)" + )] + CurrentInstanceVcirExpired { + manifest_rsync_uri: String, + effective_until_rfc3339_utc: String, + validation_time_rfc3339_utc: String, + }, + + #[error( + "current-instance VCIR current_manifest_rsync_uri does not match requested manifest URI: expected={expected} actual={actual}" + )] + ManifestUriMismatch { expected: String, actual: String }, + + #[error("manifest raw bytes missing for current-instance VCIR reconstruction: {0}")] + MissingManifestRaw(String), + + #[error("artifact raw bytes missing for current-instance VCIR reconstruction: {rsync_uri}")] + MissingArtifactRaw { rsync_uri: String }, + + #[error("invalid current-instance VCIR: {0}")] + InvalidCurrentInstanceVcir(String), + + #[error("storage error during current-instance VCIR reuse: {0}")] + Storage(#[from] StorageError), } #[derive(Debug, thiserror::Error)] @@ -129,11 +255,11 @@ pub enum ManifestProcessError { StopAllOutput(#[from] ManifestFreshError), #[error( - "manifest processing failed and no usable fetch_cache_pp is available: fresh={fresh}; cached={cached}" + "manifest processing failed and no reusable current-instance validated result is available: fresh={fresh}; reused={reused}" )] NoUsableCache { fresh: ManifestFreshError, - cached: ManifestCachedError, + reused: ManifestReuseError, }, #[error("storage error during manifest processing: {0}")] @@ -162,6 +288,32 @@ pub fn process_manifest_publication_point( ) } +pub fn process_manifest_publication_point_fresh_after_repo_sync( + store: &RocksStore, + manifest_rsync_uri: &str, + publication_point_rsync_uri: &str, + issuer_ca_der: &[u8], + issuer_ca_rsync_uri: Option<&str>, + validation_time: time::OffsetDateTime, + repo_sync_ok: bool, + repo_sync_error: Option<&str>, +) -> Result { + if repo_sync_ok { + try_build_fresh_publication_point( + store, + manifest_rsync_uri, + publication_point_rsync_uri, + issuer_ca_der, + issuer_ca_rsync_uri, + validation_time, + ) + } else { + Err(ManifestFreshError::RepoSyncFailed { + detail: repo_sync_error.unwrap_or("repo sync failed").to_string(), + }) + } +} + pub fn process_manifest_publication_point_after_repo_sync( store: &RocksStore, policy: &Policy, @@ -174,7 +326,7 @@ pub fn process_manifest_publication_point_after_repo_sync( repo_sync_error: Option<&str>, ) -> Result { let fresh = if repo_sync_ok { - try_build_fresh_pack( + try_build_fresh_publication_point( store, manifest_rsync_uri, publication_point_rsync_uri, @@ -189,13 +341,11 @@ pub fn process_manifest_publication_point_after_repo_sync( }; match fresh { - Ok(pack) => { - let key = FetchCachePpKey::from_manifest_rsync_uri(manifest_rsync_uri); - let bytes = pack.encode()?; - store.put_fetch_cache_pp(&key, &bytes)?; + Ok(fresh_point) => { + let snapshot = fresh_point.to_publication_point_snapshot(); Ok(PublicationPointResult { source: PublicationPointSource::Fresh, - pack, + snapshot, warnings: Vec::new(), }) } @@ -203,36 +353,34 @@ pub fn process_manifest_publication_point_after_repo_sync( CaFailedFetchPolicy::StopAllOutput => { Err(ManifestProcessError::StopAllOutput(fresh_err)) } - CaFailedFetchPolicy::UseFetchCachePp => { + CaFailedFetchPolicy::ReuseCurrentInstanceVcir => { let mut warnings = vec![ Warning::new(format!("manifest failed fetch: {fresh_err}")) .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) .with_context(manifest_rsync_uri), ]; - match load_and_revalidate_cached_pack( + match load_current_instance_vcir_publication_point( store, manifest_rsync_uri, publication_point_rsync_uri, - issuer_ca_der, - issuer_ca_rsync_uri, validation_time, ) { - Ok(pack) => { + Ok(snapshot) => { warnings.push( - Warning::new("using fetch_cache_pp for publication point") + Warning::new("using latest validated result for current CA instance") .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) .with_context(manifest_rsync_uri), ); Ok(PublicationPointResult { - source: PublicationPointSource::FetchCachePp, - pack, + source: PublicationPointSource::VcirCurrentInstance, + snapshot, warnings, }) } - Err(cached_err) => Err(ManifestProcessError::NoUsableCache { + Err(reused) => Err(ManifestProcessError::NoUsableCache { fresh: fresh_err, - cached: cached_err, + reused, }), } } @@ -240,84 +388,114 @@ pub fn process_manifest_publication_point_after_repo_sync( } } -pub fn load_and_revalidate_cached_pack( +pub fn load_current_instance_vcir_publication_point( store: &RocksStore, manifest_rsync_uri: &str, publication_point_rsync_uri: &str, - issuer_ca_der: &[u8], - issuer_ca_rsync_uri: Option<&str>, validation_time: time::OffsetDateTime, -) -> Result { - let key = FetchCachePpKey::from_manifest_rsync_uri(manifest_rsync_uri); - let bytes = store - .get_fetch_cache_pp(&key)? - .ok_or_else(|| ManifestCachedError::MissingFetchCachePp(key.as_str().to_string()))?; - let pack = FetchCachePpPack::decode(&bytes)?; +) -> Result { + let vcir = store.get_vcir(manifest_rsync_uri)?.ok_or_else(|| { + ManifestReuseError::MissingCurrentInstanceVcir(manifest_rsync_uri.to_string()) + })?; - if pack.manifest_rsync_uri != manifest_rsync_uri { - return Err(ManifestCachedError::InvalidPack(StorageError::RocksDb( - "cached pack manifest_rsync_uri does not match key".to_string(), - ))); - } - if pack.publication_point_rsync_uri != publication_point_rsync_uri { - return Err(ManifestCachedError::InvalidPack(StorageError::RocksDb( - "cached pack publication_point_rsync_uri does not match expected".to_string(), - ))); + if vcir.current_manifest_rsync_uri != manifest_rsync_uri { + return Err(ManifestReuseError::ManifestUriMismatch { + expected: manifest_rsync_uri.to_string(), + actual: vcir.current_manifest_rsync_uri.clone(), + }); } - revalidate_cached_pack_with_current_time( - &pack, - issuer_ca_der, - issuer_ca_rsync_uri, - validation_time, - )?; - Ok(pack) -} + if !vcir.audit_summary.failed_fetch_eligible { + return Err(ManifestReuseError::IneligibleCurrentInstanceVcir( + manifest_rsync_uri.to_string(), + )); + } -fn revalidate_cached_pack_with_current_time( - pack: &FetchCachePpPack, - issuer_ca_der: &[u8], - issuer_ca_rsync_uri: Option<&str>, - validation_time: time::OffsetDateTime, -) -> Result<(), ManifestCachedError> { - // First, re-validate the cached manifest itself with the current time. - let manifest = - decode_and_validate_manifest_with_current_time(&pack.manifest_bytes, validation_time) - .map_err(ManifestCachedError::from)?; + let instance_effective_until = vcir + .instance_gate + .instance_effective_until + .parse() + .map_err(|e| { + ManifestReuseError::InvalidCurrentInstanceVcir(format!( + "instance_gate.instance_effective_until parse failed: {e}" + )) + })?; + if validation_time > instance_effective_until { + use time::format_description::well_known::Rfc3339; + return Err(ManifestReuseError::CurrentInstanceVcirExpired { + manifest_rsync_uri: manifest_rsync_uri.to_string(), + effective_until_rfc3339_utc: instance_effective_until + .to_offset(time::UtcOffset::UTC) + .format(&Rfc3339) + .expect("format VCIR instance_effective_until"), + validation_time_rfc3339_utc: validation_time + .to_offset(time::UtcOffset::UTC) + .format(&Rfc3339) + .expect("format validation_time"), + }); + } - // Then, re-bind the manifest fileList to the cached pack contents, as per RFC 9286 §6.4-§6.5. - let by_uri: HashMap<&str, &crate::storage::PackFile> = pack - .files + let manifest_artifact = vcir + .related_artifacts .iter() - .map(|f| (f.rsync_uri.as_str(), f)) - .collect(); + .find(|artifact| { + artifact.artifact_role == VcirArtifactRole::Manifest + && artifact.uri.as_deref() == Some(manifest_rsync_uri) + }) + .ok_or_else(|| { + ManifestReuseError::InvalidCurrentInstanceVcir( + "missing manifest artifact matching manifest_rsync_uri".to_string(), + ) + })?; - let entries = manifest - .manifest - .parse_files() - .map_err(|e| ManifestFreshError::Decode(ManifestDecodeError::Validate(e)))?; - for entry in &entries { - let rsync_uri = - join_rsync_dir_and_file(&pack.publication_point_rsync_uri, entry.file_name.as_str()); - let Some(file) = by_uri.get(rsync_uri.as_str()) else { - return Err(ManifestCachedError::CachedMissingFile { rsync_uri }); + let manifest_entry = store + .get_raw_by_hash_entry(&manifest_artifact.sha256)? + .ok_or_else(|| ManifestReuseError::MissingManifestRaw(manifest_artifact.sha256.clone()))?; + + let mut seen = HashSet::new(); + let mut files = Vec::new(); + for artifact in &vcir.related_artifacts { + let Some(uri) = artifact.uri.as_ref() else { + continue; }; - if file.sha256.as_slice() != entry.hash_bytes.as_ref() { - return Err(ManifestCachedError::CachedHashMismatch { rsync_uri }); + if artifact.artifact_role == VcirArtifactRole::Manifest + || artifact.artifact_role == VcirArtifactRole::IssuerCert + || artifact.artifact_role == VcirArtifactRole::Tal + || artifact.artifact_role == VcirArtifactRole::TrustAnchorCert + { + continue; } + if !seen.insert(uri.clone()) { + continue; + } + let entry = store + .get_raw_by_hash_entry(&artifact.sha256)? + .ok_or_else(|| ManifestReuseError::MissingArtifactRaw { + rsync_uri: uri.clone(), + })?; + files.push(PackFile::from_bytes_compute_sha256(uri, entry.bytes)); } - // Finally, validate the manifest's embedded EE certificate path against the issuer CA + CRL. - // This enforces cert validity + CRL validity at `validation_time` for cached packs. - validate_manifest_embedded_ee_cert_path( - &manifest, - &pack.files, - issuer_ca_der, - issuer_ca_rsync_uri, - validation_time, - ) - .map_err(ManifestCachedError::from)?; - Ok(()) + Ok(PublicationPointSnapshot { + format_version: PublicationPointSnapshot::FORMAT_VERSION_V1, + manifest_rsync_uri: manifest_rsync_uri.to_string(), + publication_point_rsync_uri: publication_point_rsync_uri.to_string(), + manifest_number_be: vcir + .validated_manifest_meta + .validated_manifest_number + .clone(), + this_update: vcir + .validated_manifest_meta + .validated_manifest_this_update + .clone(), + next_update: vcir + .validated_manifest_meta + .validated_manifest_next_update + .clone(), + verified_at: vcir.last_successful_validation_time.clone(), + manifest_bytes: manifest_entry.bytes, + files, + }) } fn decode_and_validate_manifest_with_current_time( @@ -354,14 +532,14 @@ fn decode_and_validate_manifest_with_current_time( Ok(manifest) } -fn try_build_fresh_pack( +pub(crate) fn try_build_fresh_publication_point( store: &RocksStore, manifest_rsync_uri: &str, publication_point_rsync_uri: &str, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, validation_time: time::OffsetDateTime, -) -> Result { +) -> Result { if !rsync_uri_is_under_publication_point(manifest_rsync_uri, publication_point_rsync_uri) { return Err(ManifestFreshError::ManifestOutsidePublicationPoint { manifest_rsync_uri: manifest_rsync_uri.to_string(), @@ -397,54 +575,60 @@ fn try_build_fresh_pack( // - If the manifestNumber is equal to the previously validated manifestNumber *and* the // manifest bytes are identical, then this is the same manifest being revalidated and MUST // be accepted (otherwise, RPs would incorrectly treat stable repositories as "failed fetch" - // and fall back to fetch_cache_pp). + // and fall back to the current-instance VCIR snapshot). // - If manifestNumber is equal but the manifest bytes differ, treat this as invalid (a // repository is not allowed to change the manifest while keeping the manifestNumber). // - If manifestNumber is lower, treat as rollback and reject. // - If manifestNumber is higher, require thisUpdate to be more recent than the previously // validated thisUpdate. - let key = FetchCachePpKey::from_manifest_rsync_uri(manifest_rsync_uri); - if let Some(old_bytes) = store.get_fetch_cache_pp(&key).ok().flatten() { - if let Ok(old_pack) = FetchCachePpPack::decode(&old_bytes) { - if old_pack.manifest_rsync_uri == manifest_rsync_uri - && old_pack.publication_point_rsync_uri == publication_point_rsync_uri - { - let new_num = manifest.manifest.manifest_number.bytes_be.as_slice(); - let old_num = old_pack.manifest_number_be.as_slice(); - match cmp_minimal_be_unsigned(new_num, old_num) { - Ordering::Greater => { - let old_this_update = old_pack - .this_update - .parse() - .expect("pack internal validation ensures this_update parses"); - if this_update <= old_this_update { - use time::format_description::well_known::Rfc3339; - return Err(ManifestFreshError::ThisUpdateNotIncreasing { - old_rfc3339_utc: old_this_update - .to_offset(time::UtcOffset::UTC) - .format(&Rfc3339) - .expect("format old thisUpdate"), - new_rfc3339_utc: this_update - .format(&Rfc3339) - .expect("format new thisUpdate"), - }); - } + if let Some(old_vcir) = store.get_vcir(manifest_rsync_uri).ok().flatten() { + if old_vcir.manifest_rsync_uri == manifest_rsync_uri { + let new_num = manifest.manifest.manifest_number.bytes_be.as_slice(); + let old_num = old_vcir + .validated_manifest_meta + .validated_manifest_number + .as_slice(); + match cmp_minimal_be_unsigned(new_num, old_num) { + Ordering::Greater => { + let old_this_update = old_vcir + .validated_manifest_meta + .validated_manifest_this_update + .parse() + .expect("vcir internal validation ensures thisUpdate parses"); + if this_update <= old_this_update { + use time::format_description::well_known::Rfc3339; + return Err(ManifestFreshError::ThisUpdateNotIncreasing { + old_rfc3339_utc: old_this_update + .to_offset(time::UtcOffset::UTC) + .format(&Rfc3339) + .expect("format old thisUpdate"), + new_rfc3339_utc: this_update + .format(&Rfc3339) + .expect("format new thisUpdate"), + }); } - Ordering::Equal => { - if old_pack.manifest_bytes != manifest_bytes { - return Err(ManifestFreshError::ManifestNumberNotIncreasing { - old_hex: hex::encode_upper(old_num), - new_hex: hex::encode_upper(new_num), - }); - } - } - Ordering::Less => { + } + Ordering::Equal => { + let old_manifest_hash = + old_vcir.related_artifacts.iter().find_map(|artifact| { + (artifact.artifact_role == VcirArtifactRole::Manifest + && artifact.uri.as_deref() == Some(manifest_rsync_uri)) + .then_some(artifact.sha256.as_str()) + }); + let new_manifest_hash = hex::encode(sha2::Sha256::digest(&manifest_bytes)); + if old_manifest_hash != Some(new_manifest_hash.as_str()) { return Err(ManifestFreshError::ManifestNumberNotIncreasing { old_hex: hex::encode_upper(old_num), new_hex: hex::encode_upper(new_num), }); } } + Ordering::Less => { + return Err(ManifestFreshError::ManifestNumberNotIncreasing { + old_hex: hex::encode_upper(old_num), + new_hex: hex::encode_upper(new_num), + }); + } } } } @@ -471,9 +655,7 @@ fn try_build_fresh_pack( return Err(ManifestFreshError::HashMismatch { rsync_uri }); } - files.push(crate::storage::PackFile::from_bytes_compute_sha256( - rsync_uri, bytes, - )); + files.push(PackFile::from_bytes_compute_sha256(rsync_uri, bytes)); } // RFC 6488 §3: manifest (signed object) validity includes a valid EE cert path. @@ -486,14 +668,13 @@ fn try_build_fresh_pack( validation_time, )?; - Ok(FetchCachePpPack { - format_version: FetchCachePpPack::FORMAT_VERSION_V1, + Ok(FreshValidatedPublicationPoint { manifest_rsync_uri: manifest_rsync_uri.to_string(), publication_point_rsync_uri: publication_point_rsync_uri.to_string(), manifest_number_be: manifest.manifest.manifest_number.bytes_be.clone(), - this_update: crate::storage::PackTime::from_utc_offset_datetime(this_update), - next_update: crate::storage::PackTime::from_utc_offset_datetime(next_update), - verified_at: crate::storage::PackTime::from_utc_offset_datetime(now), + this_update: PackTime::from_utc_offset_datetime(this_update), + next_update: PackTime::from_utc_offset_datetime(next_update), + verified_at: PackTime::from_utc_offset_datetime(now), manifest_bytes, files, }) @@ -573,3 +754,500 @@ fn validate_manifest_embedded_ee_cert_path( .join(", "), )) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_model::manifest::ManifestObject; + use crate::storage::{ + PackFile, PackTime, RawByHashEntry, RocksStore, ValidatedCaInstanceResult, + ValidatedManifestMeta, VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, + VcirAuditSummary, VcirInstanceGate, VcirRelatedArtifact, VcirSummary, + }; + use std::path::Path; + + fn manifest_fixture_path() -> &'static Path { + Path::new( + "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", + ) + } + + fn issuer_ca_fixture_der() -> Vec { + std::fs::read( + "tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer", + ) + .expect("read issuer ca fixture") + } + + fn issuer_ca_rsync_uri() -> &'static str { + "rsync://rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer" + } + + fn fixture_to_rsync_uri(path: &Path) -> String { + let rel = path + .strip_prefix("tests/fixtures/repository") + .expect("path under fixture repository"); + let mut it = rel.components(); + let host = it + .next() + .expect("host component") + .as_os_str() + .to_string_lossy(); + let rest = it.as_path().to_string_lossy(); + format!("rsync://{host}/{rest}") + } + + fn fixture_dir_to_rsync_uri(dir: &Path) -> String { + let mut s = fixture_to_rsync_uri(dir); + if !s.ends_with('/') { + s.push('/'); + } + s + } + + fn load_manifest_fixture() -> ( + ManifestObject, + Vec, + String, + String, + time::OffsetDateTime, + ) { + let manifest_path = manifest_fixture_path(); + let manifest_bytes = std::fs::read(manifest_path).expect("read manifest fixture"); + let manifest = ManifestObject::decode_der(&manifest_bytes).expect("decode manifest"); + let manifest_rsync_uri = fixture_to_rsync_uri(manifest_path); + let publication_point_rsync_uri = fixture_dir_to_rsync_uri(manifest_path.parent().unwrap()); + let validation_time = manifest.manifest.this_update + time::Duration::seconds(1); + ( + manifest, + manifest_bytes, + manifest_rsync_uri, + publication_point_rsync_uri, + validation_time, + ) + } + + fn raw_by_hash_entry(uri: &str, bytes: Vec, object_type: &str) -> RawByHashEntry { + let mut entry = + RawByHashEntry::from_bytes(hex::encode(sha2::Sha256::digest(&bytes)), bytes); + entry.origin_uris.push(uri.to_string()); + entry.object_type = Some(object_type.to_string()); + entry.encoding = Some("der".to_string()); + entry + } + + fn sample_current_instance_vcir( + manifest_rsync_uri: &str, + publication_point_rsync_uri: &str, + manifest_sha256: &str, + locked_object_uri: &str, + locked_object_sha256: &str, + validation_time: time::OffsetDateTime, + failed_fetch_eligible: bool, + ) -> ValidatedCaInstanceResult { + let gate_time = + PackTime::from_utc_offset_datetime(validation_time + time::Duration::hours(1)); + ValidatedCaInstanceResult { + manifest_rsync_uri: manifest_rsync_uri.to_string(), + parent_manifest_rsync_uri: None, + tal_id: "test-tal".to_string(), + ca_subject_name: "CN=test".to_string(), + ca_ski: "00112233445566778899aabbccddeeff00112233".to_string(), + issuer_ski: "00112233445566778899aabbccddeeff00112233".to_string(), + last_successful_validation_time: PackTime::from_utc_offset_datetime(validation_time), + current_manifest_rsync_uri: manifest_rsync_uri.to_string(), + current_crl_rsync_uri: format!("{publication_point_rsync_uri}current.crl"), + validated_manifest_meta: ValidatedManifestMeta { + validated_manifest_number: vec![1], + validated_manifest_this_update: PackTime::from_utc_offset_datetime(validation_time), + validated_manifest_next_update: gate_time.clone(), + }, + instance_gate: VcirInstanceGate { + manifest_next_update: gate_time.clone(), + current_crl_next_update: gate_time.clone(), + self_ca_not_after: gate_time.clone(), + instance_effective_until: gate_time, + }, + child_entries: Vec::new(), + local_outputs: Vec::new(), + related_artifacts: vec![ + VcirRelatedArtifact { + artifact_role: VcirArtifactRole::Manifest, + artifact_kind: VcirArtifactKind::Mft, + uri: Some(manifest_rsync_uri.to_string()), + sha256: manifest_sha256.to_string(), + object_type: Some("mft".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + VcirRelatedArtifact { + artifact_role: VcirArtifactRole::SignedObject, + artifact_kind: VcirArtifactKind::Roa, + uri: Some(locked_object_uri.to_string()), + sha256: locked_object_sha256.to_string(), + object_type: Some("roa".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + ], + summary: VcirSummary { + local_vrp_count: 0, + local_aspa_count: 0, + child_count: 0, + accepted_object_count: 2, + rejected_object_count: 0, + }, + audit_summary: VcirAuditSummary { + failed_fetch_eligible, + last_failed_fetch_reason: None, + warning_count: 0, + audit_flags: Vec::new(), + }, + } + } + fn locked_files_for_manifest( + manifest: &ManifestObject, + publication_point_rsync_uri: &str, + ) -> Vec { + let manifest_path = manifest_fixture_path(); + manifest + .manifest + .parse_files() + .expect("parse files") + .into_iter() + .map(|entry| { + let file_path = manifest_path + .parent() + .unwrap() + .join(entry.file_name.as_str()); + let bytes = std::fs::read(&file_path).unwrap_or_else(|_| { + panic!("read fixture file referenced by manifest: {file_path:?}") + }); + PackFile::from_bytes_compute_sha256( + format!("{publication_point_rsync_uri}{}", entry.file_name), + bytes, + ) + }) + .collect() + } + + #[test] + fn try_build_fresh_publication_point_rejects_manifest_outside_publication_point() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(temp.path()).expect("open rocksdb"); + let (_, _, manifest_rsync_uri, _, validation_time) = load_manifest_fixture(); + let err = try_build_fresh_publication_point( + &store, + &manifest_rsync_uri, + "rsync://example.test/other/", + &issuer_ca_fixture_der(), + Some(issuer_ca_rsync_uri()), + validation_time, + ) + .unwrap_err(); + assert!( + matches!( + err, + ManifestFreshError::ManifestOutsidePublicationPoint { .. } + ), + "{err}" + ); + } + + #[test] + fn try_build_fresh_publication_point_reports_missing_manifest_when_raw_store_is_empty() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(temp.path()).expect("open rocksdb"); + let (_, _, manifest_rsync_uri, publication_point_rsync_uri, validation_time) = + load_manifest_fixture(); + let err = try_build_fresh_publication_point( + &store, + &manifest_rsync_uri, + &publication_point_rsync_uri, + &issuer_ca_fixture_der(), + Some(issuer_ca_rsync_uri()), + validation_time, + ) + .unwrap_err(); + assert!( + matches!(err, ManifestFreshError::MissingManifest { .. }), + "{err}" + ); + } + + #[test] + fn try_build_fresh_publication_point_reports_missing_locked_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(temp.path()).expect("open rocksdb"); + let ( + manifest, + manifest_bytes, + manifest_rsync_uri, + publication_point_rsync_uri, + validation_time, + ) = load_manifest_fixture(); + store + .put_raw(&manifest_rsync_uri, &manifest_bytes) + .expect("store manifest"); + let first_non_crl = manifest + .manifest + .parse_files() + .expect("parse files") + .into_iter() + .find(|entry| !entry.file_name.ends_with(".crl")) + .expect("fixture non-crl entry"); + let file_path = manifest_fixture_path() + .parent() + .unwrap() + .join(first_non_crl.file_name.as_str()); + let bytes = std::fs::read(&file_path).expect("read fixture file"); + let rsync_uri = format!("{publication_point_rsync_uri}{}", first_non_crl.file_name); + store + .put_raw(&rsync_uri, &bytes) + .expect("store single file"); + + let err = try_build_fresh_publication_point( + &store, + &manifest_rsync_uri, + &publication_point_rsync_uri, + &issuer_ca_fixture_der(), + Some(issuer_ca_rsync_uri()), + validation_time, + ) + .unwrap_err(); + assert!( + matches!(err, ManifestFreshError::MissingFile { .. }), + "{err}" + ); + } + + #[test] + fn validate_manifest_embedded_ee_cert_path_rejects_missing_crl_files() { + let (manifest, _, _, publication_point_rsync_uri, validation_time) = + load_manifest_fixture(); + let files = locked_files_for_manifest(&manifest, &publication_point_rsync_uri) + .into_iter() + .filter(|f| !f.rsync_uri.ends_with(".crl")) + .collect::>(); + + let err = validate_manifest_embedded_ee_cert_path( + &manifest, + &files, + &issuer_ca_fixture_der(), + Some(issuer_ca_rsync_uri()), + validation_time, + ) + .unwrap_err(); + assert!(matches!(err, ManifestFreshError::NoCrlFiles), "{err}"); + } + + #[test] + fn validate_manifest_embedded_ee_cert_path_rejects_missing_ee_crldp() { + let (mut manifest, _, _, publication_point_rsync_uri, validation_time) = + load_manifest_fixture(); + manifest.signed_object.signed_data.certificates[0] + .resource_cert + .tbs + .extensions + .crl_distribution_points_uris = None; + let files = locked_files_for_manifest(&manifest, &publication_point_rsync_uri); + + let err = validate_manifest_embedded_ee_cert_path( + &manifest, + &files, + &issuer_ca_fixture_der(), + Some(issuer_ca_rsync_uri()), + validation_time, + ) + .unwrap_err(); + assert!(matches!(err, ManifestFreshError::EeCrlDpMissing), "{err}"); + } + + #[test] + fn validate_manifest_embedded_ee_cert_path_rejects_unlisted_crldp_uri() { + let (manifest, _, _, publication_point_rsync_uri, validation_time) = + load_manifest_fixture(); + let mut files = locked_files_for_manifest(&manifest, &publication_point_rsync_uri) + .into_iter() + .filter(|f| !f.rsync_uri.ends_with(".crl")) + .collect::>(); + files.push(PackFile::from_bytes_compute_sha256( + "rsync://example.test/repo/unrelated.crl", + b"dummy".to_vec(), + )); + + let err = validate_manifest_embedded_ee_cert_path( + &manifest, + &files, + &issuer_ca_fixture_der(), + Some(issuer_ca_rsync_uri()), + validation_time, + ) + .unwrap_err(); + assert!(matches!(err, ManifestFreshError::EeCrlNotFound(_)), "{err}"); + } + + #[test] + fn load_current_instance_vcir_publication_point_returns_manifest_and_locked_files() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(temp.path()).expect("open rocksdb"); + let validation_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::hours(1); + let manifest_uri = "rsync://example.test/repo/current.mft"; + let publication_point_uri = "rsync://example.test/repo/"; + let locked_uri = "rsync://example.test/repo/object.roa"; + let manifest_bytes = vec![0x30, 0x31, 0x32]; + let locked_bytes = vec![0x01, 0x02, 0x03, 0x04]; + let manifest_entry = raw_by_hash_entry(manifest_uri, manifest_bytes.clone(), "mft"); + let locked_entry = raw_by_hash_entry(locked_uri, locked_bytes.clone(), "roa"); + store + .put_raw_by_hash_entry(&manifest_entry) + .expect("put manifest raw_by_hash"); + store + .put_raw_by_hash_entry(&locked_entry) + .expect("put locked raw_by_hash"); + let vcir = sample_current_instance_vcir( + manifest_uri, + publication_point_uri, + &manifest_entry.sha256_hex, + locked_uri, + &locked_entry.sha256_hex, + validation_time, + true, + ); + store.put_vcir(&vcir).expect("put vcir"); + + let point = load_current_instance_vcir_publication_point( + &store, + manifest_uri, + publication_point_uri, + validation_time, + ) + .expect("load current-instance vcir publication point"); + assert_eq!(point.manifest_bytes, manifest_bytes); + assert_eq!(point.files.len(), 1); + assert_eq!(point.files[0].rsync_uri, locked_uri); + assert_eq!(point.files[0].bytes, locked_bytes); + } + + #[test] + fn load_current_instance_vcir_publication_point_rejects_ineligible_vcir() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(temp.path()).expect("open rocksdb"); + let validation_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::hours(1); + let manifest_uri = "rsync://example.test/repo/current.mft"; + let publication_point_uri = "rsync://example.test/repo/"; + let locked_uri = "rsync://example.test/repo/object.roa"; + let manifest_entry = raw_by_hash_entry(manifest_uri, vec![0x30], "mft"); + let locked_entry = raw_by_hash_entry(locked_uri, vec![0x01], "roa"); + store + .put_raw_by_hash_entry(&manifest_entry) + .expect("put manifest raw_by_hash"); + store + .put_raw_by_hash_entry(&locked_entry) + .expect("put locked raw_by_hash"); + let vcir = sample_current_instance_vcir( + manifest_uri, + publication_point_uri, + &manifest_entry.sha256_hex, + locked_uri, + &locked_entry.sha256_hex, + validation_time, + false, + ); + store.put_vcir(&vcir).expect("put vcir"); + + let err = load_current_instance_vcir_publication_point( + &store, + manifest_uri, + publication_point_uri, + validation_time, + ) + .unwrap_err(); + assert!( + matches!(err, ManifestReuseError::IneligibleCurrentInstanceVcir(_)), + "{err}" + ); + } + + #[test] + fn load_current_instance_vcir_publication_point_rejects_expired_vcir() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(temp.path()).expect("open rocksdb"); + let validation_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::hours(2); + let manifest_uri = "rsync://example.test/repo/current.mft"; + let publication_point_uri = "rsync://example.test/repo/"; + let locked_uri = "rsync://example.test/repo/object.roa"; + let manifest_entry = raw_by_hash_entry(manifest_uri, vec![0x30], "mft"); + let locked_entry = raw_by_hash_entry(locked_uri, vec![0x01], "roa"); + store + .put_raw_by_hash_entry(&manifest_entry) + .expect("put manifest raw_by_hash"); + store + .put_raw_by_hash_entry(&locked_entry) + .expect("put locked raw_by_hash"); + let mut vcir = sample_current_instance_vcir( + manifest_uri, + publication_point_uri, + &manifest_entry.sha256_hex, + locked_uri, + &locked_entry.sha256_hex, + validation_time - time::Duration::hours(2), + true, + ); + let expired = + PackTime::from_utc_offset_datetime(validation_time - time::Duration::minutes(1)); + vcir.instance_gate.manifest_next_update = expired.clone(); + vcir.instance_gate.current_crl_next_update = expired.clone(); + vcir.instance_gate.self_ca_not_after = expired.clone(); + vcir.instance_gate.instance_effective_until = expired; + vcir.validated_manifest_meta.validated_manifest_next_update = + vcir.instance_gate.instance_effective_until.clone(); + store.put_vcir(&vcir).expect("put expired vcir"); + + let err = load_current_instance_vcir_publication_point( + &store, + manifest_uri, + publication_point_uri, + validation_time, + ) + .unwrap_err(); + assert!( + matches!(err, ManifestReuseError::CurrentInstanceVcirExpired { .. }), + "{err}" + ); + } + + #[test] + fn load_current_instance_vcir_publication_point_rejects_missing_locked_artifact_raw() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(temp.path()).expect("open rocksdb"); + let validation_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::hours(1); + let manifest_uri = "rsync://example.test/repo/current.mft"; + let publication_point_uri = "rsync://example.test/repo/"; + let locked_uri = "rsync://example.test/repo/object.roa"; + let manifest_entry = raw_by_hash_entry(manifest_uri, vec![0x30], "mft"); + store + .put_raw_by_hash_entry(&manifest_entry) + .expect("put manifest raw_by_hash"); + let vcir = sample_current_instance_vcir( + manifest_uri, + publication_point_uri, + &manifest_entry.sha256_hex, + locked_uri, + &hex::encode(sha2::Sha256::digest([0x01, 0x02])), + validation_time, + true, + ); + store.put_vcir(&vcir).expect("put vcir"); + + let err = load_current_instance_vcir_publication_point( + &store, + manifest_uri, + publication_point_uri, + validation_time, + ) + .unwrap_err(); + assert!( + matches!(err, ManifestReuseError::MissingArtifactRaw { .. }), + "{err}" + ); + } +} diff --git a/src/validation/mod.rs b/src/validation/mod.rs index 8ae8ea1..0e783dd 100644 --- a/src/validation/mod.rs +++ b/src/validation/mod.rs @@ -4,6 +4,7 @@ pub mod cert_path; pub mod from_tal; pub mod manifest; pub mod objects; +pub mod publication_point; pub mod run; pub mod run_tree_from_tal; pub mod tree; diff --git a/src/validation/objects.rs b/src/validation/objects.rs index 4d11265..226e0d1 100644 --- a/src/validation/objects.rs +++ b/src/validation/objects.rs @@ -10,8 +10,10 @@ use crate::data_model::roa::{IpPrefix, RoaAfi, RoaDecodeError, RoaObject, RoaVal use crate::data_model::signed_object::SignedObjectVerifyError; use crate::policy::{Policy, SignedObjectFailurePolicy}; use crate::report::{RfcRef, Warning}; -use crate::storage::{FetchCachePpPack, PackFile}; +use crate::storage::PackFile; use crate::validation::cert_path::{CertPathError, validate_ee_cert_path_with_prevalidated_issuer}; +use crate::validation::manifest::PublicationPointData; +use crate::validation::publication_point::PublicationPointSnapshot; use x509_parser::prelude::FromDer; use x509_parser::x509::SubjectPublicKeyInfo; @@ -81,10 +83,10 @@ pub struct ObjectsStats { pub publication_point_dropped: bool, } -/// Process objects from a fetch_cache_pp publication point pack using a known issuer CA certificate +/// Process objects from a publication point snapshot using a known issuer CA certificate /// and its effective resources (resolved via the resource-path, RFC 6487 §7.2). -pub fn process_fetch_cache_pp_pack_for_issuer( - pack: &FetchCachePpPack, +pub fn process_publication_point_for_issuer( + publication_point: &P, policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, @@ -93,23 +95,24 @@ pub fn process_fetch_cache_pp_pack_for_issuer( validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, ) -> ObjectsOutput { + let manifest_rsync_uri = publication_point.manifest_rsync_uri(); + let manifest_bytes = publication_point.manifest_bytes(); + let locked_files = publication_point.files(); let mut warnings: Vec = Vec::new(); let mut stats = ObjectsStats::default(); - stats.roa_total = pack - .files + stats.roa_total = locked_files .iter() .filter(|f| f.rsync_uri.ends_with(".roa")) .count(); - stats.aspa_total = pack - .files + stats.aspa_total = locked_files .iter() .filter(|f| f.rsync_uri.ends_with(".asa")) .count(); let mut audit: Vec = Vec::new(); // Enforce that `manifest_bytes` is actually a manifest object. - let _manifest = - ManifestObject::decode_der(&pack.manifest_bytes).expect("fetch_cache_pp manifest decodes"); + let _manifest = ManifestObject::decode_der(manifest_bytes) + .expect("publication point snapshot manifest decodes"); // Decode issuer CA once; if it fails we cannot validate ROA/ASPA EE certificates. let issuer_ca = match ResourceCertificate::decode_der(issuer_ca_der) { @@ -121,9 +124,9 @@ pub fn process_fetch_cache_pp_pack_for_issuer( "dropping publication point: issuer CA decode failed: {e}" )) .with_rfc_refs(&[RfcRef("RFC 6487 §7.2"), RfcRef("RFC 5280 §6.1")]) - .with_context(&pack.manifest_rsync_uri), + .with_context(manifest_rsync_uri), ); - for f in &pack.files { + for f in locked_files { if f.rsync_uri.ends_with(".roa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), @@ -163,7 +166,7 @@ pub fn process_fetch_cache_pp_pack_for_issuer( rem.len() )) .with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")]) - .with_context(&pack.manifest_rsync_uri), + .with_context(manifest_rsync_uri), ); return ObjectsOutput { vrps: Vec::new(), @@ -180,7 +183,7 @@ pub fn process_fetch_cache_pp_pack_for_issuer( "dropping publication point: issuer SPKI parse failed: {e}" )) .with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")]) - .with_context(&pack.manifest_rsync_uri), + .with_context(manifest_rsync_uri), ); return ObjectsOutput { vrps: Vec::new(), @@ -192,8 +195,7 @@ pub fn process_fetch_cache_pp_pack_for_issuer( } }; - let mut crl_cache: std::collections::HashMap = pack - .files + let mut crl_cache: std::collections::HashMap = locked_files .iter() .filter(|f| f.rsync_uri.ends_with(".crl")) .map(|f| { @@ -207,23 +209,26 @@ pub fn process_fetch_cache_pp_pack_for_issuer( let issuer_resources_index = build_issuer_resources_index(issuer_effective_ip, issuer_effective_as); - // If the pack has signed objects but no CRLs at all, we cannot validate any embedded EE - // certificate paths deterministically (EE CRLDP must reference an rsync URI in the pack). + // If the snapshot has signed objects but no CRLs at all, we cannot validate any embedded EE + // certificate paths deterministically (EE CRLDP must reference an rsync URI in the snapshot). if crl_cache.is_empty() && (stats.roa_total > 0 || stats.aspa_total > 0) { stats.publication_point_dropped = true; warnings.push( - Warning::new("dropping publication point: no CRL files in fetch_cache_pp") + Warning::new("dropping publication point: no CRL files in validated publication point") .with_rfc_refs(&[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §7")]) - .with_context(&pack.manifest_rsync_uri), + .with_context(manifest_rsync_uri), ); - for f in &pack.files { + for f in locked_files { if f.rsync_uri.ends_with(".roa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Skipped, - detail: Some("skipped due to missing CRL files in fetch_cache_pp".to_string()), + detail: Some( + "skipped due to missing CRL files in validated publication point" + .to_string(), + ), }); } else if f.rsync_uri.ends_with(".asa") { audit.push(ObjectAuditEntry { @@ -231,7 +236,10 @@ pub fn process_fetch_cache_pp_pack_for_issuer( sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Skipped, - detail: Some("skipped due to missing CRL files in fetch_cache_pp".to_string()), + detail: Some( + "skipped due to missing CRL files in validated publication point" + .to_string(), + ), }); } } @@ -247,7 +255,7 @@ pub fn process_fetch_cache_pp_pack_for_issuer( let mut vrps: Vec = Vec::new(); let mut aspas: Vec = Vec::new(); - for (idx, file) in pack.files.iter().enumerate() { + for (idx, file) in locked_files.iter().enumerate() { if file.rsync_uri.ends_with(".roa") { let _t = timing.as_ref().map(|t| t.span_phase("objects_roa_total")); match process_roa_with_issuer( @@ -300,7 +308,7 @@ pub fn process_fetch_cache_pp_pack_for_issuer( result: AuditObjectResult::Error, detail: Some(e.to_string()), }); - for f in pack.files.iter().skip(idx + 1) { + for f in locked_files.iter().skip(idx + 1) { if f.rsync_uri.ends_with(".roa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), @@ -333,7 +341,7 @@ pub fn process_fetch_cache_pp_pack_for_issuer( file.rsync_uri )) .with_rfc_refs(&refs) - .with_context(&pack.manifest_rsync_uri), + .with_context(manifest_rsync_uri), ); return ObjectsOutput { vrps: Vec::new(), @@ -397,7 +405,7 @@ pub fn process_fetch_cache_pp_pack_for_issuer( result: AuditObjectResult::Error, detail: Some(e.to_string()), }); - for f in pack.files.iter().skip(idx + 1) { + for f in locked_files.iter().skip(idx + 1) { if f.rsync_uri.ends_with(".roa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), @@ -430,7 +438,7 @@ pub fn process_fetch_cache_pp_pack_for_issuer( file.rsync_uri )) .with_rfc_refs(&refs) - .with_context(&pack.manifest_rsync_uri), + .with_context(manifest_rsync_uri), ); return ObjectsOutput { vrps: Vec::new(), @@ -453,6 +461,28 @@ pub fn process_fetch_cache_pp_pack_for_issuer( audit, } } +/// Compatibility wrapper that processes a publication point snapshot. +pub fn process_publication_point_snapshot_for_issuer( + pack: &PublicationPointSnapshot, + policy: &Policy, + issuer_ca_der: &[u8], + issuer_ca_rsync_uri: Option<&str>, + issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, + issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, + validation_time: time::OffsetDateTime, + timing: Option<&TimingHandle>, +) -> ObjectsOutput { + process_publication_point_for_issuer( + pack, + policy, + issuer_ca_der, + issuer_ca_rsync_uri, + issuer_effective_ip, + issuer_effective_as, + validation_time, + timing, + ) +} #[derive(Debug, thiserror::Error)] enum ObjectValidateError { @@ -480,12 +510,12 @@ enum ObjectValidateError { MissingCrlDpUris, #[error( - "no CRL available in fetch_cache_pp (cannot validate certificates) (RFC 9286 §7; RFC 6487 §4.8.6)" + "no CRL available in publication point snapshot (cannot validate certificates) (RFC 9286 §7; RFC 6487 §4.8.6)" )] MissingCrlInPack, #[error( - "CRL referenced by CRLDistributionPoints not found in fetch_cache_pp: {0} (RFC 6487 §4.8.6; RFC 9286 §4.2.1)" + "CRL referenced by CRLDistributionPoints not found in publication point snapshot: {0} (RFC 6487 §4.8.6; RFC 9286 §4.2.1)" )] CrlNotFound(String), @@ -1283,7 +1313,7 @@ mod tests { } #[test] - fn choose_crl_for_certificate_reports_missing_crl_in_pack() { + fn choose_crl_for_certificate_reports_missing_crl_in_snapshot() { let roa_der = fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa"); let roa = RoaObject::decode_der(&roa_der).expect("decode roa"); @@ -1335,7 +1365,7 @@ mod tests { } #[test] - fn choose_crl_for_certificate_reports_not_found_when_crldp_does_not_match_pack() { + fn choose_crl_for_certificate_reports_not_found_when_crldp_does_not_match_snapshot() { let roa_der = fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa"); let roa = RoaObject::decode_der(&roa_der).expect("decode roa"); @@ -1421,4 +1451,225 @@ mod tests { let err = validate_ee_resources_subset(ee, Some(&issuer_ip), None, &idx).unwrap_err(); assert!(matches!(err, ObjectValidateError::EeResourcesNotSubset)); } + + #[test] + fn extra_rfc_refs_for_crl_selection_distinguishes_crl_errors() { + assert_eq!( + extra_rfc_refs_for_crl_selection(&ObjectValidateError::MissingCrlDpUris), + RFC_CRLDP + ); + assert_eq!( + extra_rfc_refs_for_crl_selection(&ObjectValidateError::CrlNotFound( + "rsync://example.test/x.crl".to_string(), + )), + RFC_CRLDP_AND_LOCKED_PACK + ); + assert!( + extra_rfc_refs_for_crl_selection(&ObjectValidateError::MissingCrlInPack).is_empty() + ); + } + + #[test] + fn as_subset_helpers_cover_success_and_failure_paths() { + let child = AsIdentifierChoice::AsIdsOrRanges(vec![ + AsIdOrRange::Id(5), + AsIdOrRange::Range { min: 7, max: 9 }, + ]); + let parent = + AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Range { min: 1, max: 10 }]); + let parent_intervals = [(1, 10)]; + + assert!(as_choice_subset(None, Some(&parent))); + assert!(!as_choice_subset(Some(&child), None)); + assert!(!as_choice_subset( + Some(&AsIdentifierChoice::Inherit), + Some(&parent) + )); + assert!(!as_choice_subset( + Some(&child), + Some(&AsIdentifierChoice::Inherit) + )); + assert!(as_choice_subset(Some(&child), Some(&parent))); + + assert!(as_choice_subset_indexed(None, Some(&parent_intervals))); + assert!(!as_choice_subset_indexed(Some(&child), None)); + assert!(!as_choice_subset_indexed( + Some(&AsIdentifierChoice::Inherit), + Some(&parent_intervals), + )); + assert!(as_choice_subset_indexed( + Some(&child), + Some(&parent_intervals) + )); + assert!(!as_choice_subset_indexed( + Some(&AsIdentifierChoice::AsIdsOrRanges(vec![ + AsIdOrRange::Range { min: 11, max: 12 } + ])), + Some(&parent_intervals), + )); + + let child_set = AsResourceSet { + asnum: Some(child.clone()), + rdi: Some(AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Id(42)])), + }; + let parent_set = AsResourceSet { + asnum: Some(parent.clone()), + rdi: Some(AsIdentifierChoice::AsIdsOrRanges(vec![ + AsIdOrRange::Range { min: 40, max: 50 }, + ])), + }; + assert!(as_resources_is_subset(&child_set, &parent_set)); + assert!(as_resources_is_subset_indexed( + &child_set, + &parent_set, + &IssuerResourcesIndex { + asnum: Some(vec![(1, 10)]), + rdi: Some(vec![(40, 50)]), + ..IssuerResourcesIndex::default() + }, + )); + } + + #[test] + fn ip_subset_helpers_cover_strict_and_indexed_paths() { + let parent = IpResourceSet { + families: vec![ + IpAddressFamily { + afi: Afi::Ipv4, + choice: IpAddressChoice::AddressesOrRanges(vec![ + IpAddressOrRange::Prefix(IpPrefix { + afi: Afi::Ipv4, + prefix_len: 8, + addr: vec![10, 0, 0, 0], + }), + IpAddressOrRange::Range(IpAddressRange { + min: vec![192, 0, 2, 0], + max: vec![192, 0, 2, 255], + }), + ]), + }, + IpAddressFamily { + afi: Afi::Ipv6, + choice: IpAddressChoice::Inherit, + }, + ], + }; + let child = IpResourceSet { + families: vec![IpAddressFamily { + afi: Afi::Ipv4, + choice: IpAddressChoice::AddressesOrRanges(vec![ + IpAddressOrRange::Prefix(IpPrefix { + afi: Afi::Ipv4, + prefix_len: 16, + addr: vec![10, 1, 0, 0], + }), + IpAddressOrRange::Range(IpAddressRange { + min: vec![192, 0, 2, 10], + max: vec![192, 0, 2, 20], + }), + ]), + }], + }; + let strict_bad = IpResourceSet { + families: vec![IpAddressFamily { + afi: Afi::Ipv4, + choice: IpAddressChoice::Inherit, + }], + }; + + assert!(ip_resources_is_subset(&child, &parent)); + assert!(ip_resources_to_merged_intervals(&parent).contains_key(&AfiKey::V4)); + assert!(ip_resources_to_merged_intervals_strict(&child).is_ok()); + assert!(ip_resources_to_merged_intervals_strict(&strict_bad).is_err()); + + let idx = build_issuer_resources_index( + Some(&parent), + Some(&AsResourceSet { + asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![ + AsIdOrRange::Range { + min: 64500, + max: 64510, + }, + ])), + rdi: Some(AsIdentifierChoice::Inherit), + }), + ); + assert!(idx.ip_v4.is_some()); + assert!(idx.ip_v6.is_none()); + assert!(idx.asnum.is_some()); + assert!(idx.rdi.is_none()); + assert!(ip_resources_is_subset_indexed(&child, &parent, &idx)); + assert!(!ip_resources_is_subset_indexed(&strict_bad, &parent, &idx)); + } + + #[test] + fn interval_and_byte_helpers_cover_edge_cases() { + let parent = vec![(vec![0, 0, 0, 0], vec![0, 0, 0, 10])]; + assert!(interval_is_covered(&parent, &[0, 0, 0, 1], &[0, 0, 0, 2])); + assert!(!interval_is_covered( + &parent, + &[0, 0, 0, 11], + &[0, 0, 0, 12] + )); + assert!(intervals_are_covered( + &parent, + &[(vec![0, 0, 0, 1], vec![0, 0, 0, 2])] + )); + assert!(!intervals_are_covered( + &parent, + &[(vec![0, 0, 0, 9], vec![0, 0, 0, 11])], + )); + + let prefix = RcIpPrefix { + afi: Afi::Ipv4, + prefix_len: 24, + addr: vec![203, 0, 113, 7], + }; + assert_eq!( + prefix_to_range(&prefix), + (vec![203, 0, 113, 0], vec![203, 0, 113, 255]) + ); + assert_eq!(increment_bytes(&[0, 0, 0, 255]), vec![0, 0, 1, 0]); + assert!(bytes_is_next(&[0, 0, 1, 0], &[0, 0, 0, 255])); + assert!(!bytes_is_next(&[1, 2], &[1])); + } + + #[test] + fn merged_interval_helpers_cover_empty_and_break_paths() { + let mut empty: Vec<(Vec, Vec)> = Vec::new(); + merge_ip_intervals_in_place(&mut empty); + assert!(empty.is_empty()); + + let mut v = vec![ + (vec![0, 0, 0, 20], vec![0, 0, 0, 30]), + (vec![0, 0, 0, 0], vec![0, 0, 0, 10]), + (vec![0, 0, 0, 11], vec![0, 0, 0, 19]), + ]; + v.sort_by(|(a, _), (b, _)| a.cmp(b)); + merge_ip_intervals_in_place(&mut v); + assert_eq!(v, vec![(vec![0, 0, 0, 0], vec![0, 0, 0, 30])]); + + assert_eq!( + as_choice_to_merged_intervals(&AsIdentifierChoice::AsIdsOrRanges(vec![ + AsIdOrRange::Id(1), + AsIdOrRange::Range { min: 2, max: 3 }, + AsIdOrRange::Range { min: 7, max: 9 }, + ])), + vec![(1, 3), (7, 9)] + ); + assert!(as_interval_is_covered(&[(1, 3), (7, 9)], 2, 3)); + assert!(!as_interval_is_covered(&[(7, 9)], 2, 3)); + } + + #[test] + fn roa_output_helpers_cover_vrps_and_afi_strings() { + let roa_der = + fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa"); + let roa = RoaObject::decode_der(&roa_der).expect("decode roa"); + let vrps = roa_to_vrps(&roa); + assert!(!vrps.is_empty()); + assert!(vrps.iter().all(|vrp| vrp.asn == roa.roa.as_id)); + assert_eq!(roa_afi_to_string(RoaAfi::Ipv4), "ipv4"); + assert_eq!(roa_afi_to_string(RoaAfi::Ipv6), "ipv6"); + } } diff --git a/src/validation/publication_point.rs b/src/validation/publication_point.rs new file mode 100644 index 0000000..b490b7b --- /dev/null +++ b/src/validation/publication_point.rs @@ -0,0 +1,18 @@ +use crate::storage::{PackFile, PackTime}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublicationPointSnapshot { + pub format_version: u32, + pub manifest_rsync_uri: String, + pub publication_point_rsync_uri: String, + pub manifest_number_be: Vec, + pub this_update: PackTime, + pub next_update: PackTime, + pub verified_at: PackTime, + pub manifest_bytes: Vec, + pub files: Vec, +} + +impl PublicationPointSnapshot { + pub const FORMAT_VERSION_V1: u32 = 1; +} diff --git a/src/validation/run.rs b/src/validation/run.rs index a36e385..29cb4bb 100644 --- a/src/validation/run.rs +++ b/src/validation/run.rs @@ -1,34 +1,35 @@ use crate::data_model::rc::{AsResourceSet, IpResourceSet}; use crate::fetch::rsync::RsyncFetcher; use crate::policy::Policy; -use crate::storage::{FetchCachePpKey, RocksStore}; -use crate::sync::repo::{RepoSyncResult, sync_publication_point}; +use crate::report::Warning; +use crate::storage::RocksStore; use crate::sync::rrdp::Fetcher as HttpFetcher; -use crate::validation::manifest::{PublicationPointResult, process_manifest_publication_point}; -use crate::validation::objects::{ObjectsOutput, process_fetch_cache_pp_pack_for_issuer}; +use crate::validation::manifest::PublicationPointSource; +use crate::validation::objects::ObjectsOutput; +use crate::validation::tree::{CaInstanceHandle, PublicationPointRunner}; +use crate::validation::tree_runner::Rpkiv1PublicationPointRunner; +use std::collections::HashMap; +use std::sync::Mutex; #[derive(Clone, Debug, PartialEq, Eq)] pub struct RunOutput { - pub repo_sync: RepoSyncResult, - pub publication_point: PublicationPointResult, + pub publication_point_source: PublicationPointSource, + pub publication_point_warnings: Vec, pub objects: ObjectsOutput, } #[derive(Debug, thiserror::Error)] pub enum RunError { - #[error("repo sync failed: {0}")] - RepoSync(#[from] crate::sync::repo::RepoSyncError), - - #[error("manifest processing failed: {0}")] - Manifest(#[from] crate::validation::manifest::ManifestProcessError), + #[error("publication point runner failed: {0}")] + Runner(String), } -/// v1 serial offline-friendly end-to-end execution for a single publication point. +/// Offline-friendly end-to-end execution for a single publication point. /// -/// This orchestrates: -/// 1) repo sync (RRDP or rsync fallback) into `raw_objects` -/// 2) manifest RP processing into a fetch_cache_pp pack (`fetch_cache_pp:`) -/// 3) signed object processing (ROA/ASPA) from the fetch_cache_pp pack +/// This reuses the same fresh-first runtime path as the tree runner: +/// 1) repository sync (RRDP or rsync fallback) into the current repository view +/// 2) manifest processing into a validated publication point result +/// 3) signed object processing (ROA/ASPA) and latest-VCIR persistence pub fn run_publication_point_once( store: &RocksStore, policy: &Policy, @@ -44,49 +45,41 @@ pub fn run_publication_point_once( issuer_effective_as: Option<&AsResourceSet>, validation_time: time::OffsetDateTime, ) -> Result { - let repo_sync = sync_publication_point( + let handle = CaInstanceHandle { + depth: 0, + tal_id: "single-publication-point".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: issuer_ca_der.to_vec(), + ca_certificate_rsync_uri: issuer_ca_rsync_uri.map(str::to_string), + effective_ip_resources: issuer_effective_ip.cloned(), + effective_as_resources: issuer_effective_as.cloned(), + rsync_base_uri: rsync_base_uri.to_string(), + manifest_rsync_uri: manifest_rsync_uri.to_string(), + publication_point_rsync_uri: publication_point_rsync_uri.to_string(), + rrdp_notification_uri: rrdp_notification_uri.map(str::to_string), + }; + + let runner = Rpkiv1PublicationPointRunner { store, policy, - rrdp_notification_uri, - rsync_base_uri, http_fetcher, rsync_fetcher, - None, - None, - )?; - - let publication_point = process_manifest_publication_point( - store, - policy, - manifest_rsync_uri, - publication_point_rsync_uri, - issuer_ca_der, - issuer_ca_rsync_uri, validation_time, - )?; + timing: None, + download_log: None, + rrdp_dedup: false, + rrdp_repo_cache: Mutex::new(HashMap::new()), + rsync_dedup: false, + rsync_repo_cache: Mutex::new(HashMap::new()), + }; - let objects = process_fetch_cache_pp_pack_for_issuer( - &publication_point.pack, - policy, - issuer_ca_der, - issuer_ca_rsync_uri, - issuer_effective_ip, - issuer_effective_as, - validation_time, - None, - ); + let result = runner + .run_publication_point(&handle) + .map_err(RunError::Runner)?; Ok(RunOutput { - repo_sync, - publication_point, - objects, + publication_point_source: result.source, + publication_point_warnings: result.warnings, + objects: result.objects, }) } - -pub fn fetch_cache_pp_exists(store: &RocksStore, manifest_rsync_uri: &str) -> Result { - let key = FetchCachePpKey::from_manifest_rsync_uri(manifest_rsync_uri); - store - .get_fetch_cache_pp(&key) - .map(|v| v.is_some()) - .map_err(|e| e.to_string()) -} diff --git a/src/validation/run_tree_from_tal.rs b/src/validation/run_tree_from_tal.rs index 0e30929..03e463f 100644 --- a/src/validation/run_tree_from_tal.rs +++ b/src/validation/run_tree_from_tal.rs @@ -17,6 +17,44 @@ use crate::validation::tree_runner::Rpkiv1PublicationPointRunner; use std::collections::HashMap; use std::sync::Mutex; +fn tal_id_from_url_like(s: &str) -> Option { + let url = Url::parse(s).ok()?; + if let Some(last) = url + .path_segments() + .and_then(|segments| segments.filter(|seg| !seg.is_empty()).next_back()) + { + let stem = last.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(last); + let trimmed = stem.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + url.host_str().map(|host| host.to_string()) +} + +fn derive_tal_id(discovery: &DiscoveredRootCaInstance) -> String { + discovery + .tal_url + .as_deref() + .and_then(tal_id_from_url_like) + .or_else(|| { + discovery + .trust_anchor + .resolved_ta_uri + .as_ref() + .and_then(|uri| tal_id_from_url_like(uri.as_str())) + }) + .or_else(|| { + discovery + .trust_anchor + .tal + .ta_uris + .first() + .and_then(|uri| tal_id_from_url_like(uri.as_str())) + }) + .unwrap_or_else(|| "unknown-tal".to_string()) +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct RunTreeFromTalOutput { pub discovery: DiscoveredRootCaInstance, @@ -43,12 +81,15 @@ pub enum RunTreeFromTalError { pub fn root_handle_from_trust_anchor( trust_anchor: &TrustAnchor, + tal_id: String, ca_certificate_rsync_uri: Option, ca_instance: &crate::validation::ca_instance::CaInstanceUris, ) -> CaInstanceHandle { let ta_rc = trust_anchor.ta_certificate.rc_ca.clone(); CaInstanceHandle { depth: 0, + tal_id, + parent_manifest_rsync_uri: None, ca_certificate_der: trust_anchor.ta_certificate.raw_der.clone(), ca_certificate_rsync_uri, effective_ip_resources: ta_rc.tbs.extensions.ip_resources.clone(), @@ -79,14 +120,18 @@ pub fn run_tree_from_tal_url_serial( validation_time, timing: None, download_log: None, - revalidate_only: config.revalidate_only, rrdp_dedup: true, rrdp_repo_cache: Mutex::new(HashMap::new()), rsync_dedup: true, rsync_repo_cache: Mutex::new(HashMap::new()), }; - let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance); + let root = root_handle_from_trust_anchor( + &discovery.trust_anchor, + derive_tal_id(&discovery), + None, + &discovery.ca_instance, + ); let tree = run_tree_serial(root, &runner, config)?; Ok(RunTreeFromTalOutput { discovery, tree }) @@ -112,14 +157,18 @@ pub fn run_tree_from_tal_url_serial_audit( validation_time, timing: None, download_log: Some(download_log.clone()), - revalidate_only: config.revalidate_only, rrdp_dedup: true, rrdp_repo_cache: Mutex::new(HashMap::new()), rsync_dedup: true, rsync_repo_cache: Mutex::new(HashMap::new()), }; - let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance); + let root = root_handle_from_trust_anchor( + &discovery.trust_anchor, + derive_tal_id(&discovery), + None, + &discovery.ca_instance, + ); let TreeRunAuditOutput { tree, publication_points, @@ -159,14 +208,18 @@ pub fn run_tree_from_tal_url_serial_audit_with_timing( validation_time, timing: Some(timing.clone()), download_log: Some(download_log.clone()), - revalidate_only: config.revalidate_only, rrdp_dedup: true, rrdp_repo_cache: Mutex::new(HashMap::new()), rsync_dedup: true, rsync_repo_cache: Mutex::new(HashMap::new()), }; - let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance); + let root = root_handle_from_trust_anchor( + &discovery.trust_anchor, + derive_tal_id(&discovery), + None, + &discovery.ca_instance, + ); let _tree = timing.span_phase("tree_run_total"); let TreeRunAuditOutput { tree, @@ -206,14 +259,18 @@ pub fn run_tree_from_tal_and_ta_der_serial( validation_time, timing: None, download_log: None, - revalidate_only: config.revalidate_only, rrdp_dedup: true, rrdp_repo_cache: Mutex::new(HashMap::new()), rsync_dedup: true, rsync_repo_cache: Mutex::new(HashMap::new()), }; - let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance); + let root = root_handle_from_trust_anchor( + &discovery.trust_anchor, + derive_tal_id(&discovery), + None, + &discovery.ca_instance, + ); let tree = run_tree_serial(root, &runner, config)?; Ok(RunTreeFromTalOutput { discovery, tree }) @@ -242,14 +299,18 @@ pub fn run_tree_from_tal_and_ta_der_serial_audit( validation_time, timing: None, download_log: Some(download_log.clone()), - revalidate_only: config.revalidate_only, rrdp_dedup: true, rrdp_repo_cache: Mutex::new(HashMap::new()), rsync_dedup: true, rsync_repo_cache: Mutex::new(HashMap::new()), }; - let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance); + let root = root_handle_from_trust_anchor( + &discovery.trust_anchor, + derive_tal_id(&discovery), + None, + &discovery.ca_instance, + ); let TreeRunAuditOutput { tree, publication_points, @@ -292,14 +353,18 @@ pub fn run_tree_from_tal_and_ta_der_serial_audit_with_timing( validation_time, timing: Some(timing.clone()), download_log: Some(download_log.clone()), - revalidate_only: config.revalidate_only, rrdp_dedup: true, rrdp_repo_cache: Mutex::new(HashMap::new()), rsync_dedup: true, rsync_repo_cache: Mutex::new(HashMap::new()), }; - let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance); + let root = root_handle_from_trust_anchor( + &discovery.trust_anchor, + derive_tal_id(&discovery), + None, + &discovery.ca_instance, + ); let _tree = timing.span_phase("tree_run_total"); let TreeRunAuditOutput { tree, diff --git a/src/validation/tree.rs b/src/validation/tree.rs index 9865906..adae3f5 100644 --- a/src/validation/tree.rs +++ b/src/validation/tree.rs @@ -1,10 +1,10 @@ use crate::audit::DiscoveredFrom; use crate::audit::PublicationPointAudit; use crate::data_model::rc::{AsResourceSet, IpResourceSet}; -use crate::report::{RfcRef, Warning}; -use crate::storage::FetchCachePpPack; +use crate::report::Warning; use crate::validation::manifest::PublicationPointSource; use crate::validation::objects::{AspaAttestation, ObjectsOutput, Vrp}; +use crate::validation::publication_point::PublicationPointSnapshot; #[derive(Clone, Debug, PartialEq, Eq)] pub struct TreeRunConfig { @@ -12,10 +12,6 @@ pub struct TreeRunConfig { pub max_depth: Option, /// Max number of CA instances to process. pub max_instances: Option, - /// Skip RRDP/rsync fetch and re-validate from existing `fetch_cache_pp` packs in the DB. - /// - /// This is primarily intended for profiling/analysis runs to remove network noise. - pub revalidate_only: bool, } impl Default for TreeRunConfig { @@ -23,7 +19,6 @@ impl Default for TreeRunConfig { Self { max_depth: None, max_instances: None, - revalidate_only: false, } } } @@ -31,6 +26,8 @@ impl Default for TreeRunConfig { #[derive(Clone, Debug, PartialEq, Eq)] pub struct CaInstanceHandle { pub depth: usize, + pub tal_id: String, + pub parent_manifest_rsync_uri: Option, /// DER bytes of the CA certificate for this CA instance. pub ca_certificate_der: Vec, /// rsync URI of this CA certificate object (where it is published). @@ -59,15 +56,15 @@ impl CaInstanceHandle { #[derive(Clone, Debug, PartialEq, Eq)] pub struct PublicationPointRunResult { pub source: PublicationPointSource, - pub pack: FetchCachePpPack, + pub snapshot: Option, pub warnings: Vec, pub objects: ObjectsOutput, pub audit: PublicationPointAudit, - /// Candidate child CA instances discovered from this publication point. + /// Candidate child CA instances to enqueue after this publication point completes. /// - /// RFC 9286 §6.6 restriction is enforced by the tree engine: if this - /// publication point used fetch_cache_pp due to failed fetch, children MUST NOT - /// be enqueued/processed in this run. + /// - For `Fresh`, these are discovered from the current validated publication point. + /// - For `VcirCurrentInstance`, these are restored from the current instance VCIR and then + /// processed fresh-first when their own turn arrives. pub discovered_children: Vec, } @@ -187,37 +184,25 @@ pub fn run_tree_serial_audit( audit.discovered_from = node.discovered_from.clone(); publication_points.push(audit); - let enqueue_children = - res.source == PublicationPointSource::Fresh || config.revalidate_only; - if !enqueue_children && !res.discovered_children.is_empty() { - warnings.push( - Warning::new("skipping child CA discovery due to failed fetch cache use") - .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) - .with_context(&ca.manifest_rsync_uri), - ); - } - - if enqueue_children { - let mut children = res.discovered_children; - children.sort_by(|a, b| { - a.handle - .manifest_rsync_uri - .cmp(&b.handle.manifest_rsync_uri) - .then_with(|| { - a.discovered_from - .child_ca_certificate_rsync_uri - .cmp(&b.discovered_from.child_ca_certificate_rsync_uri) - }) + let mut children = res.discovered_children; + children.sort_by(|a, b| { + a.handle + .manifest_rsync_uri + .cmp(&b.handle.manifest_rsync_uri) + .then_with(|| { + a.discovered_from + .child_ca_certificate_rsync_uri + .cmp(&b.discovered_from.child_ca_certificate_rsync_uri) + }) + }); + for child in children { + queue.push_back(QueuedCaInstance { + id: next_id, + handle: child.handle.with_depth(ca.depth + 1), + parent_id: Some(node.id), + discovered_from: Some(child.discovered_from), }); - for child in children { - queue.push_back(QueuedCaInstance { - id: next_id, - handle: child.handle.with_depth(ca.depth + 1), - parent_id: Some(node.id), - discovered_from: Some(child.discovered_from), - }); - next_id += 1; - } + next_id += 1; } } diff --git a/src/validation/tree_runner.rs b/src/validation/tree_runner.rs index 9ccc94a..b70f569 100644 --- a/src/validation/tree_runner.rs +++ b/src/validation/tree_runner.rs @@ -4,10 +4,20 @@ use crate::audit::{ sha256_hex, sha256_hex_from_32, }; use crate::audit_downloads::DownloadLogHandle; +use crate::data_model::aspa::AspaObject; +use crate::data_model::crl::RpkixCrl; +use crate::data_model::manifest::ManifestObject; +use crate::data_model::rc::ResourceCertificate; +use crate::data_model::roa::{RoaAfi, RoaObject}; use crate::fetch::rsync::RsyncFetcher; use crate::policy::Policy; use crate::report::{RfcRef, Warning}; -use crate::storage::RocksStore; +use crate::storage::{ + AuditRuleIndexEntry, AuditRuleKind, PackFile, PackTime, RawByHashEntry, RocksStore, + ValidatedCaInstanceResult, VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, + VcirAuditSummary, VcirChildEntry, VcirInstanceGate, VcirLocalOutput, VcirOutputType, + VcirRelatedArtifact, VcirSummary, +}; use crate::sync::repo::sync_publication_point; use crate::sync::rrdp::Fetcher; use crate::validation::ca_instance::ca_instance_uris_from_ca_certificate; @@ -15,15 +25,19 @@ use crate::validation::ca_path::{ CaPathError, ValidatedSubordinateCaLite, validate_subordinate_ca_cert_with_prevalidated_issuer, }; use crate::validation::manifest::{ - PublicationPointSource, load_and_revalidate_cached_pack, - process_manifest_publication_point_after_repo_sync, + ManifestFreshError, PublicationPointData, PublicationPointSource, + process_manifest_publication_point_fresh_after_repo_sync, }; -use crate::validation::objects::process_fetch_cache_pp_pack_for_issuer; +use crate::validation::objects::{AspaAttestation, Vrp, process_publication_point_for_issuer}; +use crate::validation::publication_point::PublicationPointSnapshot; use crate::validation::tree::{ CaInstanceHandle, DiscoveredChildCaInstance, PublicationPointRunResult, PublicationPointRunner, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Mutex; + +use serde::Deserialize; +use serde_json::json; use x509_parser::prelude::FromDer; use x509_parser::x509::SubjectPublicKeyInfo; @@ -35,7 +49,6 @@ pub struct Rpkiv1PublicationPointRunner<'a> { pub validation_time: time::OffsetDateTime, pub timing: Option, pub download_log: Option, - pub revalidate_only: bool, /// In-run RRDP dedup: when RRDP is enabled, only sync each `rrdp_notification_uri` once per run. /// /// - If RRDP succeeded for a repo, later publication points referencing that same RRDP repo @@ -73,233 +86,185 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> { let mut warnings: Vec = Vec::new(); - let (repo_sync_ok, repo_sync_err): (bool, Option) = if self.revalidate_only { - if let Some(t) = self.timing.as_ref() { - t.record_count("repo_sync_skipped_revalidate_only", 1); - } - (false, Some("revalidate-only".to_string())) - } else { - let attempted_rrdp = - self.policy.sync_preference == crate::policy::SyncPreference::RrdpThenRsync; - let original_notification_uri = ca.rrdp_notification_uri.as_deref(); - let mut effective_notification_uri = original_notification_uri; - let mut skip_sync_due_to_dedup = false; + let attempted_rrdp = + self.policy.sync_preference == crate::policy::SyncPreference::RrdpThenRsync; + let original_notification_uri = ca.rrdp_notification_uri.as_deref(); + let mut effective_notification_uri = original_notification_uri; + let mut skip_sync_due_to_dedup = false; - if attempted_rrdp && self.rrdp_dedup { - if let Some(notification_uri) = original_notification_uri { - if let Some(rrdp_ok) = self - .rrdp_repo_cache - .lock() - .expect("rrdp_repo_cache lock") - .get(notification_uri) - .copied() - { - if let Some(t) = self.timing.as_ref() { - t.record_count("rrdp_repo_dedup_hits", 1); - } - if rrdp_ok { - if let Some(t) = self.timing.as_ref() { - t.record_count("rrdp_repo_dedup_rrdp_ok_skip", 1); - } - skip_sync_due_to_dedup = true; - } else { - if let Some(t) = self.timing.as_ref() { - t.record_count("rrdp_repo_dedup_rrdp_failed_skip", 1); - } - // Skip RRDP for this repo; go straight to rsync for this publication point. - effective_notification_uri = None; - } - } else if let Some(t) = self.timing.as_ref() { - t.record_count("rrdp_repo_dedup_misses", 1); - } - } - } - - // rsync direct dedup: if this publication point will be synced via rsync (i.e., no - // RRDP notification URI is used), skip redundant rsync fetches for the same base URI. - // - // NOTE: we only skip when we have already synced that base successfully in this run, - // to avoid reducing success rate on transient network failures. - if !skip_sync_due_to_dedup && effective_notification_uri.is_none() && self.rsync_dedup { - let base = normalize_rsync_base_uri(&ca.rsync_base_uri); - let hit_ok = self - .rsync_repo_cache + if attempted_rrdp && self.rrdp_dedup { + if let Some(notification_uri) = original_notification_uri { + if let Some(rrdp_ok) = self + .rrdp_repo_cache .lock() - .expect("rsync_repo_cache lock") - .get(&base) + .expect("rrdp_repo_cache lock") + .get(notification_uri) .copied() - .unwrap_or(false); - if hit_ok { + { if let Some(t) = self.timing.as_ref() { - t.record_count("rsync_repo_dedup_hits", 1); - t.record_count("rsync_repo_dedup_skipped_sync", 1); + t.record_count("rrdp_repo_dedup_hits", 1); + } + if rrdp_ok { + if let Some(t) = self.timing.as_ref() { + t.record_count("rrdp_repo_dedup_rrdp_ok_skip", 1); + } + skip_sync_due_to_dedup = true; + } else { + if let Some(t) = self.timing.as_ref() { + t.record_count("rrdp_repo_dedup_rrdp_failed_skip", 1); + } + effective_notification_uri = None; } - skip_sync_due_to_dedup = true; } else if let Some(t) = self.timing.as_ref() { - t.record_count("rsync_repo_dedup_misses", 1); + t.record_count("rrdp_repo_dedup_misses", 1); } } + } - if skip_sync_due_to_dedup { - (true, None) - } else { - let repo_key = - effective_notification_uri.unwrap_or_else(|| ca.rsync_base_uri.as_str()); - let _repo_total = self - .timing - .as_ref() - .map(|t| t.span_phase("repo_sync_total")); - let _repo_span = self.timing.as_ref().map(|t| t.span_rrdp_repo(repo_key)); + if !skip_sync_due_to_dedup && effective_notification_uri.is_none() && self.rsync_dedup { + let base = normalize_rsync_base_uri(&ca.rsync_base_uri); + let hit_ok = self + .rsync_repo_cache + .lock() + .expect("rsync_repo_cache lock") + .get(&base) + .copied() + .unwrap_or(false); + if hit_ok { + if let Some(t) = self.timing.as_ref() { + t.record_count("rsync_repo_dedup_hits", 1); + t.record_count("rsync_repo_dedup_skipped_sync", 1); + } + skip_sync_due_to_dedup = true; + } else if let Some(t) = self.timing.as_ref() { + t.record_count("rsync_repo_dedup_misses", 1); + } + } - match sync_publication_point( - self.store, - self.policy, - effective_notification_uri, - &ca.rsync_base_uri, - self.http_fetcher, - self.rsync_fetcher, - self.timing.as_ref(), - self.download_log.as_ref(), - ) { - Ok(res) => { - // Populate rsync dedup cache when we actually used rsync. - if self.rsync_dedup - && res.source == crate::sync::repo::RepoSyncSource::Rsync - { - let base = normalize_rsync_base_uri(&ca.rsync_base_uri); - self.rsync_repo_cache - .lock() - .expect("rsync_repo_cache lock") - .insert(base, true); - if let Some(t) = self.timing.as_ref() { - t.record_count("rsync_repo_dedup_mark_ok", 1); - } + let (repo_sync_ok, repo_sync_err): (bool, Option) = if skip_sync_due_to_dedup { + (true, None) + } else { + let repo_key = effective_notification_uri.unwrap_or_else(|| ca.rsync_base_uri.as_str()); + let _repo_total = self + .timing + .as_ref() + .map(|t| t.span_phase("repo_sync_total")); + let _repo_span = self.timing.as_ref().map(|t| t.span_rrdp_repo(repo_key)); + + match sync_publication_point( + self.store, + self.policy, + effective_notification_uri, + &ca.rsync_base_uri, + self.http_fetcher, + self.rsync_fetcher, + self.timing.as_ref(), + self.download_log.as_ref(), + ) { + Ok(res) => { + if self.rsync_dedup && res.source == crate::sync::repo::RepoSyncSource::Rsync { + let base = normalize_rsync_base_uri(&ca.rsync_base_uri); + self.rsync_repo_cache + .lock() + .expect("rsync_repo_cache lock") + .insert(base, true); + if let Some(t) = self.timing.as_ref() { + t.record_count("rsync_repo_dedup_mark_ok", 1); } + } - // Populate RRDP dedup cache when we actually attempted RRDP. - if attempted_rrdp && self.rrdp_dedup { - if let Some(notification_uri) = original_notification_uri { - if effective_notification_uri.is_some() { - let rrdp_ok = - res.source == crate::sync::repo::RepoSyncSource::Rrdp; - self.rrdp_repo_cache - .lock() - .expect("rrdp_repo_cache lock") - .insert(notification_uri.to_string(), rrdp_ok); - if let Some(t) = self.timing.as_ref() { - if rrdp_ok { - t.record_count("rrdp_repo_dedup_mark_ok", 1); - } else { - t.record_count("rrdp_repo_dedup_mark_failed", 1); - } + if attempted_rrdp && self.rrdp_dedup { + if let Some(notification_uri) = original_notification_uri { + if effective_notification_uri.is_some() { + let rrdp_ok = res.source == crate::sync::repo::RepoSyncSource::Rrdp; + self.rrdp_repo_cache + .lock() + .expect("rrdp_repo_cache lock") + .insert(notification_uri.to_string(), rrdp_ok); + if let Some(t) = self.timing.as_ref() { + if rrdp_ok { + t.record_count("rrdp_repo_dedup_mark_ok", 1); + } else { + t.record_count("rrdp_repo_dedup_mark_failed", 1); } } } } - - warnings.extend(res.warnings); - (true, None) } - Err(e) => { - if attempted_rrdp && self.rrdp_dedup { - if let Some(notification_uri) = original_notification_uri { - if effective_notification_uri.is_some() { - // rsync failure in RRDP-then-rsync implies RRDP had already failed. - self.rrdp_repo_cache - .lock() - .expect("rrdp_repo_cache lock") - .insert(notification_uri.to_string(), false); - } + + warnings.extend(res.warnings); + (true, None) + } + Err(e) => { + if attempted_rrdp && self.rrdp_dedup { + if let Some(notification_uri) = original_notification_uri { + if effective_notification_uri.is_some() { + self.rrdp_repo_cache + .lock() + .expect("rrdp_repo_cache lock") + .insert(notification_uri.to_string(), false); } } - warnings.push( - Warning::new(format!( - "repo sync failed (continuing with fetch_cache_pp only): {e}" - )) + } + warnings.push( + Warning::new(format!("repo sync failed (fresh processing stopped): {e}")) .with_rfc_refs(&[RfcRef("RFC 8182 §3.4.5"), RfcRef("RFC 9286 §6.6")]) .with_context(&ca.rsync_base_uri), - ); - (false, Some(e.to_string())) - } + ); + (false, Some(e.to_string())) } } }; - let pp = { + let fresh_publication_point = { let _manifest_total = self .timing .as_ref() .map(|t| t.span_phase("manifest_processing_total")); - if self.revalidate_only { - let pack = load_and_revalidate_cached_pack( - self.store, - &ca.manifest_rsync_uri, - &ca.publication_point_rsync_uri, - &ca.ca_certificate_der, - ca.ca_certificate_rsync_uri.as_deref(), - self.validation_time, - ) - .map_err(|e| format!("{e}"))?; - if let Some(t) = self.timing.as_ref() { - t.record_count("revalidate_only_fetch_cache_pp_used", 1); - } - crate::validation::manifest::PublicationPointResult { - source: PublicationPointSource::FetchCachePp, - pack, - warnings: Vec::new(), - } - } else { - match process_manifest_publication_point_after_repo_sync( - self.store, - self.policy, - &ca.manifest_rsync_uri, - &ca.publication_point_rsync_uri, - &ca.ca_certificate_der, - ca.ca_certificate_rsync_uri.as_deref(), - self.validation_time, - repo_sync_ok, - repo_sync_err.as_deref(), - ) { - Ok(v) => v, - Err(e) => return Err(format!("{e}")), - } - } - }; - - warnings.extend(pp.warnings.clone()); - - let objects = { - let _objects_total = self - .timing - .as_ref() - .map(|t| t.span_phase("objects_processing_total")); - process_fetch_cache_pp_pack_for_issuer( - &pp.pack, - self.policy, + process_manifest_publication_point_fresh_after_repo_sync( + self.store, + &ca.manifest_rsync_uri, + &ca.publication_point_rsync_uri, &ca.ca_certificate_der, ca.ca_certificate_rsync_uri.as_deref(), - ca.effective_ip_resources.as_ref(), - ca.effective_as_resources.as_ref(), self.validation_time, - self.timing.as_ref(), + repo_sync_ok, + repo_sync_err.as_deref(), ) }; - let (discovered_children, child_audits) = - if pp.source == PublicationPointSource::Fresh || self.revalidate_only { + match fresh_publication_point { + Ok(fresh_point) => { + let objects = { + let _objects_total = self + .timing + .as_ref() + .map(|t| t.span_phase("objects_processing_total")); + process_publication_point_for_issuer( + &fresh_point, + self.policy, + &ca.ca_certificate_der, + ca.ca_certificate_rsync_uri.as_deref(), + ca.effective_ip_resources.as_ref(), + ca.effective_as_resources.as_ref(), + self.validation_time, + self.timing.as_ref(), + ) + }; + let out = { let _child_disc_total = self .timing .as_ref() .map(|t| t.span_phase("child_discovery_total")); - discover_children_from_fresh_pack_with_audit( + discover_children_from_fresh_snapshot_with_audit( ca, - &pp.pack, + &fresh_point, self.validation_time, self.timing.as_ref(), ) }; - match out { + let (discovered_children, child_audits) = match out { Ok(out) => (out.children, out.audits), Err(e) => { warnings.push( @@ -309,21 +274,71 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> { ); (Vec::new(), Vec::new()) } + }; + + let pack = fresh_point.to_publication_point_snapshot(); + + persist_vcir_for_fresh_result( + self.store, + ca, + &pack, + &objects, + &warnings, + &child_audits, + &discovered_children, + self.validation_time, + ) + .map_err(|e| format!("persist VCIR failed: {e}"))?; + + let audit = build_publication_point_audit_from_snapshot( + ca, + PublicationPointSource::Fresh, + &pack, + &warnings, + &objects, + &child_audits, + ); + + Ok(PublicationPointRunResult { + source: PublicationPointSource::Fresh, + snapshot: Some(pack), + warnings, + objects, + audit, + discovered_children, + }) + } + Err(fresh_err) => match self.policy.ca_failed_fetch_policy { + crate::policy::CaFailedFetchPolicy::StopAllOutput => Err(format!("{fresh_err}")), + crate::policy::CaFailedFetchPolicy::ReuseCurrentInstanceVcir => { + let projection = project_current_instance_vcir_on_failed_fetch( + self.store, + ca, + &fresh_err, + self.validation_time, + ) + .map_err(|e| format!("failed fetch VCIR projection failed: {e}"))?; + warnings.extend(projection.warnings.clone()); + let audit = build_publication_point_audit_from_vcir( + ca, + projection.source, + projection.vcir.as_ref(), + projection.snapshot.as_ref(), + &warnings, + &projection.objects, + &projection.child_audits, + ); + Ok(PublicationPointRunResult { + source: projection.source, + snapshot: projection.snapshot, + warnings, + objects: projection.objects, + audit, + discovered_children: projection.discovered_children, + }) } - } else { - (Vec::new(), Vec::new()) - }; - - let audit = build_publication_point_audit(ca, &pp, &warnings, &objects, &child_audits); - - Ok(PublicationPointRunResult { - source: pp.source, - pack: pp.pack, - warnings, - objects, - audit, - discovered_children, - }) + }, + } } } @@ -351,16 +366,40 @@ enum CachedIssuerCrl { Pending(Vec), Ok(VerifiedIssuerCrl), } +#[derive(Clone, Debug)] +struct VcirReuseProjection { + source: PublicationPointSource, + vcir: Option, + snapshot: Option, + objects: crate::validation::objects::ObjectsOutput, + child_audits: Vec, + discovered_children: Vec, + warnings: Vec, +} -fn discover_children_from_fresh_pack_with_audit( +#[derive(Debug, Deserialize)] +struct VcirVrpPayload { + asn: u32, + prefix: String, + max_length: u16, +} + +#[derive(Debug, Deserialize)] +struct VcirAspaPayload { + customer_as_id: u32, + provider_as_ids: Vec, +} + +fn discover_children_from_fresh_snapshot_with_audit( issuer: &CaInstanceHandle, - pack: &crate::storage::FetchCachePpPack, + publication_point: &P, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, ) -> Result { + let locked_files = publication_point.files(); let issuer_ca_der = issuer.ca_certificate_der.as_slice(); // Issuer CA is only required when we actually attempt to validate a subordinate CA. For some - // audit-only error paths (e.g., missing CRL in pack), we still want discovery to succeed. + // audit-only error paths (e.g., missing CRL in the snapshot), we still want discovery to succeed. let issuer_ca_decode_error: Option; let issuer_ca = match crate::data_model::rc::ResourceCertificate::decode_der(issuer_ca_der) { Ok(v) => { @@ -401,8 +440,7 @@ fn discover_children_from_fresh_pack_with_audit( None }; - let mut crl_cache: std::collections::HashMap = pack - .files + let mut crl_cache: std::collections::HashMap = locked_files .iter() .filter(|f| f.rsync_uri.ends_with(".crl")) .map(|f| { @@ -473,7 +511,7 @@ fn discover_children_from_fresh_pack_with_audit( n } - for f in &pack.files { + for f in locked_files { if !f.rsync_uri.ends_with(".cer") { continue; } @@ -624,6 +662,8 @@ fn discover_children_from_fresh_pack_with_audit( out.push(DiscoveredChildCaInstance { handle: CaInstanceHandle { depth: 0, + tal_id: issuer.tal_id.clone(), + parent_manifest_rsync_uri: Some(issuer.manifest_rsync_uri.clone()), ca_certificate_der: child_der.to_vec(), ca_certificate_rsync_uri: Some(f.rsync_uri.clone()), effective_ip_resources: validated.effective_ip_resources.clone(), @@ -698,7 +738,7 @@ fn select_issuer_crl_uri_for_child<'a>( ) -> Result<&'a str, String> { if crl_cache.is_empty() { return Err( - "no CRL available in fetch_cache_pp (cannot validate certificates) (RFC 9286 §7; RFC 6487 §4.8.6)" + "no CRL available in publication point snapshot (cannot validate certificates) (RFC 9286 §7; RFC 6487 §4.8.6)" .to_string(), ); } @@ -716,7 +756,7 @@ fn select_issuer_crl_uri_for_child<'a>( } Err(format!( - "CRL referenced by child certificate CRLDistributionPoints not found in fetch_cache_pp: {} (RFC 6487 §4.8.6; RFC 9286 §4.2.1)", + "CRL referenced by child certificate CRLDistributionPoints not found in publication point snapshot: {} (RFC 6487 §4.8.6; RFC 9286 §4.2.1)", crldp_uris .iter() .map(|u| u.as_str()) @@ -789,9 +829,9 @@ fn validate_subordinate_ca_cert_with_cached_issuer( } #[cfg(test)] -fn select_issuer_crl_from_pack<'a>( +fn select_issuer_crl_from_snapshot<'a>( child_cert_der: &[u8], - pack: &'a crate::storage::FetchCachePpPack, + pack: &'a PublicationPointSnapshot, ) -> Result<(&'a str, &'a [u8]), String> { let child = crate::data_model::rc::ResourceCertificate::decode_der(child_cert_der) .map_err(|e| format!("child certificate decode failed: {e}"))?; @@ -809,7 +849,7 @@ fn select_issuer_crl_from_pack<'a>( } Err(format!( - "CRL referenced by child certificate CRLDistributionPoints not found in fetch_cache_pp: {} (RFC 6487 §4.8.6; RFC 9286 §4.2.1)", + "CRL referenced by child certificate CRLDistributionPoints not found in publication point snapshot: {} (RFC 6487 §4.8.6; RFC 9286 §4.2.1)", crldp_uris .iter() .map(|u| u.as_str()) @@ -832,19 +872,49 @@ fn kind_from_rsync_uri(uri: &str) -> AuditObjectKind { } } -fn build_publication_point_audit( +fn source_label(source: PublicationPointSource) -> String { + match source { + PublicationPointSource::Fresh => "fresh".to_string(), + PublicationPointSource::VcirCurrentInstance => "vcir_current_instance".to_string(), + PublicationPointSource::FailedFetchNoCache => "failed_fetch_no_cache".to_string(), + } +} + +fn kind_from_vcir_artifact_kind(kind: VcirArtifactKind) -> AuditObjectKind { + match kind { + VcirArtifactKind::Mft => AuditObjectKind::Manifest, + VcirArtifactKind::Crl => AuditObjectKind::Crl, + VcirArtifactKind::Cer => AuditObjectKind::Certificate, + VcirArtifactKind::Roa => AuditObjectKind::Roa, + VcirArtifactKind::Aspa => AuditObjectKind::Aspa, + VcirArtifactKind::Gbr | VcirArtifactKind::Tal | VcirArtifactKind::Other => { + AuditObjectKind::Other + } + } +} + +fn audit_result_from_vcir_status(status: VcirArtifactValidationStatus) -> AuditObjectResult { + match status { + VcirArtifactValidationStatus::Accepted => AuditObjectResult::Ok, + VcirArtifactValidationStatus::Rejected => AuditObjectResult::Error, + VcirArtifactValidationStatus::WarningOnly => AuditObjectResult::Skipped, + } +} + +fn build_publication_point_audit_from_snapshot( ca: &CaInstanceHandle, - pp: &crate::validation::manifest::PublicationPointResult, + source: PublicationPointSource, + pack: &PublicationPointSnapshot, runner_warnings: &[Warning], objects: &crate::validation::objects::ObjectsOutput, child_audits: &[ObjectAuditEntry], ) -> PublicationPointAudit { use crate::data_model::crl::RpkixCrl; - use crate::validation::manifest::PublicationPointSource; use std::collections::HashMap; + let locked_files = &pack.files; let mut audit_by_uri: HashMap = HashMap::new(); - for f in &pp.pack.files { + for f in locked_files { audit_by_uri.insert( f.rsync_uri.clone(), ObjectAuditEntry { @@ -857,8 +927,7 @@ fn build_publication_point_audit( ); } - // CRL decode status (best-effort). - for f in &pp.pack.files { + for f in locked_files { if !f.rsync_uri.ends_with(".crl") { continue; } @@ -883,26 +952,22 @@ fn build_publication_point_audit( ); } - // Child discovery audits (.cer). for e in child_audits { audit_by_uri.insert(e.rsync_uri.clone(), e.clone()); } - - // Signed object audits (.roa/.asa). for e in &objects.audit { audit_by_uri.insert(e.rsync_uri.clone(), e.clone()); } - // Emit as a stable order: manifest first, then pack files as listed in the pack. - let mut objects_out: Vec = Vec::with_capacity(pp.pack.files.len() + 1); + let mut objects_out: Vec = Vec::with_capacity(pack.files.len() + 1); objects_out.push(ObjectAuditEntry { - rsync_uri: pp.pack.manifest_rsync_uri.clone(), - sha256_hex: sha256_hex(&pp.pack.manifest_bytes), + rsync_uri: pack.manifest_rsync_uri.clone(), + sha256_hex: sha256_hex(&pack.manifest_bytes), kind: AuditObjectKind::Manifest, result: AuditObjectResult::Ok, detail: None, }); - for f in &pp.pack.files { + for f in locked_files { if let Some(e) = audit_by_uri.remove(&f.rsync_uri) { objects_out.push(e); } else { @@ -917,7 +982,6 @@ fn build_publication_point_audit( } let mut warnings = Vec::new(); - // `runner_warnings` already includes `pp.warnings` (mirrors the runtime propagation behavior). warnings.extend(runner_warnings.iter().map(AuditWarning::from)); warnings.extend(objects.warnings.iter().map(AuditWarning::from)); @@ -929,26 +993,1288 @@ fn build_publication_point_audit( manifest_rsync_uri: ca.manifest_rsync_uri.clone(), publication_point_rsync_uri: ca.publication_point_rsync_uri.clone(), rrdp_notification_uri: ca.rrdp_notification_uri.clone(), - source: match pp.source { - PublicationPointSource::Fresh => "fresh".to_string(), - PublicationPointSource::FetchCachePp => "fetch_cache_pp".to_string(), - }, - this_update_rfc3339_utc: pp.pack.this_update.rfc3339_utc.clone(), - next_update_rfc3339_utc: pp.pack.next_update.rfc3339_utc.clone(), - verified_at_rfc3339_utc: pp.pack.verified_at.rfc3339_utc.clone(), + source: source_label(source), + this_update_rfc3339_utc: pack.this_update.rfc3339_utc.clone(), + next_update_rfc3339_utc: pack.next_update.rfc3339_utc.clone(), + verified_at_rfc3339_utc: pack.verified_at.rfc3339_utc.clone(), warnings, objects: objects_out, } } +fn build_publication_point_audit_from_vcir( + ca: &CaInstanceHandle, + source: PublicationPointSource, + vcir: Option<&ValidatedCaInstanceResult>, + pack: Option<&PublicationPointSnapshot>, + runner_warnings: &[Warning], + objects: &crate::validation::objects::ObjectsOutput, + child_audits: &[ObjectAuditEntry], +) -> PublicationPointAudit { + if let Some(pack) = pack { + return build_publication_point_audit_from_snapshot( + ca, + source, + pack, + runner_warnings, + objects, + child_audits, + ); + } + + let mut warnings = Vec::new(); + warnings.extend(runner_warnings.iter().map(AuditWarning::from)); + warnings.extend(objects.warnings.iter().map(AuditWarning::from)); + + let Some(vcir) = vcir else { + return PublicationPointAudit { + node_id: None, + parent_node_id: None, + discovered_from: None, + rsync_base_uri: ca.rsync_base_uri.clone(), + manifest_rsync_uri: ca.manifest_rsync_uri.clone(), + publication_point_rsync_uri: ca.publication_point_rsync_uri.clone(), + rrdp_notification_uri: ca.rrdp_notification_uri.clone(), + source: source_label(source), + this_update_rfc3339_utc: String::new(), + next_update_rfc3339_utc: String::new(), + verified_at_rfc3339_utc: String::new(), + warnings, + objects: Vec::new(), + }; + }; + + let mut audit_by_uri: HashMap = HashMap::new(); + for artifact in &vcir.related_artifacts { + let Some(uri) = artifact.uri.as_ref() else { + continue; + }; + audit_by_uri.insert( + uri.clone(), + ObjectAuditEntry { + rsync_uri: uri.clone(), + sha256_hex: artifact.sha256.clone(), + kind: kind_from_vcir_artifact_kind(artifact.artifact_kind), + result: audit_result_from_vcir_status(artifact.validation_status), + detail: None, + }, + ); + } + for e in child_audits { + audit_by_uri.insert(e.rsync_uri.clone(), e.clone()); + } + for e in &objects.audit { + audit_by_uri.insert(e.rsync_uri.clone(), e.clone()); + } + + let mut ordered_uris: Vec = vcir + .related_artifacts + .iter() + .filter_map(|artifact| artifact.uri.clone()) + .collect(); + ordered_uris.sort(); + ordered_uris.dedup(); + + let mut objects_out: Vec = Vec::new(); + if let Some(entry) = audit_by_uri.remove(&vcir.current_manifest_rsync_uri) { + objects_out.push(entry); + } else { + objects_out.push(ObjectAuditEntry { + rsync_uri: vcir.current_manifest_rsync_uri.clone(), + sha256_hex: vcir + .related_artifacts + .iter() + .find(|artifact| { + artifact.artifact_role == VcirArtifactRole::Manifest + && artifact.uri.as_deref() == Some(vcir.current_manifest_rsync_uri.as_str()) + }) + .map(|artifact| artifact.sha256.clone()) + .unwrap_or_default(), + kind: AuditObjectKind::Manifest, + result: AuditObjectResult::Ok, + detail: None, + }); + } + + for uri in ordered_uris { + if uri == vcir.current_manifest_rsync_uri { + continue; + } + if let Some(entry) = audit_by_uri.remove(&uri) { + objects_out.push(entry); + } + } + + PublicationPointAudit { + node_id: None, + parent_node_id: None, + discovered_from: None, + rsync_base_uri: ca.rsync_base_uri.clone(), + manifest_rsync_uri: ca.manifest_rsync_uri.clone(), + publication_point_rsync_uri: ca.publication_point_rsync_uri.clone(), + rrdp_notification_uri: ca.rrdp_notification_uri.clone(), + source: source_label(source), + this_update_rfc3339_utc: vcir + .validated_manifest_meta + .validated_manifest_this_update + .rfc3339_utc + .clone(), + next_update_rfc3339_utc: vcir + .validated_manifest_meta + .validated_manifest_next_update + .rfc3339_utc + .clone(), + verified_at_rfc3339_utc: vcir.last_successful_validation_time.rfc3339_utc.clone(), + warnings, + objects: objects_out, + } +} + +fn parse_snapshot_time_value(pack_time: &PackTime) -> Result { + time::OffsetDateTime::parse( + &pack_time.rfc3339_utc, + &time::format_description::well_known::Rfc3339, + ) + .map_err(|e| format!("invalid RFC3339 time '{}': {e}", pack_time.rfc3339_utc)) +} + +fn empty_objects_output() -> crate::validation::objects::ObjectsOutput { + crate::validation::objects::ObjectsOutput { + vrps: Vec::new(), + aspas: Vec::new(), + warnings: Vec::new(), + stats: crate::validation::objects::ObjectsStats::default(), + audit: Vec::new(), + } +} + +fn project_current_instance_vcir_on_failed_fetch( + store: &RocksStore, + ca: &CaInstanceHandle, + fresh_err: &ManifestFreshError, + validation_time: time::OffsetDateTime, +) -> Result { + let mut warnings = vec![ + Warning::new(format!("manifest failed fetch: {fresh_err}")) + .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) + .with_context(&ca.manifest_rsync_uri), + ]; + + let Some(vcir) = store + .get_vcir(&ca.manifest_rsync_uri) + .map_err(|e| format!("load VCIR failed: {e}"))? + else { + warnings.push( + Warning::new( + "no latest validated result for current CA instance; no cached output reused", + ) + .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) + .with_context(&ca.manifest_rsync_uri), + ); + return Ok(VcirReuseProjection { + source: PublicationPointSource::FailedFetchNoCache, + vcir: None, + snapshot: None, + objects: empty_objects_output(), + child_audits: Vec::new(), + discovered_children: Vec::new(), + warnings, + }); + }; + + if !vcir.audit_summary.failed_fetch_eligible { + warnings.push( + Warning::new( + "latest VCIR is not marked failed-fetch eligible; no cached output reused", + ) + .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) + .with_context(&ca.manifest_rsync_uri), + ); + return Ok(VcirReuseProjection { + source: PublicationPointSource::FailedFetchNoCache, + vcir: Some(vcir), + snapshot: None, + objects: empty_objects_output(), + child_audits: Vec::new(), + discovered_children: Vec::new(), + warnings, + }); + } + + let instance_effective_until = + parse_snapshot_time_value(&vcir.instance_gate.instance_effective_until)?; + if validation_time > instance_effective_until { + warnings.push( + Warning::new( + "latest VCIR instance_gate expired; current instance contributes no cached output", + ) + .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) + .with_context(&ca.manifest_rsync_uri), + ); + return Ok(VcirReuseProjection { + source: PublicationPointSource::FailedFetchNoCache, + vcir: Some(vcir), + snapshot: None, + objects: empty_objects_output(), + child_audits: Vec::new(), + discovered_children: Vec::new(), + warnings, + }); + } + + warnings.push( + Warning::new("using latest validated result for current CA instance") + .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) + .with_context(&ca.manifest_rsync_uri), + ); + + let snapshot = reconstruct_snapshot_from_vcir(store, ca, &vcir, &mut warnings); + let objects = build_objects_output_from_vcir(&vcir, validation_time, &mut warnings); + let (discovered_children, child_audits) = + restore_children_from_vcir(store, ca, &vcir, &mut warnings); + + Ok(VcirReuseProjection { + source: PublicationPointSource::VcirCurrentInstance, + vcir: Some(vcir), + snapshot, + objects, + child_audits, + discovered_children, + warnings, + }) +} + +fn reconstruct_snapshot_from_vcir( + store: &RocksStore, + ca: &CaInstanceHandle, + vcir: &ValidatedCaInstanceResult, + warnings: &mut Vec, +) -> Option { + let manifest_artifact = vcir.related_artifacts.iter().find(|artifact| { + artifact.artifact_role == VcirArtifactRole::Manifest + && artifact.uri.as_deref() == Some(ca.manifest_rsync_uri.as_str()) + })?; + let manifest_entry = match store.get_raw_by_hash_entry(&manifest_artifact.sha256) { + Ok(Some(entry)) => entry, + Ok(None) => { + warnings.push( + Warning::new("manifest raw bytes missing for VCIR audit reconstruction") + .with_context(&ca.manifest_rsync_uri), + ); + return None; + } + Err(e) => { + warnings.push( + Warning::new(format!( + "manifest raw bytes load failed for VCIR audit reconstruction: {e}" + )) + .with_context(&ca.manifest_rsync_uri), + ); + return None; + } + }; + + let mut seen = HashSet::new(); + let mut files = Vec::new(); + for artifact in &vcir.related_artifacts { + let Some(uri) = artifact.uri.as_ref() else { + continue; + }; + if artifact.artifact_role == VcirArtifactRole::Manifest + || artifact.artifact_role == VcirArtifactRole::IssuerCert + || artifact.artifact_role == VcirArtifactRole::TrustAnchorCert + || artifact.artifact_role == VcirArtifactRole::Tal + { + continue; + } + if !seen.insert(uri.clone()) { + continue; + } + match store.get_raw_by_hash_entry(&artifact.sha256) { + Ok(Some(entry)) => files.push(PackFile::from_bytes_compute_sha256(uri, entry.bytes)), + Ok(None) => warnings.push( + Warning::new("related artifact raw bytes missing for VCIR audit reconstruction") + .with_context(uri), + ), + Err(e) => warnings.push( + Warning::new(format!( + "related artifact raw bytes load failed for VCIR audit reconstruction: {e}" + )) + .with_context(uri), + ), + } + } + + Some(PublicationPointSnapshot { + format_version: PublicationPointSnapshot::FORMAT_VERSION_V1, + publication_point_rsync_uri: ca.publication_point_rsync_uri.clone(), + manifest_rsync_uri: ca.manifest_rsync_uri.clone(), + manifest_number_be: vcir + .validated_manifest_meta + .validated_manifest_number + .clone(), + this_update: vcir + .validated_manifest_meta + .validated_manifest_this_update + .clone(), + next_update: vcir + .validated_manifest_meta + .validated_manifest_next_update + .clone(), + verified_at: vcir.last_successful_validation_time.clone(), + manifest_bytes: manifest_entry.bytes, + files, + }) +} + +fn build_objects_output_from_vcir( + vcir: &ValidatedCaInstanceResult, + validation_time: time::OffsetDateTime, + warnings: &mut Vec, +) -> crate::validation::objects::ObjectsOutput { + let mut output = empty_objects_output(); + let mut audit_by_uri: HashMap = HashMap::new(); + let mut roa_total: HashSet = HashSet::new(); + let mut aspa_total: HashSet = HashSet::new(); + let mut roa_ok: HashSet = HashSet::new(); + let mut aspa_ok: HashSet = HashSet::new(); + + for artifact in &vcir.related_artifacts { + if artifact.artifact_role != VcirArtifactRole::SignedObject { + continue; + } + if let Some(uri) = artifact.uri.as_ref() { + match artifact.artifact_kind { + VcirArtifactKind::Roa => { + roa_total.insert(uri.clone()); + } + VcirArtifactKind::Aspa => { + aspa_total.insert(uri.clone()); + } + _ => {} + } + } + } + + for local in &vcir.local_outputs { + let effective_until = match parse_snapshot_time_value(&local.item_effective_until) { + Ok(v) => v, + Err(e) => { + warnings.push( + Warning::new(format!( + "cached local output has invalid item_effective_until: {e}" + )) + .with_context(&local.source_object_uri), + ); + audit_by_uri.insert( + local.source_object_uri.clone(), + ObjectAuditEntry { + rsync_uri: local.source_object_uri.clone(), + sha256_hex: local.source_object_hash.clone(), + kind: if local.output_type == VcirOutputType::Vrp { + AuditObjectKind::Roa + } else { + AuditObjectKind::Aspa + }, + result: AuditObjectResult::Error, + detail: Some( + "cached local output has invalid item_effective_until".to_string(), + ), + }, + ); + continue; + } + }; + if validation_time > effective_until { + audit_by_uri + .entry(local.source_object_uri.clone()) + .or_insert_with(|| ObjectAuditEntry { + rsync_uri: local.source_object_uri.clone(), + sha256_hex: local.source_object_hash.clone(), + kind: if local.output_type == VcirOutputType::Vrp { + AuditObjectKind::Roa + } else { + AuditObjectKind::Aspa + }, + result: AuditObjectResult::Skipped, + detail: Some("skipped: cached local output expired".to_string()), + }); + continue; + } + + match local.output_type { + VcirOutputType::Vrp => match parse_vcir_vrp_output(local) { + Ok(vrp) => { + roa_ok.insert(local.source_object_uri.clone()); + output.vrps.push(vrp); + audit_by_uri.insert( + local.source_object_uri.clone(), + ObjectAuditEntry { + rsync_uri: local.source_object_uri.clone(), + sha256_hex: local.source_object_hash.clone(), + kind: AuditObjectKind::Roa, + result: AuditObjectResult::Ok, + detail: None, + }, + ); + } + Err(e) => { + warnings.push( + Warning::new(format!("cached ROA local output parse failed: {e}")) + .with_context(&local.source_object_uri), + ); + audit_by_uri.insert( + local.source_object_uri.clone(), + ObjectAuditEntry { + rsync_uri: local.source_object_uri.clone(), + sha256_hex: local.source_object_hash.clone(), + kind: AuditObjectKind::Roa, + result: AuditObjectResult::Error, + detail: Some(format!("cached ROA local output parse failed: {e}")), + }, + ); + } + }, + VcirOutputType::Aspa => match parse_vcir_aspa_output(local) { + Ok(aspa) => { + aspa_ok.insert(local.source_object_uri.clone()); + output.aspas.push(aspa); + audit_by_uri.insert( + local.source_object_uri.clone(), + ObjectAuditEntry { + rsync_uri: local.source_object_uri.clone(), + sha256_hex: local.source_object_hash.clone(), + kind: AuditObjectKind::Aspa, + result: AuditObjectResult::Ok, + detail: None, + }, + ); + } + Err(e) => { + warnings.push( + Warning::new(format!("cached ASPA local output parse failed: {e}")) + .with_context(&local.source_object_uri), + ); + audit_by_uri.insert( + local.source_object_uri.clone(), + ObjectAuditEntry { + rsync_uri: local.source_object_uri.clone(), + sha256_hex: local.source_object_hash.clone(), + kind: AuditObjectKind::Aspa, + result: AuditObjectResult::Error, + detail: Some(format!("cached ASPA local output parse failed: {e}")), + }, + ); + } + }, + } + } + + output.stats.roa_total = roa_total.len(); + output.stats.roa_ok = roa_ok.len(); + output.stats.aspa_total = aspa_total.len(); + output.stats.aspa_ok = aspa_ok.len(); + let mut audit: Vec<_> = audit_by_uri.into_values().collect(); + audit.sort_by(|a, b| a.rsync_uri.cmp(&b.rsync_uri)); + output.audit = audit; + output +} + +fn parse_vcir_vrp_output(local: &VcirLocalOutput) -> Result { + let payload: VcirVrpPayload = serde_json::from_str(&local.payload_json) + .map_err(|e| format!("invalid VRP payload JSON: {e}"))?; + Ok(Vrp { + asn: payload.asn, + prefix: parse_vcir_prefix(&payload.prefix)?, + max_length: payload.max_length, + }) +} + +fn parse_vcir_aspa_output(local: &VcirLocalOutput) -> Result { + let payload: VcirAspaPayload = serde_json::from_str(&local.payload_json) + .map_err(|e| format!("invalid ASPA payload JSON: {e}"))?; + Ok(AspaAttestation { + customer_as_id: payload.customer_as_id, + provider_as_ids: payload.provider_as_ids, + }) +} + +fn parse_vcir_prefix(prefix: &str) -> Result { + let (addr, len) = prefix + .split_once('/') + .ok_or_else(|| format!("prefix missing '/': {prefix}"))?; + let prefix_len = len + .parse::() + .map_err(|e| format!("invalid prefix length '{len}': {e}"))?; + let ip = addr + .parse::() + .map_err(|e| format!("invalid IP address '{addr}': {e}"))?; + match ip { + std::net::IpAddr::V4(v4) => Ok(crate::data_model::roa::IpPrefix { + afi: RoaAfi::Ipv4, + prefix_len, + addr: { + let mut addr = [0u8; 16]; + addr[..4].copy_from_slice(&v4.octets()); + addr + }, + }), + std::net::IpAddr::V6(v6) => Ok(crate::data_model::roa::IpPrefix { + afi: RoaAfi::Ipv6, + prefix_len, + addr: v6.octets(), + }), + } +} + +fn restore_children_from_vcir( + store: &RocksStore, + ca: &CaInstanceHandle, + vcir: &ValidatedCaInstanceResult, + warnings: &mut Vec, +) -> (Vec, Vec) { + let mut children = Vec::new(); + let mut audits = Vec::new(); + for child in &vcir.child_entries { + match store.get_raw_by_hash_entry(&child.child_cert_hash) { + Ok(Some(entry)) => { + children.push(DiscoveredChildCaInstance { + handle: CaInstanceHandle { + depth: 0, + tal_id: ca.tal_id.clone(), + parent_manifest_rsync_uri: Some(ca.manifest_rsync_uri.clone()), + ca_certificate_der: entry.bytes, + ca_certificate_rsync_uri: Some(child.child_cert_rsync_uri.clone()), + effective_ip_resources: child.child_effective_ip_resources.clone(), + effective_as_resources: child.child_effective_as_resources.clone(), + rsync_base_uri: child.child_rsync_base_uri.clone(), + manifest_rsync_uri: child.child_manifest_rsync_uri.clone(), + publication_point_rsync_uri: child + .child_publication_point_rsync_uri + .clone(), + rrdp_notification_uri: child.child_rrdp_notification_uri.clone(), + }, + discovered_from: crate::audit::DiscoveredFrom { + parent_manifest_rsync_uri: ca.manifest_rsync_uri.clone(), + child_ca_certificate_rsync_uri: child.child_cert_rsync_uri.clone(), + child_ca_certificate_sha256_hex: child.child_cert_hash.clone(), + }, + }); + audits.push(ObjectAuditEntry { + rsync_uri: child.child_cert_rsync_uri.clone(), + sha256_hex: child.child_cert_hash.clone(), + kind: AuditObjectKind::Certificate, + result: AuditObjectResult::Ok, + detail: Some("restored child CA instance from VCIR".to_string()), + }); + } + Ok(None) => { + warnings.push( + Warning::new("child certificate raw bytes missing for VCIR child restoration") + .with_context(&child.child_cert_rsync_uri), + ); + audits.push(ObjectAuditEntry { + rsync_uri: child.child_cert_rsync_uri.clone(), + sha256_hex: child.child_cert_hash.clone(), + kind: AuditObjectKind::Certificate, + result: AuditObjectResult::Error, + detail: Some( + "child certificate raw bytes missing for VCIR child restoration" + .to_string(), + ), + }); + } + Err(e) => { + warnings.push( + Warning::new(format!( + "child certificate raw bytes load failed for VCIR child restoration: {e}" + )) + .with_context(&child.child_cert_rsync_uri), + ); + audits.push(ObjectAuditEntry { + rsync_uri: child.child_cert_rsync_uri.clone(), + sha256_hex: child.child_cert_hash.clone(), + kind: AuditObjectKind::Certificate, + result: AuditObjectResult::Error, + detail: Some(format!( + "child certificate raw bytes load failed for VCIR child restoration: {e}" + )), + }); + } + } + } + (children, audits) +} + +fn persist_vcir_for_fresh_result( + store: &RocksStore, + ca: &CaInstanceHandle, + pack: &PublicationPointSnapshot, + objects: &crate::validation::objects::ObjectsOutput, + warnings: &[Warning], + child_audits: &[ObjectAuditEntry], + discovered_children: &[DiscoveredChildCaInstance], + validation_time: time::OffsetDateTime, +) -> Result<(), String> { + if objects.stats.publication_point_dropped { + return Ok(()); + } + + let embedded_evidence = collect_vcir_embedded_evidence(pack, objects)?; + persist_vcir_non_repository_evidence(store, ca, &embedded_evidence) + .map_err(|e| format!("store VCIR audit evidence failed: {e}"))?; + + let vcir = build_vcir_from_fresh_result( + ca, + pack, + objects, + warnings, + child_audits, + discovered_children, + validation_time, + &embedded_evidence, + )?; + let previous = store + .get_vcir(&pack.manifest_rsync_uri) + .map_err(|e| format!("load existing VCIR failed: {e}"))?; + + store + .put_vcir(&vcir) + .map_err(|e| format!("store VCIR failed: {e}"))?; + + if let Some(previous) = previous.as_ref() { + for output in &previous.local_outputs { + if let Some(kind) = audit_rule_kind_for_output(output.output_type) { + store + .delete_audit_rule_index_entry(kind, &output.rule_hash) + .map_err(|e| format!("delete stale audit rule index failed: {e}"))?; + } + } + } + + for output in &vcir.local_outputs { + let Some(kind) = audit_rule_kind_for_output(output.output_type) else { + continue; + }; + let entry = AuditRuleIndexEntry { + kind, + rule_hash: output.rule_hash.clone(), + manifest_rsync_uri: vcir.manifest_rsync_uri.clone(), + source_object_uri: output.source_object_uri.clone(), + source_object_hash: output.source_object_hash.clone(), + output_id: output.output_id.clone(), + item_effective_until: output.item_effective_until.clone(), + }; + store + .put_audit_rule_index_entry(&entry) + .map_err(|e| format!("store audit rule index failed: {e}"))?; + } + + Ok(()) +} + +fn build_vcir_from_fresh_result( + ca: &CaInstanceHandle, + pack: &PublicationPointSnapshot, + objects: &crate::validation::objects::ObjectsOutput, + warnings: &[Warning], + child_audits: &[ObjectAuditEntry], + discovered_children: &[DiscoveredChildCaInstance], + validation_time: time::OffsetDateTime, + embedded_evidence: &[VcirEmbeddedEvidence], +) -> Result { + let current_crl = select_manifest_current_crl_from_snapshot(pack)?; + let ca_cert = ResourceCertificate::decode_der(&ca.ca_certificate_der) + .map_err(|e| format!("decode current CA certificate failed: {e}"))?; + let local_outputs = build_vcir_local_outputs(ca, pack, objects)?; + let child_entries = build_vcir_child_entries(discovered_children, validation_time)?; + let related_artifacts = build_vcir_related_artifacts( + ca, + pack, + current_crl.file.rsync_uri.as_str(), + objects, + child_audits, + embedded_evidence, + ); + let accepted_object_count = related_artifacts + .iter() + .filter(|artifact| artifact.validation_status == VcirArtifactValidationStatus::Accepted) + .count() as u32; + let rejected_object_count = related_artifacts + .iter() + .filter(|artifact| artifact.validation_status == VcirArtifactValidationStatus::Rejected) + .count() as u32; + let ca_ski = hex::encode( + ca_cert + .tbs + .extensions + .subject_key_identifier + .as_ref() + .ok_or_else(|| "current CA certificate missing SubjectKeyIdentifier".to_string())?, + ); + let issuer_ski = hex::encode( + ca_cert + .tbs + .extensions + .authority_key_identifier + .as_ref() + .or(ca_cert.tbs.extensions.subject_key_identifier.as_ref()) + .ok_or_else(|| "current CA certificate missing AuthorityKeyIdentifier".to_string())?, + ); + + let vcir = ValidatedCaInstanceResult { + manifest_rsync_uri: pack.manifest_rsync_uri.clone(), + parent_manifest_rsync_uri: ca.parent_manifest_rsync_uri.clone(), + tal_id: ca.tal_id.clone(), + ca_subject_name: ca_cert.tbs.subject_name.to_string(), + ca_ski, + issuer_ski, + last_successful_validation_time: PackTime::from_utc_offset_datetime(validation_time), + current_manifest_rsync_uri: pack.manifest_rsync_uri.clone(), + current_crl_rsync_uri: current_crl.file.rsync_uri.clone(), + validated_manifest_meta: crate::storage::ValidatedManifestMeta { + validated_manifest_number: pack.manifest_number_be.clone(), + validated_manifest_this_update: pack.this_update.clone(), + validated_manifest_next_update: pack.next_update.clone(), + }, + instance_gate: VcirInstanceGate { + manifest_next_update: pack.next_update.clone(), + current_crl_next_update: PackTime::from_utc_offset_datetime( + current_crl.crl.next_update.utc, + ), + self_ca_not_after: PackTime::from_utc_offset_datetime(ca_cert.tbs.validity_not_after), + instance_effective_until: PackTime::from_utc_offset_datetime( + pack.next_update + .parse() + .map_err(|e| format!("parse snapshot next_update failed: {e}"))? + .min(current_crl.crl.next_update.utc) + .min(ca_cert.tbs.validity_not_after), + ), + }, + child_entries, + local_outputs: local_outputs.clone(), + related_artifacts: related_artifacts.clone(), + summary: VcirSummary { + local_vrp_count: local_outputs + .iter() + .filter(|output| output.output_type == VcirOutputType::Vrp) + .count() as u32, + local_aspa_count: local_outputs + .iter() + .filter(|output| output.output_type == VcirOutputType::Aspa) + .count() as u32, + child_count: discovered_children.len() as u32, + accepted_object_count, + rejected_object_count, + }, + audit_summary: VcirAuditSummary { + failed_fetch_eligible: true, + last_failed_fetch_reason: None, + warning_count: (warnings.len() + objects.warnings.len()) as u32, + audit_flags: Vec::new(), + }, + }; + vcir.validate_internal().map_err(|e| e.to_string())?; + Ok(vcir) +} + +struct CurrentCrlRef<'a> { + file: &'a PackFile, + crl: RpkixCrl, +} + +fn select_manifest_current_crl_from_snapshot( + pack: &PublicationPointSnapshot, +) -> Result, String> { + let manifest = ManifestObject::decode_der(&pack.manifest_bytes) + .map_err(|e| format!("decode snapshot manifest for VCIR failed: {e}"))?; + let ee = &manifest.signed_object.signed_data.certificates[0].resource_cert; + let crldp_uris = ee + .tbs + .extensions + .crl_distribution_points_uris + .as_ref() + .ok_or_else(|| "manifest EE certificate missing CRLDistributionPoints".to_string())?; + for uri in crldp_uris { + if let Some(file) = pack + .files + .iter() + .find(|candidate| candidate.rsync_uri == *uri) + { + let crl = RpkixCrl::decode_der(&file.bytes) + .map_err(|e| format!("decode current CRL for VCIR failed: {e}"))?; + return Ok(CurrentCrlRef { file, crl }); + } + } + Err(format!( + "manifest EE certificate CRLDistributionPoints not found in pack: {}", + crldp_uris.join(", ") + )) +} + +fn build_vcir_local_outputs( + ca: &CaInstanceHandle, + pack: &PublicationPointSnapshot, + objects: &crate::validation::objects::ObjectsOutput, +) -> Result, String> { + let accepted_roa_uris: HashSet<&str> = objects + .audit + .iter() + .filter(|entry| entry.kind == AuditObjectKind::Roa && entry.result == AuditObjectResult::Ok) + .map(|entry| entry.rsync_uri.as_str()) + .collect(); + let accepted_aspa_uris: HashSet<&str> = objects + .audit + .iter() + .filter(|entry| { + entry.kind == AuditObjectKind::Aspa && entry.result == AuditObjectResult::Ok + }) + .map(|entry| entry.rsync_uri.as_str()) + .collect(); + + let mut out = Vec::new(); + for file in &pack.files { + let source_object_hash = sha256_hex_from_32(&file.sha256); + if accepted_roa_uris.contains(file.rsync_uri.as_str()) { + let roa = RoaObject::decode_der(&file.bytes) + .map_err(|e| format!("decode accepted ROA for VCIR failed: {e}"))?; + let ee = &roa.signed_object.signed_data.certificates[0]; + let source_ee_cert_hash = sha256_hex(ee.raw_der.as_slice()); + let item_effective_until = + PackTime::from_utc_offset_datetime(ee.resource_cert.tbs.validity_not_after); + for vrp in roa_to_vrps_for_vcir(&roa) { + let prefix = vrp_prefix_to_string(&vrp); + let payload_json = json!({ + "asn": vrp.asn, + "prefix": prefix, + "max_length": vrp.max_length, + }) + .to_string(); + let rule_hash = sha256_hex( + format!( + "roa-rule:{}:{}:{}:{}", + source_object_hash, vrp.asn, prefix, vrp.max_length + ) + .as_bytes(), + ); + out.push(VcirLocalOutput { + output_id: rule_hash.clone(), + output_type: VcirOutputType::Vrp, + item_effective_until: item_effective_until.clone(), + source_object_uri: file.rsync_uri.clone(), + source_object_type: "roa".to_string(), + source_object_hash: source_object_hash.clone(), + source_ee_cert_hash: source_ee_cert_hash.clone(), + payload_json, + rule_hash, + validation_path_hint: vec![ + ca.manifest_rsync_uri.clone(), + file.rsync_uri.clone(), + source_object_hash.clone(), + ], + }); + } + } else if accepted_aspa_uris.contains(file.rsync_uri.as_str()) { + let aspa = AspaObject::decode_der(&file.bytes) + .map_err(|e| format!("decode accepted ASPA for VCIR failed: {e}"))?; + let ee = &aspa.signed_object.signed_data.certificates[0]; + let source_ee_cert_hash = sha256_hex(ee.raw_der.as_slice()); + let item_effective_until = + PackTime::from_utc_offset_datetime(ee.resource_cert.tbs.validity_not_after); + let payload_json = json!({ + "customer_as_id": aspa.aspa.customer_as_id, + "provider_as_ids": aspa.aspa.provider_as_ids, + }) + .to_string(); + let providers = aspa + .aspa + .provider_as_ids + .iter() + .map(u32::to_string) + .collect::>() + .join(","); + let rule_hash = sha256_hex( + format!( + "aspa-rule:{}:{}:{}", + source_object_hash, aspa.aspa.customer_as_id, providers + ) + .as_bytes(), + ); + out.push(VcirLocalOutput { + output_id: rule_hash.clone(), + output_type: VcirOutputType::Aspa, + item_effective_until, + source_object_uri: file.rsync_uri.clone(), + source_object_type: "aspa".to_string(), + source_object_hash: source_object_hash.clone(), + source_ee_cert_hash, + payload_json, + rule_hash, + validation_path_hint: vec![ + ca.manifest_rsync_uri.clone(), + file.rsync_uri.clone(), + source_object_hash, + ], + }); + } + } + Ok(out) +} + +fn build_vcir_child_entries( + discovered_children: &[DiscoveredChildCaInstance], + validation_time: time::OffsetDateTime, +) -> Result, String> { + let mut out = Vec::with_capacity(discovered_children.len()); + for child in discovered_children { + let child_cert = ResourceCertificate::decode_der(&child.handle.ca_certificate_der) + .map_err(|e| format!("decode child certificate for VCIR failed: {e}"))?; + let child_ski = child_cert + .tbs + .extensions + .subject_key_identifier + .as_ref() + .ok_or_else(|| "child certificate missing SubjectKeyIdentifier".to_string())?; + out.push(VcirChildEntry { + child_manifest_rsync_uri: child.handle.manifest_rsync_uri.clone(), + child_cert_rsync_uri: child.discovered_from.child_ca_certificate_rsync_uri.clone(), + child_cert_hash: child + .discovered_from + .child_ca_certificate_sha256_hex + .clone(), + child_ski: hex::encode(child_ski), + child_rsync_base_uri: child.handle.rsync_base_uri.clone(), + child_publication_point_rsync_uri: child.handle.publication_point_rsync_uri.clone(), + child_rrdp_notification_uri: child.handle.rrdp_notification_uri.clone(), + child_effective_ip_resources: child.handle.effective_ip_resources.clone(), + child_effective_as_resources: child.handle.effective_as_resources.clone(), + accepted_at_validation_time: PackTime::from_utc_offset_datetime(validation_time), + }); + } + Ok(out) +} + +#[derive(Clone, Debug)] +struct VcirEmbeddedEvidence { + artifact: VcirRelatedArtifact, + raw_entry: RawByHashEntry, +} + +fn collect_vcir_embedded_evidence( + pack: &PublicationPointSnapshot, + objects: &crate::validation::objects::ObjectsOutput, +) -> Result, String> { + let mut evidence = Vec::new(); + let mut seen_hashes = HashSet::new(); + + let manifest = ManifestObject::decode_der(&pack.manifest_bytes) + .map_err(|e| format!("decode manifest for VCIR embedded evidence failed: {e}"))?; + if let Some(ee) = manifest.signed_object.signed_data.certificates.first() { + let ee_hash = sha256_hex(ee.raw_der.as_slice()); + if seen_hashes.insert(ee_hash.clone()) { + evidence.push(VcirEmbeddedEvidence { + artifact: VcirRelatedArtifact { + artifact_role: VcirArtifactRole::EeCert, + artifact_kind: VcirArtifactKind::Cer, + uri: None, + sha256: ee_hash.clone(), + object_type: Some("cer".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + raw_entry: embedded_raw_entry(ee_hash, ee.raw_der.to_vec()), + }); + } + } + + let accepted_uris: HashSet<&str> = objects + .audit + .iter() + .filter(|entry| matches!(entry.result, AuditObjectResult::Ok)) + .map(|entry| entry.rsync_uri.as_str()) + .collect(); + + for file in &pack.files { + let Some(kind) = signed_object_kind_from_uri(file.rsync_uri.as_str()) else { + continue; + }; + if !accepted_uris.contains(file.rsync_uri.as_str()) { + continue; + } + let ee_der = match kind { + VcirArtifactKind::Roa => RoaObject::decode_der(&file.bytes) + .map_err(|e| format!("decode accepted ROA for VCIR embedded evidence failed: {e}"))? + .signed_object + .signed_data + .certificates + .first() + .map(|cert| cert.raw_der.to_vec()), + VcirArtifactKind::Aspa => AspaObject::decode_der(&file.bytes) + .map_err(|e| { + format!("decode accepted ASPA for VCIR embedded evidence failed: {e}") + })? + .signed_object + .signed_data + .certificates + .first() + .map(|cert| cert.raw_der.to_vec()), + _ => None, + }; + let Some(ee_der) = ee_der else { + continue; + }; + let ee_hash = sha256_hex(ee_der.as_slice()); + if !seen_hashes.insert(ee_hash.clone()) { + continue; + } + evidence.push(VcirEmbeddedEvidence { + artifact: VcirRelatedArtifact { + artifact_role: VcirArtifactRole::EeCert, + artifact_kind: VcirArtifactKind::Cer, + uri: None, + sha256: ee_hash.clone(), + object_type: Some("cer".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + raw_entry: embedded_raw_entry(ee_hash, ee_der), + }); + } + + Ok(evidence) +} + +fn persist_vcir_non_repository_evidence( + store: &RocksStore, + ca: &CaInstanceHandle, + embedded_evidence: &[VcirEmbeddedEvidence], +) -> Result<(), String> { + let current_ca_hash = sha256_hex(&ca.ca_certificate_der); + let mut current_ca_entry = + RawByHashEntry::from_bytes(current_ca_hash, ca.ca_certificate_der.clone()); + if let Some(uri) = ca.ca_certificate_rsync_uri.as_ref() { + current_ca_entry.origin_uris.push(uri.clone()); + } + current_ca_entry.object_type = Some("cer".to_string()); + current_ca_entry.encoding = Some("der".to_string()); + upsert_raw_by_hash_entry(store, current_ca_entry)?; + + for evidence in embedded_evidence { + upsert_raw_by_hash_entry(store, evidence.raw_entry.clone())?; + } + Ok(()) +} + +fn upsert_raw_by_hash_entry(store: &RocksStore, entry: RawByHashEntry) -> Result<(), String> { + match store.get_raw_by_hash_entry(&entry.sha256_hex) { + Ok(Some(existing)) => { + if existing.bytes != entry.bytes { + return Err(format!( + "raw_by_hash collision for sha256 {} while storing VCIR audit evidence", + entry.sha256_hex + )); + } + let mut merged = existing; + let mut changed = false; + for uri in entry.origin_uris { + if !merged + .origin_uris + .iter() + .any(|existing_uri| existing_uri == &uri) + { + merged.origin_uris.push(uri); + changed = true; + } + } + if merged.object_type.is_none() && entry.object_type.is_some() { + merged.object_type = entry.object_type; + changed = true; + } + if merged.encoding.is_none() && entry.encoding.is_some() { + merged.encoding = entry.encoding; + changed = true; + } + if changed { + store + .put_raw_by_hash_entry(&merged) + .map_err(|e| format!("update raw_by_hash entry failed: {e}"))?; + } + Ok(()) + } + Ok(None) => store + .put_raw_by_hash_entry(&entry) + .map_err(|e| format!("store raw_by_hash entry failed: {e}")), + Err(e) => Err(format!("load raw_by_hash entry failed: {e}")), + } +} + +fn embedded_raw_entry(sha256_hex: String, bytes: Vec) -> RawByHashEntry { + let mut entry = RawByHashEntry::from_bytes(sha256_hex, bytes); + entry.object_type = Some("cer".to_string()); + entry.encoding = Some("der".to_string()); + entry +} + +fn signed_object_kind_from_uri(uri: &str) -> Option { + if uri.ends_with(".roa") { + Some(VcirArtifactKind::Roa) + } else if uri.ends_with(".asa") { + Some(VcirArtifactKind::Aspa) + } else if uri.ends_with(".gbr") { + Some(VcirArtifactKind::Gbr) + } else { + None + } +} + +fn build_vcir_related_artifacts( + ca: &CaInstanceHandle, + pack: &PublicationPointSnapshot, + current_crl_rsync_uri: &str, + objects: &crate::validation::objects::ObjectsOutput, + child_audits: &[ObjectAuditEntry], + embedded_evidence: &[VcirEmbeddedEvidence], +) -> Vec { + let mut audit_by_uri: HashMap<&str, AuditObjectResult> = HashMap::new(); + for entry in child_audits.iter().chain(objects.audit.iter()) { + audit_by_uri.insert(entry.rsync_uri.as_str(), entry.result.clone()); + } + + let mut artifacts = Vec::with_capacity(pack.files.len() + 2); + artifacts.push(VcirRelatedArtifact { + artifact_role: VcirArtifactRole::Manifest, + artifact_kind: VcirArtifactKind::Mft, + uri: Some(pack.manifest_rsync_uri.clone()), + sha256: sha256_hex(&pack.manifest_bytes), + object_type: Some("mft".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }); + artifacts.push(VcirRelatedArtifact { + artifact_role: if ca.parent_manifest_rsync_uri.is_none() { + VcirArtifactRole::TrustAnchorCert + } else { + VcirArtifactRole::IssuerCert + }, + artifact_kind: VcirArtifactKind::Cer, + uri: ca.ca_certificate_rsync_uri.clone(), + sha256: sha256_hex(&ca.ca_certificate_der), + object_type: Some("cer".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }); + + for file in &pack.files { + let result = audit_by_uri + .get(file.rsync_uri.as_str()) + .cloned() + .unwrap_or(AuditObjectResult::Ok); + let (artifact_role, artifact_kind) = artifact_role_and_kind(file, current_crl_rsync_uri); + artifacts.push(VcirRelatedArtifact { + artifact_role, + artifact_kind, + uri: Some(file.rsync_uri.clone()), + sha256: sha256_hex_from_32(&file.sha256), + object_type: object_type_from_uri(file.rsync_uri.as_str()), + validation_status: audit_result_to_vcir_status(&result), + }); + } + + artifacts.extend( + embedded_evidence + .iter() + .map(|evidence| evidence.artifact.clone()), + ); + artifacts +} + +fn artifact_role_and_kind( + file: &PackFile, + current_crl_rsync_uri: &str, +) -> (VcirArtifactRole, VcirArtifactKind) { + if file.rsync_uri == current_crl_rsync_uri { + (VcirArtifactRole::CurrentCrl, VcirArtifactKind::Crl) + } else if file.rsync_uri.ends_with(".cer") { + (VcirArtifactRole::ChildCaCert, VcirArtifactKind::Cer) + } else if file.rsync_uri.ends_with(".roa") { + (VcirArtifactRole::SignedObject, VcirArtifactKind::Roa) + } else if file.rsync_uri.ends_with(".asa") { + (VcirArtifactRole::SignedObject, VcirArtifactKind::Aspa) + } else if file.rsync_uri.ends_with(".gbr") { + (VcirArtifactRole::SignedObject, VcirArtifactKind::Gbr) + } else if file.rsync_uri.ends_with(".crl") { + (VcirArtifactRole::Other, VcirArtifactKind::Crl) + } else if file.rsync_uri.ends_with(".mft") { + (VcirArtifactRole::Manifest, VcirArtifactKind::Mft) + } else { + (VcirArtifactRole::Other, VcirArtifactKind::Other) + } +} + +fn object_type_from_uri(uri: &str) -> Option { + uri.rsplit_once('.') + .map(|(_, ext)| ext.to_ascii_lowercase()) +} + +fn audit_result_to_vcir_status(result: &AuditObjectResult) -> VcirArtifactValidationStatus { + match result { + AuditObjectResult::Ok => VcirArtifactValidationStatus::Accepted, + AuditObjectResult::Error => VcirArtifactValidationStatus::Rejected, + AuditObjectResult::Skipped => VcirArtifactValidationStatus::WarningOnly, + } +} + +fn audit_rule_kind_for_output(output_type: VcirOutputType) -> Option { + match output_type { + VcirOutputType::Vrp => Some(AuditRuleKind::Roa), + VcirOutputType::Aspa => Some(AuditRuleKind::Aspa), + } +} + +fn roa_to_vrps_for_vcir(roa: &RoaObject) -> Vec { + let asn = roa.roa.as_id; + let mut out = Vec::new(); + for fam in &roa.roa.ip_addr_blocks { + for entry in &fam.addresses { + let max_length = entry.max_length.unwrap_or(entry.prefix.prefix_len); + out.push(Vrp { + asn, + prefix: entry.prefix.clone(), + max_length, + }); + } + } + out +} + +fn vrp_prefix_to_string(vrp: &Vrp) -> String { + match vrp.prefix.afi { + RoaAfi::Ipv4 => { + let addr = std::net::Ipv4Addr::new( + vrp.prefix.addr[0], + vrp.prefix.addr[1], + vrp.prefix.addr[2], + vrp.prefix.addr[3], + ); + format!("{addr}/{}", vrp.prefix.prefix_len) + } + RoaAfi::Ipv6 => { + let addr = std::net::Ipv6Addr::from(vrp.prefix.addr); + format!("{addr}/{}", vrp.prefix.prefix_len) + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::data_model::rc::ResourceCertificate; use crate::fetch::rsync::LocalDirRsyncFetcher; use crate::fetch::rsync::{RsyncFetchError, RsyncFetcher}; - use crate::storage::{FetchCachePpPack, PackFile, PackTime}; + use crate::storage::{ + PackFile, PackTime, RawByHashEntry, ValidatedCaInstanceResult, ValidatedManifestMeta, + VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, VcirAuditSummary, + VcirChildEntry, VcirInstanceGate, VcirLocalOutput, VcirOutputType, VcirRelatedArtifact, + VcirSummary, + }; use crate::sync::rrdp::Fetcher; + use crate::validation::publication_point::PublicationPointSnapshot; use crate::validation::tree::PublicationPointRunner; use std::process::Command; @@ -1158,10 +2484,10 @@ authorityKeyIdentifier = keyid:always } } - fn dummy_pack_with_files(files: Vec) -> FetchCachePpPack { + fn dummy_pack_with_files(files: Vec) -> PublicationPointSnapshot { let now = time::OffsetDateTime::now_utc(); - FetchCachePpPack { - format_version: FetchCachePpPack::FORMAT_VERSION_V1, + PublicationPointSnapshot { + format_version: PublicationPointSnapshot::FORMAT_VERSION_V1, manifest_rsync_uri: "rsync://example.test/repo/issuer/issuer.mft".to_string(), publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), manifest_number_be: vec![1], @@ -1173,6 +2499,139 @@ authorityKeyIdentifier = keyid:always } } + fn sample_vcir_for_projection( + now: time::OffsetDateTime, + child_cert_hash: &str, + ) -> ValidatedCaInstanceResult { + let manifest_uri = "rsync://example.test/repo/issuer/issuer.mft".to_string(); + let current_crl_uri = "rsync://example.test/repo/issuer/issuer.crl".to_string(); + let child_cert_uri = "rsync://example.test/repo/issuer/child.cer".to_string(); + let child_manifest_uri = "rsync://example.test/repo/child/child.mft".to_string(); + let roa_uri = "rsync://example.test/repo/issuer/a.roa".to_string(); + let aspa_uri = "rsync://example.test/repo/issuer/a.asa".to_string(); + let manifest_hash = sha256_hex(b"manifest-bytes"); + let current_crl_hash = sha256_hex(b"current-crl-bytes"); + let roa_hash = sha256_hex(b"roa-bytes"); + let aspa_hash = sha256_hex(b"aspa-bytes"); + let ee_hash = sha256_hex(b"ee-cert-bytes"); + let gate_until = PackTime::from_utc_offset_datetime(now + time::Duration::hours(1)); + ValidatedCaInstanceResult { + manifest_rsync_uri: manifest_uri.clone(), + parent_manifest_rsync_uri: None, + tal_id: "test-tal".to_string(), + ca_subject_name: "CN=Issuer".to_string(), + ca_ski: "11".repeat(20), + issuer_ski: "22".repeat(20), + last_successful_validation_time: PackTime::from_utc_offset_datetime(now), + current_manifest_rsync_uri: manifest_uri.clone(), + current_crl_rsync_uri: current_crl_uri.clone(), + validated_manifest_meta: ValidatedManifestMeta { + validated_manifest_number: vec![1], + validated_manifest_this_update: PackTime::from_utc_offset_datetime(now), + validated_manifest_next_update: gate_until.clone(), + }, + instance_gate: VcirInstanceGate { + manifest_next_update: gate_until.clone(), + current_crl_next_update: gate_until.clone(), + self_ca_not_after: PackTime::from_utc_offset_datetime(now + time::Duration::hours(2)), + instance_effective_until: gate_until.clone(), + }, + child_entries: vec![VcirChildEntry { + child_manifest_rsync_uri: child_manifest_uri, + child_cert_rsync_uri: child_cert_uri.clone(), + child_cert_hash: child_cert_hash.to_string(), + child_ski: "33".repeat(20), + child_rsync_base_uri: "rsync://example.test/repo/child/".to_string(), + child_publication_point_rsync_uri: "rsync://example.test/repo/child/".to_string(), + child_rrdp_notification_uri: Some("https://example.test/child-notify.xml".to_string()), + child_effective_ip_resources: None, + child_effective_as_resources: None, + accepted_at_validation_time: PackTime::from_utc_offset_datetime(now), + }], + local_outputs: vec![ + VcirLocalOutput { + output_id: sha256_hex(b"vrp-out"), + output_type: VcirOutputType::Vrp, + item_effective_until: PackTime::from_utc_offset_datetime(now + time::Duration::minutes(30)), + source_object_uri: roa_uri.clone(), + source_object_type: "roa".to_string(), + source_object_hash: roa_hash.clone(), + source_ee_cert_hash: ee_hash.clone(), + payload_json: serde_json::json!({"asn": 64496, "prefix": "203.0.113.0/24", "max_length": 24}).to_string(), + rule_hash: sha256_hex(b"roa-rule"), + validation_path_hint: vec![manifest_uri.clone(), roa_uri.clone()], + }, + VcirLocalOutput { + output_id: sha256_hex(b"aspa-out"), + output_type: VcirOutputType::Aspa, + item_effective_until: PackTime::from_utc_offset_datetime(now + time::Duration::minutes(30)), + source_object_uri: aspa_uri.clone(), + source_object_type: "aspa".to_string(), + source_object_hash: aspa_hash.clone(), + source_ee_cert_hash: ee_hash, + payload_json: serde_json::json!({"customer_as_id": 64496, "provider_as_ids": [64497, 64498]}).to_string(), + rule_hash: sha256_hex(b"aspa-rule"), + validation_path_hint: vec![manifest_uri.clone(), aspa_uri.clone()], + }, + ], + related_artifacts: vec![ + VcirRelatedArtifact { + artifact_role: VcirArtifactRole::Manifest, + artifact_kind: VcirArtifactKind::Mft, + uri: Some(manifest_uri.clone()), + sha256: manifest_hash, + object_type: Some("mft".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + VcirRelatedArtifact { + artifact_role: VcirArtifactRole::CurrentCrl, + artifact_kind: VcirArtifactKind::Crl, + uri: Some(current_crl_uri), + sha256: current_crl_hash, + object_type: Some("crl".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + VcirRelatedArtifact { + artifact_role: VcirArtifactRole::ChildCaCert, + artifact_kind: VcirArtifactKind::Cer, + uri: Some(child_cert_uri), + sha256: child_cert_hash.to_string(), + object_type: Some("cer".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + VcirRelatedArtifact { + artifact_role: VcirArtifactRole::SignedObject, + artifact_kind: VcirArtifactKind::Roa, + uri: Some(roa_uri), + sha256: roa_hash, + object_type: Some("roa".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + VcirRelatedArtifact { + artifact_role: VcirArtifactRole::SignedObject, + artifact_kind: VcirArtifactKind::Aspa, + uri: Some(aspa_uri), + sha256: aspa_hash, + object_type: Some("aspa".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }, + ], + summary: VcirSummary { + local_vrp_count: 1, + local_aspa_count: 1, + child_count: 1, + accepted_object_count: 4, + rejected_object_count: 0, + }, + audit_summary: VcirAuditSummary { + failed_fetch_eligible: true, + last_failed_fetch_reason: None, + warning_count: 0, + audit_flags: Vec::new(), + }, + } + } + #[test] fn never_http_fetcher_returns_error() { let f = NeverHttpFetcher; @@ -1205,7 +2664,7 @@ authorityKeyIdentifier = keyid:always } #[test] - fn select_issuer_crl_from_pack_reports_missing_crldp_for_self_signed_cert() { + fn select_issuer_crl_from_snapshot_reports_missing_crldp_for_self_signed_cert() { let ta_der = std::fs::read( std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/ta/apnic-ta.cer"), @@ -1213,12 +2672,12 @@ authorityKeyIdentifier = keyid:always .expect("read TA fixture"); let pack = dummy_pack_with_files(vec![]); - let err = select_issuer_crl_from_pack(&ta_der, &pack).unwrap_err(); + let err = select_issuer_crl_from_snapshot(&ta_der, &pack).unwrap_err(); assert!(err.contains("CRLDistributionPoints missing"), "{err}"); } #[test] - fn select_issuer_crl_from_pack_finds_matching_crl() { + fn select_issuer_crl_from_snapshot_finds_matching_crl() { // Use real fixtures to ensure child cert has CRLDP rsync URI and CRL exists. let child_cert_der = std::fs::read(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( @@ -1236,7 +2695,7 @@ authorityKeyIdentifier = keyid:always )]); let (uri, found) = - select_issuer_crl_from_pack(child_cert_der.as_slice(), &pack).expect("find crl"); + select_issuer_crl_from_snapshot(child_cert_der.as_slice(), &pack).expect("find crl"); assert_eq!( uri, "rsync://ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.crl" @@ -1262,6 +2721,8 @@ authorityKeyIdentifier = keyid:always let issuer_ca = ResourceCertificate::decode_der(&g.issuer_ca_der).expect("decode issuer"); let issuer = CaInstanceHandle { depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, ca_certificate_der: g.issuer_ca_der.clone(), ca_certificate_rsync_uri: None, effective_ip_resources: issuer_ca.tbs.extensions.ip_resources.clone(), @@ -1273,7 +2734,7 @@ authorityKeyIdentifier = keyid:always }; let now = time::OffsetDateTime::now_utc(); - let children = discover_children_from_fresh_pack_with_audit(&issuer, &pack, now, None) + let children = discover_children_from_fresh_snapshot_with_audit(&issuer, &pack, now, None) .expect("discover children") .children; assert_eq!(children.len(), 1); @@ -1322,6 +2783,8 @@ authorityKeyIdentifier = keyid:always let issuer = CaInstanceHandle { depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, ca_certificate_der: vec![1], ca_certificate_rsync_uri: None, effective_ip_resources: None, @@ -1332,7 +2795,7 @@ authorityKeyIdentifier = keyid:always rrdp_notification_uri: None, }; - let out = discover_children_from_fresh_pack_with_audit(&issuer, &pack, now, None) + let out = discover_children_from_fresh_snapshot_with_audit(&issuer, &pack, now, None) .expect("discovery should succeed with audit error"); assert_eq!(out.children.len(), 0); assert_eq!(out.audits.len(), 1); @@ -1394,7 +2857,6 @@ authorityKeyIdentifier = keyid:always validation_time, timing: None, download_log: None, - revalidate_only: false, rrdp_dedup: false, rrdp_repo_cache: Mutex::new(HashMap::new()), rsync_dedup: false, @@ -1413,6 +2875,8 @@ authorityKeyIdentifier = keyid:always let handle = CaInstanceHandle { depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, ca_certificate_der: issuer_ca_der, ca_certificate_rsync_uri: Some("rsync://rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer".to_string()), effective_ip_resources: issuer_ca.tbs.extensions.ip_resources.clone(), @@ -1427,12 +2891,62 @@ authorityKeyIdentifier = keyid:always .run_publication_point(&handle) .expect("run publication point"); assert_eq!(out.source, PublicationPointSource::Fresh); - assert_eq!(out.pack.manifest_rsync_uri, manifest_rsync_uri); - assert!(out.pack.files.len() > 1); + let pack = out.snapshot.expect("fresh run pack"); + assert_eq!(pack.manifest_rsync_uri, manifest_rsync_uri); + assert!(pack.files.len() > 1); assert!( out.objects.vrps.len() > 1, "expected to extract VRPs from ROAs" ); + + let vcir = store + .get_vcir(&manifest_rsync_uri) + .expect("get vcir") + .expect("vcir exists after fresh run"); + assert_eq!(vcir.manifest_rsync_uri, manifest_rsync_uri); + assert_eq!(vcir.tal_id, "test-tal"); + assert!( + vcir.local_outputs + .iter() + .any(|output| output.output_type == crate::storage::VcirOutputType::Vrp), + "expected VCIR local_outputs to contain VRP entries" + ); + let first_vrp = vcir + .local_outputs + .iter() + .find(|output| output.output_type == crate::storage::VcirOutputType::Vrp) + .expect("first VCIR VRP output"); + let audit_rule = store + .get_audit_rule_index_entry(crate::storage::AuditRuleKind::Roa, &first_vrp.rule_hash) + .expect("get audit rule index") + .expect("audit rule index exists"); + assert_eq!(audit_rule.manifest_rsync_uri, manifest_rsync_uri); + assert_eq!(audit_rule.output_id, first_vrp.output_id); + assert!(vcir.related_artifacts.iter().any(|artifact| { + artifact.artifact_role == VcirArtifactRole::EeCert + && artifact.artifact_kind == VcirArtifactKind::Cer + })); + let ee_entry = store + .get_raw_by_hash_entry(&first_vrp.source_ee_cert_hash) + .expect("get source ee raw") + .expect("source ee raw exists"); + assert_eq!(ee_entry.object_type.as_deref(), Some("cer")); + assert_eq!(ee_entry.encoding.as_deref(), Some("der")); + + let trace = crate::audit_trace::trace_rule_to_root( + &store, + crate::storage::AuditRuleKind::Roa, + &first_vrp.rule_hash, + ) + .expect("trace rule") + .expect("trace exists"); + assert_eq!(trace.chain_leaf_to_root.len(), 1); + assert_eq!( + trace.chain_leaf_to_root[0].manifest_rsync_uri, + manifest_rsync_uri + ); + assert!(trace.source_object_raw.raw_present); + assert!(trace.source_ee_cert_raw.raw_present); } #[test] @@ -1469,6 +2983,8 @@ authorityKeyIdentifier = keyid:always let handle = CaInstanceHandle { depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, ca_certificate_der: issuer_ca_der, ca_certificate_rsync_uri: Some("rsync://rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer".to_string()), effective_ip_resources: issuer_ca.tbs.extensions.ip_resources.clone(), @@ -1507,7 +3023,6 @@ authorityKeyIdentifier = keyid:always validation_time, timing: None, download_log: None, - revalidate_only: false, rrdp_dedup: false, rrdp_repo_cache: Mutex::new(HashMap::new()), rsync_dedup: true, @@ -1530,7 +3045,8 @@ authorityKeyIdentifier = keyid:always } #[test] - fn runner_when_repo_sync_fails_uses_fetch_cache_pp_and_skips_child_discovery() { + fn runner_when_repo_sync_fails_uses_current_instance_vcir_and_keeps_children_empty_for_fixture() + { let fixture_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0"); assert!(fixture_dir.is_dir(), "fixture directory must exist"); @@ -1563,6 +3079,8 @@ authorityKeyIdentifier = keyid:always let handle = CaInstanceHandle { depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, ca_certificate_der: issuer_ca_der, ca_certificate_rsync_uri: Some("rsync://rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer".to_string()), effective_ip_resources: issuer_ca.tbs.extensions.ip_resources.clone(), @@ -1573,7 +3091,7 @@ authorityKeyIdentifier = keyid:always rrdp_notification_uri: None, }; - // First: successful repo sync to populate fetch_cache_pp. + // First: successful fresh run to populate the latest VCIR baseline. let ok_runner = Rpkiv1PublicationPointRunner { store: &store, policy: &policy, @@ -1582,7 +3100,6 @@ authorityKeyIdentifier = keyid:always validation_time, timing: None, download_log: None, - revalidate_only: false, rrdp_dedup: false, rrdp_repo_cache: Mutex::new(HashMap::new()), rsync_dedup: false, @@ -1597,7 +3114,7 @@ authorityKeyIdentifier = keyid:always "fixture has no child .cer" ); - // Second: repo sync fails, but we can still use fetch_cache_pp. + // Second: repo sync fails, but we can still reuse current-instance VCIR. let bad_runner = Rpkiv1PublicationPointRunner { store: &store, policy: &policy, @@ -1606,7 +3123,6 @@ authorityKeyIdentifier = keyid:always validation_time, timing: None, download_log: None, - revalidate_only: false, rrdp_dedup: false, rrdp_repo_cache: Mutex::new(HashMap::new()), rsync_dedup: false, @@ -1614,8 +3130,8 @@ authorityKeyIdentifier = keyid:always }; let second = bad_runner .run_publication_point(&handle) - .expect("should fall back to fetch_cache_pp"); - assert_eq!(second.source, PublicationPointSource::FetchCachePp); + .expect("should reuse current-instance VCIR"); + assert_eq!(second.source, PublicationPointSource::VcirCurrentInstance); assert!(second.discovered_children.is_empty()); assert!( second @@ -1633,12 +3149,14 @@ authorityKeyIdentifier = keyid:always PackFile::from_bytes_compute_sha256("rsync://example.test/repo/dup.roa", vec![2u8]), ]); let pp = crate::validation::manifest::PublicationPointResult { - source: crate::validation::manifest::PublicationPointSource::FetchCachePp, - pack: pack.clone(), + source: crate::validation::manifest::PublicationPointSource::VcirCurrentInstance, + snapshot: pack.clone(), warnings: Vec::new(), }; let ca = CaInstanceHandle { depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, ca_certificate_der: vec![1], ca_certificate_rsync_uri: None, effective_ip_resources: None, @@ -1656,8 +3174,15 @@ authorityKeyIdentifier = keyid:always audit: Vec::new(), }; - let audit = build_publication_point_audit(&ca, &pp, &[], &objects, &[]); - assert_eq!(audit.source, "fetch_cache_pp"); + let audit = build_publication_point_audit_from_snapshot( + &ca, + pp.source, + &pp.snapshot, + &[], + &objects, + &[], + ); + assert_eq!(audit.source, "vcir_current_instance"); assert!( audit .objects @@ -1683,12 +3208,14 @@ authorityKeyIdentifier = keyid:always let pp = crate::validation::manifest::PublicationPointResult { source: crate::validation::manifest::PublicationPointSource::Fresh, - pack: pack.clone(), + snapshot: pack.clone(), warnings: Vec::new(), }; let issuer = CaInstanceHandle { depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, ca_certificate_der: vec![1], ca_certificate_rsync_uri: None, effective_ip_resources: None, @@ -1713,7 +3240,14 @@ authorityKeyIdentifier = keyid:always }], }; - let audit = build_publication_point_audit(&issuer, &pp, &[], &objects, &[]); + let audit = build_publication_point_audit_from_snapshot( + &issuer, + pp.source, + &pp.snapshot, + &[], + &objects, + &[], + ); assert_eq!(audit.objects[0].kind, AuditObjectKind::Manifest); let crl = audit @@ -1755,6 +3289,8 @@ authorityKeyIdentifier = keyid:always let issuer_ca = ResourceCertificate::decode_der(&g.issuer_ca_der).expect("decode issuer"); let issuer = CaInstanceHandle { depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, ca_certificate_der: g.issuer_ca_der.clone(), ca_certificate_rsync_uri: None, effective_ip_resources: issuer_ca.tbs.extensions.ip_resources.clone(), @@ -1766,10 +3302,853 @@ authorityKeyIdentifier = keyid:always }; let now = time::OffsetDateTime::now_utc(); - let out = discover_children_from_fresh_pack_with_audit(&issuer, &pack, now, None) + let out = discover_children_from_fresh_snapshot_with_audit(&issuer, &pack, now, None) .expect("discover children"); assert!(out.children.is_empty()); assert_eq!(out.audits.len(), 1); assert!(matches!(out.audits[0].result, AuditObjectResult::Error)); } + + #[test] + fn select_issuer_crl_uri_for_child_covers_missing_and_not_found_paths() { + let g = generate_chain_and_crl(); + let child = ResourceCertificate::decode_der(&g.child_ca_der).expect("decode child cert"); + + let empty: std::collections::HashMap = + std::collections::HashMap::new(); + let err = select_issuer_crl_uri_for_child(&child, &empty).unwrap_err(); + assert!(err.contains("no CRL available"), "{err}"); + + let ta_der = std::fs::read( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/ta/apnic-ta.cer"), + ) + .expect("read TA fixture"); + let ta = ResourceCertificate::decode_der(&ta_der).expect("decode TA fixture"); + let mut cache = std::collections::HashMap::new(); + cache.insert( + "rsync://example.test/repo/issuer/issuer.crl".to_string(), + CachedIssuerCrl::Pending(g.issuer_crl_der.clone()), + ); + let err = select_issuer_crl_uri_for_child(&ta, &cache).unwrap_err(); + assert!(err.contains("CRLDistributionPoints missing"), "{err}"); + + let mut wrong = std::collections::HashMap::new(); + wrong.insert( + "rsync://example.test/repo/issuer/other.crl".to_string(), + CachedIssuerCrl::Pending(g.issuer_crl_der), + ); + let err = select_issuer_crl_uri_for_child(&child, &wrong).unwrap_err(); + assert!( + err.contains("not found in publication point snapshot"), + "{err}" + ); + } + + #[test] + fn ensure_issuer_crl_verified_promotes_pending_cache_entry() { + let g = generate_chain_and_crl(); + let mut cache = std::collections::HashMap::new(); + let crl_uri = "rsync://example.test/repo/issuer/issuer.crl".to_string(); + cache.insert( + crl_uri.clone(), + CachedIssuerCrl::Pending(g.issuer_crl_der.clone()), + ); + + let first = ensure_issuer_crl_verified(&crl_uri, &mut cache, &g.issuer_ca_der) + .expect("verify pending CRL"); + assert!(first.revoked_serials.is_empty()); + assert!(matches!(cache.get(&crl_uri), Some(CachedIssuerCrl::Ok(_)))); + + let second = ensure_issuer_crl_verified(&crl_uri, &mut cache, &g.issuer_ca_der) + .expect("reuse verified CRL"); + assert!(second.revoked_serials.is_empty()); + } + + #[test] + fn discover_children_with_invalid_issuer_der_records_error_audit() { + let g = generate_chain_and_crl(); + let pack = dummy_pack_with_files(vec![ + PackFile::from_bytes_compute_sha256( + "rsync://example.test/repo/issuer/issuer.crl", + g.issuer_crl_der.clone(), + ), + PackFile::from_bytes_compute_sha256( + "rsync://example.test/repo/issuer/child.cer", + g.child_ca_der.clone(), + ), + ]); + + let issuer = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: vec![0u8], + ca_certificate_rsync_uri: None, + effective_ip_resources: None, + effective_as_resources: None, + rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(), + manifest_rsync_uri: "rsync://example.test/repo/issuer/issuer.mft".to_string(), + publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), + rrdp_notification_uri: None, + }; + + let out = discover_children_from_fresh_snapshot_with_audit( + &issuer, + &pack, + time::OffsetDateTime::now_utc(), + None, + ) + .expect("discover children with invalid issuer der"); + assert!(out.children.is_empty()); + assert_eq!(out.audits.len(), 1); + assert!(matches!(out.audits[0].result, AuditObjectResult::Error)); + assert!( + out.audits[0] + .detail + .as_deref() + .unwrap_or("") + .contains("issuer CA decode failed") + ); + } + + #[test] + fn project_current_instance_vcir_reuses_local_outputs_and_restores_children() { + let now = time::OffsetDateTime::now_utc(); + let g = generate_chain_and_crl(); + let child_cert_hash = sha256_hex(&g.child_ca_der); + let vcir = sample_vcir_for_projection(now, &child_cert_hash); + + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + store.put_vcir(&vcir).expect("put vcir"); + for (bytes, uri, object_type) in [ + ( + b"manifest-bytes".to_vec(), + Some(vcir.current_manifest_rsync_uri.clone()), + Some("mft".to_string()), + ), + ( + b"current-crl-bytes".to_vec(), + Some(vcir.current_crl_rsync_uri.clone()), + Some("crl".to_string()), + ), + ( + g.child_ca_der.clone(), + Some(vcir.child_entries[0].child_cert_rsync_uri.clone()), + Some("cer".to_string()), + ), + ( + b"roa-bytes".to_vec(), + Some("rsync://example.test/repo/issuer/a.roa".to_string()), + Some("roa".to_string()), + ), + ( + b"aspa-bytes".to_vec(), + Some("rsync://example.test/repo/issuer/a.asa".to_string()), + Some("aspa".to_string()), + ), + ] { + let mut entry = RawByHashEntry::from_bytes(sha256_hex(&bytes), bytes); + if let Some(uri) = uri { + entry.origin_uris.push(uri); + } + entry.object_type = object_type; + entry.encoding = Some("der".to_string()); + store + .put_raw_by_hash_entry(&entry) + .expect("put raw_by_hash"); + } + + let ca = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: Vec::new(), + ca_certificate_rsync_uri: None, + effective_ip_resources: None, + effective_as_resources: None, + rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(), + manifest_rsync_uri: vcir.manifest_rsync_uri.clone(), + publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), + rrdp_notification_uri: Some("https://example.test/notify.xml".to_string()), + }; + + let projection = project_current_instance_vcir_on_failed_fetch( + &store, + &ca, + &ManifestFreshError::RepoSyncFailed { + detail: "synthetic".to_string(), + }, + now, + ) + .expect("project vcir"); + + assert_eq!( + projection.source, + PublicationPointSource::VcirCurrentInstance + ); + assert_eq!(projection.objects.vrps.len(), 1); + assert_eq!(projection.objects.aspas.len(), 1); + assert_eq!(projection.discovered_children.len(), 1); + assert_eq!( + projection.discovered_children[0].handle.manifest_rsync_uri, + "rsync://example.test/repo/child/child.mft" + ); + assert!( + projection.snapshot.is_some(), + "expected reconstructed snapshot" + ); + } + + #[test] + fn project_current_instance_vcir_returns_no_output_when_instance_gate_expired() { + let now = time::OffsetDateTime::now_utc(); + let child_cert_hash = sha256_hex(b"child-cert"); + let mut vcir = sample_vcir_for_projection(now, &child_cert_hash); + let this_update = PackTime::from_utc_offset_datetime(now - time::Duration::minutes(2)); + let expired = PackTime::from_utc_offset_datetime(now - time::Duration::minutes(1)); + vcir.validated_manifest_meta.validated_manifest_this_update = this_update; + vcir.validated_manifest_meta.validated_manifest_next_update = expired.clone(); + vcir.instance_gate.manifest_next_update = expired.clone(); + vcir.instance_gate.current_crl_next_update = expired.clone(); + vcir.instance_gate.instance_effective_until = expired; + + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + store.put_vcir(&vcir).expect("put vcir"); + + let ca = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: Vec::new(), + ca_certificate_rsync_uri: None, + effective_ip_resources: None, + effective_as_resources: None, + rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(), + manifest_rsync_uri: vcir.manifest_rsync_uri.clone(), + publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), + rrdp_notification_uri: None, + }; + + let projection = project_current_instance_vcir_on_failed_fetch( + &store, + &ca, + &ManifestFreshError::RepoSyncFailed { + detail: "synthetic".to_string(), + }, + now, + ) + .expect("project vcir"); + + assert_eq!( + projection.source, + PublicationPointSource::FailedFetchNoCache + ); + assert!(projection.objects.vrps.is_empty()); + assert!(projection.objects.aspas.is_empty()); + assert!(projection.discovered_children.is_empty()); + } + + #[test] + fn project_current_instance_vcir_returns_no_output_when_latest_result_missing() { + let now = time::OffsetDateTime::now_utc(); + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + let ca = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: Vec::new(), + ca_certificate_rsync_uri: None, + effective_ip_resources: None, + effective_as_resources: None, + rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(), + manifest_rsync_uri: "rsync://example.test/repo/issuer/issuer.mft".to_string(), + publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), + rrdp_notification_uri: None, + }; + + let projection = project_current_instance_vcir_on_failed_fetch( + &store, + &ca, + &ManifestFreshError::RepoSyncFailed { + detail: "synthetic".to_string(), + }, + now, + ) + .expect("project without cached vcir"); + + assert_eq!( + projection.source, + PublicationPointSource::FailedFetchNoCache + ); + assert!(projection.vcir.is_none()); + assert!(projection.snapshot.is_none()); + assert!(projection.objects.audit.is_empty()); + assert!(projection.discovered_children.is_empty()); + assert!(projection.warnings.iter().any(|warning| { + warning + .message + .contains("no latest validated result for current CA instance") + })); + } + + #[test] + fn project_current_instance_vcir_returns_no_output_when_latest_result_is_ineligible() { + let now = time::OffsetDateTime::now_utc(); + let child_cert_hash = sha256_hex(b"child-cert"); + let mut vcir = sample_vcir_for_projection(now, &child_cert_hash); + vcir.audit_summary.failed_fetch_eligible = false; + + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + store.put_vcir(&vcir).expect("put vcir"); + + let ca = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: Vec::new(), + ca_certificate_rsync_uri: None, + effective_ip_resources: None, + effective_as_resources: None, + rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(), + manifest_rsync_uri: vcir.manifest_rsync_uri.clone(), + publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), + rrdp_notification_uri: None, + }; + + let projection = project_current_instance_vcir_on_failed_fetch( + &store, + &ca, + &ManifestFreshError::RepoSyncFailed { + detail: "synthetic".to_string(), + }, + now, + ) + .expect("project ineligible vcir"); + + assert_eq!( + projection.source, + PublicationPointSource::FailedFetchNoCache + ); + assert!(projection.vcir.is_some()); + assert!(projection.snapshot.is_none()); + assert!(projection.discovered_children.is_empty()); + assert!(projection.warnings.iter().any(|warning| { + warning + .message + .contains("latest VCIR is not marked failed-fetch eligible") + })); + } + + #[test] + fn parse_snapshot_time_value_reports_invalid_timestamp() { + let err = parse_snapshot_time_value(&PackTime { + rfc3339_utc: "not-a-time".to_string(), + }) + .unwrap_err(); + + assert!(err.contains("invalid RFC3339 time 'not-a-time'"), "{err}"); + } + + #[test] + fn build_objects_output_from_vcir_tracks_expired_and_invalid_cached_outputs() { + let now = time::OffsetDateTime::now_utc(); + let child_cert_hash = sha256_hex(b"child-cert"); + let mut vcir = sample_vcir_for_projection(now, &child_cert_hash); + + let bad_time_uri = "rsync://example.test/repo/issuer/bad-time.roa".to_string(); + let expired_uri = "rsync://example.test/repo/issuer/expired.asa".to_string(); + let bad_json_uri = "rsync://example.test/repo/issuer/bad-json.roa".to_string(); + let bad_prefix_uri = "rsync://example.test/repo/issuer/bad-prefix.roa".to_string(); + let bad_aspa_uri = "rsync://example.test/repo/issuer/bad-aspa.asa".to_string(); + + for (uri, kind) in [ + (bad_time_uri.clone(), VcirArtifactKind::Roa), + (expired_uri.clone(), VcirArtifactKind::Aspa), + (bad_json_uri.clone(), VcirArtifactKind::Roa), + (bad_prefix_uri.clone(), VcirArtifactKind::Roa), + (bad_aspa_uri.clone(), VcirArtifactKind::Aspa), + ] { + vcir.related_artifacts.push(VcirRelatedArtifact { + artifact_role: VcirArtifactRole::SignedObject, + artifact_kind: kind, + uri: Some(uri.clone()), + sha256: sha256_hex(uri.as_bytes()), + object_type: Some( + match kind { + VcirArtifactKind::Aspa => "aspa", + _ => "roa", + } + .to_string(), + ), + validation_status: VcirArtifactValidationStatus::Accepted, + }); + } + + vcir.local_outputs.push(VcirLocalOutput { + output_id: sha256_hex(b"bad-time"), + output_type: VcirOutputType::Vrp, + item_effective_until: PackTime { + rfc3339_utc: "bad-time-value".to_string(), + }, + source_object_uri: bad_time_uri.clone(), + source_object_type: "roa".to_string(), + source_object_hash: sha256_hex(b"bad-time-src"), + source_ee_cert_hash: sha256_hex(b"bad-time-ee"), + payload_json: + serde_json::json!({"asn": 64496, "prefix": "203.0.113.0/24", "max_length": 24}) + .to_string(), + rule_hash: sha256_hex(b"bad-time-rule"), + validation_path_hint: vec![vcir.manifest_rsync_uri.clone(), bad_time_uri.clone()], + }); + vcir.local_outputs.push(VcirLocalOutput { + output_id: sha256_hex(b"expired"), + output_type: VcirOutputType::Aspa, + item_effective_until: PackTime::from_utc_offset_datetime( + now - time::Duration::minutes(1), + ), + source_object_uri: expired_uri.clone(), + source_object_type: "aspa".to_string(), + source_object_hash: sha256_hex(b"expired-src"), + source_ee_cert_hash: sha256_hex(b"expired-ee"), + payload_json: serde_json::json!({"customer_as_id": 64500, "provider_as_ids": [64501]}) + .to_string(), + rule_hash: sha256_hex(b"expired-rule"), + validation_path_hint: vec![vcir.manifest_rsync_uri.clone(), expired_uri.clone()], + }); + vcir.local_outputs.push(VcirLocalOutput { + output_id: sha256_hex(b"bad-json"), + output_type: VcirOutputType::Vrp, + item_effective_until: PackTime::from_utc_offset_datetime( + now + time::Duration::minutes(5), + ), + source_object_uri: bad_json_uri.clone(), + source_object_type: "roa".to_string(), + source_object_hash: sha256_hex(b"bad-json-src"), + source_ee_cert_hash: sha256_hex(b"bad-json-ee"), + payload_json: "{not-json".to_string(), + rule_hash: sha256_hex(b"bad-json-rule"), + validation_path_hint: vec![vcir.manifest_rsync_uri.clone(), bad_json_uri.clone()], + }); + vcir.local_outputs.push(VcirLocalOutput { + output_id: sha256_hex(b"bad-prefix"), + output_type: VcirOutputType::Vrp, + item_effective_until: PackTime::from_utc_offset_datetime( + now + time::Duration::minutes(5), + ), + source_object_uri: bad_prefix_uri.clone(), + source_object_type: "roa".to_string(), + source_object_hash: sha256_hex(b"bad-prefix-src"), + source_ee_cert_hash: sha256_hex(b"bad-prefix-ee"), + payload_json: + serde_json::json!({"asn": 64510, "prefix": "203.0.113.0", "max_length": 24}) + .to_string(), + rule_hash: sha256_hex(b"bad-prefix-rule"), + validation_path_hint: vec![vcir.manifest_rsync_uri.clone(), bad_prefix_uri.clone()], + }); + vcir.local_outputs.push(VcirLocalOutput { + output_id: sha256_hex(b"bad-aspa"), + output_type: VcirOutputType::Aspa, + item_effective_until: PackTime::from_utc_offset_datetime( + now + time::Duration::minutes(5), + ), + source_object_uri: bad_aspa_uri.clone(), + source_object_type: "aspa".to_string(), + source_object_hash: sha256_hex(b"bad-aspa-src"), + source_ee_cert_hash: sha256_hex(b"bad-aspa-ee"), + payload_json: serde_json::json!({"customer_as_id": 64520}).to_string(), + rule_hash: sha256_hex(b"bad-aspa-rule"), + validation_path_hint: vec![vcir.manifest_rsync_uri.clone(), bad_aspa_uri.clone()], + }); + + let mut warnings = Vec::new(); + let output = build_objects_output_from_vcir(&vcir, now, &mut warnings); + + assert_eq!(output.vrps.len(), 1); + assert_eq!(output.aspas.len(), 1); + assert_eq!(output.stats.roa_total, 4); + assert_eq!(output.stats.roa_ok, 1); + assert_eq!(output.stats.aspa_total, 3); + assert_eq!(output.stats.aspa_ok, 1); + assert!(warnings.iter().any(|warning| { + warning + .message + .contains("cached local output has invalid item_effective_until") + })); + assert!(warnings.iter().any(|warning| { + warning + .message + .contains("cached ROA local output parse failed") + })); + assert!(warnings.iter().any(|warning| { + warning + .message + .contains("cached ASPA local output parse failed") + })); + assert!(output.audit.iter().any(|entry| { + entry.rsync_uri == expired_uri + && matches!(entry.result, AuditObjectResult::Skipped) + && entry.detail.as_deref() == Some("skipped: cached local output expired") + })); + assert!(output.audit.iter().any(|entry| { + entry.rsync_uri == bad_time_uri && matches!(entry.result, AuditObjectResult::Error) + })); + assert!(output.audit.iter().any(|entry| { + entry.rsync_uri == bad_prefix_uri + && matches!(entry.result, AuditObjectResult::Error) + && entry + .detail + .as_deref() + .unwrap_or("") + .contains("cached ROA local output parse failed") + })); + } + + #[test] + fn build_publication_point_audit_from_vcir_uses_vcir_metadata_and_overlays_child_and_object_audits() + { + let now = time::OffsetDateTime::now_utc(); + let child_cert_hash = sha256_hex(b"child-cert"); + let mut vcir = sample_vcir_for_projection(now, &child_cert_hash); + vcir.related_artifacts + .retain(|artifact| artifact.artifact_role != VcirArtifactRole::Manifest); + + let ca = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: Vec::new(), + ca_certificate_rsync_uri: None, + effective_ip_resources: None, + effective_as_resources: None, + rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(), + manifest_rsync_uri: vcir.manifest_rsync_uri.clone(), + publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), + rrdp_notification_uri: Some("https://example.test/notify.xml".to_string()), + }; + let runner_warnings = vec![Warning::new("runner warning")]; + let objects = crate::validation::objects::ObjectsOutput { + vrps: Vec::new(), + aspas: Vec::new(), + warnings: vec![Warning::new("objects warning")], + stats: crate::validation::objects::ObjectsStats::default(), + audit: vec![ObjectAuditEntry { + rsync_uri: "rsync://example.test/repo/issuer/a.roa".to_string(), + sha256_hex: sha256_hex(b"override-roa"), + kind: AuditObjectKind::Roa, + result: AuditObjectResult::Error, + detail: Some("overridden from object audit".to_string()), + }], + }; + let child_audits = vec![ObjectAuditEntry { + rsync_uri: vcir.child_entries[0].child_cert_rsync_uri.clone(), + sha256_hex: vcir.child_entries[0].child_cert_hash.clone(), + kind: AuditObjectKind::Certificate, + result: AuditObjectResult::Ok, + detail: Some("restored child CA instance from VCIR".to_string()), + }]; + + let audit = build_publication_point_audit_from_vcir( + &ca, + PublicationPointSource::VcirCurrentInstance, + Some(&vcir), + None, + &runner_warnings, + &objects, + &child_audits, + ); + + assert_eq!(audit.source, "vcir_current_instance"); + assert_eq!(audit.objects[0].rsync_uri, vcir.current_manifest_rsync_uri); + assert_eq!(audit.objects[0].kind, AuditObjectKind::Manifest); + assert_eq!( + audit.this_update_rfc3339_utc, + vcir.validated_manifest_meta + .validated_manifest_this_update + .rfc3339_utc + ); + assert_eq!( + audit.next_update_rfc3339_utc, + vcir.validated_manifest_meta + .validated_manifest_next_update + .rfc3339_utc + ); + assert_eq!( + audit.verified_at_rfc3339_utc, + vcir.last_successful_validation_time.rfc3339_utc + ); + assert_eq!(audit.warnings.len(), 2); + assert!(audit.objects.iter().any(|entry| { + entry.rsync_uri == "rsync://example.test/repo/issuer/a.roa" + && matches!(entry.result, AuditObjectResult::Error) + && entry.detail.as_deref() == Some("overridden from object audit") + })); + assert!(audit.objects.iter().any(|entry| { + entry.rsync_uri == vcir.child_entries[0].child_cert_rsync_uri + && matches!(entry.result, AuditObjectResult::Ok) + })); + } + + #[test] + fn build_publication_point_audit_from_vcir_without_cached_inputs_returns_empty_listing() { + let ca = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: Vec::new(), + ca_certificate_rsync_uri: None, + effective_ip_resources: None, + effective_as_resources: None, + rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(), + manifest_rsync_uri: "rsync://example.test/repo/issuer/issuer.mft".to_string(), + publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), + rrdp_notification_uri: None, + }; + + let audit = build_publication_point_audit_from_vcir( + &ca, + PublicationPointSource::FailedFetchNoCache, + None, + None, + &[Warning::new("runner warning")], + &crate::validation::objects::ObjectsOutput { + vrps: Vec::new(), + aspas: Vec::new(), + warnings: vec![Warning::new("object warning")], + stats: crate::validation::objects::ObjectsStats::default(), + audit: Vec::new(), + }, + &[], + ); + + assert_eq!(audit.source, "failed_fetch_no_cache"); + assert!(audit.this_update_rfc3339_utc.is_empty()); + assert!(audit.next_update_rfc3339_utc.is_empty()); + assert!(audit.verified_at_rfc3339_utc.is_empty()); + assert_eq!(audit.warnings.len(), 2); + assert!(audit.objects.is_empty()); + } + + #[test] + fn reconstruct_snapshot_from_vcir_reports_missing_manifest_and_related_raw_bytes() { + let now = time::OffsetDateTime::now_utc(); + let child_cert_hash = sha256_hex(b"child-cert"); + let mut vcir = sample_vcir_for_projection(now, &child_cert_hash); + let dup_uri = "rsync://example.test/repo/issuer/dup.roa".to_string(); + vcir.related_artifacts.push(VcirRelatedArtifact { + artifact_role: VcirArtifactRole::SignedObject, + artifact_kind: VcirArtifactKind::Roa, + uri: Some(dup_uri.clone()), + sha256: sha256_hex(b"dup-roa-1"), + object_type: Some("roa".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }); + vcir.related_artifacts.push(VcirRelatedArtifact { + artifact_role: VcirArtifactRole::SignedObject, + artifact_kind: VcirArtifactKind::Roa, + uri: Some(dup_uri.clone()), + sha256: sha256_hex(b"dup-roa-2"), + object_type: Some("roa".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }); + vcir.related_artifacts.push(VcirRelatedArtifact { + artifact_role: VcirArtifactRole::IssuerCert, + artifact_kind: VcirArtifactKind::Cer, + uri: Some("rsync://example.test/repo/issuer/issuer.cer".to_string()), + sha256: sha256_hex(b"issuer-cert"), + object_type: Some("cer".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }); + + let ca = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: Vec::new(), + ca_certificate_rsync_uri: None, + effective_ip_resources: None, + effective_as_resources: None, + rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(), + manifest_rsync_uri: vcir.manifest_rsync_uri.clone(), + publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), + rrdp_notification_uri: None, + }; + + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + let mut warnings = Vec::new(); + assert!(reconstruct_snapshot_from_vcir(&store, &ca, &vcir, &mut warnings).is_none()); + assert!(warnings.iter().any(|warning| { + warning + .message + .contains("manifest raw bytes missing for VCIR audit reconstruction") + })); + + let manifest_bytes = b"manifest-bytes".to_vec(); + let current_crl_bytes = b"current-crl-bytes".to_vec(); + let child_bytes = b"child-cert".to_vec(); + let roa_bytes = b"roa-bytes".to_vec(); + for (bytes, uri, object_type) in [ + ( + manifest_bytes.clone(), + Some(vcir.manifest_rsync_uri.clone()), + Some("mft".to_string()), + ), + ( + current_crl_bytes, + Some(vcir.current_crl_rsync_uri.clone()), + Some("crl".to_string()), + ), + ( + child_bytes, + Some(vcir.child_entries[0].child_cert_rsync_uri.clone()), + Some("cer".to_string()), + ), + ( + roa_bytes, + Some("rsync://example.test/repo/issuer/a.roa".to_string()), + Some("roa".to_string()), + ), + ] { + let mut entry = RawByHashEntry::from_bytes(sha256_hex(&bytes), bytes); + if let Some(uri) = uri { + entry.origin_uris.push(uri); + } + entry.object_type = object_type; + entry.encoding = Some("der".to_string()); + store.put_raw_by_hash_entry(&entry).expect("put raw entry"); + } + + warnings.clear(); + let pack = reconstruct_snapshot_from_vcir(&store, &ca, &vcir, &mut warnings) + .expect("reconstruct pack with partial related artifacts"); + assert_eq!(pack.manifest_bytes, manifest_bytes); + assert_eq!(pack.files.len(), 3, "crl + child cert + roa only"); + assert!( + pack.files + .iter() + .any(|file| file.rsync_uri.ends_with("issuer.crl")) + ); + assert!( + pack.files + .iter() + .any(|file| file.rsync_uri.ends_with("child.cer")) + ); + assert!( + pack.files + .iter() + .any(|file| file.rsync_uri.ends_with("a.roa")) + ); + assert!( + !pack + .files + .iter() + .any(|file| file.rsync_uri.ends_with("issuer.cer")) + ); + assert!(warnings.iter().any(|warning| { + warning + .message + .contains("related artifact raw bytes missing for VCIR audit reconstruction") + })); + } + + #[test] + fn runner_dedup_paths_execute_with_timing_enabled() { + let fixture_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0"); + let rsync_base_uri = "rsync://rpki.cernet.net/repo/cernet/0/".to_string(); + let manifest_file = "05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft"; + let manifest_rsync_uri = format!("{rsync_base_uri}{manifest_file}"); + let fixture_manifest_bytes = + std::fs::read(fixture_dir.join(manifest_file)).expect("read manifest fixture"); + let fixture_manifest = + crate::data_model::manifest::ManifestObject::decode_der(&fixture_manifest_bytes) + .expect("decode manifest fixture"); + let validation_time = fixture_manifest.manifest.this_update + time::Duration::seconds(60); + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + let issuer_ca_der = std::fs::read( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer", + ), + ) + .expect("read issuer ca fixture"); + let issuer_ca = ResourceCertificate::decode_der(&issuer_ca_der).expect("decode issuer ca"); + let handle = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: issuer_ca_der, + ca_certificate_rsync_uri: Some("rsync://rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer".to_string()), + effective_ip_resources: issuer_ca.tbs.extensions.ip_resources.clone(), + effective_as_resources: issuer_ca.tbs.extensions.as_resources.clone(), + rsync_base_uri: rsync_base_uri.clone(), + manifest_rsync_uri: manifest_rsync_uri.clone(), + publication_point_rsync_uri: rsync_base_uri.clone(), + rrdp_notification_uri: Some("https://example.test/notification.xml".to_string()), + }; + let timing = + crate::analysis::timing::TimingHandle::new(crate::analysis::timing::TimingMeta { + recorded_at_utc_rfc3339: "2026-03-11T00:00:00Z".to_string(), + validation_time_utc_rfc3339: "2026-03-11T00:00:00Z".to_string(), + tal_url: None, + db_path: None, + }); + let policy_rrdp = Policy::default(); + let runner_rrdp = Rpkiv1PublicationPointRunner { + store: &store, + policy: &policy_rrdp, + http_fetcher: &NeverHttpFetcher, + rsync_fetcher: &LocalDirRsyncFetcher::new(&fixture_dir), + validation_time, + timing: Some(timing.clone()), + download_log: None, + rrdp_dedup: true, + rrdp_repo_cache: Mutex::new(HashMap::new()), + rsync_dedup: false, + rsync_repo_cache: Mutex::new(HashMap::new()), + }; + let first = runner_rrdp + .run_publication_point(&handle) + .expect("rrdp fallback to rsync"); + assert_eq!(first.source, PublicationPointSource::Fresh); + let second = runner_rrdp + .run_publication_point(&handle) + .expect("rrdp dedup skip"); + assert_eq!(second.source, PublicationPointSource::Fresh); + + let policy_rsync = Policy { + sync_preference: crate::policy::SyncPreference::RsyncOnly, + ..Policy::default() + }; + let runner_rsync = Rpkiv1PublicationPointRunner { + store: &store, + policy: &policy_rsync, + http_fetcher: &NeverHttpFetcher, + rsync_fetcher: &LocalDirRsyncFetcher::new(&fixture_dir), + validation_time, + timing: Some(timing), + download_log: None, + rrdp_dedup: false, + rrdp_repo_cache: Mutex::new(HashMap::new()), + rsync_dedup: true, + rsync_repo_cache: Mutex::new(HashMap::new()), + }; + let third = runner_rsync + .run_publication_point(&handle) + .expect("rsync first run"); + assert_eq!(third.source, PublicationPointSource::Fresh); + let fourth = runner_rsync + .run_publication_point(&handle) + .expect("rsync dedup run"); + assert_eq!(fourth.source, PublicationPointSource::Fresh); + assert_eq!( + normalize_rsync_base_uri("rsync://example.test/repo"), + "rsync://example.test/repo/" + ); + } } diff --git a/tests/bench_stage2_decode_profile_selected_der_v2.rs b/tests/bench_stage2_decode_profile_selected_der_v2.rs index e00d3da..e56078d 100644 --- a/tests/bench_stage2_decode_profile_selected_der_v2.rs +++ b/tests/bench_stage2_decode_profile_selected_der_v2.rs @@ -6,8 +6,8 @@ use rpki::data_model::rc::{ }; use rpki::data_model::roa::RoaObject; +use rpki::storage::PackFile; use rpki::storage::RocksStore; -use rpki::storage::pack::PackFile; use std::path::{Path, PathBuf}; use std::time::Instant; diff --git a/tests/test_apnic_rrdp_delta_live_20260226.rs b/tests/test_apnic_rrdp_delta_live_20260226.rs index e3ccbcc..0557c63 100644 --- a/tests/test_apnic_rrdp_delta_live_20260226.rs +++ b/tests/test_apnic_rrdp_delta_live_20260226.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use rpki::fetch::http::{BlockingHttpFetcher, HttpFetcherConfig}; use rpki::fetch::rsync::{RsyncFetchError, RsyncFetcher}; use rpki::policy::{CaFailedFetchPolicy, Policy, SyncPreference}; -use rpki::storage::{FetchCachePpKey, RocksStore}; +use rpki::storage::RocksStore; use rpki::sync::repo::{RepoSyncSource, sync_publication_point}; use rpki::sync::rrdp::{Fetcher, parse_notification, sync_from_notification}; use rpki::validation::from_tal::discover_root_ca_instance_from_tal_url; @@ -80,13 +80,13 @@ impl Fetcher for CountingDenyUriFetcher { fn live_policy() -> Policy { let mut p = Policy::default(); p.sync_preference = SyncPreference::RrdpThenRsync; - p.ca_failed_fetch_policy = CaFailedFetchPolicy::UseFetchCachePp; + p.ca_failed_fetch_policy = CaFailedFetchPolicy::ReuseCurrentInstanceVcir; p } #[test] #[ignore = "live network: APNIC RRDP snapshot bootstrap into persistent RocksDB"] -fn apnic_live_bootstrap_snapshot_and_fetch_cache_pp_pack_to_persistent_db() { +fn apnic_live_bootstrap_snapshot_and_persist_root_vcir_to_persistent_db() { let http = live_http_fetcher(); let rsync = AlwaysFailRsyncFetcher; @@ -120,8 +120,8 @@ fn apnic_live_bootstrap_snapshot_and_fetch_cache_pp_pack_to_persistent_db() { assert_eq!(sync.source, RepoSyncSource::Rrdp); - // Build + persist a fetch_cache_pp pack for the root publication point so later runs can - // validate behavior under failed fetch conditions (RFC 9286 §6.6). + // Build the root publication point and persist the latest VCIR so later runs can + // validate current-instance failed-fetch reuse behavior (RFC 9286 §6.6). let ta_der = discovery.trust_anchor.ta_certificate.raw_der; let pp = process_manifest_publication_point( &store, @@ -136,9 +136,10 @@ fn apnic_live_bootstrap_snapshot_and_fetch_cache_pp_pack_to_persistent_db() { assert_eq!(pp.source, PublicationPointSource::Fresh); - let key = FetchCachePpKey::from_manifest_rsync_uri(&ca_instance.manifest_rsync_uri); - let cached = store.get_fetch_cache_pp(&key).expect("get fetch_cache_pp"); - assert!(cached.is_some(), "expected fetch_cache_pp to be stored"); + let cached = store + .get_vcir(&ca_instance.manifest_rsync_uri) + .expect("get vcir"); + assert!(cached.is_some(), "expected VCIR to be stored"); eprintln!( "OK: bootstrap complete; persistent db at: {}", @@ -262,15 +263,15 @@ fn apnic_live_delta_only_from_persistent_db() { } #[test] -#[ignore = "offline/synthetic: after bootstrap, force repo sync failure and assert fetch_cache_pp is used (RFC 9286 §6.6)"] -fn apnic_root_repo_sync_failure_uses_fetch_cache_pp_pack() { +#[ignore = "offline/synthetic: after bootstrap, force repo sync failure and assert current-instance VCIR is reused (RFC 9286 §6.6)"] +fn apnic_root_repo_sync_failure_reuses_current_instance_vcir() { let http = live_http_fetcher(); let db_dir = persistent_db_dir(); let store = RocksStore::open(&db_dir).expect("open rocksdb (must have been bootstrapped)"); let mut policy = live_policy(); policy.sync_preference = SyncPreference::RrdpThenRsync; - policy.ca_failed_fetch_policy = CaFailedFetchPolicy::UseFetchCachePp; + policy.ca_failed_fetch_policy = CaFailedFetchPolicy::ReuseCurrentInstanceVcir; let validation_time = time::OffsetDateTime::now_utc(); @@ -278,12 +279,13 @@ fn apnic_root_repo_sync_failure_uses_fetch_cache_pp_pack() { .expect("discover root CA instance from APNIC TAL"); let ca_instance = discovery.ca_instance; - // Ensure cache exists (created by bootstrap). - let key = FetchCachePpKey::from_manifest_rsync_uri(&ca_instance.manifest_rsync_uri); - let cached = store.get_fetch_cache_pp(&key).expect("get fetch_cache_pp"); + // Ensure current-instance VCIR exists (created by bootstrap). + let cached = store + .get_vcir(&ca_instance.manifest_rsync_uri) + .expect("get vcir"); assert!( cached.is_some(), - "missing fetch_cache_pp; run bootstrap test first. db_dir={}", + "missing VCIR; run bootstrap test first. db_dir={}", db_dir.display() ); @@ -301,13 +303,13 @@ fn apnic_root_repo_sync_failure_uses_fetch_cache_pp_pack() { false, Some("synthetic repo sync failure"), ) - .expect("must fall back to fetch_cache_pp"); + .expect("must reuse current-instance VCIR"); - assert_eq!(pp.source, PublicationPointSource::FetchCachePp); + assert_eq!(pp.source, PublicationPointSource::VcirCurrentInstance); assert!( - pp.warnings - .iter() - .any(|w| w.message.contains("using fetch_cache_pp")), - "expected cache-use warning" + pp.warnings.iter().any(|w| w + .message + .contains("using latest validated result for current CA instance")), + "expected current-instance VCIR reuse warning" ); } diff --git a/tests/test_apnic_stats_live_stage2.rs b/tests/test_apnic_stats_live_stage2.rs index 9cb2241..9b0e241 100644 --- a/tests/test_apnic_stats_live_stage2.rs +++ b/tests/test_apnic_stats_live_stage2.rs @@ -63,37 +63,39 @@ impl LiveStats { rpki::validation::manifest::PublicationPointSource::Fresh => { self.publication_points_fresh += 1 } - rpki::validation::manifest::PublicationPointSource::FetchCachePp => { + rpki::validation::manifest::PublicationPointSource::VcirCurrentInstance => { self.publication_points_cached += 1 } + rpki::validation::manifest::PublicationPointSource::FailedFetchNoCache => {} } - // Include manifest object URI itself. - self.pack_uris_total += 1; - self.pack_file_uris_unique - .insert(res.pack.manifest_rsync_uri.clone()); - *self - .pack_uris_by_ext_total - .entry(ext_of_uri(&res.pack.manifest_rsync_uri)) - .or_insert(0) += 1; - - for f in &res.pack.files { + if let Some(pack) = res.snapshot.as_ref() { self.pack_uris_total += 1; - self.pack_file_uris_unique.insert(f.rsync_uri.clone()); + self.pack_file_uris_unique + .insert(pack.manifest_rsync_uri.clone()); *self .pack_uris_by_ext_total - .entry(ext_of_uri(&f.rsync_uri)) + .entry(ext_of_uri(&pack.manifest_rsync_uri)) .or_insert(0) += 1; - if f.rsync_uri.ends_with(".crl") { - self.crl_total += 1; - if RpkixCrl::decode_der(&f.bytes).is_ok() { - self.crl_decode_ok += 1; - } - } + for f in &pack.files { + self.pack_uris_total += 1; + self.pack_file_uris_unique.insert(f.rsync_uri.clone()); + *self + .pack_uris_by_ext_total + .entry(ext_of_uri(&f.rsync_uri)) + .or_insert(0) += 1; - if f.rsync_uri.ends_with(".cer") { - self.child_ca_cert_candidates_total += 1; + if f.rsync_uri.ends_with(".crl") { + self.crl_total += 1; + if RpkixCrl::decode_der(&f.bytes).is_ok() { + self.crl_decode_ok += 1; + } + } + + if f.rsync_uri.ends_with(".cer") { + self.child_ca_cert_candidates_total += 1; + } } } @@ -168,7 +170,6 @@ fn apnic_tree_full_stats_serial() { validation_time, timing: None, download_log: None, - revalidate_only: false, rrdp_dedup: true, rrdp_repo_cache: std::sync::Mutex::new(std::collections::HashMap::new()), rsync_dedup: true, @@ -181,8 +182,12 @@ fn apnic_tree_full_stats_serial() { stats: &stats, }; - let root: CaInstanceHandle = - root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance); + let root: CaInstanceHandle = root_handle_from_trust_anchor( + &discovery.trust_anchor, + "test-tal".to_string(), + None, + &discovery.ca_instance, + ); let max_depth = std::env::var("RPKI_APNIC_MAX_DEPTH") .ok() @@ -197,7 +202,6 @@ fn apnic_tree_full_stats_serial() { &TreeRunConfig { max_depth, max_instances, - revalidate_only: false, }, ) .expect("run tree"); @@ -213,11 +217,6 @@ fn apnic_tree_full_stats_serial() { } } - let fetch_cache_pp_total = store - .fetch_cache_pp_iter_all() - .expect("fetch_cache_pp_iter_all") - .count(); - println!("APNIC Stage2 full-tree serial stats"); println!("tal_url={APNIC_TAL_URL}"); println!( @@ -246,7 +245,6 @@ fn apnic_tree_full_stats_serial() { stats.pack_file_uris_unique.len() ); println!("pack_uris_by_ext_total={:?}", stats.pack_uris_by_ext_total); - println!("fetch_cache_pp_total={fetch_cache_pp_total}"); println!(); println!( "crl_total={} crl_decode_ok={}", @@ -273,7 +271,6 @@ fn apnic_tree_full_stats_serial() { "rocksdb_raw_objects_total={} raw_by_ext={:?}", raw_total, raw_by_ext ); - println!("rocksdb_fetch_cache_pp_total={fetch_cache_pp_total}"); // Loose sanity assertions (avoid flakiness due to repository churn). // diff --git a/tests/test_apnic_tree_live_m15.rs b/tests/test_apnic_tree_live_m15.rs index 602a494..36fa14e 100644 --- a/tests/test_apnic_tree_live_m15.rs +++ b/tests/test_apnic_tree_live_m15.rs @@ -36,7 +36,6 @@ fn apnic_tree_depth1_processes_more_than_root() { &TreeRunConfig { max_depth: Some(1), max_instances: Some(2), - revalidate_only: false, }, ) .expect("run tree from tal"); @@ -75,7 +74,6 @@ fn apnic_tree_root_only_processes_root_with_long_timeouts() { &TreeRunConfig { max_depth: Some(0), max_instances: Some(1), - revalidate_only: false, }, ) .expect("run APNIC root-only"); diff --git a/tests/test_ca_path_m15.rs b/tests/test_ca_path_m15.rs index 30bba0a..a4cd54e 100644 --- a/tests/test_ca_path_m15.rs +++ b/tests/test_ca_path_m15.rs @@ -467,3 +467,337 @@ fn validate_subordinate_ca_rejects_tampered_crl_signature() { .unwrap_err(); assert!(matches!(err, CaPathError::CrlVerify(_))); } + +#[test] +fn validate_subordinate_ca_rejects_non_ca_child_and_non_ca_issuer() { + let generated = generate_chain_and_crl( + "keyUsage = critical, keyCertSign, cRLSign +sbgp-ipAddrBlock = critical, IPv4:10.0.0.0/16 +", + false, + ); + let issuer = ResourceCertificate::decode_der(&generated.issuer_ca_der).expect("decode issuer"); + let ee_child_der = + std::fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa") + .expect("read roa fixture"); + let roa = rpki::data_model::roa::RoaObject::decode_der(&ee_child_der).expect("decode roa"); + let ee_der = roa.signed_object.signed_data.certificates[0] + .raw_der + .clone(); + + let err = validate_subordinate_ca_cert( + &ee_der, + &generated.issuer_ca_der, + &generated.issuer_crl_der, + Some("rsync://example.test/repo/issuer/issuer.cer"), + "rsync://example.test/repo/issuer/issuer.crl", + issuer.tbs.extensions.ip_resources.as_ref(), + issuer.tbs.extensions.as_resources.as_ref(), + time::OffsetDateTime::now_utc(), + ) + .unwrap_err(); + assert!(matches!(err, CaPathError::ChildNotCa), "{err}"); + + let err = validate_subordinate_ca_cert( + &generated.child_ca_der, + &ee_der, + &generated.issuer_crl_der, + Some("rsync://example.test/repo/issuer/issuer.cer"), + "rsync://example.test/repo/issuer/issuer.crl", + issuer.tbs.extensions.ip_resources.as_ref(), + issuer.tbs.extensions.as_resources.as_ref(), + time::OffsetDateTime::now_utc(), + ) + .unwrap_err(); + assert!(matches!(err, CaPathError::IssuerNotCa), "{err}"); +} + +#[test] +fn validate_subordinate_ca_rejects_mismatched_issuer_subject_and_missing_resources() { + let with_resources = generate_chain_and_crl( + "keyUsage = critical, keyCertSign, cRLSign +sbgp-ipAddrBlock = critical, IPv4:10.0.0.0/16 +sbgp-autonomousSysNum = critical, AS:64496 +", + false, + ); + let issuer = + ResourceCertificate::decode_der(&with_resources.issuer_ca_der).expect("decode issuer"); + let wrong_issuer_der = std::fs::read( + "tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/R-lVU1XGsAeqzV1Fv0HjOD6ZFkE.cer", + ) + .expect("read wrong issuer fixture"); + let wrong_issuer = + ResourceCertificate::decode_der(&wrong_issuer_der).expect("decode wrong issuer"); + + let err = validate_subordinate_ca_cert( + &with_resources.child_ca_der, + &wrong_issuer_der, + &with_resources.issuer_crl_der, + Some("rsync://example.test/repo/issuer/issuer.cer"), + "rsync://example.test/repo/issuer/issuer.crl", + wrong_issuer.tbs.extensions.ip_resources.as_ref(), + wrong_issuer.tbs.extensions.as_resources.as_ref(), + time::OffsetDateTime::now_utc(), + ) + .unwrap_err(); + assert!( + matches!(err, CaPathError::IssuerSubjectMismatch { .. }), + "{err}" + ); + + let no_resources = generate_chain_and_crl( + "keyUsage = critical, keyCertSign, cRLSign +", + false, + ); + let err = validate_subordinate_ca_cert( + &no_resources.child_ca_der, + &no_resources.issuer_ca_der, + &no_resources.issuer_crl_der, + Some("rsync://example.test/repo/issuer/issuer.cer"), + "rsync://example.test/repo/issuer/issuer.crl", + issuer.tbs.extensions.ip_resources.as_ref(), + issuer.tbs.extensions.as_resources.as_ref(), + time::OffsetDateTime::now_utc(), + ) + .unwrap_err(); + assert!(matches!(err, CaPathError::ResourcesMissing), "{err}"); +} + +#[test] +fn validate_subordinate_ca_with_prevalidated_issuer_covers_success_and_error_paths() { + use rpki::data_model::common::BigUnsigned; + use rpki::data_model::crl::RpkixCrl; + use rpki::validation::ca_path::validate_subordinate_ca_cert_with_prevalidated_issuer; + use std::collections::HashSet; + use x509_parser::prelude::FromDer; + use x509_parser::x509::SubjectPublicKeyInfo; + + let generated = generate_chain_and_crl( + "keyUsage = critical, keyCertSign, cRLSign +sbgp-ipAddrBlock = critical, IPv4:10.0.0.0/16 +sbgp-autonomousSysNum = critical, AS:64496 +", + false, + ); + let issuer = ResourceCertificate::decode_der(&generated.issuer_ca_der).expect("decode issuer"); + let child = ResourceCertificate::decode_der(&generated.child_ca_der).expect("decode child"); + let issuer_crl = RpkixCrl::decode_der(&generated.issuer_crl_der).expect("decode crl"); + let (rem, issuer_spki) = SubjectPublicKeyInfo::from_der(&issuer.tbs.subject_public_key_info) + .expect("parse issuer spki"); + assert!(rem.is_empty()); + let now = time::OffsetDateTime::now_utc(); + + let validated = validate_subordinate_ca_cert_with_prevalidated_issuer( + &generated.child_ca_der, + child.clone(), + &issuer, + &issuer_spki, + &issuer_crl, + &HashSet::new(), + Some("rsync://example.test/repo/issuer/issuer.cer"), + "rsync://example.test/repo/issuer/issuer.crl", + issuer.tbs.extensions.ip_resources.as_ref(), + issuer.tbs.extensions.as_resources.as_ref(), + now, + ) + .expect("validate subordinate with prevalidated issuer"); + assert!(validated.effective_ip_resources.is_some()); + assert!(validated.effective_as_resources.is_some()); + + let mut revoked = HashSet::new(); + revoked.insert(BigUnsigned::from_biguint(&child.tbs.serial_number).bytes_be); + let err = validate_subordinate_ca_cert_with_prevalidated_issuer( + &generated.child_ca_der, + child, + &issuer, + &issuer_spki, + &issuer_crl, + &revoked, + Some("rsync://example.test/repo/issuer/issuer.cer"), + "rsync://example.test/repo/issuer/issuer.crl", + issuer.tbs.extensions.ip_resources.as_ref(), + issuer.tbs.extensions.as_resources.as_ref(), + now, + ) + .unwrap_err(); + assert!(matches!(err, CaPathError::ChildRevoked), "{err}"); +} + +#[test] +fn validate_subordinate_ca_with_prevalidated_issuer_rejects_non_ca_inputs_and_invalid_times() { + use rpki::data_model::crl::RpkixCrl; + use rpki::validation::ca_path::validate_subordinate_ca_cert_with_prevalidated_issuer; + use std::collections::HashSet; + use x509_parser::prelude::FromDer; + use x509_parser::x509::SubjectPublicKeyInfo; + + let generated = generate_chain_and_crl( + "keyUsage = critical, keyCertSign, cRLSign +sbgp-ipAddrBlock = critical, IPv4:10.0.0.0/16 +sbgp-autonomousSysNum = critical, AS:64496 +", + false, + ); + let issuer = ResourceCertificate::decode_der(&generated.issuer_ca_der).expect("decode issuer"); + let child = ResourceCertificate::decode_der(&generated.child_ca_der).expect("decode child"); + let issuer_crl = RpkixCrl::decode_der(&generated.issuer_crl_der).expect("decode crl"); + let (rem, issuer_spki) = SubjectPublicKeyInfo::from_der(&issuer.tbs.subject_public_key_info) + .expect("parse issuer spki"); + assert!(rem.is_empty()); + + let roa_der = + std::fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa") + .expect("read roa fixture"); + let roa = rpki::data_model::roa::RoaObject::decode_der(&roa_der).expect("decode roa"); + let ee_rc = + ResourceCertificate::decode_der(&roa.signed_object.signed_data.certificates[0].raw_der) + .expect("decode ee rc"); + let (rem, ee_spki) = + SubjectPublicKeyInfo::from_der(&ee_rc.tbs.subject_public_key_info).expect("parse ee spki"); + assert!(rem.is_empty()); + + let err = validate_subordinate_ca_cert_with_prevalidated_issuer( + &generated.child_ca_der, + child.clone(), + &ee_rc, + &ee_spki, + &issuer_crl, + &HashSet::new(), + Some("rsync://example.test/repo/issuer/issuer.cer"), + "rsync://example.test/repo/issuer/issuer.crl", + issuer.tbs.extensions.ip_resources.as_ref(), + issuer.tbs.extensions.as_resources.as_ref(), + time::OffsetDateTime::now_utc(), + ) + .unwrap_err(); + assert!(matches!(err, CaPathError::IssuerNotCa), "{err}"); + + let err = validate_subordinate_ca_cert_with_prevalidated_issuer( + &generated.child_ca_der, + ee_rc, + &issuer, + &issuer_spki, + &issuer_crl, + &HashSet::new(), + Some("rsync://example.test/repo/issuer/issuer.cer"), + "rsync://example.test/repo/issuer/issuer.crl", + issuer.tbs.extensions.ip_resources.as_ref(), + issuer.tbs.extensions.as_resources.as_ref(), + time::OffsetDateTime::now_utc(), + ) + .unwrap_err(); + assert!(matches!(err, CaPathError::ChildNotCa), "{err}"); + + let err = validate_subordinate_ca_cert_with_prevalidated_issuer( + &generated.child_ca_der, + child.clone(), + &issuer, + &issuer_spki, + &issuer_crl, + &HashSet::new(), + Some("rsync://example.test/repo/issuer/issuer.cer"), + "rsync://example.test/repo/issuer/issuer.crl", + issuer.tbs.extensions.ip_resources.as_ref(), + issuer.tbs.extensions.as_resources.as_ref(), + child.tbs.validity_not_before - time::Duration::seconds(1), + ) + .unwrap_err(); + assert!( + matches!(err, CaPathError::CertificateNotValidAtTime), + "{err}" + ); + + let err = validate_subordinate_ca_cert_with_prevalidated_issuer( + &generated.child_ca_der, + child, + &issuer, + &issuer_spki, + &issuer_crl, + &HashSet::new(), + Some("rsync://example.test/repo/issuer/issuer.cer"), + "rsync://example.test/repo/issuer/issuer.crl", + issuer.tbs.extensions.ip_resources.as_ref(), + issuer.tbs.extensions.as_resources.as_ref(), + issuer_crl.next_update.utc + time::Duration::seconds(1), + ) + .unwrap_err(); + assert!(matches!(err, CaPathError::CrlNotValidAtTime), "{err}"); +} + +#[test] +fn validate_subordinate_ca_with_prevalidated_issuer_rejects_mismatch_and_missing_resources() { + use rpki::data_model::crl::RpkixCrl; + use rpki::validation::ca_path::validate_subordinate_ca_cert_with_prevalidated_issuer; + use std::collections::HashSet; + use x509_parser::prelude::FromDer; + use x509_parser::x509::SubjectPublicKeyInfo; + + let generated = generate_chain_and_crl( + "keyUsage = critical, keyCertSign, cRLSign +sbgp-ipAddrBlock = critical, IPv4:10.0.0.0/16 +sbgp-autonomousSysNum = critical, AS:64496 +", + false, + ); + let issuer = ResourceCertificate::decode_der(&generated.issuer_ca_der).expect("decode issuer"); + let child = ResourceCertificate::decode_der(&generated.child_ca_der).expect("decode child"); + let issuer_crl = RpkixCrl::decode_der(&generated.issuer_crl_der).expect("decode crl"); + let (rem, issuer_spki) = SubjectPublicKeyInfo::from_der(&issuer.tbs.subject_public_key_info) + .expect("parse issuer spki"); + assert!(rem.is_empty()); + + let mut mismatched_child = child.clone(); + mismatched_child.tbs.issuer_name = mismatched_child.tbs.subject_name.clone(); + + let err = validate_subordinate_ca_cert_with_prevalidated_issuer( + &generated.child_ca_der, + mismatched_child, + &issuer, + &issuer_spki, + &issuer_crl, + &HashSet::new(), + Some("rsync://example.test/repo/issuer/issuer.cer"), + "rsync://example.test/repo/issuer/issuer.crl", + issuer.tbs.extensions.ip_resources.as_ref(), + issuer.tbs.extensions.as_resources.as_ref(), + time::OffsetDateTime::now_utc(), + ) + .unwrap_err(); + assert!( + matches!(err, CaPathError::IssuerSubjectMismatch { .. }), + "{err}" + ); + + let no_resources = generate_chain_and_crl( + "keyUsage = critical, keyCertSign, cRLSign +", + false, + ); + let no_resources_child = ResourceCertificate::decode_der(&no_resources.child_ca_der) + .expect("decode no resources child"); + let no_resources_issuer = ResourceCertificate::decode_der(&no_resources.issuer_ca_der) + .expect("decode no resources issuer"); + let no_resources_crl = + RpkixCrl::decode_der(&no_resources.issuer_crl_der).expect("decode no resources crl"); + let (rem, no_resources_spki) = + SubjectPublicKeyInfo::from_der(&no_resources_issuer.tbs.subject_public_key_info) + .expect("parse no resources issuer spki"); + assert!(rem.is_empty()); + let err = validate_subordinate_ca_cert_with_prevalidated_issuer( + &no_resources.child_ca_der, + no_resources_child, + &no_resources_issuer, + &no_resources_spki, + &no_resources_crl, + &HashSet::new(), + Some("rsync://example.test/repo/issuer/issuer.cer"), + "rsync://example.test/repo/issuer/issuer.crl", + no_resources_issuer.tbs.extensions.ip_resources.as_ref(), + no_resources_issuer.tbs.extensions.as_resources.as_ref(), + time::OffsetDateTime::now_utc(), + ) + .unwrap_err(); + assert!(matches!(err, CaPathError::ResourcesMissing), "{err}"); +} diff --git a/tests/test_cert_path_key_usage.rs b/tests/test_cert_path_key_usage.rs index 69deee0..da643e6 100644 --- a/tests/test_cert_path_key_usage.rs +++ b/tests/test_cert_path_key_usage.rs @@ -244,3 +244,250 @@ fn ee_key_usage_wrong_bits_is_rejected() { .unwrap_err(); assert!(matches!(err, CertPathError::KeyUsageInvalidBits), "{err}"); } + +#[test] +fn validate_ee_cert_path_with_prevalidated_issuer_covers_success_and_error_paths() { + use rpki::data_model::common::BigUnsigned; + use rpki::data_model::crl::RpkixCrl; + use rpki::data_model::rc::ResourceCertificate; + use rpki::validation::cert_path::validate_ee_cert_path_with_prevalidated_issuer; + use std::collections::HashSet; + use x509_parser::prelude::FromDer; + use x509_parser::x509::SubjectPublicKeyInfo; + + let g = generate_issuer_ca_ee_and_crl( + "keyUsage = critical, digitalSignature +", + ); + let issuer = ResourceCertificate::decode_der(&g.issuer_ca_der).expect("decode issuer"); + let ee = ResourceCertificate::decode_der(&g.ee_der).expect("decode ee"); + let issuer_crl = RpkixCrl::decode_der(&g.issuer_crl_der).expect("decode crl"); + let (rem, issuer_spki) = SubjectPublicKeyInfo::from_der(&issuer.tbs.subject_public_key_info) + .expect("parse issuer spki"); + assert!(rem.is_empty()); + let now = time::OffsetDateTime::now_utc(); + + validate_ee_cert_path_with_prevalidated_issuer( + &g.ee_der, + &issuer, + &issuer_spki, + &issuer_crl, + &HashSet::new(), + Some("rsync://example.test/repo/issuer/issuer.cer"), + Some("rsync://example.test/repo/issuer/issuer.crl"), + now, + ) + .expect("prevalidated ee path ok"); + + let mut revoked = HashSet::new(); + revoked.insert(BigUnsigned::from_biguint(&ee.tbs.serial_number).bytes_be); + let err = validate_ee_cert_path_with_prevalidated_issuer( + &g.ee_der, + &issuer, + &issuer_spki, + &issuer_crl, + &revoked, + Some("rsync://example.test/repo/issuer/issuer.cer"), + Some("rsync://example.test/repo/issuer/issuer.crl"), + now, + ) + .unwrap_err(); + assert!(matches!(err, CertPathError::EeRevoked), "{err}"); +} + +#[test] +fn validate_ee_cert_path_with_prevalidated_issuer_rejects_non_ee_and_non_ca_issuer() { + use rpki::data_model::crl::RpkixCrl; + use rpki::data_model::rc::ResourceCertificate; + use rpki::validation::cert_path::validate_ee_cert_path_with_prevalidated_issuer; + use std::collections::HashSet; + use x509_parser::prelude::FromDer; + use x509_parser::x509::SubjectPublicKeyInfo; + + let g = generate_issuer_ca_ee_and_crl( + "keyUsage = critical, digitalSignature +", + ); + let issuer = ResourceCertificate::decode_der(&g.issuer_ca_der).expect("decode issuer"); + let ee = ResourceCertificate::decode_der(&g.ee_der).expect("decode ee"); + let issuer_crl = RpkixCrl::decode_der(&g.issuer_crl_der).expect("decode crl"); + let (rem, issuer_spki) = SubjectPublicKeyInfo::from_der(&issuer.tbs.subject_public_key_info) + .expect("parse issuer spki"); + assert!(rem.is_empty()); + let now = time::OffsetDateTime::now_utc(); + + let err = validate_ee_cert_path_with_prevalidated_issuer( + &g.issuer_ca_der, + &issuer, + &issuer_spki, + &issuer_crl, + &HashSet::new(), + None, + None, + now, + ) + .unwrap_err(); + assert!(matches!(err, CertPathError::EeNotEe), "{err}"); + + let (rem, bad_spki) = + SubjectPublicKeyInfo::from_der(&ee.tbs.subject_public_key_info).expect("parse ee spki"); + assert!(rem.is_empty()); + let err = validate_ee_cert_path_with_prevalidated_issuer( + &g.ee_der, + &ee, + &bad_spki, + &issuer_crl, + &HashSet::new(), + None, + None, + now, + ) + .unwrap_err(); + assert!(matches!(err, CertPathError::IssuerNotCa), "{err}"); +} + +#[test] +fn validate_ee_cert_path_with_prevalidated_issuer_rejects_mismatched_issuer_subject() { + use rpki::data_model::crl::RpkixCrl; + use rpki::data_model::rc::ResourceCertificate; + use rpki::validation::cert_path::validate_ee_cert_path_with_prevalidated_issuer; + use std::collections::HashSet; + use x509_parser::prelude::FromDer; + use x509_parser::x509::SubjectPublicKeyInfo; + + let g = generate_issuer_ca_ee_and_crl( + "keyUsage = critical, digitalSignature +", + ); + let wrong_issuer_der = std::fs::read( + "tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/R-lVU1XGsAeqzV1Fv0HjOD6ZFkE.cer", + ) + .expect("read wrong issuer fixture"); + let wrong_issuer = + ResourceCertificate::decode_der(&wrong_issuer_der).expect("decode wrong issuer"); + let issuer_crl = RpkixCrl::decode_der(&g.issuer_crl_der).expect("decode crl"); + let (rem, wrong_spki) = + SubjectPublicKeyInfo::from_der(&wrong_issuer.tbs.subject_public_key_info) + .expect("parse wrong issuer spki"); + assert!(rem.is_empty()); + + let err = validate_ee_cert_path_with_prevalidated_issuer( + &g.ee_der, + &wrong_issuer, + &wrong_spki, + &issuer_crl, + &HashSet::new(), + None, + None, + time::OffsetDateTime::now_utc(), + ) + .unwrap_err(); + assert!( + matches!(err, CertPathError::IssuerSubjectMismatch { .. }), + "{err}" + ); +} + +#[test] +fn validate_ee_cert_path_rejects_non_ee_and_non_ca_issuer() { + use rpki::data_model::roa::RoaObject; + + let g = generate_issuer_ca_ee_and_crl( + "keyUsage = critical, digitalSignature +", + ); + let roa_der = + std::fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa") + .expect("read roa fixture"); + let roa = RoaObject::decode_der(&roa_der).expect("decode roa"); + let ee_from_roa = roa.signed_object.signed_data.certificates[0] + .raw_der + .clone(); + let now = time::OffsetDateTime::now_utc(); + + let err = validate_ee_cert_path( + &g.issuer_ca_der, + &g.issuer_ca_der, + &g.issuer_crl_der, + None, + None, + now, + ) + .unwrap_err(); + assert!(matches!(err, CertPathError::EeNotEe), "{err}"); + + let err = validate_ee_cert_path(&g.ee_der, &ee_from_roa, &g.issuer_crl_der, None, None, now) + .unwrap_err(); + assert!(matches!(err, CertPathError::IssuerNotCa), "{err}"); +} + +#[test] +fn validate_ee_cert_path_rejects_stale_crl() { + use rpki::data_model::crl::RpkixCrl; + + let g = generate_issuer_ca_ee_and_crl( + "keyUsage = critical, digitalSignature +", + ); + let crl = RpkixCrl::decode_der(&g.issuer_crl_der).expect("decode crl"); + let err = validate_ee_cert_path( + &g.ee_der, + &g.issuer_ca_der, + &g.issuer_crl_der, + None, + None, + crl.next_update.utc + time::Duration::seconds(1), + ) + .unwrap_err(); + assert!(matches!(err, CertPathError::CrlNotValidAtTime), "{err}"); +} + +#[test] +fn validate_ee_cert_path_with_prevalidated_issuer_rejects_invalid_times() { + use rpki::data_model::crl::RpkixCrl; + use rpki::data_model::rc::ResourceCertificate; + use rpki::validation::cert_path::validate_ee_cert_path_with_prevalidated_issuer; + use std::collections::HashSet; + use x509_parser::prelude::FromDer; + use x509_parser::x509::SubjectPublicKeyInfo; + + let g = generate_issuer_ca_ee_and_crl( + "keyUsage = critical, digitalSignature +", + ); + let issuer = ResourceCertificate::decode_der(&g.issuer_ca_der).expect("decode issuer"); + let ee = ResourceCertificate::decode_der(&g.ee_der).expect("decode ee"); + let issuer_crl = RpkixCrl::decode_der(&g.issuer_crl_der).expect("decode crl"); + let (rem, issuer_spki) = SubjectPublicKeyInfo::from_der(&issuer.tbs.subject_public_key_info) + .expect("parse issuer spki"); + assert!(rem.is_empty()); + + let err = validate_ee_cert_path_with_prevalidated_issuer( + &g.ee_der, + &issuer, + &issuer_spki, + &issuer_crl, + &HashSet::new(), + Some("rsync://example.test/repo/issuer/issuer.cer"), + Some("rsync://example.test/repo/issuer/issuer.crl"), + ee.tbs.validity_not_before - time::Duration::seconds(1), + ) + .unwrap_err(); + assert!( + matches!(err, CertPathError::CertificateNotValidAtTime), + "{err}" + ); + + let err = validate_ee_cert_path_with_prevalidated_issuer( + &g.ee_der, + &issuer, + &issuer_spki, + &issuer_crl, + &HashSet::new(), + Some("rsync://example.test/repo/issuer/issuer.cer"), + Some("rsync://example.test/repo/issuer/issuer.crl"), + issuer_crl.next_update.utc + time::Duration::seconds(1), + ) + .unwrap_err(); + assert!(matches!(err, CertPathError::CrlNotValidAtTime), "{err}"); +} diff --git a/tests/test_deterministic_semantics_m4.rs b/tests/test_deterministic_semantics_m4.rs index c6f7f1b..0bda41e 100644 --- a/tests/test_deterministic_semantics_m4.rs +++ b/tests/test_deterministic_semantics_m4.rs @@ -1,8 +1,9 @@ use rpki::audit::PublicationPointAudit; use rpki::policy::{Policy, SignedObjectFailurePolicy}; -use rpki::storage::{FetchCachePpPack, PackFile, PackTime}; +use rpki::storage::{PackFile, PackTime}; use rpki::validation::manifest::PublicationPointSource; -use rpki::validation::objects::process_fetch_cache_pp_pack_for_issuer; +use rpki::validation::objects::process_publication_point_snapshot_for_issuer; +use rpki::validation::publication_point::PublicationPointSnapshot; use rpki::validation::tree::{ CaInstanceHandle, PublicationPointRunResult, PublicationPointRunner, TreeRunConfig, run_tree_serial_audit, @@ -13,12 +14,12 @@ fn fixture_bytes(path: &str) -> Vec { .unwrap_or_else(|e| panic!("read fixture {path}: {e}")) } -fn dummy_pack(files: Vec) -> FetchCachePpPack { +fn dummy_snapshot(files: Vec) -> PublicationPointSnapshot { let now = time::OffsetDateTime::now_utc(); let manifest_rsync_uri = "rsync://rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft"; - FetchCachePpPack { - format_version: FetchCachePpPack::FORMAT_VERSION_V1, + PublicationPointSnapshot { + format_version: PublicationPointSnapshot::FORMAT_VERSION_V1, manifest_rsync_uri: manifest_rsync_uri.to_string(), publication_point_rsync_uri: "rsync://rpki.cernet.net/repo/cernet/0/".to_string(), manifest_number_be: vec![1], @@ -34,7 +35,7 @@ fn dummy_pack(files: Vec) -> FetchCachePpPack { struct SinglePackRunner { policy: Policy, - pack: FetchCachePpPack, + snapshot: PublicationPointSnapshot, } impl PublicationPointRunner for SinglePackRunner { @@ -42,8 +43,8 @@ impl PublicationPointRunner for SinglePackRunner { &self, ca: &CaInstanceHandle, ) -> Result { - let objects = process_fetch_cache_pp_pack_for_issuer( - &self.pack, + let objects = process_publication_point_snapshot_for_issuer( + &self.snapshot, &self.policy, &ca.ca_certificate_der, ca.ca_certificate_rsync_uri.as_deref(), @@ -55,7 +56,7 @@ impl PublicationPointRunner for SinglePackRunner { Ok(PublicationPointRunResult { source: PublicationPointSource::Fresh, - pack: self.pack.clone(), + snapshot: Some(self.snapshot.clone()), warnings: Vec::new(), objects, audit: PublicationPointAudit::default(), @@ -70,7 +71,7 @@ fn crl_mismatch_drops_publication_point_and_cites_rfc_sections() { fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa"); // Include at least one CRL file but with a URI that does NOT match the EE certificate's CRLDP. - let pack = dummy_pack(vec![ + let pack = dummy_snapshot(vec![ PackFile::from_bytes_compute_sha256("rsync://example.test/repo/not-it.crl", vec![0x01]), PackFile::from_bytes_compute_sha256("rsync://example.test/repo/a.roa", roa_bytes), ]); @@ -78,10 +79,15 @@ fn crl_mismatch_drops_publication_point_and_cites_rfc_sections() { let mut policy = Policy::default(); policy.signed_object_failure_policy = SignedObjectFailurePolicy::DropPublicationPoint; - let runner = SinglePackRunner { policy, pack }; + let runner = SinglePackRunner { + policy, + snapshot: pack, + }; let root = CaInstanceHandle { depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, // Use a real, parseable CA certificate DER so objects processing can reach CRL selection. // The test only asserts CRLDP/locked-pack error handling, not signature chaining. ca_certificate_der: fixture_bytes("tests/fixtures/ta/apnic-ta.cer"), @@ -102,7 +108,6 @@ fn crl_mismatch_drops_publication_point_and_cites_rfc_sections() { &TreeRunConfig { max_depth: Some(0), max_instances: Some(1), - revalidate_only: false, }, ) .expect("run tree audit"); diff --git a/tests/test_fetch_cache_pp_revalidation_m3.rs b/tests/test_fetch_cache_pp_revalidation_m3.rs deleted file mode 100644 index cdd49f8..0000000 --- a/tests/test_fetch_cache_pp_revalidation_m3.rs +++ /dev/null @@ -1,280 +0,0 @@ -use std::path::Path; - -use rpki::data_model::manifest::ManifestObject; -use rpki::policy::{CaFailedFetchPolicy, Policy}; -use rpki::storage::{FetchCachePpKey, FetchCachePpPack, RocksStore}; -use rpki::validation::manifest::{ - PublicationPointSource, process_manifest_publication_point, - process_manifest_publication_point_after_repo_sync, -}; - -fn issuer_ca_fixture() -> Vec { - std::fs::read( - "tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer", - ) - .expect("read issuer ca fixture") -} - -fn issuer_ca_rsync_uri() -> &'static str { - "rsync://rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer" -} - -fn fixture_to_rsync_uri(path: &Path) -> String { - let rel = path - .strip_prefix("tests/fixtures/repository") - .expect("path under tests/fixtures/repository"); - let mut it = rel.components(); - let host = it - .next() - .expect("host component") - .as_os_str() - .to_string_lossy(); - let rest = it.as_path().to_string_lossy(); - format!("rsync://{host}/{rest}") -} - -fn fixture_dir_to_rsync_uri(dir: &Path) -> String { - let mut s = fixture_to_rsync_uri(dir); - if !s.ends_with('/') { - s.push('/'); - } - s -} - -fn load_cernet_manifest_fixture() -> (std::path::PathBuf, Vec, ManifestObject) { - let manifest_path = Path::new( - "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", - ) - .to_path_buf(); - let bytes = std::fs::read(&manifest_path).expect("read manifest fixture"); - let obj = ManifestObject::decode_der(&bytes).expect("decode manifest fixture"); - (manifest_path, bytes, obj) -} - -fn store_raw_publication_point_files( - store: &RocksStore, - manifest_path: &Path, - manifest_rsync_uri: &str, - manifest_bytes: &[u8], - manifest: &ManifestObject, - publication_point_rsync_uri: &str, -) { - store - .put_raw(manifest_rsync_uri, manifest_bytes) - .expect("store manifest raw"); - let entries = manifest - .manifest - .parse_files() - .expect("parse validated manifest fileList"); - for entry in &entries { - let file_path = manifest_path - .parent() - .unwrap() - .join(entry.file_name.as_str()); - let bytes = std::fs::read(&file_path) - .unwrap_or_else(|_| panic!("read fixture file referenced by manifest: {file_path:?}")); - let rsync_uri = format!("{publication_point_rsync_uri}{}", entry.file_name); - store.put_raw(&rsync_uri, &bytes).expect("store file raw"); - } -} - -#[test] -fn cached_pack_revalidation_rejects_missing_file_referenced_by_manifest() { - let (manifest_path, manifest_bytes, manifest) = load_cernet_manifest_fixture(); - let validation_time = manifest.manifest.this_update + time::Duration::seconds(1); - - let manifest_rsync_uri = fixture_to_rsync_uri(&manifest_path); - let publication_point_rsync_uri = fixture_dir_to_rsync_uri(manifest_path.parent().unwrap()); - - let temp = tempfile::tempdir().expect("tempdir"); - let store = RocksStore::open(temp.path()).expect("open rocksdb"); - store_raw_publication_point_files( - &store, - &manifest_path, - &manifest_rsync_uri, - &manifest_bytes, - &manifest, - &publication_point_rsync_uri, - ); - - let mut policy = Policy::default(); - policy.ca_failed_fetch_policy = CaFailedFetchPolicy::UseFetchCachePp; - let issuer_ca_der = issuer_ca_fixture(); - - let _fresh = process_manifest_publication_point( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - ) - .expect("fresh run stores fetch_cache_pp"); - - let key = FetchCachePpKey::from_manifest_rsync_uri(&manifest_rsync_uri); - let cached_bytes = store - .get_fetch_cache_pp(&key) - .expect("get fetch_cache_pp") - .expect("fetch_cache_pp exists"); - let mut pack = FetchCachePpPack::decode(&cached_bytes).expect("decode pack"); - - // Remove one file from the pack: pack stays internally consistent, but no longer satisfies - // RFC 9286 §6.4 when revalidated against the manifest fileList. - pack.files.pop().expect("non-empty pack"); - let bytes = pack.encode().expect("encode pack"); - store - .put_fetch_cache_pp(&key, &bytes) - .expect("overwrite fetch_cache_pp"); - - // Force cache path: remove raw manifest so fresh processing fails at §6.2. - store - .delete_raw(&manifest_rsync_uri) - .expect("delete raw manifest"); - - let err = process_manifest_publication_point( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - ) - .expect_err("cache pack missing file must be rejected"); - let msg = err.to_string(); - assert!(msg.contains("cached fetch_cache_pp missing file"), "{msg}"); - assert!(msg.contains("RFC 9286 §6.4"), "{msg}"); -} - -#[test] -fn cached_pack_revalidation_rejects_hash_mismatch_against_manifest_filelist() { - let (manifest_path, manifest_bytes, manifest) = load_cernet_manifest_fixture(); - let validation_time = manifest.manifest.this_update + time::Duration::seconds(1); - - let manifest_rsync_uri = fixture_to_rsync_uri(&manifest_path); - let publication_point_rsync_uri = fixture_dir_to_rsync_uri(manifest_path.parent().unwrap()); - - let temp = tempfile::tempdir().expect("tempdir"); - let store = RocksStore::open(temp.path()).expect("open rocksdb"); - store_raw_publication_point_files( - &store, - &manifest_path, - &manifest_rsync_uri, - &manifest_bytes, - &manifest, - &publication_point_rsync_uri, - ); - - let mut policy = Policy::default(); - policy.ca_failed_fetch_policy = CaFailedFetchPolicy::UseFetchCachePp; - let issuer_ca_der = issuer_ca_fixture(); - - let _fresh = process_manifest_publication_point( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - ) - .expect("fresh run stores fetch_cache_pp"); - - let key = FetchCachePpKey::from_manifest_rsync_uri(&manifest_rsync_uri); - let cached_bytes = store - .get_fetch_cache_pp(&key) - .expect("get fetch_cache_pp") - .expect("fetch_cache_pp exists"); - let mut pack = FetchCachePpPack::decode(&cached_bytes).expect("decode pack"); - - // Mutate one file but keep pack internally consistent by recomputing its sha256 field. - let victim = pack.files.first_mut().expect("non-empty pack"); - victim.bytes[0] ^= 0xFF; - victim.sha256 = victim.compute_sha256(); - let bytes = pack.encode().expect("encode pack"); - store - .put_fetch_cache_pp(&key, &bytes) - .expect("overwrite fetch_cache_pp"); - - // Force cache path. - store - .delete_raw(&manifest_rsync_uri) - .expect("delete raw manifest"); - - let err = process_manifest_publication_point( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - ) - .expect_err("cache pack hash mismatch must be rejected"); - let msg = err.to_string(); - assert!( - msg.contains("cached fetch_cache_pp file hash mismatch"), - "{msg}" - ); - assert!(msg.contains("RFC 9286 §6.5"), "{msg}"); -} - -#[test] -fn repo_sync_failure_forces_fetch_cache_pp_even_if_raw_objects_are_present() { - let (manifest_path, manifest_bytes, manifest) = load_cernet_manifest_fixture(); - let validation_time = manifest.manifest.this_update + time::Duration::seconds(1); - - let manifest_rsync_uri = fixture_to_rsync_uri(&manifest_path); - let publication_point_rsync_uri = fixture_dir_to_rsync_uri(manifest_path.parent().unwrap()); - - let temp = tempfile::tempdir().expect("tempdir"); - let store = RocksStore::open(temp.path()).expect("open rocksdb"); - store_raw_publication_point_files( - &store, - &manifest_path, - &manifest_rsync_uri, - &manifest_bytes, - &manifest, - &publication_point_rsync_uri, - ); - - let mut policy = Policy::default(); - policy.ca_failed_fetch_policy = CaFailedFetchPolicy::UseFetchCachePp; - let issuer_ca_der = issuer_ca_fixture(); - - // First run: fresh processing stores fetch_cache_pp. - let _fresh = process_manifest_publication_point( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - ) - .expect("fresh run stores fetch_cache_pp"); - - // Second run: simulate repo sync failure. Even though raw_objects still contain everything - // needed for a fresh pack, failed fetch semantics require using cached objects only. - let res = process_manifest_publication_point_after_repo_sync( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - false, - Some("synthetic repo sync failure"), - ) - .expect("must fall back to fetch_cache_pp"); - - assert_eq!(res.source, PublicationPointSource::FetchCachePp); - assert!( - res.warnings - .iter() - .any(|w| w.message.contains("using fetch_cache_pp")), - "expected fetch_cache_pp warning" - ); -} diff --git a/tests/test_from_tal_offline.rs b/tests/test_from_tal_offline.rs index 439de99..1cdbba4 100644 --- a/tests/test_from_tal_offline.rs +++ b/tests/test_from_tal_offline.rs @@ -10,6 +10,7 @@ use rpki::validation::from_tal::{ discover_root_ca_instance_from_tal_and_ta_der, discover_root_ca_instance_from_tal_url, run_root_from_tal_url_once, }; +use rpki::validation::manifest::PublicationPointSource; use url::Url; struct MapFetcher { @@ -42,6 +43,26 @@ impl RsyncFetcher for EmptyRsync { } } +fn openssl_available() -> bool { + std::process::Command::new("openssl") + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn run(cmd: &mut std::process::Command) { + let out = cmd.output().expect("run command"); + if !out.status.success() { + panic!( + "command failed: {:?}\nstdout={}\nstderr={}", + cmd, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + } +} + fn apnic_tal_bytes() -> Vec { std::fs::read("tests/fixtures/tal/apnic-rfc7730-https.tal").expect("read apnic TAL fixture") } @@ -145,7 +166,7 @@ fn discover_root_from_tal_url_errors_when_tal_fetch_fails() { } #[test] -fn run_root_from_tal_url_once_propagates_run_error_when_repo_is_empty() { +fn run_root_from_tal_url_once_returns_failed_fetch_no_cache_when_repo_is_empty() { let tal_bytes = apnic_tal_bytes(); let tal = Tal::decode_bytes(&tal_bytes).expect("decode TAL"); let ta_uri = tal.ta_uris[0].as_str().to_string(); @@ -161,7 +182,7 @@ fn run_root_from_tal_url_once_propagates_run_error_when_repo_is_empty() { let mut policy = Policy::default(); policy.sync_preference = SyncPreference::RsyncOnly; - let err = run_root_from_tal_url_once( + let out = run_root_from_tal_url_once( &store, &policy, "https://example.test/apnic.tal", @@ -169,7 +190,105 @@ fn run_root_from_tal_url_once_propagates_run_error_when_repo_is_empty() { &EmptyRsync, time::OffsetDateTime::now_utc(), ) - .unwrap_err(); + .expect("run should return failed-fetch-no-cache output"); - assert!(matches!(err, FromTalError::Run(_))); + assert_eq!( + out.run.publication_point_source, + PublicationPointSource::FailedFetchNoCache + ); + assert!(out.run.objects.vrps.is_empty()); + assert!(out.run.objects.aspas.is_empty()); + assert!( + out.run + .publication_point_warnings + .iter() + .any(|warning| warning.message.contains("no latest validated result")) + ); +} + +#[test] +fn discover_root_records_ca_instance_discovery_failure_when_ta_lacks_sia() { + use base64::Engine; + + assert!(openssl_available(), "openssl is required for this test"); + + let temp = tempfile::tempdir().expect("tempdir"); + let dir = temp.path(); + std::fs::write( + dir.join("openssl.cnf"), + r#" +[ req ] +prompt = no +distinguished_name = dn +x509_extensions = v3_ta + +[ dn ] +CN = Test TA Without SIA + +[ v3_ta ] +basicConstraints = critical,CA:true +keyUsage = critical, keyCertSign, cRLSign +subjectKeyIdentifier = hash +certificatePolicies = critical, 1.3.6.1.5.5.7.14.2 +sbgp-ipAddrBlock = critical, IPv4:10.0.0.0/8 +sbgp-autonomousSysNum = critical, AS:64496-64511 +"#, + ) + .expect("write openssl cnf"); + + run(std::process::Command::new("openssl") + .arg("genrsa") + .arg("-out") + .arg(dir.join("ta.key")) + .arg("2048")); + run(std::process::Command::new("openssl") + .arg("req") + .arg("-new") + .arg("-x509") + .arg("-sha256") + .arg("-days") + .arg("365") + .arg("-key") + .arg(dir.join("ta.key")) + .arg("-config") + .arg(dir.join("openssl.cnf")) + .arg("-extensions") + .arg("v3_ta") + .arg("-out") + .arg(dir.join("ta.pem"))); + run(std::process::Command::new("openssl") + .arg("x509") + .arg("-in") + .arg(dir.join("ta.pem")) + .arg("-outform") + .arg("DER") + .arg("-out") + .arg(dir.join("ta.cer"))); + run(std::process::Command::new("sh").arg("-c").arg(format!( + "openssl x509 -in {} -pubkey -noout | openssl pkey -pubin -outform DER > {}", + dir.join("ta.pem").display(), + dir.join("spki.der").display(), + ))); + + let ta_uri = "https://example.test/no-sia-ta.cer"; + let tal_text = format!( + "{ta_uri}\n\n{}\n", + base64::engine::general_purpose::STANDARD + .encode(std::fs::read(dir.join("spki.der")).expect("read spki der")) + ); + let tal = Tal::decode_bytes(tal_text.as_bytes()).expect("decode generated tal"); + + let mut map = HashMap::new(); + map.insert( + ta_uri.to_string(), + std::fs::read(dir.join("ta.cer")).expect("read ta der"), + ); + let fetcher = MapFetcher::new(map); + + let err = discover_root_ca_instance_from_tal(&fetcher, tal, None).unwrap_err(); + assert!(matches!(err, FromTalError::TaFetch(_)), "{err}"); + assert!( + err.to_string().contains("CA instance discovery failed"), + "{err}" + ); } diff --git a/tests/test_manifest_cache_errors_more.rs b/tests/test_manifest_cache_errors_more.rs index 43486a5..ef0caef 100644 --- a/tests/test_manifest_cache_errors_more.rs +++ b/tests/test_manifest_cache_errors_more.rs @@ -1,8 +1,5 @@ -use std::path::Path; - -use rpki::data_model::manifest::ManifestObject; use rpki::policy::{CaFailedFetchPolicy, Policy}; -use rpki::storage::{FetchCachePpKey, FetchCachePpPack, RocksStore}; +use rpki::storage::RocksStore; use rpki::validation::manifest::process_manifest_publication_point; fn issuer_ca_fixture() -> Vec { @@ -16,35 +13,13 @@ fn issuer_ca_rsync_uri() -> &'static str { "rsync://rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer" } -fn fixture_to_rsync_uri(path: &Path) -> String { - let rel = path - .strip_prefix("tests/fixtures/repository") - .expect("path under tests/fixtures/repository"); - let mut it = rel.components(); - let host = it - .next() - .expect("host component") - .as_os_str() - .to_string_lossy(); - let rest = it.as_path().to_string_lossy(); - format!("rsync://{host}/{rest}") -} - -fn fixture_dir_to_rsync_uri(dir: &Path) -> String { - let mut s = fixture_to_rsync_uri(dir); - if !s.ends_with('/') { - s.push('/'); - } - s -} - #[test] fn cache_is_not_used_when_missing_and_fresh_manifest_is_missing() { let temp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(temp.path()).expect("open rocksdb"); let mut policy = Policy::default(); - policy.ca_failed_fetch_policy = CaFailedFetchPolicy::UseFetchCachePp; + policy.ca_failed_fetch_policy = CaFailedFetchPolicy::ReuseCurrentInstanceVcir; let issuer_ca_der = issuer_ca_fixture(); let err = process_manifest_publication_point( @@ -56,89 +31,15 @@ fn cache_is_not_used_when_missing_and_fresh_manifest_is_missing() { Some(issuer_ca_rsync_uri()), time::OffsetDateTime::from_unix_timestamp(0).unwrap(), ) - .expect_err("no raw and no fetch_cache_pp should fail"); - - assert!(err.to_string().contains("fetch_cache_pp entry missing")); -} - -#[test] -fn cache_pack_publication_point_mismatch_is_rejected() { - let manifest_path = Path::new( - "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", - ); - let manifest_bytes = std::fs::read(manifest_path).expect("read manifest fixture"); - let manifest = ManifestObject::decode_der(&manifest_bytes).expect("decode manifest fixture"); - - let validation_time = manifest.manifest.this_update + time::Duration::seconds(1); - - let manifest_rsync_uri = fixture_to_rsync_uri(manifest_path); - let publication_point_rsync_uri = fixture_dir_to_rsync_uri(manifest_path.parent().unwrap()); - - let temp = tempfile::tempdir().expect("tempdir"); - let store = RocksStore::open(temp.path()).expect("open rocksdb"); - - store - .put_raw(&manifest_rsync_uri, &manifest_bytes) - .expect("store manifest"); - let entries = manifest - .manifest - .parse_files() - .expect("parse validated manifest fileList"); - for entry in &entries { - let file_path = manifest_path - .parent() - .unwrap() - .join(entry.file_name.as_str()); - let bytes = std::fs::read(&file_path) - .unwrap_or_else(|_| panic!("read fixture file referenced by manifest: {file_path:?}")); - let rsync_uri = format!("{publication_point_rsync_uri}{}", entry.file_name); - store.put_raw(&rsync_uri, &bytes).expect("store file"); - } - - let policy = Policy::default(); - let issuer_ca_der = issuer_ca_fixture(); - let _ = process_manifest_publication_point( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - ) - .expect("first run stores fetch_cache_pp pack"); - - // Corrupt the cached pack by changing the publication point. - let key = FetchCachePpKey::from_manifest_rsync_uri(&manifest_rsync_uri); - let bytes = store - .get_fetch_cache_pp(&key) - .expect("get fetch_cache_pp") - .expect("fetch_cache_pp exists"); - let mut pack = FetchCachePpPack::decode(&bytes).expect("decode pack"); - pack.publication_point_rsync_uri = "rsync://evil.invalid/repo/".to_string(); - let bytes = pack.encode().expect("re-encode pack"); - store - .put_fetch_cache_pp(&key, &bytes) - .expect("overwrite fetch_cache_pp"); - - // Remove raw manifest to force cache path. - store - .delete_raw(&manifest_rsync_uri) - .expect("delete raw manifest"); - - let err = process_manifest_publication_point( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - ) - .expect_err("cache pack mismatch should fail"); + .expect_err("no raw and no current-instance VCIR should fail"); + let msg = err.to_string(); assert!( - err.to_string() - .contains("publication_point_rsync_uri does not match expected") + msg.contains("no reusable current-instance validated result is available"), + "{msg}" + ); + assert!( + msg.contains("latest current-instance VCIR missing"), + "{msg}" ); } diff --git a/tests/test_manifest_processor_m4.rs b/tests/test_manifest_processor_m4.rs index ea759e2..f07fde9 100644 --- a/tests/test_manifest_processor_m4.rs +++ b/tests/test_manifest_processor_m4.rs @@ -1,8 +1,14 @@ use std::path::Path; +use sha2::Digest; + use rpki::data_model::manifest::ManifestObject; use rpki::policy::{CaFailedFetchPolicy, Policy}; -use rpki::storage::{FetchCachePpKey, FetchCachePpPack, RocksStore}; +use rpki::storage::{ + PackTime, RawByHashEntry, RocksStore, ValidatedCaInstanceResult, ValidatedManifestMeta, + VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, VcirAuditSummary, + VcirInstanceGate, VcirRelatedArtifact, VcirSummary, +}; use rpki::validation::manifest::{PublicationPointSource, process_manifest_publication_point}; fn issuer_ca_fixture() -> Vec { @@ -38,8 +44,78 @@ fn fixture_dir_to_rsync_uri(dir: &Path) -> String { s } +fn store_validated_manifest_baseline( + store: &RocksStore, + manifest_rsync_uri: &str, + manifest_bytes: &[u8], + manifest_number_be: Vec, + this_update: time::OffsetDateTime, + next_update: time::OffsetDateTime, +) { + let manifest_sha256 = hex::encode(sha2::Sha256::digest(manifest_bytes)); + let mut manifest_raw = + RawByHashEntry::from_bytes(manifest_sha256.clone(), manifest_bytes.to_vec()); + manifest_raw + .origin_uris + .push(manifest_rsync_uri.to_string()); + manifest_raw.object_type = Some("mft".to_string()); + manifest_raw.encoding = Some("der".to_string()); + store + .put_raw_by_hash_entry(&manifest_raw) + .expect("store VCIR manifest raw_by_hash"); + + let vcir = ValidatedCaInstanceResult { + manifest_rsync_uri: manifest_rsync_uri.to_string(), + parent_manifest_rsync_uri: None, + tal_id: "test-tal".to_string(), + ca_subject_name: "CN=test".to_string(), + ca_ski: "aa".to_string(), + issuer_ski: "aa".to_string(), + last_successful_validation_time: PackTime::from_utc_offset_datetime(this_update), + current_manifest_rsync_uri: manifest_rsync_uri.to_string(), + current_crl_rsync_uri: format!("{manifest_rsync_uri}.crl"), + validated_manifest_meta: ValidatedManifestMeta { + validated_manifest_number: manifest_number_be, + validated_manifest_this_update: PackTime::from_utc_offset_datetime(this_update), + validated_manifest_next_update: PackTime::from_utc_offset_datetime(next_update), + }, + instance_gate: VcirInstanceGate { + manifest_next_update: PackTime::from_utc_offset_datetime(next_update), + current_crl_next_update: PackTime::from_utc_offset_datetime(next_update), + self_ca_not_after: PackTime::from_utc_offset_datetime(next_update), + instance_effective_until: PackTime::from_utc_offset_datetime(next_update), + }, + child_entries: Vec::new(), + local_outputs: Vec::new(), + related_artifacts: vec![VcirRelatedArtifact { + artifact_role: VcirArtifactRole::Manifest, + artifact_kind: VcirArtifactKind::Mft, + uri: Some(manifest_rsync_uri.to_string()), + sha256: manifest_sha256, + object_type: Some("mft".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }], + summary: VcirSummary { + local_vrp_count: 0, + local_aspa_count: 0, + child_count: 0, + accepted_object_count: 1, + rejected_object_count: 0, + }, + audit_summary: VcirAuditSummary { + failed_fetch_eligible: true, + last_failed_fetch_reason: None, + warning_count: 0, + audit_flags: Vec::new(), + }, + }; + store + .put_vcir(&vcir) + .expect("store validated manifest baseline"); +} + #[test] -fn manifest_success_writes_fetch_cache_pp_pack() { +fn manifest_success_returns_validated_publication_point_data() { let manifest_path = Path::new( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -87,21 +163,15 @@ fn manifest_success_writes_fetch_cache_pp_pack() { assert_eq!(out.source, PublicationPointSource::Fresh); assert!(out.warnings.is_empty()); - let key = FetchCachePpKey::from_manifest_rsync_uri(&manifest_rsync_uri); - let stored = store - .get_fetch_cache_pp(&key) - .expect("get fetch_cache_pp") - .expect("fetch_cache_pp pack exists"); - let decoded = FetchCachePpPack::decode(&stored).expect("decode stored pack"); - assert_eq!(decoded.manifest_rsync_uri, manifest_rsync_uri); + assert_eq!(out.snapshot.manifest_rsync_uri, manifest_rsync_uri); assert_eq!( - decoded.publication_point_rsync_uri, + out.snapshot.publication_point_rsync_uri, publication_point_rsync_uri ); } #[test] -fn manifest_hash_mismatch_falls_back_to_fetch_cache_pp_when_enabled() { +fn manifest_hash_mismatch_reuses_current_instance_vcir_when_enabled() { let manifest_path = Path::new( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -144,15 +214,17 @@ fn manifest_hash_mismatch_falls_back_to_fetch_cache_pp_when_enabled() { Some(issuer_ca_rsync_uri()), validation_time, ) - .expect("first run stores fetch_cache_pp pack"); + .expect("first run returns validated publication point"); assert_eq!(first.source, PublicationPointSource::Fresh); - let key = FetchCachePpKey::from_manifest_rsync_uri(&manifest_rsync_uri); - let cached_bytes = store - .get_fetch_cache_pp(&key) - .expect("get fetch_cache_pp") - .expect("fetch_cache_pp pack exists"); - let cached_pack = FetchCachePpPack::decode(&cached_bytes).expect("decode cached"); + store_validated_manifest_baseline( + &store, + &manifest_rsync_uri, + &manifest_bytes, + manifest.manifest.manifest_number.bytes_be.clone(), + manifest.manifest.this_update, + manifest.manifest.next_update, + ); let entries = manifest .manifest @@ -176,10 +248,14 @@ fn manifest_hash_mismatch_falls_back_to_fetch_cache_pp_when_enabled() { Some(issuer_ca_rsync_uri()), validation_time, ) - .expect("second run falls back to fetch_cache_pp"); - assert_eq!(second.source, PublicationPointSource::FetchCachePp); - assert!(!second.warnings.is_empty()); - assert_eq!(second.pack, cached_pack); + .expect("second run reuses current-instance VCIR"); + assert_eq!(second.source, PublicationPointSource::VcirCurrentInstance); + assert!( + second.warnings.iter().any(|w| w + .message + .contains("using latest validated result for current CA instance")), + "expected current-instance VCIR reuse warning" + ); } #[test] @@ -216,7 +292,7 @@ fn manifest_failed_fetch_stop_all_output() { } let mut policy = Policy::default(); - policy.ca_failed_fetch_policy = CaFailedFetchPolicy::UseFetchCachePp; + policy.ca_failed_fetch_policy = CaFailedFetchPolicy::ReuseCurrentInstanceVcir; let issuer_ca_der = issuer_ca_fixture(); let _ = process_manifest_publication_point( &store, @@ -227,7 +303,7 @@ fn manifest_failed_fetch_stop_all_output() { Some(issuer_ca_rsync_uri()), validation_time, ) - .expect("first run stores fetch_cache_pp pack"); + .expect("first run returns validated publication point"); let entries = manifest .manifest @@ -252,13 +328,13 @@ fn manifest_failed_fetch_stop_all_output() { Some(issuer_ca_rsync_uri()), validation_time, ) - .expect_err("stop_all_output should not use fetch_cache_pp"); + .expect_err("stop_all_output should not reuse current-instance VCIR"); let msg = err.to_string(); assert!(msg.contains("cache use is disabled")); } #[test] -fn manifest_fallback_pack_is_revalidated_and_rejected_if_stale() { +fn manifest_failed_fetch_rejects_stale_current_instance_vcir() { let manifest_path = Path::new( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -302,7 +378,16 @@ fn manifest_fallback_pack_is_revalidated_and_rejected_if_stale() { Some(issuer_ca_rsync_uri()), ok_time, ) - .expect("first run stores fetch_cache_pp pack"); + .expect("first run returns validated publication point"); + + store_validated_manifest_baseline( + &store, + &manifest_rsync_uri, + &manifest_bytes, + manifest.manifest.manifest_number.bytes_be.clone(), + manifest.manifest.this_update, + manifest.manifest.next_update, + ); store .delete_raw(&manifest_rsync_uri) @@ -317,9 +402,9 @@ fn manifest_fallback_pack_is_revalidated_and_rejected_if_stale() { Some(issuer_ca_rsync_uri()), stale_time, ) - .expect_err("stale validation_time must reject fetch_cache_pp pack"); + .expect_err("stale validation_time must reject current-instance VCIR reuse"); let msg = err.to_string(); - assert!(msg.contains("not valid at validation_time")); + assert!(msg.contains("instance_gate expired"), "{msg}"); } #[test] @@ -369,9 +454,18 @@ fn manifest_revalidation_with_unchanged_manifest_is_fresh() { Some(issuer_ca_rsync_uri()), t1, ) - .expect("first run builds and stores fetch_cache_pp pack"); + .expect("first run returns validated publication point"); assert_eq!(first.source, PublicationPointSource::Fresh); + store_validated_manifest_baseline( + &store, + &manifest_rsync_uri, + &manifest_bytes, + first.snapshot.manifest_number_be.clone(), + manifest.manifest.this_update, + manifest.manifest.next_update, + ); + let second = process_manifest_publication_point( &store, &policy, @@ -384,16 +478,19 @@ fn manifest_revalidation_with_unchanged_manifest_is_fresh() { .expect("second run should accept revalidation of the same manifest"); assert_eq!(second.source, PublicationPointSource::Fresh); assert!(second.warnings.is_empty()); - assert_eq!(second.pack.manifest_bytes, first.pack.manifest_bytes); assert_eq!( - second.pack.manifest_number_be, - first.pack.manifest_number_be + second.snapshot.manifest_bytes, + first.snapshot.manifest_bytes ); - assert_eq!(second.pack.files, first.pack.files); + assert_eq!( + second.snapshot.manifest_number_be, + first.snapshot.manifest_number_be + ); + assert_eq!(second.snapshot.files, first.snapshot.files); } #[test] -fn manifest_rollback_is_treated_as_failed_fetch_and_uses_fetch_cache_pp() { +fn manifest_rollback_is_treated_as_failed_fetch_and_reuses_current_instance_vcir() { let manifest_path = Path::new( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -439,16 +536,11 @@ fn manifest_rollback_is_treated_as_failed_fetch_and_uses_fetch_cache_pp() { Some(issuer_ca_rsync_uri()), t1, ) - .expect("first run builds and stores fetch_cache_pp pack"); + .expect("first run returns validated publication point"); assert_eq!(first.source, PublicationPointSource::Fresh); // Simulate a previously validated manifest with a higher manifestNumber (rollback detection). - let key = FetchCachePpKey::from_manifest_rsync_uri(&manifest_rsync_uri); - let stored = store - .get_fetch_cache_pp(&key) - .expect("get fetch_cache_pp") - .expect("fetch_cache_pp pack exists"); - let mut bumped = FetchCachePpPack::decode(&stored).expect("decode stored pack"); + let mut bumped = first.snapshot.clone(); // Deterministically bump the cached manifestNumber to be strictly greater than the current one. for i in (0..bumped.manifest_number_be.len()).rev() { let (v, carry) = bumped.manifest_number_be[i].overflowing_add(1); @@ -461,10 +553,14 @@ fn manifest_rollback_is_treated_as_failed_fetch_and_uses_fetch_cache_pp() { break; } } - let bumped_bytes = bumped.encode().expect("encode bumped pack"); - store - .put_fetch_cache_pp(&key, &bumped_bytes) - .expect("store bumped pack"); + store_validated_manifest_baseline( + &store, + &manifest_rsync_uri, + &manifest_bytes, + bumped.manifest_number_be.clone(), + manifest.manifest.this_update, + manifest.manifest.next_update, + ); let second = process_manifest_publication_point( &store, @@ -475,9 +571,8 @@ fn manifest_rollback_is_treated_as_failed_fetch_and_uses_fetch_cache_pp() { Some(issuer_ca_rsync_uri()), t2, ) - .expect("second run should treat rollback as failed fetch and use cache"); - assert_eq!(second.source, PublicationPointSource::FetchCachePp); - assert_eq!(second.pack, bumped); + .expect("second run should treat rollback as failed fetch and reuse current-instance VCIR"); + assert_eq!(second.source, PublicationPointSource::VcirCurrentInstance); assert!( second .warnings diff --git a/tests/test_manifest_processor_more_errors_cov.rs b/tests/test_manifest_processor_more_errors_cov.rs index 760c641..863af97 100644 --- a/tests/test_manifest_processor_more_errors_cov.rs +++ b/tests/test_manifest_processor_more_errors_cov.rs @@ -90,7 +90,7 @@ fn manifest_outside_publication_point_yields_no_usable_cache() { ) .unwrap_err(); - // With no cached pack available for this wrong publication point, we get NoUsableCache. + // With no reusable current-instance snapshot/VCIR for this wrong publication point, we get NoUsableCache. assert!( matches!(err, ManifestProcessError::NoUsableCache { .. }), "{err}" @@ -98,7 +98,7 @@ fn manifest_outside_publication_point_yields_no_usable_cache() { } #[test] -fn manifest_outside_publication_point_detects_cached_pack_pp_mismatch() { +fn manifest_outside_publication_point_detects_current_instance_snapshot_pp_mismatch() { let manifest_path = Path::new( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -133,7 +133,7 @@ fn manifest_outside_publication_point_detects_cached_pack_pp_mismatch() { let policy = Policy::default(); let issuer_ca_der = issuer_ca_fixture_der(); - // First run creates and stores fetch_cache_pp pack (Fresh). + // First run validates a fresh publication point snapshot and stores the latest VCIR. let first = process_manifest_publication_point( &store, &policy, @@ -147,7 +147,7 @@ fn manifest_outside_publication_point_detects_cached_pack_pp_mismatch() { assert_eq!(first.source, PublicationPointSource::Fresh); // Second run with wrong publication point: fresh fails outside PP; cache load also fails - // because the cached pack's publication_point_rsync_uri doesn't match the expected one. + // because the reusable current-instance snapshot carries a different publication_point_rsync_uri. let wrong_pp = "rsync://example.test/not-the-pp/"; let err = process_manifest_publication_point( &store, diff --git a/tests/test_manifest_processor_repo_sync_and_cached_pack_cov.rs b/tests/test_manifest_processor_repo_sync_and_cached_snapshot_cov.rs similarity index 55% rename from tests/test_manifest_processor_repo_sync_and_cached_pack_cov.rs rename to tests/test_manifest_processor_repo_sync_and_cached_snapshot_cov.rs index d752cca..8ec8649 100644 --- a/tests/test_manifest_processor_repo_sync_and_cached_pack_cov.rs +++ b/tests/test_manifest_processor_repo_sync_and_cached_snapshot_cov.rs @@ -1,8 +1,14 @@ use std::path::Path; +use sha2::Digest; + use rpki::data_model::manifest::ManifestObject; use rpki::policy::{CaFailedFetchPolicy, Policy}; -use rpki::storage::{FetchCachePpKey, FetchCachePpPack, RocksStore}; +use rpki::storage::{ + PackTime, RawByHashEntry, RocksStore, ValidatedCaInstanceResult, ValidatedManifestMeta, + VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, VcirAuditSummary, + VcirInstanceGate, VcirRelatedArtifact, VcirSummary, +}; use rpki::validation::manifest::{ ManifestProcessError, PublicationPointSource, process_manifest_publication_point, process_manifest_publication_point_after_repo_sync, @@ -41,6 +47,76 @@ fn fixture_dir_to_rsync_uri(dir: &Path) -> String { s } +fn store_validated_manifest_baseline( + store: &RocksStore, + manifest_rsync_uri: &str, + manifest_bytes: &[u8], + manifest_number_be: Vec, + this_update: time::OffsetDateTime, + next_update: time::OffsetDateTime, +) { + let manifest_sha256 = hex::encode(sha2::Sha256::digest(manifest_bytes)); + let mut manifest_raw = + RawByHashEntry::from_bytes(manifest_sha256.clone(), manifest_bytes.to_vec()); + manifest_raw + .origin_uris + .push(manifest_rsync_uri.to_string()); + manifest_raw.object_type = Some("mft".to_string()); + manifest_raw.encoding = Some("der".to_string()); + store + .put_raw_by_hash_entry(&manifest_raw) + .expect("store VCIR manifest raw_by_hash"); + + let vcir = ValidatedCaInstanceResult { + manifest_rsync_uri: manifest_rsync_uri.to_string(), + parent_manifest_rsync_uri: None, + tal_id: "test-tal".to_string(), + ca_subject_name: "CN=test".to_string(), + ca_ski: "aa".to_string(), + issuer_ski: "aa".to_string(), + last_successful_validation_time: PackTime::from_utc_offset_datetime(this_update), + current_manifest_rsync_uri: manifest_rsync_uri.to_string(), + current_crl_rsync_uri: format!("{manifest_rsync_uri}.crl"), + validated_manifest_meta: ValidatedManifestMeta { + validated_manifest_number: manifest_number_be, + validated_manifest_this_update: PackTime::from_utc_offset_datetime(this_update), + validated_manifest_next_update: PackTime::from_utc_offset_datetime(next_update), + }, + instance_gate: VcirInstanceGate { + manifest_next_update: PackTime::from_utc_offset_datetime(next_update), + current_crl_next_update: PackTime::from_utc_offset_datetime(next_update), + self_ca_not_after: PackTime::from_utc_offset_datetime(next_update), + instance_effective_until: PackTime::from_utc_offset_datetime(next_update), + }, + child_entries: Vec::new(), + local_outputs: Vec::new(), + related_artifacts: vec![VcirRelatedArtifact { + artifact_role: VcirArtifactRole::Manifest, + artifact_kind: VcirArtifactKind::Mft, + uri: Some(manifest_rsync_uri.to_string()), + sha256: manifest_sha256, + object_type: Some("mft".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }], + summary: VcirSummary { + local_vrp_count: 0, + local_aspa_count: 0, + child_count: 0, + accepted_object_count: 1, + rejected_object_count: 0, + }, + audit_summary: VcirAuditSummary { + failed_fetch_eligible: true, + last_failed_fetch_reason: None, + warning_count: 0, + audit_flags: Vec::new(), + }, + }; + store + .put_vcir(&vcir) + .expect("store validated manifest baseline"); +} + fn store_manifest_and_locked_files( store: &RocksStore, manifest_path: &Path, @@ -70,7 +146,7 @@ fn store_manifest_and_locked_files( } #[test] -fn repo_sync_failed_can_fall_back_to_fetch_cache_pp_when_present() { +fn repo_sync_failed_can_reuse_current_instance_vcir_when_present() { let manifest_path = Path::new( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -94,7 +170,7 @@ fn repo_sync_failed_can_fall_back_to_fetch_cache_pp_when_present() { let issuer_ca_der = issuer_ca_fixture_der(); - // First run: build and store a valid fetch_cache_pp pack (Fresh). + // First run: build a fresh publication point result, then seed a current-instance VCIR baseline. let policy = Policy::default(); let first = process_manifest_publication_point( &store, @@ -108,7 +184,16 @@ fn repo_sync_failed_can_fall_back_to_fetch_cache_pp_when_present() { .expect("first run ok"); assert_eq!(first.source, PublicationPointSource::Fresh); - // Second run: simulate RRDP/rsync repo sync failure and ensure we still accept the cached pack. + store_validated_manifest_baseline( + &store, + &manifest_rsync_uri, + &manifest_bytes, + manifest.manifest.manifest_number.bytes_be.clone(), + manifest.manifest.this_update, + manifest.manifest.next_update, + ); + + // Seed a latest current-instance VCIR baseline, then simulate RRDP/rsync repo sync failure. let second = process_manifest_publication_point_after_repo_sync( &store, &policy, @@ -120,83 +205,13 @@ fn repo_sync_failed_can_fall_back_to_fetch_cache_pp_when_present() { false, Some("repo sync failed in test"), ) - .expect("repo sync failure should fall back to fetch_cache_pp"); - assert_eq!(second.source, PublicationPointSource::FetchCachePp); - assert_eq!(second.pack, first.pack); - assert!(!second.warnings.is_empty()); -} - -#[test] -fn cached_pack_manifest_rsync_uri_mismatch_is_rejected_as_invalid_pack() { - let manifest_path = Path::new( - "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", - ); - let manifest_bytes = std::fs::read(manifest_path).expect("read manifest fixture"); - let manifest = ManifestObject::decode_der(&manifest_bytes).expect("decode manifest fixture"); - let validation_time = manifest.manifest.this_update + time::Duration::seconds(1); - - let manifest_rsync_uri = fixture_to_rsync_uri(manifest_path); - let publication_point_rsync_uri = fixture_dir_to_rsync_uri(manifest_path.parent().unwrap()); - - let temp = tempfile::tempdir().expect("tempdir"); - let store = RocksStore::open(temp.path()).expect("open rocksdb"); - store_manifest_and_locked_files( - &store, - manifest_path, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &manifest, - &manifest_bytes, - ); - - let issuer_ca_der = issuer_ca_fixture_der(); - let policy = Policy::default(); - - // First run stores a valid pack. - let _ = process_manifest_publication_point( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - ) - .expect("first run stores pack"); - - // Corrupt cached pack metadata: manifest_rsync_uri doesn't match the key. - let key = FetchCachePpKey::from_manifest_rsync_uri(&manifest_rsync_uri); - let cached_bytes = store - .get_fetch_cache_pp(&key) - .expect("get fetch_cache_pp") - .expect("fetch_cache_pp exists"); - let mut pack = FetchCachePpPack::decode(&cached_bytes).expect("decode pack"); - pack.manifest_rsync_uri = "rsync://example.test/wrong.mft".to_string(); - store - .put_fetch_cache_pp(&key, &pack.encode().expect("encode pack")) - .expect("store corrupted pack"); - - // Force fresh failure and trigger cache load. - let err = process_manifest_publication_point_after_repo_sync( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - false, - Some("repo sync failed in test"), - ) - .unwrap_err(); + .expect("repo sync failure should reuse current-instance VCIR"); + assert_eq!(second.source, PublicationPointSource::VcirCurrentInstance); assert!( - matches!(err, ManifestProcessError::NoUsableCache { .. }), - "{err}" - ); - assert!( - err.to_string() - .contains("cached pack manifest_rsync_uri does not match key"), - "unexpected error: {err}" + second.warnings.iter().any(|w| w + .message + .contains("using latest validated result for current CA instance")), + "expected current-instance VCIR reuse warning" ); } @@ -269,7 +284,7 @@ fn manifest_missing_locked_file_is_treated_as_failed_fetch() { } #[test] -fn manifest_number_increases_but_this_update_not_increasing_is_failed_fetch() { +fn manifest_number_increases_but_this_update_not_increasing_reuses_current_instance_vcir() { let manifest_path = Path::new( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -294,7 +309,7 @@ fn manifest_number_increases_but_this_update_not_increasing_is_failed_fetch() { let issuer_ca_der = issuer_ca_fixture_der(); let policy = Policy::default(); - // Build and store a valid pack first. + // Build and store a valid publication point snapshot first. let _ = process_manifest_publication_point( &store, &policy, @@ -304,23 +319,18 @@ fn manifest_number_increases_but_this_update_not_increasing_is_failed_fetch() { Some(issuer_ca_rsync_uri()), validation_time, ) - .expect("first run stores pack"); + .expect("first run returns validated publication point"); - // Replace the cached pack with an "older" manifestNumber but a newer thisUpdate to trigger + // Seed VCIR with an older manifestNumber but a newer thisUpdate to trigger // RFC 9286 §4.2.1 thisUpdate monotonicity failure on the fresh path. - let key = FetchCachePpKey::from_manifest_rsync_uri(&manifest_rsync_uri); - let cached_bytes = store - .get_fetch_cache_pp(&key) - .expect("get fetch_cache_pp") - .expect("fetch_cache_pp exists"); - let mut old_pack = FetchCachePpPack::decode(&cached_bytes).expect("decode pack"); - old_pack.manifest_number_be = vec![0]; - old_pack.this_update = rpki::storage::PackTime::from_utc_offset_datetime( + store_validated_manifest_baseline( + &store, + &manifest_rsync_uri, + &manifest_bytes, + vec![0], manifest.manifest.this_update + time::Duration::hours(24), + manifest.manifest.next_update, ); - store - .put_fetch_cache_pp(&key, &old_pack.encode().expect("encode pack")) - .expect("store adjusted pack"); let out = process_manifest_publication_point( &store, @@ -331,8 +341,8 @@ fn manifest_number_increases_but_this_update_not_increasing_is_failed_fetch() { Some(issuer_ca_rsync_uri()), validation_time, ) - .expect("should fall back to fetch_cache_pp"); - assert_eq!(out.source, PublicationPointSource::FetchCachePp); + .expect("should reuse current-instance VCIR"); + assert_eq!(out.source, PublicationPointSource::VcirCurrentInstance); assert!( out.warnings .iter() @@ -368,7 +378,7 @@ fn manifest_number_equal_but_bytes_differ_is_rejected_without_cache() { let mut policy = Policy::default(); policy.ca_failed_fetch_policy = CaFailedFetchPolicy::StopAllOutput; - // Store a cached pack that has the same manifestNumber but different manifest bytes. + // Store a cached snapshot that has the same manifestNumber but different manifest bytes. let _ = process_manifest_publication_point( &store, &policy, @@ -378,18 +388,18 @@ fn manifest_number_equal_but_bytes_differ_is_rejected_without_cache() { Some(issuer_ca_rsync_uri()), validation_time, ) - .expect("first run stores pack"); + .expect("first run returns validated publication point"); - let key = FetchCachePpKey::from_manifest_rsync_uri(&manifest_rsync_uri); - let cached_bytes = store - .get_fetch_cache_pp(&key) - .expect("get fetch_cache_pp") - .expect("fetch_cache_pp exists"); - let mut pack = FetchCachePpPack::decode(&cached_bytes).expect("decode pack"); - pack.manifest_bytes[0] ^= 0xFF; - store - .put_fetch_cache_pp(&key, &pack.encode().expect("encode pack")) - .expect("store adjusted pack"); + let mut mutated_manifest_bytes = manifest_bytes.clone(); + mutated_manifest_bytes[0] ^= 0xFF; + store_validated_manifest_baseline( + &store, + &manifest_rsync_uri, + &mutated_manifest_bytes, + manifest.manifest.manifest_number.bytes_be.clone(), + manifest.manifest.this_update, + manifest.manifest.next_update, + ); let err = process_manifest_publication_point( &store, @@ -455,154 +465,3 @@ fn manifest_embedded_ee_cert_path_validation_fails_with_wrong_issuer_ca() { "unexpected error: {err}" ); } - -#[test] -fn cached_pack_missing_file_is_rejected_during_revalidation() { - let manifest_path = Path::new( - "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", - ); - let manifest_bytes = std::fs::read(manifest_path).expect("read manifest fixture"); - let manifest = ManifestObject::decode_der(&manifest_bytes).expect("decode manifest fixture"); - let validation_time = manifest.manifest.this_update + time::Duration::seconds(1); - - let manifest_rsync_uri = fixture_to_rsync_uri(manifest_path); - let publication_point_rsync_uri = fixture_dir_to_rsync_uri(manifest_path.parent().unwrap()); - - let temp = tempfile::tempdir().expect("tempdir"); - let store = RocksStore::open(temp.path()).expect("open rocksdb"); - store_manifest_and_locked_files( - &store, - manifest_path, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &manifest, - &manifest_bytes, - ); - - let issuer_ca_der = issuer_ca_fixture_der(); - let policy = Policy::default(); - - // Store a valid pack first. - let _ = process_manifest_publication_point( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - ) - .expect("first run stores pack"); - - // Corrupt cached pack by removing one referenced file. - let key = FetchCachePpKey::from_manifest_rsync_uri(&manifest_rsync_uri); - let cached_bytes = store - .get_fetch_cache_pp(&key) - .expect("get fetch_cache_pp") - .expect("fetch_cache_pp exists"); - let mut pack = FetchCachePpPack::decode(&cached_bytes).expect("decode pack"); - assert!(!pack.files.is_empty(), "fixture should lock some files"); - pack.files.pop(); - store - .put_fetch_cache_pp(&key, &pack.encode().expect("encode pack")) - .expect("store corrupted pack"); - - // Force the fresh path to fail and trigger cache revalidation. - let err = process_manifest_publication_point_after_repo_sync( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - false, - Some("repo sync failed in test"), - ) - .unwrap_err(); - assert!( - matches!(err, ManifestProcessError::NoUsableCache { .. }), - "{err}" - ); - assert!( - err.to_string() - .contains("cached fetch_cache_pp missing file"), - "unexpected error: {err}" - ); -} - -#[test] -fn cached_pack_hash_mismatch_is_rejected_during_revalidation() { - let manifest_path = Path::new( - "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", - ); - let manifest_bytes = std::fs::read(manifest_path).expect("read manifest fixture"); - let manifest = ManifestObject::decode_der(&manifest_bytes).expect("decode manifest fixture"); - let validation_time = manifest.manifest.this_update + time::Duration::seconds(1); - - let manifest_rsync_uri = fixture_to_rsync_uri(manifest_path); - let publication_point_rsync_uri = fixture_dir_to_rsync_uri(manifest_path.parent().unwrap()); - - let temp = tempfile::tempdir().expect("tempdir"); - let store = RocksStore::open(temp.path()).expect("open rocksdb"); - store_manifest_and_locked_files( - &store, - manifest_path, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &manifest, - &manifest_bytes, - ); - - let issuer_ca_der = issuer_ca_fixture_der(); - let policy = Policy::default(); - - // Store a valid pack first. - let _ = process_manifest_publication_point( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - ) - .expect("first run stores pack"); - - // Corrupt cached pack by changing one file's bytes+sha256 so internal validation passes, - // but the manifest fileList binding check fails (RFC 9286 §6.5). - let key = FetchCachePpKey::from_manifest_rsync_uri(&manifest_rsync_uri); - let cached_bytes = store - .get_fetch_cache_pp(&key) - .expect("get fetch_cache_pp") - .expect("fetch_cache_pp exists"); - let mut pack = FetchCachePpPack::decode(&cached_bytes).expect("decode pack"); - let victim = pack.files.first_mut().expect("non-empty file list"); - victim.bytes[0] ^= 0xFF; - victim.sha256 = victim.compute_sha256(); - store - .put_fetch_cache_pp(&key, &pack.encode().expect("encode pack")) - .expect("store corrupted pack"); - - let err = process_manifest_publication_point_after_repo_sync( - &store, - &policy, - &manifest_rsync_uri, - &publication_point_rsync_uri, - &issuer_ca_der, - Some(issuer_ca_rsync_uri()), - validation_time, - false, - Some("repo sync failed in test"), - ) - .unwrap_err(); - assert!( - matches!(err, ManifestProcessError::NoUsableCache { .. }), - "{err}" - ); - assert!( - err.to_string() - .contains("cached fetch_cache_pp file hash mismatch"), - "unexpected error: {err}" - ); -} diff --git a/tests/test_manifest_rfc9286_section6_1.rs b/tests/test_manifest_rfc9286_section6_1.rs index 819d95a..a2e5f7c 100644 --- a/tests/test_manifest_rfc9286_section6_1.rs +++ b/tests/test_manifest_rfc9286_section6_1.rs @@ -2,7 +2,7 @@ use std::path::Path; use rpki::data_model::manifest::ManifestObject; use rpki::policy::{CaFailedFetchPolicy, Policy}; -use rpki::storage::{FetchCachePpKey, RocksStore}; +use rpki::storage::RocksStore; use rpki::validation::manifest::process_manifest_publication_point; fn issuer_ca_fixture() -> Vec { @@ -70,12 +70,11 @@ fn manifest_outside_publication_point_is_failed_fetch_rfc9286_section6_1() { let msg = err.to_string(); assert!(msg.contains("RFC 9286 §6.1"), "{msg}"); - let key = FetchCachePpKey::from_manifest_rsync_uri(manifest_rsync_uri); assert!( store - .get_fetch_cache_pp(&key) - .expect("get fetch_cache_pp") + .get_vcir(manifest_rsync_uri) + .expect("get vcir") .is_none(), - "must not write fetch_cache_pp on failed fetch" + "must not write VCIR on failed fetch" ); } diff --git a/tests/test_objects_errors_more.rs b/tests/test_objects_errors_more.rs index 1cf25b8..a83cdb9 100644 --- a/tests/test_objects_errors_more.rs +++ b/tests/test_objects_errors_more.rs @@ -6,7 +6,7 @@ use rpki::data_model::rc::ResourceCertificate; use rpki::policy::{Policy, SignedObjectFailurePolicy}; use rpki::storage::{PackFile, RocksStore}; use rpki::validation::manifest::process_manifest_publication_point; -use rpki::validation::objects::process_fetch_cache_pp_pack_for_issuer; +use rpki::validation::objects::process_publication_point_snapshot_for_issuer; fn fixture_to_rsync_uri(path: &Path) -> String { let rel = path @@ -31,7 +31,7 @@ fn fixture_dir_to_rsync_uri(dir: &Path) -> String { } fn build_cernet_pack_and_validation_time() -> ( - rpki::storage::FetchCachePpPack, + rpki::validation::publication_point::PublicationPointSnapshot, time::OffsetDateTime, Vec, ResourceCertificate, @@ -85,11 +85,11 @@ fn build_cernet_pack_and_validation_time() -> ( let issuer_ca = ResourceCertificate::decode_der(&issuer_ca_der).expect("decode issuer CA cert"); let crl_file = out - .pack + .snapshot .files .iter() .find(|f| f.rsync_uri.ends_with(".crl")) - .expect("crl present in pack"); + .expect("crl present in snapshot"); let crl = RpkixCrl::decode_der(&crl_file.bytes).expect("decode crl"); let mut t = manifest.manifest.this_update; @@ -101,7 +101,7 @@ fn build_cernet_pack_and_validation_time() -> ( } t += time::Duration::seconds(1); - (out.pack, t, issuer_ca_der, issuer_ca) + (out.snapshot, t, issuer_ca_der, issuer_ca) } #[test] @@ -113,7 +113,7 @@ fn missing_crl_causes_roas_to_be_dropped_under_drop_object_policy() { let mut policy = Policy::default(); policy.signed_object_failure_policy = SignedObjectFailurePolicy::DropObject; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &issuer_ca_der, @@ -139,7 +139,7 @@ fn wrong_issuer_ca_cert_causes_roas_to_be_dropped_under_drop_object_policy() { // Use an unrelated trust anchor certificate as the issuer to force EE cert path validation to fail. let wrong_issuer_ca_der = std::fs::read("tests/fixtures/ta/arin-ta.cer").expect("read wrong issuer ca"); - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &wrong_issuer_ca_der, @@ -167,7 +167,7 @@ fn invalid_aspa_object_is_reported_as_warning_under_drop_object_policy() { let mut policy = Policy::default(); policy.signed_object_failure_policy = SignedObjectFailurePolicy::DropObject; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &issuer_ca_der, diff --git a/tests/test_objects_policy_m8.rs b/tests/test_objects_policy_m8.rs index c907627..9239ae6 100644 --- a/tests/test_objects_policy_m8.rs +++ b/tests/test_objects_policy_m8.rs @@ -6,7 +6,7 @@ use rpki::data_model::rc::ResourceCertificate; use rpki::policy::{Policy, SignedObjectFailurePolicy}; use rpki::storage::{PackFile, RocksStore}; use rpki::validation::manifest::process_manifest_publication_point; -use rpki::validation::objects::process_fetch_cache_pp_pack_for_issuer; +use rpki::validation::objects::process_publication_point_snapshot_for_issuer; fn fixture_to_rsync_uri(path: &Path) -> String { let rel = path @@ -31,7 +31,7 @@ fn fixture_dir_to_rsync_uri(dir: &Path) -> String { } fn build_cernet_pack_and_validation_time() -> ( - rpki::storage::FetchCachePpPack, + rpki::validation::publication_point::PublicationPointSnapshot, time::OffsetDateTime, Vec, ResourceCertificate, @@ -85,11 +85,11 @@ fn build_cernet_pack_and_validation_time() -> ( let issuer_ca = ResourceCertificate::decode_der(&issuer_ca_der).expect("decode issuer CA cert"); let crl_file = out - .pack + .snapshot .files .iter() .find(|f| f.rsync_uri.ends_with(".crl")) - .expect("crl present in pack"); + .expect("crl present in snapshot"); let crl = RpkixCrl::decode_der(&crl_file.bytes).expect("decode crl"); // Choose a validation_time that is within: @@ -106,7 +106,7 @@ fn build_cernet_pack_and_validation_time() -> ( } t += time::Duration::seconds(1); - (out.pack, t, issuer_ca_der, issuer_ca) + (out.snapshot, t, issuer_ca_der, issuer_ca) } #[test] @@ -119,13 +119,13 @@ fn drop_object_policy_drops_only_failing_object() { .iter() .find(|f| f.rsync_uri.ends_with("AS4538.roa")) .map(|f| f.rsync_uri.clone()) - .expect("AS4538.roa present in pack"); + .expect("AS4538.roa present in snapshot"); let tamper_idx = pack .files .iter() .position(|f| f.rsync_uri.ends_with(".roa") && f.rsync_uri != valid_roa_uri) - .expect("another ROA present in pack"); + .expect("another ROA present in snapshot"); let victim_uri = pack.files[tamper_idx].rsync_uri.clone(); let mut tampered = pack.files[tamper_idx].bytes.clone(); @@ -136,7 +136,7 @@ fn drop_object_policy_drops_only_failing_object() { let mut policy = Policy::default(); policy.signed_object_failure_policy = SignedObjectFailurePolicy::DropObject; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &issuer_ca_der, @@ -168,7 +168,7 @@ fn drop_publication_point_policy_drops_the_publication_point() { .files .iter() .position(|f| f.rsync_uri.ends_with(".roa")) - .expect("a ROA present in pack"); + .expect("a ROA present in snapshot"); let victim_uri = pack.files[tamper_idx].rsync_uri.clone(); let mut tampered = pack.files[tamper_idx].bytes.clone(); @@ -179,7 +179,7 @@ fn drop_publication_point_policy_drops_the_publication_point() { let mut policy = Policy::default(); policy.signed_object_failure_policy = SignedObjectFailurePolicy::DropPublicationPoint; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &issuer_ca_der, diff --git a/tests/test_objects_process_pack_for_issuer.rs b/tests/test_objects_process_publication_point_snapshot.rs similarity index 88% rename from tests/test_objects_process_pack_for_issuer.rs rename to tests/test_objects_process_publication_point_snapshot.rs index 43e9dc5..4d1241d 100644 --- a/tests/test_objects_process_pack_for_issuer.rs +++ b/tests/test_objects_process_publication_point_snapshot.rs @@ -1,10 +1,11 @@ use rpki::fetch::rsync::LocalDirRsyncFetcher; use rpki::policy::{Policy, SignedObjectFailurePolicy, SyncPreference}; -use rpki::storage::{FetchCachePpPack, PackFile, PackTime, RocksStore}; +use rpki::storage::{PackFile, PackTime, RocksStore}; use rpki::sync::repo::sync_publication_point; use rpki::sync::rrdp::Fetcher; use rpki::validation::manifest::process_manifest_publication_point; -use rpki::validation::objects::process_fetch_cache_pp_pack_for_issuer; +use rpki::validation::objects::process_publication_point_snapshot_for_issuer; +use rpki::validation::publication_point::PublicationPointSnapshot; struct NoopHttpFetcher; impl Fetcher for NoopHttpFetcher { @@ -50,16 +51,16 @@ fn issuer_ca_rsync_uri() -> &'static str { "rsync://rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer" } -fn minimal_pack( +fn minimal_snapshot( manifest_rsync_uri: &str, publication_point_rsync_uri: &str, manifest_bytes: Vec, files: Vec, validation_time: time::OffsetDateTime, -) -> FetchCachePpPack { - // Keep times consistent enough to pass internal pack validation. - FetchCachePpPack { - format_version: FetchCachePpPack::FORMAT_VERSION_V1, +) -> PublicationPointSnapshot { + // Keep times consistent enough for a valid snapshot. + PublicationPointSnapshot { + format_version: PublicationPointSnapshot::FORMAT_VERSION_V1, manifest_rsync_uri: manifest_rsync_uri.to_string(), publication_point_rsync_uri: publication_point_rsync_uri.to_string(), manifest_number_be: vec![1], @@ -71,12 +72,12 @@ fn minimal_pack( } } -fn build_fetch_cache_pp_from_local_rsync_fixture( +fn build_publication_point_snapshot_from_local_rsync_fixture( dir: &std::path::Path, rsync_base_uri: &str, manifest_rsync_uri: &str, validation_time: time::OffsetDateTime, -) -> rpki::storage::FetchCachePpPack { +) -> rpki::validation::publication_point::PublicationPointSnapshot { let store_dir = tempfile::tempdir().expect("store dir"); let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); let policy = Policy { @@ -107,16 +108,16 @@ fn build_fetch_cache_pp_from_local_rsync_fixture( ) .expect("process manifest"); - pp.pack + pp.snapshot } #[test] -fn process_pack_for_issuer_extracts_vrps_from_real_cernet_fixture() { +fn process_snapshot_for_issuer_extracts_vrps_from_real_cernet_fixture() { let (dir, rsync_base_uri, manifest_file) = cernet_fixture(); let manifest_rsync_uri = format!("{rsync_base_uri}{manifest_file}"); let validation_time = validation_time_from_manifest_fixture(&dir, &manifest_file); - let pack = build_fetch_cache_pp_from_local_rsync_fixture( + let pack = build_publication_point_snapshot_from_local_rsync_fixture( &dir, &rsync_base_uri, &manifest_rsync_uri, @@ -128,7 +129,7 @@ fn process_pack_for_issuer_extracts_vrps_from_real_cernet_fixture() { .expect("decode issuer ca"); let policy = Policy::default(); - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &issuer_ca_der, @@ -153,7 +154,7 @@ fn signed_object_failure_policy_drop_object_drops_only_bad_object() { let manifest_rsync_uri = format!("{rsync_base_uri}{manifest_file}"); let validation_time = validation_time_from_manifest_fixture(&dir, &manifest_file); - let mut pack = build_fetch_cache_pp_from_local_rsync_fixture( + let mut pack = build_publication_point_snapshot_from_local_rsync_fixture( &dir, &rsync_base_uri, &manifest_rsync_uri, @@ -171,7 +172,7 @@ fn signed_object_failure_policy_drop_object_drops_only_bad_object() { .files .iter() .position(|f| f.rsync_uri.ends_with(".roa")) - .expect("pack contains roa"); + .expect("snapshot contains roa"); let bad_uri = pack.files[bad_idx].rsync_uri.clone(); pack.files[bad_idx] = PackFile::from_bytes_compute_sha256(bad_uri, vec![0u8]); @@ -183,7 +184,7 @@ fn signed_object_failure_policy_drop_object_drops_only_bad_object() { signed_object_failure_policy: SignedObjectFailurePolicy::DropObject, ..Policy::default() }; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &issuer_ca_der, @@ -216,7 +217,7 @@ fn signed_object_failure_policy_drop_publication_point_drops_all_output() { let manifest_rsync_uri = format!("{rsync_base_uri}{manifest_file}"); let validation_time = validation_time_from_manifest_fixture(&dir, &manifest_file); - let mut pack = build_fetch_cache_pp_from_local_rsync_fixture( + let mut pack = build_publication_point_snapshot_from_local_rsync_fixture( &dir, &rsync_base_uri, &manifest_rsync_uri, @@ -234,7 +235,7 @@ fn signed_object_failure_policy_drop_publication_point_drops_all_output() { .files .iter() .position(|f| f.rsync_uri.ends_with(".roa")) - .expect("pack contains roa"); + .expect("snapshot contains roa"); let bad_uri = pack.files[bad_idx].rsync_uri.clone(); pack.files[bad_idx] = PackFile::from_bytes_compute_sha256(bad_uri, vec![0u8]); @@ -246,7 +247,7 @@ fn signed_object_failure_policy_drop_publication_point_drops_all_output() { signed_object_failure_policy: SignedObjectFailurePolicy::DropPublicationPoint, ..Policy::default() }; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &issuer_ca_der, @@ -283,13 +284,13 @@ fn signed_object_failure_policy_drop_publication_point_drops_all_output() { } #[test] -fn process_pack_for_issuer_without_crl_drops_publication_point() { +fn process_snapshot_for_issuer_without_crl_drops_publication_point() { let (dir, rsync_base_uri, manifest_file) = cernet_fixture(); let manifest_rsync_uri = format!("{rsync_base_uri}{manifest_file}"); let validation_time = validation_time_from_manifest_fixture(&dir, &manifest_file); let manifest_bytes = std::fs::read(dir.join(&manifest_file)).expect("read mft"); - let pack = minimal_pack( + let pack = minimal_snapshot( &manifest_rsync_uri, &rsync_base_uri, manifest_bytes, @@ -301,7 +302,7 @@ fn process_pack_for_issuer_without_crl_drops_publication_point() { ); let policy = Policy::default(); - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &[], @@ -318,7 +319,7 @@ fn process_pack_for_issuer_without_crl_drops_publication_point() { } #[test] -fn process_pack_for_issuer_handles_invalid_aspa_bytes() { +fn process_snapshot_for_issuer_handles_invalid_aspa_bytes() { let (dir, rsync_base_uri, manifest_file) = cernet_fixture(); let manifest_rsync_uri = format!("{rsync_base_uri}{manifest_file}"); let validation_time = validation_time_from_manifest_fixture(&dir, &manifest_file); @@ -326,7 +327,7 @@ fn process_pack_for_issuer_handles_invalid_aspa_bytes() { let crl_bytes = std::fs::read(dir.join("05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.crl")).expect("read crl"); - let pack = minimal_pack( + let pack = minimal_snapshot( &manifest_rsync_uri, &rsync_base_uri, manifest_bytes, @@ -344,7 +345,7 @@ fn process_pack_for_issuer_handles_invalid_aspa_bytes() { signed_object_failure_policy: SignedObjectFailurePolicy::DropObject, ..Policy::default() }; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &[], @@ -359,7 +360,7 @@ fn process_pack_for_issuer_handles_invalid_aspa_bytes() { } #[test] -fn process_pack_for_issuer_drop_publication_point_on_invalid_aspa_bytes() { +fn process_snapshot_for_issuer_drop_publication_point_on_invalid_aspa_bytes() { let (dir, rsync_base_uri, manifest_file) = cernet_fixture(); let manifest_rsync_uri = format!("{rsync_base_uri}{manifest_file}"); let validation_time = validation_time_from_manifest_fixture(&dir, &manifest_file); @@ -367,7 +368,7 @@ fn process_pack_for_issuer_drop_publication_point_on_invalid_aspa_bytes() { let crl_bytes = std::fs::read(dir.join("05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.crl")).expect("read crl"); - let pack = minimal_pack( + let pack = minimal_snapshot( &manifest_rsync_uri, &rsync_base_uri, manifest_bytes, @@ -387,7 +388,7 @@ fn process_pack_for_issuer_drop_publication_point_on_invalid_aspa_bytes() { signed_object_failure_policy: SignedObjectFailurePolicy::DropPublicationPoint, ..Policy::default() }; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &[], diff --git a/tests/test_objects_processing_coverage_m18.rs b/tests/test_objects_processing_coverage_m18.rs index f47c839..013d09a 100644 --- a/tests/test_objects_processing_coverage_m18.rs +++ b/tests/test_objects_processing_coverage_m18.rs @@ -1,16 +1,17 @@ use rpki::policy::{Policy, SignedObjectFailurePolicy}; -use rpki::storage::{FetchCachePpPack, PackFile, PackTime}; -use rpki::validation::objects::process_fetch_cache_pp_pack_for_issuer; +use rpki::storage::{PackFile, PackTime}; +use rpki::validation::objects::process_publication_point_snapshot_for_issuer; +use rpki::validation::publication_point::PublicationPointSnapshot; fn fixture_bytes(path: &str) -> Vec { std::fs::read(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path)) .unwrap_or_else(|e| panic!("read fixture {path}: {e}")) } -fn dummy_pack(manifest_bytes: Vec, files: Vec) -> FetchCachePpPack { +fn dummy_snapshot(manifest_bytes: Vec, files: Vec) -> PublicationPointSnapshot { let now = time::OffsetDateTime::now_utc(); - FetchCachePpPack { - format_version: FetchCachePpPack::FORMAT_VERSION_V1, + PublicationPointSnapshot { + format_version: PublicationPointSnapshot::FORMAT_VERSION_V1, manifest_rsync_uri: "rsync://example.test/repo/pp/manifest.mft".to_string(), publication_point_rsync_uri: "rsync://example.test/repo/pp/".to_string(), manifest_number_be: vec![1], @@ -23,7 +24,7 @@ fn dummy_pack(manifest_bytes: Vec, files: Vec) -> FetchCachePpPack } #[test] -fn process_pack_drop_object_on_wrong_issuer_ca_for_roa() { +fn process_snapshot_drop_object_on_wrong_issuer_ca_for_roa() { let manifest_bytes = fixture_bytes( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -43,7 +44,7 @@ fn process_pack_drop_object_on_wrong_issuer_ca_for_roa() { "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.crl", ); - let pack = dummy_pack( + let pack = dummy_snapshot( manifest_bytes, vec![ PackFile::from_bytes_compute_sha256(ee_crldp, crl_bytes), @@ -60,7 +61,7 @@ fn process_pack_drop_object_on_wrong_issuer_ca_for_roa() { }; let wrong_issuer_ca_der = fixture_bytes("tests/fixtures/ta/arin-ta.cer"); - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &wrong_issuer_ca_der, @@ -78,7 +79,7 @@ fn process_pack_drop_object_on_wrong_issuer_ca_for_roa() { } #[test] -fn process_pack_drop_publication_point_on_wrong_issuer_ca_for_roa_skips_rest() { +fn process_snapshot_drop_publication_point_on_wrong_issuer_ca_for_roa_skips_rest() { let manifest_bytes = fixture_bytes( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -101,7 +102,7 @@ fn process_pack_drop_publication_point_on_wrong_issuer_ca_for_roa_skips_rest() { "tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa", ); - let pack = dummy_pack( + let pack = dummy_snapshot( manifest_bytes, vec![ PackFile::from_bytes_compute_sha256(ee_crldp, crl_bytes), @@ -123,7 +124,7 @@ fn process_pack_drop_publication_point_on_wrong_issuer_ca_for_roa_skips_rest() { }; let wrong_issuer_ca_der = fixture_bytes("tests/fixtures/ta/arin-ta.cer"); - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &wrong_issuer_ca_der, @@ -138,7 +139,7 @@ fn process_pack_drop_publication_point_on_wrong_issuer_ca_for_roa_skips_rest() { } #[test] -fn process_pack_drop_object_on_wrong_issuer_ca_for_aspa() { +fn process_snapshot_drop_object_on_wrong_issuer_ca_for_aspa() { let manifest_bytes = fixture_bytes( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -159,7 +160,7 @@ fn process_pack_drop_object_on_wrong_issuer_ca_for_aspa() { "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.crl", ); - let pack = dummy_pack( + let pack = dummy_snapshot( manifest_bytes, vec![ PackFile::from_bytes_compute_sha256(ee_crldp, crl_bytes), @@ -173,7 +174,7 @@ fn process_pack_drop_object_on_wrong_issuer_ca_for_aspa() { }; let wrong_issuer_ca_der = fixture_bytes("tests/fixtures/ta/arin-ta.cer"); - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &wrong_issuer_ca_der, @@ -191,7 +192,7 @@ fn process_pack_drop_object_on_wrong_issuer_ca_for_aspa() { } #[test] -fn process_pack_drop_publication_point_on_wrong_issuer_ca_for_aspa_skips_rest() { +fn process_snapshot_drop_publication_point_on_wrong_issuer_ca_for_aspa_skips_rest() { let manifest_bytes = fixture_bytes( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -214,7 +215,7 @@ fn process_pack_drop_publication_point_on_wrong_issuer_ca_for_aspa_skips_rest() "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.crl", ); - let pack = dummy_pack( + let pack = dummy_snapshot( manifest_bytes, vec![ PackFile::from_bytes_compute_sha256(ee_crldp, crl_bytes), @@ -229,7 +230,7 @@ fn process_pack_drop_publication_point_on_wrong_issuer_ca_for_aspa_skips_rest() }; let wrong_issuer_ca_der = fixture_bytes("tests/fixtures/ta/arin-ta.cer"); - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &wrong_issuer_ca_der, @@ -243,7 +244,7 @@ fn process_pack_drop_publication_point_on_wrong_issuer_ca_for_aspa_skips_rest() } #[test] -fn process_pack_for_issuer_marks_objects_skipped_when_missing_issuer_crl() { +fn process_snapshot_for_issuer_marks_objects_skipped_when_missing_issuer_crl() { let manifest_bytes = fixture_bytes( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -253,7 +254,7 @@ fn process_pack_for_issuer_marks_objects_skipped_when_missing_issuer_crl() { "tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa", ); - let pack = dummy_pack( + let pack = dummy_snapshot( manifest_bytes, vec![ PackFile::from_bytes_compute_sha256("rsync://example.test/repo/pp/a.roa", roa_bytes), @@ -266,7 +267,7 @@ fn process_pack_for_issuer_marks_objects_skipped_when_missing_issuer_crl() { ..Policy::default() }; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &fixture_bytes("tests/fixtures/ta/apnic-ta.cer"), @@ -287,12 +288,12 @@ fn process_pack_for_issuer_marks_objects_skipped_when_missing_issuer_crl() { } #[test] -fn process_pack_for_issuer_drop_object_records_errors_and_continues() { +fn process_snapshot_for_issuer_drop_object_records_errors_and_continues() { let manifest_bytes = fixture_bytes( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); - let pack = dummy_pack( + let pack = dummy_snapshot( manifest_bytes, vec![ PackFile::from_bytes_compute_sha256( @@ -309,7 +310,7 @@ fn process_pack_for_issuer_drop_object_records_errors_and_continues() { ..Policy::default() }; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &fixture_bytes("tests/fixtures/ta/apnic-ta.cer"), @@ -328,12 +329,12 @@ fn process_pack_for_issuer_drop_object_records_errors_and_continues() { } #[test] -fn process_pack_for_issuer_drop_publication_point_records_skips_for_rest() { +fn process_snapshot_for_issuer_drop_publication_point_records_skips_for_rest() { let manifest_bytes = fixture_bytes( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); - let pack = dummy_pack( + let pack = dummy_snapshot( manifest_bytes, vec![ PackFile::from_bytes_compute_sha256( @@ -351,7 +352,7 @@ fn process_pack_for_issuer_drop_publication_point_records_skips_for_rest() { ..Policy::default() }; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &fixture_bytes("tests/fixtures/ta/apnic-ta.cer"), @@ -370,7 +371,7 @@ fn process_pack_for_issuer_drop_publication_point_records_skips_for_rest() { } #[test] -fn process_pack_for_issuer_selects_crl_by_ee_crldp_uri_roa() { +fn process_snapshot_for_issuer_selects_crl_by_ee_crldp_uri_roa() { let manifest_bytes = fixture_bytes( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -389,7 +390,7 @@ fn process_pack_for_issuer_selects_crl_by_ee_crldp_uri_roa() { // Provide a CRL file with the *exact* rsync URI referenced by the embedded EE certificate. // Bytes need not be valid for this test: we just want to cover deterministic selection. - let pack = dummy_pack( + let pack = dummy_snapshot( manifest_bytes, vec![ PackFile::from_bytes_compute_sha256(ee_crldp, vec![0x01]), @@ -402,7 +403,7 @@ fn process_pack_for_issuer_selects_crl_by_ee_crldp_uri_roa() { ..Policy::default() }; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &fixture_bytes("tests/fixtures/ta/apnic-ta.cer"), @@ -420,7 +421,7 @@ fn process_pack_for_issuer_selects_crl_by_ee_crldp_uri_roa() { } #[test] -fn process_pack_for_issuer_rejects_roa_when_crldp_crl_missing() { +fn process_snapshot_for_issuer_rejects_roa_when_crldp_crl_missing() { let manifest_bytes = fixture_bytes( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -428,7 +429,7 @@ fn process_pack_for_issuer_rejects_roa_when_crldp_crl_missing() { fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa"); // Pack has a CRL, but its URI does not match the embedded EE certificate CRLDP. - let pack = dummy_pack( + let pack = dummy_snapshot( manifest_bytes, vec![ PackFile::from_bytes_compute_sha256( @@ -444,7 +445,7 @@ fn process_pack_for_issuer_rejects_roa_when_crldp_crl_missing() { ..Policy::default() }; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &fixture_bytes("tests/fixtures/ta/apnic-ta.cer"), @@ -469,7 +470,7 @@ fn process_pack_for_issuer_rejects_roa_when_crldp_crl_missing() { } #[test] -fn process_pack_for_issuer_selects_crl_by_ee_crldp_uri_aspa() { +fn process_snapshot_for_issuer_selects_crl_by_ee_crldp_uri_aspa() { let manifest_bytes = fixture_bytes( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); @@ -487,7 +488,7 @@ fn process_pack_for_issuer_selects_crl_by_ee_crldp_uri_aspa() { .as_str() .to_string(); - let pack = dummy_pack( + let pack = dummy_snapshot( manifest_bytes, vec![ PackFile::from_bytes_compute_sha256(ee_crldp, vec![0x01]), @@ -500,7 +501,7 @@ fn process_pack_for_issuer_selects_crl_by_ee_crldp_uri_aspa() { ..Policy::default() }; - let out = process_fetch_cache_pp_pack_for_issuer( + let out = process_publication_point_snapshot_for_issuer( &pack, &policy, &fixture_bytes("tests/fixtures/ta/apnic-ta.cer"), diff --git a/tests/test_policy.rs b/tests/test_policy.rs index 30ca0ca..ea02d8a 100644 --- a/tests/test_policy.rs +++ b/tests/test_policy.rs @@ -6,7 +6,7 @@ fn policy_defaults_are_correct() { assert_eq!(p.sync_preference, SyncPreference::RrdpThenRsync); assert_eq!( p.ca_failed_fetch_policy, - CaFailedFetchPolicy::UseFetchCachePp + CaFailedFetchPolicy::ReuseCurrentInstanceVcir ); assert_eq!( p.signed_object_failure_policy, @@ -30,6 +30,17 @@ signed_object_failure_policy = "drop_publication_point" ); } +#[test] +fn policy_toml_parsing_accepts_reuse_current_instance_vcir() { + let toml = r#"ca_failed_fetch_policy = "reuse_current_instance_vcir" +"#; + let p = Policy::from_toml_str(toml).expect("parse TOML policy"); + assert_eq!( + p.ca_failed_fetch_policy, + CaFailedFetchPolicy::ReuseCurrentInstanceVcir + ); +} + #[test] fn policy_toml_parsing_uses_defaults_when_missing() { let p = Policy::from_toml_str("").expect("parse empty TOML policy"); diff --git a/tests/test_run_m9.rs b/tests/test_run_m9.rs index d1d3ca7..eb1da9a 100644 --- a/tests/test_run_m9.rs +++ b/tests/test_run_m9.rs @@ -7,7 +7,8 @@ use rpki::fetch::rsync::LocalDirRsyncFetcher; use rpki::policy::{Policy, SyncPreference}; use rpki::storage::RocksStore; use rpki::sync::rrdp::Fetcher; -use rpki::validation::run::{fetch_cache_pp_exists, run_publication_point_once}; +use rpki::validation::manifest::PublicationPointSource; +use rpki::validation::run::run_publication_point_once; fn fixture_to_rsync_uri(path: &Path) -> String { let rel = path @@ -40,7 +41,7 @@ impl Fetcher for NeverHttpFetcher { } #[test] -fn e2e_offline_uses_rsync_then_writes_fetch_cache_pp_then_outputs_vrps() { +fn e2e_offline_uses_rsync_then_writes_latest_vcir_then_outputs_vrps() { let fixture_dir = Path::new("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0"); let rsync_base_uri = "rsync://rpki.cernet.net/repo/cernet/0/"; let manifest_path = fixture_dir.join("05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft"); @@ -80,14 +81,6 @@ fn e2e_offline_uses_rsync_then_writes_fetch_cache_pp_then_outputs_vrps() { let temp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(temp.path()).expect("open rocksdb"); - let expected_files = std::fs::read_dir(fixture_dir) - .expect("read fixture dir") - .filter_map(|e| e.ok()) - .filter_map(|e| e.metadata().ok().map(|m| (e, m))) - .filter(|(_e, m)| m.is_file()) - .count(); - assert!(expected_files >= 3, "fixture dir seems incomplete"); - let out = run_publication_point_once( &store, &policy, @@ -105,9 +98,14 @@ fn e2e_offline_uses_rsync_then_writes_fetch_cache_pp_then_outputs_vrps() { ) .expect("run publication point once"); - assert!(fetch_cache_pp_exists(&store, &manifest_rsync_uri).expect("exists check")); - assert_eq!(out.repo_sync.objects_written, expected_files); - + assert!( + store + .get_vcir(&manifest_rsync_uri) + .expect("get vcir") + .is_some() + ); + assert_eq!(out.publication_point_source, PublicationPointSource::Fresh); + assert!(out.publication_point_warnings.is_empty()); assert!( out.objects.vrps.iter().any(|v| v.asn == 4538), "expected VRPs for AS4538" diff --git a/tests/test_run_tree_from_tal_offline_m17.rs b/tests/test_run_tree_from_tal_offline_m17.rs index df82477..464aec3 100644 --- a/tests/test_run_tree_from_tal_offline_m17.rs +++ b/tests/test_run_tree_from_tal_offline_m17.rs @@ -1,8 +1,10 @@ +use rpki::analysis::timing::{TimingHandle, TimingMeta}; use rpki::validation::from_tal::discover_root_ca_instance_from_tal_and_ta_der; use rpki::validation::run_tree_from_tal::root_handle_from_trust_anchor; use rpki::validation::run_tree_from_tal::{ run_tree_from_tal_and_ta_der_serial, run_tree_from_tal_and_ta_der_serial_audit, - run_tree_from_tal_url_serial, run_tree_from_tal_url_serial_audit, + run_tree_from_tal_and_ta_der_serial_audit_with_timing, run_tree_from_tal_url_serial, + run_tree_from_tal_url_serial_audit, run_tree_from_tal_url_serial_audit_with_timing, }; use rpki::validation::tree::TreeRunConfig; @@ -31,6 +33,22 @@ impl rpki::fetch::rsync::RsyncFetcher for EmptyRsyncFetcher { } } +fn test_timing_handle(temp: &tempfile::TempDir) -> TimingHandle { + TimingHandle::new(TimingMeta { + recorded_at_utc_rfc3339: "2026-03-11T00:00:00Z".to_string(), + validation_time_utc_rfc3339: "2026-03-11T00:00:00Z".to_string(), + tal_url: Some("mock:apnic.tal".to_string()), + db_path: Some(temp.path().display().to_string()), + }) +} + +fn read_timing_json(temp: &tempfile::TempDir, timing: &TimingHandle) -> serde_json::Value { + let path = temp.path().join("timing.json"); + timing.write_json(&path, 20).expect("write timing json"); + serde_json::from_slice(&std::fs::read(&path).expect("read timing json")) + .expect("parse timing json") +} + #[test] fn root_handle_is_constructible_from_fixture_tal_and_ta() { let tal_bytes = std::fs::read("tests/fixtures/tal/apnic-rfc7730-https.tal") @@ -40,7 +58,12 @@ fn root_handle_is_constructible_from_fixture_tal_and_ta() { let discovery = discover_root_ca_instance_from_tal_and_ta_der(&tal_bytes, &ta_der, None).expect("discover"); - let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance); + let root = root_handle_from_trust_anchor( + &discovery.trust_anchor, + "test-tal".to_string(), + None, + &discovery.ca_instance, + ); assert_eq!(root.depth, 0); assert_eq!( @@ -91,18 +114,18 @@ fn run_tree_from_tal_url_entry_executes_and_records_failure_when_repo_empty() { &TreeRunConfig { max_depth: Some(0), max_instances: Some(1), - revalidate_only: false, }, ) .expect("run tree"); - assert_eq!(out.tree.instances_processed, 0); - assert_eq!(out.tree.instances_failed, 1); + assert_eq!(out.tree.instances_processed, 1); + assert_eq!(out.tree.instances_failed, 0); assert!( out.tree .warnings .iter() - .any(|w| w.message.contains("publication point failed")), - "expected failure warning" + .any(|w| w.message.contains("manifest failed fetch") + || w.message.contains("no latest validated result")), + "expected failed-fetch warning" ); } @@ -135,18 +158,18 @@ fn run_tree_from_tal_and_ta_der_entry_executes_and_records_failure_when_repo_emp &TreeRunConfig { max_depth: Some(0), max_instances: Some(1), - revalidate_only: false, }, ) .expect("run tree"); - assert_eq!(out.tree.instances_processed, 0); - assert_eq!(out.tree.instances_failed, 1); + assert_eq!(out.tree.instances_processed, 1); + assert_eq!(out.tree.instances_failed, 0); assert!( out.tree .warnings .iter() - .any(|w| w.message.contains("publication point failed")), - "expected failure warning" + .any(|w| w.message.contains("manifest failed fetch") + || w.message.contains("no latest validated result")), + "expected failed-fetch warning" ); } @@ -187,14 +210,14 @@ fn run_tree_from_tal_url_audit_entry_collects_no_publication_points_when_repo_em &TreeRunConfig { max_depth: Some(0), max_instances: Some(1), - revalidate_only: false, }, ) .expect("run tree audit"); - assert_eq!(out.tree.instances_processed, 0); - assert_eq!(out.tree.instances_failed, 1); - assert!(out.publication_points.is_empty()); + assert_eq!(out.tree.instances_processed, 1); + assert_eq!(out.tree.instances_failed, 0); + assert_eq!(out.publication_points.len(), 1); + assert_eq!(out.publication_points[0].source, "failed_fetch_no_cache"); } #[test] @@ -227,12 +250,113 @@ fn run_tree_from_tal_and_ta_der_audit_entry_collects_no_publication_points_when_ &TreeRunConfig { max_depth: Some(0), max_instances: Some(1), - revalidate_only: false, }, ) .expect("run tree audit"); - assert_eq!(out.tree.instances_processed, 0); - assert_eq!(out.tree.instances_failed, 1); - assert!(out.publication_points.is_empty()); + assert_eq!(out.tree.instances_processed, 1); + assert_eq!(out.tree.instances_failed, 0); + assert_eq!(out.publication_points.len(), 1); + assert_eq!(out.publication_points[0].source, "failed_fetch_no_cache"); +} + +#[test] +fn run_tree_from_tal_url_audit_with_timing_records_phases_when_repo_empty() { + let tal_url = "mock:apnic.tal"; + let tal_bytes = std::fs::read("tests/fixtures/tal/apnic-rfc7730-https.tal") + .expect("read apnic tal fixture"); + let ta_der = std::fs::read("tests/fixtures/ta/apnic-ta.cer").expect("read apnic ta fixture"); + + let mut map = HashMap::new(); + map.insert(tal_url.to_string(), tal_bytes); + map.insert( + "https://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer".to_string(), + ta_der.clone(), + ); + map.insert( + "rsync://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer".to_string(), + ta_der.clone(), + ); + let http = MapHttpFetcher { map }; + let rsync = EmptyRsyncFetcher; + + let temp = tempfile::tempdir().expect("tempdir"); + let store = rpki::storage::RocksStore::open(temp.path()).expect("open rocksdb"); + let policy = rpki::policy::Policy { + sync_preference: rpki::policy::SyncPreference::RsyncOnly, + ..rpki::policy::Policy::default() + }; + let timing = test_timing_handle(&temp); + + let out = run_tree_from_tal_url_serial_audit_with_timing( + &store, + &policy, + tal_url, + &http, + &rsync, + time::OffsetDateTime::now_utc(), + &TreeRunConfig { + max_depth: Some(0), + max_instances: Some(1), + }, + &timing, + ) + .expect("run tree audit with timing"); + + assert_eq!(out.tree.instances_processed, 1); + assert_eq!(out.tree.instances_failed, 0); + + let report = read_timing_json(&temp, &timing); + assert_eq!(report["phases"]["tal_bootstrap"]["count"].as_u64(), Some(1)); + assert_eq!( + report["phases"]["tree_run_total"]["count"].as_u64(), + Some(1) + ); +} + +#[test] +fn run_tree_from_tal_and_ta_der_audit_with_timing_records_phases_when_repo_empty() { + let tal_bytes = std::fs::read("tests/fixtures/tal/apnic-rfc7730-https.tal") + .expect("read apnic tal fixture"); + let ta_der = std::fs::read("tests/fixtures/ta/apnic-ta.cer").expect("read apnic ta fixture"); + + let http = MapHttpFetcher { + map: HashMap::new(), + }; + let rsync = EmptyRsyncFetcher; + + let temp = tempfile::tempdir().expect("tempdir"); + let store = rpki::storage::RocksStore::open(temp.path()).expect("open rocksdb"); + let policy = rpki::policy::Policy { + sync_preference: rpki::policy::SyncPreference::RsyncOnly, + ..rpki::policy::Policy::default() + }; + let timing = test_timing_handle(&temp); + + let out = run_tree_from_tal_and_ta_der_serial_audit_with_timing( + &store, + &policy, + &tal_bytes, + &ta_der, + None, + &http, + &rsync, + time::OffsetDateTime::now_utc(), + &TreeRunConfig { + max_depth: Some(0), + max_instances: Some(1), + }, + &timing, + ) + .expect("run tree audit with timing"); + + assert_eq!(out.tree.instances_processed, 1); + assert_eq!(out.tree.instances_failed, 0); + + let report = read_timing_json(&temp, &timing); + assert_eq!(report["phases"]["tal_bootstrap"]["count"].as_u64(), Some(1)); + assert_eq!( + report["phases"]["tree_run_total"]["count"].as_u64(), + Some(1) + ); } diff --git a/tests/test_storage_iter_all.rs b/tests/test_storage_iter_all.rs index 7fe8a0b..2886677 100644 --- a/tests/test_storage_iter_all.rs +++ b/tests/test_storage_iter_all.rs @@ -1,7 +1,7 @@ -use rpki::storage::{FetchCachePpKey, RocksStore}; +use rpki::storage::RocksStore; #[test] -fn storage_iter_all_lists_raw_and_fetch_cache_pp_entries() { +fn storage_iter_all_lists_raw_entries() { let temp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(temp.path()).expect("open rocksdb"); @@ -12,11 +12,6 @@ fn storage_iter_all_lists_raw_and_fetch_cache_pp_entries() { .put_raw("rsync://example.test/repo/b.roa", b"b") .expect("put_raw b"); - let key = FetchCachePpKey::from_manifest_rsync_uri("rsync://example.test/repo/m.mft"); - store - .put_fetch_cache_pp(&key, b"x") - .expect("put_fetch_cache_pp"); - let raw_keys = store .raw_iter_all() .expect("raw_iter_all") @@ -25,11 +20,4 @@ fn storage_iter_all_lists_raw_and_fetch_cache_pp_entries() { assert_eq!(raw_keys.len(), 2); assert!(raw_keys.contains(&"rsync://example.test/repo/a.cer".to_string())); assert!(raw_keys.contains(&"rsync://example.test/repo/b.roa".to_string())); - - let keys = store - .fetch_cache_pp_iter_all() - .expect("fetch_cache_pp_iter_all") - .map(|(k, _v)| String::from_utf8(k.to_vec()).expect("utf8 key")) - .collect::>(); - assert_eq!(keys, vec![key.as_str().to_string()]); } diff --git a/tests/test_storage_misc_coverage_more.rs b/tests/test_storage_misc_coverage_more.rs index d0cb7bf..59c11a6 100644 --- a/tests/test_storage_misc_coverage_more.rs +++ b/tests/test_storage_misc_coverage_more.rs @@ -1,6 +1,6 @@ use rocksdb::WriteBatch; -use rpki::storage::{FetchCachePpKey, RocksStore}; +use rpki::storage::RocksStore; #[test] fn storage_delete_rrdp_state_works() { @@ -57,15 +57,6 @@ fn storage_raw_iter_prefix_filters_by_prefix() { } } -#[test] -fn storage_fetch_cache_pp_key_format_is_stable() { - let k = FetchCachePpKey::from_manifest_rsync_uri("rsync://example.net/repo/manifest.mft"); - assert_eq!( - k.as_str(), - "fetch_cache_pp:rsync://example.net/repo/manifest.mft" - ); -} - #[test] fn storage_write_batch_accepts_empty_batch() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/tests/test_storage_rocksdb.rs b/tests/test_storage_rocksdb.rs index 2c8e384..d007b4a 100644 --- a/tests/test_storage_rocksdb.rs +++ b/tests/test_storage_rocksdb.rs @@ -1,6 +1,6 @@ use std::path::Path; -use rpki::storage::{FetchCachePpKey, RocksStore}; +use rpki::storage::RocksStore; #[test] fn storage_opens_and_creates_column_families() { @@ -24,26 +24,6 @@ fn raw_objects_roundtrip_by_rsync_uri() { assert!(got.is_none()); } -#[test] -fn fetch_cache_pp_roundtrip_by_manifest_uri() { - let dir = tempfile::tempdir().expect("tempdir"); - let store = RocksStore::open(dir.path()).expect("open rocksdb"); - - let manifest_uri = "rsync://example.invalid/repo/manifest.mft"; - let key = FetchCachePpKey::from_manifest_rsync_uri(manifest_uri); - assert_eq!( - key.as_str(), - "fetch_cache_pp:rsync://example.invalid/repo/manifest.mft" - ); - - let bytes = b"pack"; - store - .put_fetch_cache_pp(&key, bytes) - .expect("put fetch_cache_pp"); - let got = store.get_fetch_cache_pp(&key).expect("get fetch_cache_pp"); - assert_eq!(got.as_deref(), Some(bytes.as_slice())); -} - #[test] fn rrdp_state_roundtrip_by_notification_uri() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/tests/test_tree_failure_handling.rs b/tests/test_tree_failure_handling.rs index f01e2dd..b48af6d 100644 --- a/tests/test_tree_failure_handling.rs +++ b/tests/test_tree_failure_handling.rs @@ -2,17 +2,18 @@ use std::collections::HashMap; use rpki::audit::{DiscoveredFrom, PublicationPointAudit}; use rpki::report::Warning; -use rpki::storage::{FetchCachePpPack, PackTime}; +use rpki::storage::PackTime; use rpki::validation::manifest::PublicationPointSource; use rpki::validation::objects::{ObjectsOutput, ObjectsStats}; +use rpki::validation::publication_point::PublicationPointSnapshot; use rpki::validation::tree::{ CaInstanceHandle, DiscoveredChildCaInstance, PublicationPointRunResult, PublicationPointRunner, TreeRunConfig, run_tree_serial, }; -fn empty_pack(manifest_uri: &str, pp_uri: &str) -> FetchCachePpPack { - FetchCachePpPack { - format_version: FetchCachePpPack::FORMAT_VERSION_V1, +fn empty_snapshot(manifest_uri: &str, pp_uri: &str) -> PublicationPointSnapshot { + PublicationPointSnapshot { + format_version: PublicationPointSnapshot::FORMAT_VERSION_V1, publication_point_rsync_uri: pp_uri.to_string(), manifest_rsync_uri: manifest_uri.to_string(), manifest_number_be: vec![1], @@ -33,6 +34,8 @@ fn empty_pack(manifest_uri: &str, pp_uri: &str) -> FetchCachePpPack { fn ca_handle(manifest_uri: &str) -> CaInstanceHandle { CaInstanceHandle { depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, ca_certificate_der: Vec::new(), ca_certificate_rsync_uri: None, effective_ip_resources: None, @@ -104,7 +107,7 @@ fn tree_continues_when_a_publication_point_fails() { root_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, - pack: empty_pack(root_manifest, "rsync://example.test/repo/"), + snapshot: Some(empty_snapshot(root_manifest, "rsync://example.test/repo/")), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), @@ -125,7 +128,10 @@ fn tree_continues_when_a_publication_point_fails() { ok_child_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, - pack: empty_pack(ok_child_manifest, "rsync://example.test/repo/ok-child/"), + snapshot: Some(empty_snapshot( + ok_child_manifest, + "rsync://example.test/repo/ok-child/", + )), warnings: vec![Warning::new("ok child warning")], objects: ObjectsOutput { vrps: Vec::new(), diff --git a/tests/test_tree_traversal_m14.rs b/tests/test_tree_traversal_m14.rs index fa5a335..c39bc28 100644 --- a/tests/test_tree_traversal_m14.rs +++ b/tests/test_tree_traversal_m14.rs @@ -2,9 +2,10 @@ use std::collections::HashMap; use rpki::audit::{DiscoveredFrom, PublicationPointAudit}; use rpki::report::Warning; -use rpki::storage::{FetchCachePpPack, PackFile, PackTime}; +use rpki::storage::{PackFile, PackTime}; use rpki::validation::manifest::PublicationPointSource; use rpki::validation::objects::{ObjectsOutput, ObjectsStats}; +use rpki::validation::publication_point::PublicationPointSnapshot; use rpki::validation::tree::{ CaInstanceHandle, DiscoveredChildCaInstance, PublicationPointRunResult, PublicationPointRunner, TreeRunConfig, run_tree_serial, run_tree_serial_audit, @@ -43,8 +44,8 @@ impl PublicationPointRunner for MockRunner { } } -fn empty_pack(manifest_uri: &str, pp_uri: &str) -> FetchCachePpPack { - FetchCachePpPack { +fn empty_snapshot(manifest_uri: &str, pp_uri: &str) -> PublicationPointSnapshot { + PublicationPointSnapshot { format_version: 1, publication_point_rsync_uri: pp_uri.to_string(), manifest_rsync_uri: manifest_uri.to_string(), @@ -66,6 +67,8 @@ fn empty_pack(manifest_uri: &str, pp_uri: &str) -> FetchCachePpPack { fn ca_handle(manifest_uri: &str) -> CaInstanceHandle { CaInstanceHandle { depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, ca_certificate_der: Vec::new(), ca_certificate_rsync_uri: None, effective_ip_resources: None, @@ -97,7 +100,7 @@ fn discovered_child( } #[test] -fn tree_enqueues_children_only_for_fresh_publication_points() { +fn tree_enqueues_children_for_fresh_and_current_instance_vcir_results() { let root_manifest = "rsync://example.test/repo/root.mft"; let child1_manifest = "rsync://example.test/repo/child1.mft"; let child2_manifest = "rsync://example.test/repo/child2.mft"; @@ -114,7 +117,7 @@ fn tree_enqueues_children_only_for_fresh_publication_points() { root_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, - pack: empty_pack(root_manifest, "rsync://example.test/repo/"), + snapshot: Some(empty_snapshot(root_manifest, "rsync://example.test/repo/")), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), @@ -130,8 +133,11 @@ fn tree_enqueues_children_only_for_fresh_publication_points() { .with( child1_manifest, PublicationPointRunResult { - source: PublicationPointSource::FetchCachePp, - pack: empty_pack(child1_manifest, "rsync://example.test/repo/child1/"), + source: PublicationPointSource::VcirCurrentInstance, + snapshot: Some(empty_snapshot( + child1_manifest, + "rsync://example.test/repo/child1/", + )), warnings: vec![Warning::new("child1 warning")], objects: ObjectsOutput { vrps: Vec::new(), @@ -148,7 +154,30 @@ fn tree_enqueues_children_only_for_fresh_publication_points() { child2_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, - pack: empty_pack(child2_manifest, "rsync://example.test/repo/child2/"), + snapshot: Some(empty_snapshot( + child2_manifest, + "rsync://example.test/repo/child2/", + )), + warnings: Vec::new(), + objects: ObjectsOutput { + vrps: Vec::new(), + aspas: Vec::new(), + warnings: Vec::new(), + stats: ObjectsStats::default(), + audit: Vec::new(), + }, + audit: PublicationPointAudit::default(), + discovered_children: Vec::new(), + }, + ) + .with( + grandchild_manifest, + PublicationPointRunResult { + source: PublicationPointSource::Fresh, + snapshot: Some(empty_snapshot( + grandchild_manifest, + "rsync://example.test/repo/grandchild/", + )), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), @@ -165,14 +194,20 @@ fn tree_enqueues_children_only_for_fresh_publication_points() { let out = run_tree_serial(ca_handle(root_manifest), &runner, &TreeRunConfig::default()) .expect("run tree"); - // root + child1 + child2. grandchild must NOT be processed because child1 used cache. - assert_eq!(out.instances_processed, 3); + // root + child1 + child2 + grandchild. child1 uses current-instance VCIR, but its child + // entry is still restored and processed fresh-first later in the traversal. + assert_eq!(out.instances_processed, 4); assert_eq!(out.instances_failed, 0); let called = runner.called(); assert_eq!( called, - vec![root_manifest, child1_manifest, child2_manifest] + vec![ + root_manifest, + child1_manifest, + child2_manifest, + grandchild_manifest + ] ); assert!( @@ -184,8 +219,8 @@ fn tree_enqueues_children_only_for_fresh_publication_points() { assert!( out.warnings .iter() - .any(|w| w.message.contains("skipping child CA discovery")), - "expected RFC 9286 §6.6 enforcement warning" + .all(|w| !w.message.contains("skipping child CA discovery")), + "did not expect old skip-child warning" ); } @@ -199,7 +234,7 @@ fn tree_respects_max_depth_and_max_instances() { root_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, - pack: empty_pack(root_manifest, "rsync://example.test/repo/"), + snapshot: Some(empty_snapshot(root_manifest, "rsync://example.test/repo/")), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), @@ -216,7 +251,10 @@ fn tree_respects_max_depth_and_max_instances() { child_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, - pack: empty_pack(child_manifest, "rsync://example.test/repo/child/"), + snapshot: Some(empty_snapshot( + child_manifest, + "rsync://example.test/repo/child/", + )), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), @@ -236,7 +274,6 @@ fn tree_respects_max_depth_and_max_instances() { &TreeRunConfig { max_depth: Some(0), max_instances: None, - revalidate_only: false, }, ) .expect("run tree depth-limited"); @@ -249,7 +286,6 @@ fn tree_respects_max_depth_and_max_instances() { &TreeRunConfig { max_depth: None, max_instances: Some(1), - revalidate_only: false, }, ) .expect("run tree instance-limited"); @@ -267,7 +303,7 @@ fn tree_audit_includes_parent_and_discovered_from_for_non_root_nodes() { root_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, - pack: empty_pack(root_manifest, "rsync://example.test/repo/"), + snapshot: Some(empty_snapshot(root_manifest, "rsync://example.test/repo/")), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), @@ -284,7 +320,10 @@ fn tree_audit_includes_parent_and_discovered_from_for_non_root_nodes() { child_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, - pack: empty_pack(child_manifest, "rsync://example.test/repo/child/"), + snapshot: Some(empty_snapshot( + child_manifest, + "rsync://example.test/repo/child/", + )), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), @@ -320,16 +359,23 @@ fn tree_audit_includes_parent_and_discovered_from_for_non_root_nodes() { } #[test] -fn tree_revalidate_only_enqueues_children_from_fetch_cache_pp() { +fn tree_prefers_lexicographically_first_discovery_when_duplicate_manifest_is_queued() { let root_manifest = "rsync://example.test/repo/root.mft"; - let child_manifest = "rsync://example.test/repo/child.mft"; + let duplicate_manifest = "rsync://example.test/repo/child.mft"; + + let mut first = discovered_child(root_manifest, duplicate_manifest); + first.discovered_from.child_ca_certificate_rsync_uri = + "rsync://example.test/repo/z-child.cer".to_string(); + let mut second = discovered_child(root_manifest, duplicate_manifest); + second.discovered_from.child_ca_certificate_rsync_uri = + "rsync://example.test/repo/a-child.cer".to_string(); let runner = MockRunner::default() .with( root_manifest, PublicationPointRunResult { - source: PublicationPointSource::FetchCachePp, - pack: empty_pack(root_manifest, "rsync://example.test/repo/"), + source: PublicationPointSource::Fresh, + snapshot: Some(empty_snapshot(root_manifest, "rsync://example.test/repo/")), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), @@ -339,14 +385,17 @@ fn tree_revalidate_only_enqueues_children_from_fetch_cache_pp() { audit: Vec::new(), }, audit: PublicationPointAudit::default(), - discovered_children: vec![discovered_child(root_manifest, child_manifest)], + discovered_children: vec![first, second], }, ) .with( - child_manifest, + duplicate_manifest, PublicationPointRunResult { - source: PublicationPointSource::FetchCachePp, - pack: empty_pack(child_manifest, "rsync://example.test/repo/child/"), + source: PublicationPointSource::Fresh, + snapshot: Some(empty_snapshot( + duplicate_manifest, + "rsync://example.test/repo/child/", + )), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), @@ -360,27 +409,21 @@ fn tree_revalidate_only_enqueues_children_from_fetch_cache_pp() { }, ); - let out = run_tree_serial( - ca_handle(root_manifest), - &runner, - &TreeRunConfig { - max_depth: None, - max_instances: None, - revalidate_only: false, - }, - ) - .expect("run tree"); - assert_eq!(out.instances_processed, 1); + let out = run_tree_serial_audit(ca_handle(root_manifest), &runner, &TreeRunConfig::default()) + .expect("run tree audit"); - let out = run_tree_serial( - ca_handle(root_manifest), - &runner, - &TreeRunConfig { - max_depth: None, - max_instances: None, - revalidate_only: true, - }, - ) - .expect("run tree"); - assert_eq!(out.instances_processed, 2); + assert_eq!(out.tree.instances_processed, 2); + assert_eq!( + runner.called(), + vec![root_manifest.to_string(), duplicate_manifest.to_string()] + ); + let child_audit = &out.publication_points[1]; + assert_eq!( + child_audit + .discovered_from + .as_ref() + .expect("child discovered_from") + .child_ca_certificate_rsync_uri, + "rsync://example.test/repo/a-child.cer" + ); } diff --git a/tests/test_verified_pack.rs b/tests/test_verified_pack.rs deleted file mode 100644 index ae7419d..0000000 --- a/tests/test_verified_pack.rs +++ /dev/null @@ -1,69 +0,0 @@ -use rpki::storage::{FetchCachePpPack, PackFile, PackTime}; - -fn sample_pack() -> FetchCachePpPack { - let this_update = - PackTime::from_utc_offset_datetime(time::OffsetDateTime::from_unix_timestamp(0).unwrap()); - let next_update = PackTime::from_utc_offset_datetime( - time::OffsetDateTime::from_unix_timestamp(3600).unwrap(), - ); - let verified_at = - PackTime::from_utc_offset_datetime(time::OffsetDateTime::from_unix_timestamp(10).unwrap()); - - let file1 = PackFile::from_bytes_compute_sha256( - "rsync://example.net/repo/CA/1.crl", - b"crl-bytes".to_vec(), - ); - let file2 = PackFile::from_bytes_compute_sha256( - "rsync://example.net/repo/CA/2.cer", - b"cer-bytes".to_vec(), - ); - - FetchCachePpPack { - format_version: FetchCachePpPack::FORMAT_VERSION_V1, - manifest_rsync_uri: "rsync://example.net/repo/CA/manifest.mft".to_string(), - publication_point_rsync_uri: "rsync://example.net/repo/CA/".to_string(), - manifest_number_be: vec![1], - this_update, - next_update, - verified_at, - manifest_bytes: b"manifest-bytes".to_vec(), - files: vec![file1, file2], - } -} - -#[test] -fn pack_encode_decode_roundtrip() { - let pack = sample_pack(); - let bytes = pack.encode().expect("encode"); - let decoded = FetchCachePpPack::decode(&bytes).expect("decode"); - assert_eq!(decoded, pack); -} - -#[test] -fn pack_rejects_missing_manifest() { - let mut pack = sample_pack(); - pack.manifest_bytes.clear(); - let bytes = pack.encode().expect("encode"); - assert!(FetchCachePpPack::decode(&bytes).is_err()); -} - -#[test] -fn pack_rejects_duplicate_rsync_uri_entries() { - let mut pack = sample_pack(); - let dup = - PackFile::from_bytes_compute_sha256("rsync://example.net/repo/CA/1.crl", b"other".to_vec()); - pack.files.push(dup); - let bytes = pack.encode().expect("encode"); - assert!(FetchCachePpPack::decode(&bytes).is_err()); -} - -#[test] -fn pack_includes_this_update_next_update() { - let pack = sample_pack(); - let bytes = pack.encode().expect("encode"); - let decoded = FetchCachePpPack::decode(&bytes).expect("decode"); - - let this_update = decoded.this_update.parse().expect("parse this_update"); - let next_update = decoded.next_update.parse().expect("parse next_update"); - assert!(next_update > this_update); -} diff --git a/tests/test_verified_pack_decode_errors_more.rs b/tests/test_verified_pack_decode_errors_more.rs deleted file mode 100644 index 398e439..0000000 --- a/tests/test_verified_pack_decode_errors_more.rs +++ /dev/null @@ -1,129 +0,0 @@ -use rpki::storage::{FetchCachePpPack, PackFile, PackTime}; - -fn base_pack() -> FetchCachePpPack { - let this_update = - PackTime::from_utc_offset_datetime(time::OffsetDateTime::from_unix_timestamp(0).unwrap()); - let next_update = PackTime::from_utc_offset_datetime( - time::OffsetDateTime::from_unix_timestamp(3600).unwrap(), - ); - let verified_at = - PackTime::from_utc_offset_datetime(time::OffsetDateTime::from_unix_timestamp(10).unwrap()); - - let file = - PackFile::from_bytes_compute_sha256("rsync://example.net/repo/obj.cer", b"x".to_vec()); - - FetchCachePpPack { - format_version: FetchCachePpPack::FORMAT_VERSION_V1, - manifest_rsync_uri: "rsync://example.net/repo/manifest.mft".to_string(), - publication_point_rsync_uri: "rsync://example.net/repo/".to_string(), - manifest_number_be: vec![1], - this_update, - next_update, - verified_at, - manifest_bytes: b"manifest".to_vec(), - files: vec![file], - } -} - -#[test] -fn pack_rejects_unsupported_format_version() { - let mut pack = base_pack(); - pack.format_version = 999; - let bytes = pack.encode().expect("encode"); - assert!( - FetchCachePpPack::decode(&bytes) - .unwrap_err() - .to_string() - .contains("unsupported pack format_version") - ); -} - -#[test] -fn pack_rejects_missing_manifest_rsync_uri() { - let mut pack = base_pack(); - pack.manifest_rsync_uri.clear(); - let bytes = pack.encode().expect("encode"); - assert!(FetchCachePpPack::decode(&bytes).is_err()); -} - -#[test] -fn pack_rejects_missing_publication_point_rsync_uri() { - let mut pack = base_pack(); - pack.publication_point_rsync_uri.clear(); - let bytes = pack.encode().expect("encode"); - assert!(FetchCachePpPack::decode(&bytes).is_err()); -} - -#[test] -fn pack_rejects_missing_manifest_number() { - let mut pack = base_pack(); - pack.manifest_number_be.clear(); - let bytes = pack.encode().expect("encode"); - let err = FetchCachePpPack::decode(&bytes).unwrap_err(); - assert!(err.to_string().contains("missing required field")); -} - -#[test] -fn pack_rejects_manifest_number_too_long() { - let mut pack = base_pack(); - pack.manifest_number_be = vec![1u8; 21]; - let bytes = pack.encode().expect("encode"); - let err = FetchCachePpPack::decode(&bytes).unwrap_err(); - assert!(err.to_string().contains("at most 20 octets")); -} - -#[test] -fn pack_rejects_manifest_number_with_leading_zeros() { - let mut pack = base_pack(); - pack.manifest_number_be = vec![0u8, 1u8]; - let bytes = pack.encode().expect("encode"); - let err = FetchCachePpPack::decode(&bytes).unwrap_err(); - assert!(err.to_string().contains("leading zeros")); -} - -#[test] -fn pack_rejects_invalid_time_fields() { - let mut pack = base_pack(); - pack.this_update = PackTime { - rfc3339_utc: "not-a-time".to_string(), - }; - let bytes = pack.encode().expect("encode"); - assert!(FetchCachePpPack::decode(&bytes).is_err()); -} - -#[test] -fn pack_rejects_empty_file_bytes() { - let mut pack = base_pack(); - let mut sha = [0u8; 32]; - sha[0] = 1; - pack.files = vec![PackFile::new( - "rsync://example.net/repo/empty.cer", - Vec::new(), - sha, - )]; - let bytes = pack.encode().expect("encode"); - assert!(FetchCachePpPack::decode(&bytes).is_err()); -} - -#[test] -fn pack_rejects_file_hash_mismatch() { - let mut pack = base_pack(); - pack.files = vec![PackFile::new( - "rsync://example.net/repo/bad.cer", - b"abc".to_vec(), - [0u8; 32], - )]; - let bytes = pack.encode().expect("encode"); - let err = FetchCachePpPack::decode(&bytes).unwrap_err(); - assert!(err.to_string().contains("file hash mismatch")); -} - -#[test] -fn pack_rejects_missing_file_rsync_uri() { - let mut pack = base_pack(); - let file = PackFile::from_bytes_compute_sha256("", b"x".to_vec()); - pack.files = vec![file]; - let bytes = pack.encode().expect("encode"); - let err = FetchCachePpPack::decode(&bytes).unwrap_err(); - assert!(err.to_string().contains("missing required field")); -}