From 9f7981e117279809ef91ef99f7120ad3f6dc8f45 Mon Sep 17 00:00:00 2001 From: yuyr Date: Fri, 29 May 2026 17:32:40 +0800 Subject: [PATCH] =?UTF-8?q?20260529=20=E4=BF=AE=E5=A4=8DCIR=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E4=B8=8E=E6=8B=92=E7=BB=9D=E5=AE=A1=E8=AE=A1=E8=AF=AD?= =?UTF-8?q?=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cir/export.rs | 138 ++++++++++++++++++++-- src/validation/manifest.rs | 45 ++++++++ src/validation/tree_runner.rs | 208 +++++++++++++++++++++++++++++++++- 3 files changed, 380 insertions(+), 11 deletions(-) diff --git a/src/cir/export.rs b/src/cir/export.rs index a077501..b1057c3 100644 --- a/src/cir/export.rs +++ b/src/cir/export.rs @@ -193,18 +193,11 @@ pub fn build_cir_from_run_multi( reject_list_sha256: Vec::new(), rejected_objects: Vec::new(), }; - let object_uri_set = cir - .objects - .iter() - .map(|item| item.rsync_uri.as_str()) - .collect::>(); let mut rejected_objects = publication_points .iter() .flat_map(|pp| pp.objects.iter()) - .filter(|item| { - item.result == AuditObjectResult::Error - && object_uri_set.contains(item.rsync_uri.as_str()) - }) + .filter(|item| item.result == AuditObjectResult::Error) + .filter(|item| item.rsync_uri.starts_with("rsync://")) .map(|item| CirRejectedObject { object_uri: item.rsync_uri.clone(), reason: item.detail.clone(), @@ -754,7 +747,7 @@ mod tests { sha256_hex: "33".repeat(32), kind: crate::audit::AuditObjectKind::Roa, result: crate::audit::AuditObjectResult::Error, - detail: Some("not in cir object set".to_string()), + detail: Some("second rejected roa".to_string()), }, ], ..PublicationPointAudit::default() @@ -785,6 +778,18 @@ mod tests { cir.rejected_objects[1].object_uri, "rsync://example.test/repo/c.roa" ); + assert!( + cir.objects + .iter() + .any(|item| item.rsync_uri == "rsync://example.test/repo/a.roa"), + "rejected audit objects were still consumed as validation input", + ); + assert!( + cir.objects + .iter() + .any(|item| item.rsync_uri == "rsync://example.test/repo/c.roa"), + "rejected audit objects were still consumed as validation input", + ); assert!( !cir.objects .iter() @@ -793,6 +798,119 @@ mod tests { ); } + #[test] + fn build_cir_from_run_multi_records_crl_expired_manifest_in_objects_and_rejects() { + let td = tempfile::tempdir().unwrap(); + let store = RocksStore::open(td.path()).unwrap(); + let ta = sample_trust_anchor(); + let manifest_uri = "rsync://example.test/repo/expired-crl.mft"; + let reject_reason = "manifest embedded EE certificate path validation failed: CRL not valid at validation_time (RFC 5280 §6.3.3(g); RFC 5280 §5.1.2.4-§5.1.2.5; RFC 6487 §5)"; + let publication_points = vec![PublicationPointAudit { + objects: vec![crate::audit::ObjectAuditEntry { + rsync_uri: manifest_uri.to_string(), + sha256_hex: "44".repeat(32), + kind: crate::audit::AuditObjectKind::Manifest, + result: crate::audit::AuditObjectResult::Error, + detail: Some(reject_reason.to_string()), + }], + ..PublicationPointAudit::default() + }]; + + let cir = build_cir_from_run_multi( + &store, + &[CirTrustAnchorBinding { + trust_anchor: &ta, + tal_uri: "https://example.test/root.tal", + }], + sample_time(), + &publication_points, + None, + ) + .expect("build cir"); + + assert!( + cir.objects + .iter() + .any(|item| item.rsync_uri == manifest_uri), + "manifest rejected because issuer CRL is expired was still read as validation input", + ); + assert_eq!(cir.rejected_objects.len(), 1); + assert_eq!(cir.rejected_objects[0].object_uri, manifest_uri); + assert_eq!( + cir.rejected_objects[0].reason.as_deref(), + Some(reject_reason) + ); + assert_eq!( + cir.reject_list_sha256, + compute_reject_list_sha256([manifest_uri].into_iter()) + ); + } + + #[test] + fn build_cir_from_run_multi_excludes_manifest_locked_files_when_manifest_is_rejected() { + let td = tempfile::tempdir().unwrap(); + let store = RocksStore::open(td.path()).unwrap(); + let ta = sample_trust_anchor(); + let manifest_uri = "rsync://example.test/repo/rejected.mft"; + let roa_uri = "rsync://example.test/repo/listed.roa"; + let crl_uri = "rsync://example.test/repo/listed.crl"; + let publication_points = vec![PublicationPointAudit { + objects: vec![ + crate::audit::ObjectAuditEntry { + rsync_uri: manifest_uri.to_string(), + sha256_hex: "44".repeat(32), + kind: crate::audit::AuditObjectKind::Manifest, + result: crate::audit::AuditObjectResult::Error, + detail: Some("manifest EE cert path rejected".to_string()), + }, + crate::audit::ObjectAuditEntry { + rsync_uri: roa_uri.to_string(), + sha256_hex: "55".repeat(32), + kind: crate::audit::AuditObjectKind::Roa, + result: crate::audit::AuditObjectResult::Skipped, + detail: Some("manifest rejected before locked object validation".to_string()), + }, + crate::audit::ObjectAuditEntry { + rsync_uri: crl_uri.to_string(), + sha256_hex: "66".repeat(32), + kind: crate::audit::AuditObjectKind::Crl, + result: crate::audit::AuditObjectResult::Skipped, + detail: Some("manifest rejected before locked object validation".to_string()), + }, + ], + ..PublicationPointAudit::default() + }]; + + let cir = build_cir_from_run_multi( + &store, + &[CirTrustAnchorBinding { + trust_anchor: &ta, + tal_uri: "https://example.test/root.tal", + }], + sample_time(), + &publication_points, + None, + ) + .expect("build cir"); + + assert!( + cir.objects + .iter() + .any(|item| item.rsync_uri == manifest_uri), + "rejected manifest is still a current-run validation input", + ); + assert!( + !cir.objects.iter().any(|item| item.rsync_uri == roa_uri), + "ROA listed by a rejected manifest must not enter CIR objects", + ); + assert!( + !cir.objects.iter().any(|item| item.rsync_uri == crl_uri), + "CRL listed by a rejected manifest must not enter CIR objects", + ); + assert_eq!(cir.rejected_objects.len(), 1); + assert_eq!(cir.rejected_objects[0].object_uri, manifest_uri); + } + #[test] fn build_cir_from_run_multi_reject_digest_ignores_reason_text() { let td = tempfile::tempdir().unwrap(); diff --git a/src/validation/manifest.rs b/src/validation/manifest.rs index 1009d22..f7e6db5 100644 --- a/src/validation/manifest.rs +++ b/src/validation/manifest.rs @@ -1602,6 +1602,51 @@ mod tests { assert!(matches!(err, ManifestFreshError::EeCrlNotFound(_)), "{err}"); } + #[test] + fn validate_manifest_embedded_ee_cert_path_rejects_expired_crl() { + let (manifest, _, _, publication_point_rsync_uri, _) = load_manifest_fixture(); + let files = locked_files_for_manifest(&manifest, &publication_point_rsync_uri); + let ee = &manifest.signed_object.signed_data.certificates[0]; + let crldp_uri = ee + .resource_cert + .tbs + .extensions + .crl_distribution_points_uris + .as_ref() + .and_then(|uris| uris.first()) + .expect("fixture manifest EE CRLDP") + .as_str() + .to_string(); + let crl_file = files + .iter() + .find(|file| file.rsync_uri == crldp_uri) + .expect("fixture CRL referenced by manifest EE"); + let crl = crate::data_model::crl::RpkixCrl::decode_der( + crl_file.bytes().expect("read fixture crl bytes"), + ) + .expect("decode fixture crl"); + let validation_time = crl.next_update.utc; + + 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::EeCertPath( + crate::validation::cert_path::CertPathError::CrlNotValidAtTime + ) + ), + "{err}" + ); + } + #[test] fn load_current_instance_vcir_publication_point_returns_manifest_and_locked_files() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/validation/tree_runner.rs b/src/validation/tree_runner.rs index dcd1e02..71c6bef 100644 --- a/src/validation/tree_runner.rs +++ b/src/validation/tree_runner.rs @@ -183,6 +183,36 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> { } } + fn current_manifest_hash_hex_for_audit(&self, ca: &CaInstanceHandle) -> Option { + if let Some(index_handle) = self.current_repo_index.as_ref() + && let Ok(index) = index_handle.lock() + && let Some(entry) = index.get_by_uri(&ca.manifest_rsync_uri) + { + return Some(entry.current_hash_hex.clone()); + } + + self.store + .load_current_object_with_hash_by_uri(&ca.manifest_rsync_uri) + .ok() + .flatten() + .map(|current| current.current_hash_hex) + } + + fn rejected_manifest_audit_entry_for_failed_fetch( + &self, + ca: &CaInstanceHandle, + fresh_err: &ManifestFreshError, + ) -> Option { + let sha256_hex = self.current_manifest_hash_hex_for_audit(ca)?; + Some(ObjectAuditEntry { + rsync_uri: ca.manifest_rsync_uri.clone(), + sha256_hex, + kind: AuditObjectKind::Manifest, + result: AuditObjectResult::Error, + detail: Some(fresh_err.to_string()), + }) + } + pub(crate) fn stage_fresh_publication_point_after_repo_ready( &self, ca: &CaInstanceHandle, @@ -811,13 +841,21 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> { } crate::policy::CaFailedFetchPolicy::ReuseCurrentInstanceVcir => { let projection_started = std::time::Instant::now(); - let projection = project_current_instance_vcir_on_failed_fetch( + let mut 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}"))?; + if matches!( + projection.source, + PublicationPointSource::FailedFetchNoCache + ) && let Some(rejected_manifest) = + self.rejected_manifest_audit_entry_for_failed_fetch(ca, &fresh_err) + { + projection.objects.audit.push(rejected_manifest); + } self.append_ccr_manifest_projection_from_reuse(&projection)?; let projection_ms = projection_started.elapsed().as_millis() as u64; warnings.extend(projection.warnings.clone()); @@ -1849,6 +1887,40 @@ fn build_publication_point_audit_from_vcir( }; }; + if source == PublicationPointSource::FailedFetchNoCache { + let mut objects_out = Vec::with_capacity(objects.audit.len() + child_audits.len()); + objects_out.extend(child_audits.iter().cloned()); + objects_out.extend(objects.audit.iter().cloned()); + 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), + repo_sync_source: repo_sync_source.map(ToString::to_string), + repo_sync_phase: repo_sync_phase.map(ToString::to_string), + repo_sync_duration_ms, + repo_sync_error: repo_sync_error.map(ToString::to_string), + repo_terminal_state: terminal_state_label(source).to_string(), + 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, + }; + } + let mut audit_by_uri: HashMap = HashMap::new(); for artifact in &vcir.related_artifacts { let Some(uri) = artifact.uri.as_ref() else { @@ -6428,6 +6500,140 @@ authorityKeyIdentifier = keyid:always })); } + #[test] + fn build_publication_point_audit_from_vcir_failed_no_cache_keeps_current_reject_only() { + let now = time::OffsetDateTime::now_utc(); + let child_cert_hash = sha256_hex(b"child-cert"); + let vcir = sample_vcir_for_projection(now, &child_cert_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 objects = crate::validation::objects::ObjectsOutput { + vrps: Vec::new(), + aspas: Vec::new(), + router_keys: Vec::new(), + local_outputs_cache: Vec::new(), + warnings: Vec::new(), + stats: crate::validation::objects::ObjectsStats::default(), + audit: vec![ObjectAuditEntry { + rsync_uri: vcir.current_manifest_rsync_uri.clone(), + sha256_hex: sha256_hex(b"current-manifest"), + kind: AuditObjectKind::Manifest, + result: AuditObjectResult::Error, + detail: Some("manifest is not valid at validation_time".to_string()), + }], + }; + + let audit = build_publication_point_audit_from_vcir( + &ca, + PublicationPointSource::FailedFetchNoCache, + Some("rsync"), + Some("rsync_only_ok"), + Some(123), + None, + Some(&vcir), + None, + &[Warning::new("latest VCIR instance_gate expired")], + &objects, + &[], + ); + + assert_eq!(audit.source, "failed_fetch_no_cache"); + assert_eq!(audit.repo_terminal_state, "failed_no_cache"); + assert_eq!( + audit.this_update_rfc3339_utc, + vcir.validated_manifest_meta + .validated_manifest_this_update + .rfc3339_utc + ); + assert_eq!(audit.objects.len(), 1); + assert_eq!(audit.objects[0].rsync_uri, vcir.current_manifest_rsync_uri); + assert!(matches!(audit.objects[0].result, AuditObjectResult::Error)); + assert!( + !audit + .objects + .iter() + .any(|entry| entry.rsync_uri == "rsync://example.test/repo/issuer/a.roa"), + "failed-no-cache must not expand old VCIR related artifacts into current-run audit", + ); + assert!( + !audit + .objects + .iter() + .any(|entry| entry.rsync_uri == "rsync://example.test/repo/issuer/issuer.crl"), + "failed-no-cache must not expose old CRL as current-run CIR input", + ); + } + + #[test] + fn rejected_manifest_audit_entry_for_failed_fetch_uses_current_repo_hash() { + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + let policy = Policy::default(); + let runner = sample_runner_with_ccr_accumulator(&store, &policy); + let manifest_uri = "rsync://example.test/repo/issuer/issuer.mft"; + let manifest_hash = sha256_hex(b"manifest-bytes"); + store + .put_blob_bytes_batch(&[(manifest_hash.clone(), b"manifest-bytes".to_vec())]) + .expect("put manifest bytes"); + store + .put_repository_view_entry(&crate::storage::RepositoryViewEntry { + rsync_uri: manifest_uri.to_string(), + current_hash: Some(manifest_hash.clone()), + repository_source: Some("rsync://example.test/repo/issuer/".to_string()), + object_type: Some("mft".to_string()), + state: crate::storage::RepositoryViewState::Present, + }) + .expect("put repository view"); + 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: manifest_uri.to_string(), + publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), + rrdp_notification_uri: None, + }; + + let entry = runner + .rejected_manifest_audit_entry_for_failed_fetch( + &ca, + &ManifestFreshError::StaleOrEarly { + this_update_rfc3339_utc: "2026-05-27T08:37:07Z".to_string(), + next_update_rfc3339_utc: "2026-05-28T10:01:07Z".to_string(), + validation_time_rfc3339_utc: "2026-05-28T10:11:00Z".to_string(), + }, + ) + .expect("rejected manifest audit entry"); + + assert_eq!(entry.rsync_uri, manifest_uri); + assert_eq!(entry.sha256_hex, manifest_hash); + assert_eq!(entry.kind, AuditObjectKind::Manifest); + assert_eq!(entry.result, AuditObjectResult::Error); + assert!( + entry + .detail + .as_deref() + .unwrap_or("") + .contains("manifest is not valid at validation_time") + ); + } + #[test] fn build_publication_point_audit_from_vcir_without_cached_inputs_returns_empty_listing() { let ca = CaInstanceHandle {