20260529 修复CIR输入与拒绝审计语义

This commit is contained in:
yuyr 2026-05-29 17:32:40 +08:00
parent 57c23f19aa
commit 9f7981e117
3 changed files with 380 additions and 11 deletions

View File

@ -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::<BTreeSet<_>>();
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();

View File

@ -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");

View File

@ -183,6 +183,36 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
}
}
fn current_manifest_hash_hex_for_audit(&self, ca: &CaInstanceHandle) -> Option<String> {
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<ObjectAuditEntry> {
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<String, ObjectAuditEntry> = 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 {