初始化 Panda RPKI v0.1.0 开源候选版本
Some checks failed
ci / rust (push) Has been cancelled
ci / docker (push) Has been cancelled

This commit is contained in:
Panda RPKI OSS Local
2026-09-09 18:01:15 +08:00
commit 277cbca878
203 changed files with 92495 additions and 0 deletions

View File

@@ -0,0 +1,132 @@
use crate::model::oid::{OID_AD_CA_REPOSITORY, OID_AD_RPKI_MANIFEST, OID_AD_RPKI_NOTIFY};
use crate::model::rc::{ResourceCertKind, ResourceCertificate, SubjectInfoAccess};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CaInstanceUris {
/// rsync sync base URI used for live/materialized publication point fetches (must end with `/`).
///
/// This is the parent directory of `manifest_rsync_uri`, not necessarily the full
/// `id-ad-caRepository` URI. Using the manifest parent keeps sync scope narrow while
/// `publication_point_rsync_uri` preserves the full RFC 9286 publication-point value.
pub rsync_base_uri: String,
/// rsync URI for the manifest object (`.mft`).
pub manifest_rsync_uri: String,
/// Publication point rsync URI (RFC 9286 terminology).
pub publication_point_rsync_uri: String,
/// Optional RRDP notification URI (https).
pub rrdp_notification_uri: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum CaInstanceUrisError {
#[error("certificate must be a CA certificate (RFC 6487 §4.8.1)")]
NotCa,
#[error(
"CA certificate must contain Subject Information Access extension (RFC 6487 §4.8.8; RFC 5280 §4.2.2.2)"
)]
MissingSia,
#[error("CA certificate SIA must contain id-ad-caRepository rsync URI (RFC 6487 §4.8.8.1)")]
MissingCaRepository,
#[error("CA certificate SIA must contain id-ad-rpkiManifest rsync URI (RFC 6487 §4.8.8.2)")]
MissingRpkiManifest,
#[error(
"SIA id-ad-caRepository accessLocation must be rsync:// URI, got {0} (RFC 6487 §4.8.8.1)"
)]
CaRepositoryNotRsync(String),
#[error(
"SIA id-ad-rpkiManifest accessLocation must be rsync:// URI, got {0} (RFC 6487 §4.8.8.2)"
)]
RpkiManifestNotRsync(String),
#[error(
"SIA id-ad-rpkiNotify accessLocation must be https:// URI, got {0} (RFC 8182 §3.4.1; RFC 6487 §4.8.8.3)"
)]
RpkiNotifyNotHttps(String),
#[error(
"manifest rsync URI must be under CA publication point: manifest={manifest_rsync_uri} publication_point={publication_point_rsync_uri} (RFC 9286 §6.1)"
)]
ManifestNotUnderPublicationPoint {
manifest_rsync_uri: String,
publication_point_rsync_uri: String,
},
}
pub fn ca_instance_uris_from_ca_certificate(
cert: &ResourceCertificate,
) -> Result<CaInstanceUris, CaInstanceUrisError> {
if cert.kind != ResourceCertKind::Ca {
return Err(CaInstanceUrisError::NotCa);
}
let sia = cert
.tbs
.extensions
.subject_info_access
.as_ref()
.ok_or(CaInstanceUrisError::MissingSia)?;
let access_descriptions = match sia {
SubjectInfoAccess::Ca(ca) => &ca.access_descriptions,
SubjectInfoAccess::Ee(_ee) => return Err(CaInstanceUrisError::MissingSia),
};
let mut ca_repo: Option<String> = None;
let mut manifest: Option<String> = None;
let mut notify: Option<String> = None;
for ad in access_descriptions {
if ad.access_method_oid == OID_AD_CA_REPOSITORY {
let u = ad.access_location.as_str();
if !u.starts_with("rsync://") {
return Err(CaInstanceUrisError::CaRepositoryNotRsync(u.to_string()));
}
ca_repo.get_or_insert(u.to_string());
} else if ad.access_method_oid == OID_AD_RPKI_MANIFEST {
let u = ad.access_location.as_str();
if !u.starts_with("rsync://") {
return Err(CaInstanceUrisError::RpkiManifestNotRsync(u.to_string()));
}
manifest.get_or_insert(u.to_string());
} else if ad.access_method_oid == OID_AD_RPKI_NOTIFY {
let u = ad.access_location.as_str();
if !u.starts_with("https://") {
return Err(CaInstanceUrisError::RpkiNotifyNotHttps(u.to_string()));
}
notify.get_or_insert(u.to_string());
}
}
let mut publication_point_rsync_uri =
ca_repo.ok_or(CaInstanceUrisError::MissingCaRepository)?;
if !publication_point_rsync_uri.ends_with('/') {
publication_point_rsync_uri.push('/');
}
let manifest_rsync_uri = manifest.ok_or(CaInstanceUrisError::MissingRpkiManifest)?;
if !manifest_rsync_uri.starts_with(&publication_point_rsync_uri) {
return Err(CaInstanceUrisError::ManifestNotUnderPublicationPoint {
manifest_rsync_uri,
publication_point_rsync_uri,
});
}
let manifest_parent = manifest_rsync_uri
.rsplit_once('/')
.map(|(parent, _)| format!("{parent}/"))
.ok_or_else(|| CaInstanceUrisError::ManifestNotUnderPublicationPoint {
manifest_rsync_uri: manifest_rsync_uri.clone(),
publication_point_rsync_uri: publication_point_rsync_uri.clone(),
})?;
Ok(CaInstanceUris {
rsync_base_uri: manifest_parent,
manifest_rsync_uri,
publication_point_rsync_uri,
rrdp_notification_uri: notify,
})
}

11
src/validation/ca_path.rs Normal file
View File

@@ -0,0 +1,11 @@
#![allow(clippy::too_many_arguments, clippy::type_complexity)]
include!("ca_path/types_and_validation.rs");
include!("ca_path/certificate_checks.rs");
include!("ca_path/resource_resolution.rs");
include!("ca_path/ip_resources.rs");
include!("ca_path/increment.rs");
#[cfg(test)]
#[path = "ca_path/tests.rs"]
mod tests;

View File

@@ -0,0 +1,138 @@
// Certificate profile, signature, CRL, and validity checks.
fn parse_subject_pki_from_der(der: &[u8]) -> Result<SubjectPublicKeyInfo<'_>, CaPathError> {
let (rem, spki) = SubjectPublicKeyInfo::from_der(der)
.map_err(|e| CaPathError::IssuerSpkiParse(e.to_string()))?;
if !rem.is_empty() {
return Err(CaPathError::IssuerSpkiTrailingBytes(rem.len()));
}
Ok(spki)
}
fn parse_x509_cert(der: &[u8]) -> Result<X509Certificate<'_>, CaPathError> {
let (rem, cert) = X509Certificate::from_der(der)
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))?;
if !rem.is_empty() {
return Err(CaPathError::ChildSignatureInvalid(
"trailing bytes after child certificate".to_string(),
));
}
Ok(cert)
}
fn verify_child_signature(
child: &X509Certificate<'_>,
issuer_spki: &SubjectPublicKeyInfo<'_>,
) -> Result<(), CaPathError> {
child
.verify_signature(Some(issuer_spki))
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))
}
fn validate_child_aki_matches_issuer_ski(
child: &ResourceCertificate,
issuer: &ResourceCertificate,
) -> Result<(), CaPathError> {
let Some(issuer_ski) = issuer.tbs.extensions.subject_key_identifier.as_deref() else {
return Err(CaPathError::IssuerSkiMissing);
};
let Some(child_aki) = child.tbs.extensions.authority_key_identifier.as_deref() else {
return Err(CaPathError::ChildAkiMissing);
};
if child_aki != issuer_ski {
return Err(CaPathError::ChildAkiMismatch);
}
Ok(())
}
fn validate_child_aia_points_to_issuer_uri(
child: &ResourceCertificate,
issuer_ca_rsync_uri: &str,
) -> Result<(), CaPathError> {
let Some(uris) = child.tbs.extensions.ca_issuers_uris.as_ref() else {
return Err(CaPathError::ChildAiaMissing);
};
if !uris.iter().any(|u| u.as_str() == issuer_ca_rsync_uri) {
return Err(CaPathError::ChildAiaIssuerUriMismatch);
}
Ok(())
}
fn validate_child_crldp_contains_issuer_crl_uri(
child: &ResourceCertificate,
issuer_crl_rsync_uri: &str,
) -> Result<(), CaPathError> {
let Some(uris) = child.tbs.extensions.crl_distribution_points_uris.as_ref() else {
return Err(CaPathError::ChildCrlDpMissing);
};
if !uris.iter().any(|u| u.as_str() == issuer_crl_rsync_uri) {
return Err(CaPathError::ChildCrlDpUriMismatch);
}
Ok(())
}
fn validate_child_ca_key_usage(cert: &X509Certificate<'_>) -> Result<(), CaPathError> {
let mut ku_critical: Option<bool> = None;
for ext in cert.extensions() {
if ext.oid.as_bytes() == OID_KEY_USAGE_RAW {
ku_critical = Some(ext.critical);
break;
}
}
let Some(critical) = ku_critical else {
return Err(CaPathError::KeyUsageMissing);
};
if !critical {
return Err(CaPathError::KeyUsageNotCritical);
}
let Some(ku) = cert
.key_usage()
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))?
else {
return Err(CaPathError::KeyUsageMissing);
};
let v = &ku.value;
let ok = v.key_cert_sign()
&& v.crl_sign()
&& !v.digital_signature()
&& !v.non_repudiation()
&& !v.key_encipherment()
&& !v.data_encipherment()
&& !v.key_agreement()
&& !v.encipher_only()
&& !v.decipher_only();
if !ok {
return Err(CaPathError::KeyUsageInvalidBits);
}
Ok(())
}
fn time_within_validity(
t: time::OffsetDateTime,
not_before: time::OffsetDateTime,
not_after: time::OffsetDateTime,
) -> bool {
let t = t.to_offset(time::UtcOffset::UTC);
let not_before = not_before.to_offset(time::UtcOffset::UTC);
let not_after = not_after.to_offset(time::UtcOffset::UTC);
t >= not_before && t <= not_after
}
fn crl_valid_at_time(crl: &RpkixCrl, t: time::OffsetDateTime) -> bool {
let t = t.to_offset(time::UtcOffset::UTC);
let this_update = crl.this_update.utc.to_offset(time::UtcOffset::UTC);
let next_update = crl.next_update.utc.to_offset(time::UtcOffset::UTC);
t >= this_update && t < next_update
}
fn is_serial_revoked_by_crl(cert: &ResourceCertificate, crl: &RpkixCrl) -> bool {
let serial = BigUnsigned::from_biguint(&cert.tbs.serial_number);
crl.revoked_certs
.iter()
.any(|rc| rc.serial_number == serial)
}

View File

@@ -0,0 +1,13 @@
// Small byte-vector helper used by resource interval tests.
fn increment_bytes(v: &[u8]) -> Vec<u8> {
let mut out = v.to_vec();
for i in (0..out.len()).rev() {
if out[i] != 0xFF {
out[i] += 1;
out[i + 1..].fill(0);
return out;
}
}
vec![0u8; out.len()]
}

View File

@@ -0,0 +1,274 @@
// IP interval normalization, intersection, and coverage helpers.
fn ip_resources_by_afi_items(
set: &IpResourceSet,
) -> Result<
std::collections::BTreeMap<
crate::model::rc::Afi,
Vec<crate::model::rc::IpAddressOrRange>,
>,
CaPathError,
> {
let mut m: std::collections::BTreeMap<
crate::model::rc::Afi,
Vec<crate::model::rc::IpAddressOrRange>,
> = std::collections::BTreeMap::new();
for fam in &set.families {
match &fam.choice {
IpAddressChoice::Inherit => return Err(CaPathError::InheritWithoutParentResources),
IpAddressChoice::AddressesOrRanges(items) => {
m.insert(fam.afi, items.clone());
}
}
}
Ok(m)
}
fn ip_resources_to_merged_intervals_by_afi(
set: &IpResourceSet,
) -> HashMap<crate::model::rc::Afi, Vec<(Vec<u8>, Vec<u8>)>> {
let mut m: HashMap<crate::model::rc::Afi, Vec<(Vec<u8>, Vec<u8>)>> = HashMap::new();
for fam in &set.families {
match &fam.choice {
IpAddressChoice::Inherit => {
// When used in subset checks, treat inherit as "all" by leaving it absent.
// Resolution should have happened earlier.
}
IpAddressChoice::AddressesOrRanges(items) => {
let ent = m.entry(fam.afi).or_default();
for item in items {
match item {
crate::model::rc::IpAddressOrRange::Prefix(p) => {
let (min, max) = prefix_to_range(p);
ent.push((min, max));
}
crate::model::rc::IpAddressOrRange::Range(r) => {
ent.push((r.min.clone(), r.max.clone()));
}
}
}
}
}
}
for (_afi, v) in m.iter_mut() {
v.sort_by(|(a, _), (b, _)| a.cmp(b));
*v = merge_ip_intervals(v);
}
m
}
fn ip_family_items_subset_with_parent_intervals(
child_items: &[crate::model::rc::IpAddressOrRange],
parent_intervals: &[(Vec<u8>, Vec<u8>)],
) -> bool {
if parent_intervals.is_empty() {
return false;
}
let mut child_intervals: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for item in child_items {
match item {
crate::model::rc::IpAddressOrRange::Prefix(p) => {
child_intervals.push(prefix_to_range(p))
}
crate::model::rc::IpAddressOrRange::Range(r) => {
child_intervals.push((r.min.clone(), r.max.clone()))
}
}
}
child_intervals.sort_by(|(a, _), (b, _)| a.cmp(b));
let child_intervals = merge_ip_intervals(&child_intervals);
for (cmin, cmax) in &child_intervals {
if !interval_is_covered(parent_intervals, cmin, cmax) {
return false;
}
}
true
}
fn ip_items_to_merged_intervals(
items: &[crate::model::rc::IpAddressOrRange],
) -> Vec<(Vec<u8>, Vec<u8>)> {
let mut intervals = Vec::new();
for item in items {
match item {
crate::model::rc::IpAddressOrRange::Prefix(p) => {
intervals.push(prefix_to_range(p))
}
crate::model::rc::IpAddressOrRange::Range(r) => {
intervals.push((r.min.clone(), r.max.clone()))
}
}
}
intervals.sort_by(|(a, _), (b, _)| a.cmp(b));
merge_ip_intervals(&intervals)
}
fn intersect_ip_items_with_parent_intervals(
items: &[crate::model::rc::IpAddressOrRange],
parent_intervals: &[(Vec<u8>, Vec<u8>)],
) -> Vec<(Vec<u8>, Vec<u8>)> {
let child_intervals = ip_items_to_merged_intervals(items);
intersect_ip_intervals(&child_intervals, parent_intervals)
}
fn intersect_ip_intervals(
child_intervals: &[(Vec<u8>, Vec<u8>)],
parent_intervals: &[(Vec<u8>, Vec<u8>)],
) -> Vec<(Vec<u8>, Vec<u8>)> {
let mut out = Vec::new();
let mut parent_index = 0usize;
for (child_min, child_max) in child_intervals {
while parent_index < parent_intervals.len()
&& parent_intervals[parent_index].1.as_slice() < child_min.as_slice()
{
parent_index += 1;
}
let mut scan = parent_index;
while scan < parent_intervals.len()
&& parent_intervals[scan].0.as_slice() <= child_max.as_slice()
{
let (parent_min, parent_max) = &parent_intervals[scan];
let min = if bytes_leq(child_min, parent_min) {
parent_min.clone()
} else {
child_min.clone()
};
let max = if bytes_leq(child_max, parent_max) {
child_max.clone()
} else {
parent_max.clone()
};
if bytes_leq(&min, &max) {
out.push((min, max));
}
scan += 1;
}
}
merge_ip_intervals(&out)
}
fn ip_intervals_to_ranges(
afi: crate::model::rc::Afi,
intervals: &[(Vec<u8>, Vec<u8>)],
) -> Vec<crate::model::rc::IpAddressOrRange> {
intervals
.iter()
.map(|(min, max)| {
crate::model::rc::IpAddressOrRange::Range(crate::model::rc::IpAddressRange {
min: normalize_ip_bytes(afi, min),
max: normalize_ip_bytes(afi, max),
})
})
.collect()
}
fn normalize_ip_bytes(afi: crate::model::rc::Afi, bytes: &[u8]) -> Vec<u8> {
let target_len = afi.octets_len();
if bytes.len() == target_len {
return bytes.to_vec();
}
let mut out = vec![0u8; target_len];
let copy_len = bytes.len().min(target_len);
out[..copy_len].copy_from_slice(&bytes[..copy_len]);
out
}
fn intervals_changed<T: PartialEq>(left: &[T], right: &[T]) -> bool {
left != right
}
fn intersect_as_intervals(child: &[(u32, u32)], parent: &[(u32, u32)]) -> Vec<(u32, u32)> {
let mut out = Vec::new();
let mut parent_index = 0usize;
for (child_min, child_max) in child {
while parent_index < parent.len() && parent[parent_index].1 < *child_min {
parent_index += 1;
}
let mut scan = parent_index;
while scan < parent.len() && parent[scan].0 <= *child_max {
let min = (*child_min).max(parent[scan].0);
let max = (*child_max).min(parent[scan].1);
if min <= max {
out.push((min, max));
}
scan += 1;
}
}
merge_as_intervals(&out)
}
fn as_intervals_to_items(intervals: &[(u32, u32)]) -> Vec<crate::model::rc::AsIdOrRange> {
intervals
.iter()
.map(|(min, max)| {
if min == max {
crate::model::rc::AsIdOrRange::Id(*min)
} else {
crate::model::rc::AsIdOrRange::Range {
min: *min,
max: *max,
}
}
})
.collect()
}
fn merge_ip_intervals(v: &[(Vec<u8>, Vec<u8>)]) -> Vec<(Vec<u8>, Vec<u8>)> {
let mut out: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for (min, max) in v {
let Some(last) = out.last_mut() else {
out.push((min.clone(), max.clone()));
continue;
};
if bytes_leq(min, &increment_bytes(&last.1)) {
if bytes_leq(&last.1, max) {
last.1 = max.clone();
}
continue;
}
out.push((min.clone(), max.clone()));
}
out
}
fn interval_is_covered(parent: &[(Vec<u8>, Vec<u8>)], min: &[u8], max: &[u8]) -> bool {
for (pmin, pmax) in parent {
if bytes_leq(pmin, min) && bytes_leq(max, pmax) {
return true;
}
if pmin.as_slice() > min {
break;
}
}
false
}
fn prefix_to_range(prefix: &crate::model::rc::IpPrefix) -> (Vec<u8>, Vec<u8>) {
let mut min = prefix.addr.clone();
let mut max = prefix.addr.clone();
let bitlen = match prefix.afi {
crate::model::rc::Afi::Ipv4 => 32u16,
crate::model::rc::Afi::Ipv6 => 128u16,
};
let plen = prefix.prefix_len.min(bitlen);
for bit in plen..bitlen {
let byte = (bit / 8) as usize;
let offset = 7 - (bit % 8);
let mask = 1u8 << offset;
min[byte] &= !mask;
max[byte] |= mask;
}
(min, max)
}
fn bytes_leq(a: &[u8], b: &[u8]) -> bool {
a <= b
}

View File

@@ -0,0 +1,440 @@
// Effective IP/AS resource resolution and interval helpers.
#[derive(Clone, Debug)]
struct ResourceResolution {
effective_ip_resources: Option<IpResourceSet>,
effective_as_resources: Option<AsResourceSet>,
warnings: ResourceValidationWarnings,
}
fn resolve_child_resources(
child_ip: Option<&IpResourceSet>,
issuer_effective_ip: Option<&IpResourceSet>,
child_as: Option<&AsResourceSet>,
issuer_effective_as: Option<&AsResourceSet>,
issuer_resources_index: &IssuerEffectiveResourcesIndex,
mode: ResourceValidationMode,
) -> Result<ResourceResolution, CaPathError> {
match mode {
ResourceValidationMode::Rfc6487 => Ok(ResourceResolution {
effective_ip_resources: resolve_child_ip_resources_indexed(
child_ip,
issuer_effective_ip,
issuer_resources_index.parent_ip_by_afi_items.as_ref(),
&issuer_resources_index.parent_ip_merged_intervals,
)?,
effective_as_resources: resolve_child_as_resources_indexed(
child_as,
issuer_effective_as,
issuer_resources_index.parent_asnum_intervals.as_deref(),
issuer_resources_index.parent_rdi_intervals.as_deref(),
)?,
warnings: ResourceValidationWarnings::default(),
}),
ResourceValidationMode::ValidationUpdate03 => {
let (effective_ip_resources, ip_reduced_by_vrs, ip_vrs_empty) =
resolve_child_ip_resources_vrs(
child_ip,
issuer_effective_ip,
issuer_resources_index.parent_ip_by_afi_items.as_ref(),
&issuer_resources_index.parent_ip_merged_intervals,
)?;
let (effective_as_resources, as_reduced_by_vrs, as_vrs_empty) =
resolve_child_as_resources_vrs(
child_as,
issuer_effective_as,
issuer_resources_index.parent_asnum_intervals.as_deref(),
issuer_resources_index.parent_rdi_intervals.as_deref(),
)?;
Ok(ResourceResolution {
effective_ip_resources,
effective_as_resources,
warnings: ResourceValidationWarnings {
ip_reduced_by_vrs,
as_reduced_by_vrs,
ip_vrs_empty,
as_vrs_empty,
},
})
}
}
}
#[cfg(test)]
fn resolve_child_ip_resources(
child_ip: Option<&IpResourceSet>,
issuer_effective: Option<&IpResourceSet>,
) -> Result<Option<IpResourceSet>, CaPathError> {
let precomputed_parent_by_afi = issuer_effective
.map(ip_resources_by_afi_items)
.transpose()?;
let precomputed_parent_intervals = issuer_effective
.map(ip_resources_to_merged_intervals_by_afi)
.unwrap_or_default();
resolve_child_ip_resources_indexed(
child_ip,
issuer_effective,
precomputed_parent_by_afi.as_ref(),
&precomputed_parent_intervals,
)
}
fn resolve_child_ip_resources_indexed(
child_ip: Option<&IpResourceSet>,
issuer_effective: Option<&IpResourceSet>,
parent_by_afi: Option<
&BTreeMap<crate::model::rc::Afi, Vec<crate::model::rc::IpAddressOrRange>>,
>,
parent_intervals_by_afi: &HashMap<crate::model::rc::Afi, Vec<(Vec<u8>, Vec<u8>)>>,
) -> Result<Option<IpResourceSet>, CaPathError> {
let Some(child_ip) = child_ip else {
return Ok(None);
};
let Some(_parent) = issuer_effective else {
if child_ip.has_any_inherit() {
return Err(CaPathError::InheritWithoutParentResources);
}
// With no parent effective resources, we cannot validate subset.
return Err(CaPathError::ResourcesNotSubset);
};
// Resolve per-AFI inherit, producing an effective set with no inherit.
let parent_by_afi = parent_by_afi.ok_or(CaPathError::InheritWithoutParentResources)?;
let mut out_families: Vec<crate::model::rc::IpAddressFamily> = Vec::new();
for fam in &child_ip.families {
match &fam.choice {
IpAddressChoice::Inherit => {
let items = parent_by_afi
.get(&fam.afi)
.ok_or(CaPathError::InheritWithoutParentResources)?;
out_families.push(crate::model::rc::IpAddressFamily {
afi: fam.afi,
choice: IpAddressChoice::AddressesOrRanges(items.clone()),
});
}
IpAddressChoice::AddressesOrRanges(items) => {
// Subset check against parent union for that AFI.
let parent_intervals = parent_intervals_by_afi
.get(&fam.afi)
.map(Vec::as_slice)
.unwrap_or(&[]);
if !ip_family_items_subset_with_parent_intervals(items, parent_intervals) {
return Err(CaPathError::ResourcesNotSubset);
}
out_families.push(crate::model::rc::IpAddressFamily {
afi: fam.afi,
choice: IpAddressChoice::AddressesOrRanges(items.clone()),
});
}
}
}
Ok(Some(IpResourceSet {
families: out_families,
}))
}
fn resolve_child_ip_resources_vrs(
child_ip: Option<&IpResourceSet>,
issuer_effective: Option<&IpResourceSet>,
parent_by_afi: Option<
&BTreeMap<crate::model::rc::Afi, Vec<crate::model::rc::IpAddressOrRange>>,
>,
parent_intervals_by_afi: &HashMap<crate::model::rc::Afi, Vec<(Vec<u8>, Vec<u8>)>>,
) -> Result<(Option<IpResourceSet>, bool, bool), CaPathError> {
let Some(child_ip) = child_ip else {
return Ok((None, false, false));
};
if child_ip.has_any_inherit() && issuer_effective.is_none() {
return Err(CaPathError::InheritWithoutParentResources);
}
let parent_by_afi = parent_by_afi.unwrap_or_else(|| {
static EMPTY: std::sync::OnceLock<
BTreeMap<crate::model::rc::Afi, Vec<crate::model::rc::IpAddressOrRange>>,
> = std::sync::OnceLock::new();
EMPTY.get_or_init(BTreeMap::new)
});
let mut out_families = Vec::new();
let mut reduced = false;
let mut saw_declared = false;
for fam in &child_ip.families {
match &fam.choice {
IpAddressChoice::Inherit => {
let items = parent_by_afi
.get(&fam.afi)
.ok_or(CaPathError::InheritWithoutParentResources)?;
out_families.push(crate::model::rc::IpAddressFamily {
afi: fam.afi,
choice: IpAddressChoice::AddressesOrRanges(items.clone()),
});
}
IpAddressChoice::AddressesOrRanges(items) => {
saw_declared = saw_declared || !items.is_empty();
let parent_intervals = parent_intervals_by_afi
.get(&fam.afi)
.map(Vec::as_slice)
.unwrap_or(&[]);
let intersections =
intersect_ip_items_with_parent_intervals(items, parent_intervals);
let child_intervals = ip_items_to_merged_intervals(items);
if intervals_changed(&child_intervals, &intersections) {
reduced = true;
}
if !intersections.is_empty() {
out_families.push(crate::model::rc::IpAddressFamily {
afi: fam.afi,
choice: IpAddressChoice::AddressesOrRanges(ip_intervals_to_ranges(
fam.afi,
&intersections,
)),
});
}
}
}
}
let empty = saw_declared && out_families.is_empty();
Ok((
Some(IpResourceSet {
families: out_families,
}),
reduced,
empty,
))
}
#[cfg(test)]
fn resolve_child_as_resources(
child_as: Option<&AsResourceSet>,
issuer_effective: Option<&AsResourceSet>,
) -> Result<Option<AsResourceSet>, CaPathError> {
let precomputed_asnum = issuer_effective
.and_then(|resources| resources.asnum.as_ref().map(as_choice_to_merged_intervals));
let precomputed_rdi = issuer_effective
.and_then(|resources| resources.rdi.as_ref().map(as_choice_to_merged_intervals));
resolve_child_as_resources_indexed(
child_as,
issuer_effective,
precomputed_asnum.as_deref(),
precomputed_rdi.as_deref(),
)
}
fn resolve_child_as_resources_indexed(
child_as: Option<&AsResourceSet>,
issuer_effective: Option<&AsResourceSet>,
parent_asnum_intervals: Option<&[(u32, u32)]>,
parent_rdi_intervals: Option<&[(u32, u32)]>,
) -> Result<Option<AsResourceSet>, CaPathError> {
let Some(child_as) = child_as else {
return Ok(None);
};
let Some(parent) = issuer_effective else {
if matches!(child_as.asnum, Some(AsIdentifierChoice::Inherit))
|| matches!(child_as.rdi, Some(AsIdentifierChoice::Inherit))
{
return Err(CaPathError::InheritWithoutParentResources);
}
return Err(CaPathError::ResourcesNotSubset);
};
let asnum = match child_as.asnum.as_ref() {
None => None,
Some(AsIdentifierChoice::Inherit) => parent
.asnum
.clone()
.ok_or(CaPathError::InheritWithoutParentResources)
.map(Some)?,
Some(_) => {
if !as_choice_subset_with_parent_intervals(
child_as.asnum.as_ref(),
parent.asnum.as_ref(),
parent_asnum_intervals,
) {
return Err(CaPathError::ResourcesNotSubset);
}
child_as.asnum.clone()
}
};
let rdi = match child_as.rdi.as_ref() {
None => None,
Some(AsIdentifierChoice::Inherit) => parent
.rdi
.clone()
.ok_or(CaPathError::InheritWithoutParentResources)
.map(Some)?,
Some(_) => {
if !as_choice_subset_with_parent_intervals(
child_as.rdi.as_ref(),
parent.rdi.as_ref(),
parent_rdi_intervals,
) {
return Err(CaPathError::ResourcesNotSubset);
}
child_as.rdi.clone()
}
};
Ok(Some(AsResourceSet { asnum, rdi }))
}
fn resolve_child_as_resources_vrs(
child_as: Option<&AsResourceSet>,
issuer_effective: Option<&AsResourceSet>,
parent_asnum_intervals: Option<&[(u32, u32)]>,
parent_rdi_intervals: Option<&[(u32, u32)]>,
) -> Result<(Option<AsResourceSet>, bool, bool), CaPathError> {
let Some(child_as) = child_as else {
return Ok((None, false, false));
};
if issuer_effective.is_none()
&& (matches!(child_as.asnum, Some(AsIdentifierChoice::Inherit))
|| matches!(child_as.rdi, Some(AsIdentifierChoice::Inherit)))
{
return Err(CaPathError::InheritWithoutParentResources);
}
let (asnum, asnum_reduced, asnum_empty) = resolve_as_choice_vrs(
child_as.asnum.as_ref(),
issuer_effective.and_then(|p| p.asnum.as_ref()),
parent_asnum_intervals,
)?;
let (rdi, rdi_reduced, rdi_empty) = resolve_as_choice_vrs(
child_as.rdi.as_ref(),
issuer_effective.and_then(|p| p.rdi.as_ref()),
parent_rdi_intervals,
)?;
Ok((
Some(AsResourceSet { asnum, rdi }),
asnum_reduced || rdi_reduced,
asnum_empty || rdi_empty,
))
}
fn resolve_as_choice_vrs(
child: Option<&AsIdentifierChoice>,
parent: Option<&AsIdentifierChoice>,
parent_intervals_hint: Option<&[(u32, u32)]>,
) -> Result<(Option<AsIdentifierChoice>, bool, bool), CaPathError> {
let Some(child) = child else {
return Ok((None, false, false));
};
match child {
AsIdentifierChoice::Inherit => {
let parent = parent
.cloned()
.ok_or(CaPathError::InheritWithoutParentResources)?;
Ok((Some(parent), false, false))
}
AsIdentifierChoice::AsIdsOrRanges(_) => {
let child_intervals = as_choice_to_merged_intervals(child);
let parent_intervals;
let parent_intervals = match parent_intervals_hint {
Some(v) => v,
None => {
parent_intervals = parent
.map(as_choice_to_merged_intervals)
.unwrap_or_default();
parent_intervals.as_slice()
}
};
let intersections = intersect_as_intervals(&child_intervals, parent_intervals);
let reduced = child_intervals != intersections;
let empty = !child_intervals.is_empty() && intersections.is_empty();
Ok((
Some(AsIdentifierChoice::AsIdsOrRanges(as_intervals_to_items(
&intersections,
))),
reduced,
empty,
))
}
}
}
fn as_choice_subset_with_parent_intervals(
child: Option<&AsIdentifierChoice>,
parent: Option<&AsIdentifierChoice>,
parent_intervals_hint: Option<&[(u32, u32)]>,
) -> bool {
let Some(child) = child else {
return true;
};
let Some(parent) = parent else {
return false;
};
// Treat inherit as "all of parent" here; actual resolution is handled elsewhere.
if matches!(child, AsIdentifierChoice::Inherit) {
return true;
}
if matches!(parent, AsIdentifierChoice::Inherit) {
return true;
}
let child_intervals = as_choice_to_merged_intervals(child);
let owned_parent_intervals;
let parent_intervals = match parent_intervals_hint {
Some(intervals) => intervals,
None => {
owned_parent_intervals = as_choice_to_merged_intervals(parent);
owned_parent_intervals.as_slice()
}
};
for (cmin, cmax) in &child_intervals {
if !as_interval_is_covered(parent_intervals, *cmin, *cmax) {
return false;
}
}
true
}
fn as_choice_to_merged_intervals(choice: &AsIdentifierChoice) -> Vec<(u32, u32)> {
let mut v = Vec::new();
match choice {
AsIdentifierChoice::Inherit => {}
AsIdentifierChoice::AsIdsOrRanges(items) => {
for item in items {
match item {
crate::model::rc::AsIdOrRange::Id(id) => v.push((*id, *id)),
crate::model::rc::AsIdOrRange::Range { min, max } => v.push((*min, *max)),
}
}
}
}
v.sort_by_key(|(a, _b)| *a);
merge_as_intervals(&v)
}
fn merge_as_intervals(v: &[(u32, u32)]) -> Vec<(u32, u32)> {
let mut out: Vec<(u32, u32)> = Vec::new();
for (min, max) in v {
let Some(last) = out.last_mut() else {
out.push((*min, *max));
continue;
};
if *min <= last.1.saturating_add(1) {
last.1 = last.1.max(*max);
continue;
}
out.push((*min, *max));
}
out
}
fn as_interval_is_covered(parent: &[(u32, u32)], min: u32, max: u32) -> bool {
for (pmin, pmax) in parent {
if *pmin <= min && max <= *pmax {
return true;
}
if *pmin > min {
break;
}
}
false
}

View File

@@ -0,0 +1,913 @@
// CA path resource and certificate validation tests.
use super::*;
use crate::model::common::X509NameDer;
use crate::model::oid::OID_CP_IPADDR_ASNUMBER;
use crate::model::rc::{
Afi, AsIdOrRange, AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpAddressFamily,
IpAddressOrRange, IpPrefix, IpResourceSet,
};
use crate::model::rc::{
BasicConstraintsProfile, CertificatePoliciesProfile, RcExtensions, ResourceCertKind,
ResourceCertificate, RpkixTbsCertificate,
};
use der_parser::num_bigint::BigUint;
use std::process::Command;
fn dummy_cert(
kind: ResourceCertKind,
subject_dn: &str,
issuer_dn: &str,
ski: Option<Vec<u8>>,
aki: Option<Vec<u8>>,
aia: Option<Vec<&str>>,
crldp: Option<Vec<&str>>,
) -> ResourceCertificate {
let aia = aia.map(|v| v.into_iter().map(|s| s.to_string()).collect::<Vec<_>>());
let crldp = crldp.map(|v| v.into_iter().map(|s| s.to_string()).collect::<Vec<_>>());
ResourceCertificate {
raw_der: Vec::new(),
kind,
tbs: RpkixTbsCertificate {
version: 2,
serial_number: BigUint::from(1u8),
signature_algorithm: "1.2.840.113549.1.1.11".to_string(),
issuer_name: X509NameDer(issuer_dn.as_bytes().to_vec()),
subject_name: X509NameDer(subject_dn.as_bytes().to_vec()),
validity_not_before: time::OffsetDateTime::UNIX_EPOCH,
validity_not_after: time::OffsetDateTime::UNIX_EPOCH,
subject_public_key_info: Vec::new(),
extensions: RcExtensions {
basic_constraints_ca: kind == ResourceCertKind::Ca,
basic_constraints: (kind == ResourceCertKind::Ca).then_some(
BasicConstraintsProfile {
ca: true,
critical: true,
path_len_constraint: None,
},
),
subject_key_identifier: ski,
authority_key_identifier: aki,
crl_distribution_points_uris: crldp,
ca_issuers_uris: aia,
subject_info_access: None,
certificate_policies_oid: (kind == ResourceCertKind::Ca)
.then_some(OID_CP_IPADDR_ASNUMBER.to_string()),
certificate_policies: (kind == ResourceCertKind::Ca).then_some(
CertificatePoliciesProfile {
policy_oid: OID_CP_IPADDR_ASNUMBER.to_string(),
qualifier_oids: Vec::new(),
},
),
extension_oids: Vec::new(),
ip_resources: None,
as_resources: None,
},
},
}
}
fn openssl_available() -> bool {
Command::new("openssl")
.arg("version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn write_cert_der_with_addext(dir: &std::path::Path, addext: Option<&str>) -> Vec<u8> {
assert!(openssl_available(), "openssl is required for this test");
let key = dir.join("k.pem");
let cert = dir.join("c.pem");
let der = dir.join("c.der");
let mut cmd = Command::new("openssl");
cmd.arg("req")
.arg("-x509")
.arg("-newkey")
.arg("rsa:2048")
.arg("-nodes")
.arg("-keyout")
.arg(&key)
.arg("-subj")
.arg("/CN=ku")
.arg("-days")
.arg("1")
.arg("-out")
.arg(&cert);
if let Some(ext) = addext {
cmd.arg("-addext").arg(ext);
}
let out = cmd.output().expect("openssl req");
assert!(
out.status.success(),
"openssl req failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = Command::new("openssl")
.arg("x509")
.arg("-in")
.arg(&cert)
.arg("-outform")
.arg("DER")
.arg("-out")
.arg(&der)
.output()
.expect("openssl x509");
assert!(
out.status.success(),
"openssl x509 failed: {}",
String::from_utf8_lossy(&out.stderr)
);
std::fs::read(&der).expect("read der")
}
fn gen_issuer_and_child_der(dir: &std::path::Path) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
assert!(openssl_available(), "openssl is required for this test");
let issuer_key = dir.join("issuer.key");
let issuer_csr = dir.join("issuer.csr");
let issuer_pem = dir.join("issuer.pem");
let issuer_der = dir.join("issuer.der");
let child_key = dir.join("child.key");
let child_csr = dir.join("child.csr");
let child_pem = dir.join("child.pem");
let child_der = dir.join("child.der");
let other_key = dir.join("other.key");
let other_csr = dir.join("other.csr");
let other_pem = dir.join("other.pem");
let other_der = dir.join("other.der");
let run = |cmd: &mut Command| {
let out = cmd.output().expect("run openssl");
assert!(
out.status.success(),
"command failed: {:?}\nstderr={}",
cmd,
String::from_utf8_lossy(&out.stderr)
);
};
// Issuer self-signed.
run(Command::new("openssl")
.args(["genrsa", "-out"])
.arg(&issuer_key)
.arg("2048"));
run(Command::new("openssl")
.args(["req", "-new", "-key"])
.arg(&issuer_key)
.args(["-subj", "/CN=issuer", "-out"])
.arg(&issuer_csr));
run(Command::new("openssl")
.args(["x509", "-req", "-in"])
.arg(&issuer_csr)
.args(["-signkey"])
.arg(&issuer_key)
.args(["-days", "1", "-out"])
.arg(&issuer_pem));
run(Command::new("openssl")
.args(["x509", "-in"])
.arg(&issuer_pem)
.args(["-outform", "DER", "-out"])
.arg(&issuer_der));
// Child signed by issuer.
run(Command::new("openssl")
.args(["genrsa", "-out"])
.arg(&child_key)
.arg("2048"));
run(Command::new("openssl")
.args(["req", "-new", "-key"])
.arg(&child_key)
.args(["-subj", "/CN=child", "-out"])
.arg(&child_csr));
run(Command::new("openssl")
.args(["x509", "-req", "-in"])
.arg(&child_csr)
.args(["-CA"])
.arg(&issuer_pem)
.args(["-CAkey"])
.arg(&issuer_key)
.args(["-CAcreateserial", "-days", "1", "-out"])
.arg(&child_pem));
run(Command::new("openssl")
.args(["x509", "-in"])
.arg(&child_pem)
.args(["-outform", "DER", "-out"])
.arg(&child_der));
// Other self-signed issuer.
run(Command::new("openssl")
.args(["genrsa", "-out"])
.arg(&other_key)
.arg("2048"));
run(Command::new("openssl")
.args(["req", "-new", "-key"])
.arg(&other_key)
.args(["-subj", "/CN=other", "-out"])
.arg(&other_csr));
run(Command::new("openssl")
.args(["x509", "-req", "-in"])
.arg(&other_csr)
.args(["-signkey"])
.arg(&other_key)
.args(["-days", "1", "-out"])
.arg(&other_pem));
run(Command::new("openssl")
.args(["x509", "-in"])
.arg(&other_pem)
.args(["-outform", "DER", "-out"])
.arg(&other_der));
(
std::fs::read(&issuer_der).expect("read issuer der"),
std::fs::read(&child_der).expect("read child der"),
std::fs::read(&other_der).expect("read other der"),
)
}
#[test]
fn resolve_child_ip_resources_rejects_inherit_without_parent_effective_resources() {
let child = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::Inherit,
}],
};
let err = resolve_child_ip_resources(Some(&child), None).unwrap_err();
assert!(matches!(err, CaPathError::InheritWithoutParentResources));
}
#[test]
fn resolve_child_ip_resources_rejects_non_inherit_without_parent_effective_resources() {
let child = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![]),
}],
};
let err = resolve_child_ip_resources(Some(&child), None).unwrap_err();
assert!(matches!(err, CaPathError::ResourcesNotSubset));
}
#[test]
fn ip_resources_by_afi_items_rejects_inherit_families() {
let parent = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv6,
choice: IpAddressChoice::Inherit,
}],
};
let err = ip_resources_by_afi_items(&parent).unwrap_err();
assert!(matches!(err, CaPathError::InheritWithoutParentResources));
}
#[test]
fn resolve_child_as_resources_rejects_inherit_without_parent_effective_resources() {
let child = AsResourceSet {
asnum: Some(AsIdentifierChoice::Inherit),
rdi: None,
};
let err = resolve_child_as_resources(Some(&child), None).unwrap_err();
assert!(matches!(err, CaPathError::InheritWithoutParentResources));
}
#[test]
fn validation_update_03_intersects_overclaiming_child_ip_resources() {
let issuer = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![10, 0, 0, 0],
})]),
}],
};
let child = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(IpPrefix {
afi: Afi::Ipv4,
prefix_len: 23,
addr: vec![10, 0, 0, 0],
})]),
}],
};
let index = IssuerEffectiveResourcesIndex::from_effective_resources(Some(&issuer), None)
.expect("index");
let resolved = resolve_child_resources(
Some(&child),
Some(&issuer),
None,
None,
&index,
ResourceValidationMode::ValidationUpdate03,
)
.expect("vrs resolution");
assert!(resolved.warnings.ip_reduced_by_vrs);
assert!(!resolved.warnings.ip_vrs_empty);
let effective = resolved.effective_ip_resources.expect("effective ip");
assert!(effective.families[0].contains_prefix(&IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![10, 0, 0, 0],
}));
assert!(!effective.families[0].contains_prefix(&IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![10, 0, 1, 0],
}));
}
#[test]
fn validation_update_03_keeps_empty_vrs_instead_of_rejecting_child_ca() {
let issuer = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![10, 0, 0, 0],
})]),
}],
};
let child = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![192, 0, 2, 0],
})]),
}],
};
let index = IssuerEffectiveResourcesIndex::from_effective_resources(Some(&issuer), None)
.expect("index");
let strict_err = resolve_child_resources(
Some(&child),
Some(&issuer),
None,
None,
&index,
ResourceValidationMode::Rfc6487,
)
.unwrap_err();
assert!(matches!(strict_err, CaPathError::ResourcesNotSubset));
let resolved = resolve_child_resources(
Some(&child),
Some(&issuer),
None,
None,
&index,
ResourceValidationMode::ValidationUpdate03,
)
.expect("vrs resolution");
assert!(resolved.warnings.ip_reduced_by_vrs);
assert!(resolved.warnings.ip_vrs_empty);
assert!(
resolved
.effective_ip_resources
.expect("effective ip")
.families
.is_empty()
);
}
#[test]
fn validation_update_03_intersects_overclaiming_child_as_resources() {
let issuer = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64500,
max: 64510,
},
])),
rdi: None,
};
let child = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64505,
max: 64520,
},
])),
rdi: None,
};
let index = IssuerEffectiveResourcesIndex::from_effective_resources(None, Some(&issuer))
.expect("index");
let resolved = resolve_child_resources(
None,
None,
Some(&child),
Some(&issuer),
&index,
ResourceValidationMode::ValidationUpdate03,
)
.expect("vrs resolution");
assert!(resolved.warnings.as_reduced_by_vrs);
assert!(!resolved.warnings.as_vrs_empty);
let effective = resolved.effective_as_resources.expect("effective as");
assert_eq!(
effective.asnum,
Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64505,
max: 64510,
},
]))
);
}
#[test]
fn validation_update_03_records_all_resource_warning_summary_parts() {
let issuer_ip = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![10, 0, 0, 0],
})]),
}],
};
let child_ip = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![192, 0, 2, 0],
})]),
}],
};
let issuer_as = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64500,
max: 64510,
},
])),
rdi: None,
};
let child_as = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64520,
max: 64530,
},
])),
rdi: None,
};
let index =
IssuerEffectiveResourcesIndex::from_effective_resources(Some(&issuer_ip), Some(&issuer_as))
.expect("index");
let resolved = resolve_child_resources(
Some(&child_ip),
Some(&issuer_ip),
Some(&child_as),
Some(&issuer_as),
&index,
ResourceValidationMode::ValidationUpdate03,
)
.expect("vrs resolution");
assert!(resolved.warnings.ip_reduced_by_vrs);
assert!(resolved.warnings.ip_vrs_empty);
assert!(resolved.warnings.as_reduced_by_vrs);
assert!(resolved.warnings.as_vrs_empty);
assert_eq!(
resolved.warnings.summary(),
"ip_reduced_by_vrs,as_reduced_by_vrs,ip_vrs_empty,as_vrs_empty"
);
assert!(!resolved.warnings.is_empty());
}
#[test]
fn child_aki_mismatch_is_rejected() {
let issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let child = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
Some(vec![9]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_child_aki_matches_issuer_ski(&child, &issuer).unwrap_err();
assert!(matches!(err, CaPathError::ChildAkiMismatch), "{err}");
}
#[test]
fn child_aia_missing_is_rejected() {
let _issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let child = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
None,
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_child_aia_points_to_issuer_uri(&child, "rsync://example.test/issuer.cer")
.unwrap_err();
assert!(matches!(err, CaPathError::ChildAiaMissing), "{err}");
// Also cover issuer ski missing.
let issuer_missing_ski = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
None,
None,
None,
None,
);
let err = validate_child_aki_matches_issuer_ski(&child, &issuer_missing_ski).unwrap_err();
assert!(matches!(err, CaPathError::IssuerSkiMissing), "{err}");
}
#[test]
fn child_aia_issuer_uri_mismatch_is_rejected() {
let _issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let child = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/other.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_child_aia_points_to_issuer_uri(&child, "rsync://example.test/issuer.cer")
.unwrap_err();
assert!(
matches!(err, CaPathError::ChildAiaIssuerUriMismatch),
"{err}"
);
}
#[test]
fn child_crldp_mismatch_is_rejected() {
let issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let child = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
None,
);
let err =
validate_child_crldp_contains_issuer_crl_uri(&child, "rsync://example.test/issuer.crl")
.unwrap_err();
assert!(matches!(err, CaPathError::ChildCrlDpMissing), "{err}");
let child = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/other.crl"]),
);
let err =
validate_child_crldp_contains_issuer_crl_uri(&child, "rsync://example.test/issuer.crl")
.unwrap_err();
assert!(matches!(err, CaPathError::ChildCrlDpUriMismatch), "{err}");
// Cover child AKI missing.
let child_missing_aki = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
None,
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_child_aki_matches_issuer_ski(&child_missing_aki, &issuer).unwrap_err();
assert!(matches!(err, CaPathError::ChildAkiMissing), "{err}");
}
#[test]
fn child_binding_checks_accept_when_matching() {
let issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let child = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
validate_child_aki_matches_issuer_ski(&child, &issuer).expect("aki ok");
validate_child_aia_points_to_issuer_uri(&child, "rsync://example.test/issuer.cer")
.expect("aia ok");
validate_child_crldp_contains_issuer_crl_uri(&child, "rsync://example.test/issuer.crl")
.expect("crldp ok");
}
#[test]
fn validate_child_ca_key_usage_accepts_only_keycertsign_and_crlsign_critical() {
let td = tempfile::tempdir().expect("tempdir");
let der =
write_cert_der_with_addext(td.path(), Some("keyUsage = critical, keyCertSign, cRLSign"));
let cert = parse_x509_cert(&der).expect("x509 parse ok");
validate_child_ca_key_usage(&cert).expect("key usage ok");
}
#[test]
fn validate_child_ca_key_usage_rejects_missing_noncritical_and_invalid_bits() {
let td = tempfile::tempdir().expect("tempdir");
let missing = write_cert_der_with_addext(td.path(), None);
let cert = parse_x509_cert(&missing).expect("x509 parse ok");
let err = validate_child_ca_key_usage(&cert).unwrap_err();
assert!(matches!(err, CaPathError::KeyUsageMissing), "{err}");
let td = tempfile::tempdir().expect("tempdir");
let noncritical =
write_cert_der_with_addext(td.path(), Some("keyUsage = keyCertSign, cRLSign"));
let cert = parse_x509_cert(&noncritical).expect("x509 parse ok");
let err = validate_child_ca_key_usage(&cert).unwrap_err();
assert!(matches!(err, CaPathError::KeyUsageNotCritical), "{err}");
let td = tempfile::tempdir().expect("tempdir");
let invalid = write_cert_der_with_addext(
td.path(),
Some("keyUsage = critical, keyCertSign, cRLSign, digitalSignature"),
);
let cert = parse_x509_cert(&invalid).expect("x509 parse ok");
let err = validate_child_ca_key_usage(&cert).unwrap_err();
assert!(matches!(err, CaPathError::KeyUsageInvalidBits), "{err}");
}
#[test]
fn verify_cert_signature_with_issuer_accepts_valid_chain_and_rejects_wrong_issuer() {
let td = tempfile::tempdir().expect("tempdir");
let (issuer, child, other) = gen_issuer_and_child_der(td.path());
let issuer_cert = parse_x509_cert(&issuer).expect("x509 parse issuer");
let child_cert = parse_x509_cert(&child).expect("x509 parse child");
verify_child_signature(&child_cert, &issuer_cert.tbs_certificate.subject_pki)
.expect("signature ok");
let other_cert = parse_x509_cert(&other).expect("x509 parse other");
let err =
verify_child_signature(&child_cert, &other_cert.tbs_certificate.subject_pki).unwrap_err();
assert!(
matches!(err, CaPathError::ChildSignatureInvalid(_)),
"{err}"
);
}
#[test]
fn issuer_effective_resources_index_and_indexed_resolvers_cover_success_and_failure_paths() {
use crate::model::rc::{AsIdOrRange, IpPrefix};
let parent_ip = 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],
})]),
}],
};
let parent_as = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64500,
max: 64599,
},
])),
rdi: Some(AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Id(
65000,
)])),
};
let idx =
IssuerEffectiveResourcesIndex::from_effective_resources(Some(&parent_ip), Some(&parent_as))
.expect("index builds");
assert_eq!(
idx.parent_ip_by_afi_items.as_ref().map(|v| v.len()),
Some(1)
);
assert_eq!(idx.parent_ip_merged_intervals.len(), 1);
assert_eq!(
idx.parent_asnum_intervals.as_ref().map(|v| v.len()),
Some(1)
);
assert_eq!(idx.parent_rdi_intervals.as_ref().map(|v| v.len()), Some(1));
let child_ip_subset = 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],
})]),
}],
};
assert!(
resolve_child_ip_resources_indexed(
Some(&child_ip_subset),
Some(&parent_ip),
idx.parent_ip_by_afi_items.as_ref(),
&idx.parent_ip_merged_intervals,
)
.expect("subset should resolve")
.is_some()
);
let child_ip_bad = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(IpPrefix {
afi: Afi::Ipv4,
prefix_len: 16,
addr: vec![11, 0, 0, 0],
})]),
}],
};
let err = resolve_child_ip_resources_indexed(
Some(&child_ip_bad),
Some(&parent_ip),
idx.parent_ip_by_afi_items.as_ref(),
&idx.parent_ip_merged_intervals,
)
.unwrap_err();
assert!(matches!(err, CaPathError::ResourcesNotSubset));
let child_as_subset = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Id(
64542,
)])),
rdi: Some(AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Id(
65000,
)])),
};
assert!(
resolve_child_as_resources_indexed(
Some(&child_as_subset),
Some(&parent_as),
idx.parent_asnum_intervals.as_deref(),
idx.parent_rdi_intervals.as_deref(),
)
.expect("subset as resolves")
.is_some()
);
let child_as_bad = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Id(
65123,
)])),
rdi: None,
};
let err = resolve_child_as_resources_indexed(
Some(&child_as_bad),
Some(&parent_as),
idx.parent_asnum_intervals.as_deref(),
idx.parent_rdi_intervals.as_deref(),
)
.unwrap_err();
assert!(matches!(err, CaPathError::ResourcesNotSubset));
}
#[test]
fn resolve_child_ip_and_as_resources_success_paths() {
use crate::model::rc::{AsIdOrRange, IpAddressOrRange, IpPrefix};
let parent_ip = 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],
})]),
}],
};
let child_ip_inherit = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::Inherit,
}],
};
let eff = resolve_child_ip_resources(Some(&child_ip_inherit), Some(&parent_ip))
.expect("inherit resolves")
.expect("some ip");
assert_eq!(eff.families.len(), 1);
assert!(matches!(
eff.families[0].choice,
IpAddressChoice::AddressesOrRanges(_)
));
let child_ip_subset = 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],
})]),
}],
};
resolve_child_ip_resources(Some(&child_ip_subset), Some(&parent_ip))
.expect("subset ok")
.expect("some");
let child_ip_bad = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(IpPrefix {
afi: Afi::Ipv4,
prefix_len: 16,
addr: vec![11, 0, 0, 0],
})]),
}],
};
let err = resolve_child_ip_resources(Some(&child_ip_bad), Some(&parent_ip)).unwrap_err();
assert!(matches!(err, CaPathError::ResourcesNotSubset), "{err}");
let parent_as = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range { min: 1, max: 100 },
])),
rdi: None,
};
let child_as_inherit = AsResourceSet {
asnum: Some(AsIdentifierChoice::Inherit),
rdi: None,
};
let eff_as = resolve_child_as_resources(Some(&child_as_inherit), Some(&parent_as))
.expect("inherit as")
.expect("some");
assert_eq!(eff_as.asnum, parent_as.asnum);
let child_as_subset = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Id(50)])),
rdi: None,
};
resolve_child_as_resources(Some(&child_as_subset), Some(&parent_as))
.expect("subset as")
.expect("some");
let child_as_bad = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Id(
200,
)])),
rdi: None,
};
let err = resolve_child_as_resources(Some(&child_as_bad), Some(&parent_as)).unwrap_err();
assert!(matches!(err, CaPathError::ResourcesNotSubset), "{err}");
}

View File

@@ -0,0 +1,464 @@
// Resource certificate path types and public validation entry points.
use crate::model::common::BigUnsigned;
use crate::model::crl::{CrlDecodeError, CrlVerifyError, RpkixCrl};
use crate::model::oid::OID_KEY_USAGE_RAW;
use crate::model::rc::{
AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpResourceSet, ResourceCertKind,
ResourceCertificate, ResourceCertificateDecodeError, ResourceCertificateProfileError,
ResourceCertificateRole,
};
use crate::validation::policy::ResourceValidationMode;
use x509_parser::prelude::{FromDer, X509Certificate};
use crate::validation::x509_name::x509_names_equivalent;
use std::collections::{BTreeMap, HashMap, HashSet};
use x509_parser::x509::SubjectPublicKeyInfo;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ValidatedSubordinateCa {
pub child_ca: ResourceCertificate,
pub issuer_ca: ResourceCertificate,
pub issuer_crl: RpkixCrl,
pub effective_ip_resources: Option<IpResourceSet>,
pub effective_as_resources: Option<AsResourceSet>,
pub resource_warnings: ResourceValidationWarnings,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ValidatedSubordinateCaLite {
pub child_ca: ResourceCertificate,
pub effective_ip_resources: Option<IpResourceSet>,
pub effective_as_resources: Option<AsResourceSet>,
pub resource_warnings: ResourceValidationWarnings,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ResourceValidationWarnings {
pub ip_reduced_by_vrs: bool,
pub as_reduced_by_vrs: bool,
pub ip_vrs_empty: bool,
pub as_vrs_empty: bool,
}
impl ResourceValidationWarnings {
pub fn is_empty(&self) -> bool {
!self.ip_reduced_by_vrs
&& !self.as_reduced_by_vrs
&& !self.ip_vrs_empty
&& !self.as_vrs_empty
}
pub fn summary(&self) -> String {
let mut parts = Vec::new();
if self.ip_reduced_by_vrs {
parts.push("ip_reduced_by_vrs");
}
if self.as_reduced_by_vrs {
parts.push("as_reduced_by_vrs");
}
if self.ip_vrs_empty {
parts.push("ip_vrs_empty");
}
if self.as_vrs_empty {
parts.push("as_vrs_empty");
}
parts.join(",")
}
}
#[derive(Clone, Debug, Default)]
pub struct IssuerEffectiveResourcesIndex {
parent_ip_by_afi_items:
Option<BTreeMap<crate::model::rc::Afi, Vec<crate::model::rc::IpAddressOrRange>>>,
parent_ip_merged_intervals: HashMap<crate::model::rc::Afi, Vec<(Vec<u8>, Vec<u8>)>>,
parent_asnum_intervals: Option<Vec<(u32, u32)>>,
parent_rdi_intervals: Option<Vec<(u32, u32)>>,
}
impl IssuerEffectiveResourcesIndex {
pub fn from_effective_resources(
issuer_effective_ip: Option<&IpResourceSet>,
issuer_effective_as: Option<&AsResourceSet>,
) -> Result<Self, CaPathError> {
let parent_ip_by_afi_items = issuer_effective_ip
.map(ip_resources_by_afi_items)
.transpose()?;
let parent_ip_merged_intervals = issuer_effective_ip
.map(ip_resources_to_merged_intervals_by_afi)
.unwrap_or_default();
let parent_asnum_intervals = issuer_effective_as
.and_then(|resources| resources.asnum.as_ref().map(as_choice_to_merged_intervals));
let parent_rdi_intervals = issuer_effective_as
.and_then(|resources| resources.rdi.as_ref().map(as_choice_to_merged_intervals));
Ok(Self {
parent_ip_by_afi_items,
parent_ip_merged_intervals,
parent_asnum_intervals,
parent_rdi_intervals,
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum CaPathError {
#[error("child CA certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")]
ChildDecode(#[from] ResourceCertificateDecodeError),
#[error("issuer CA certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")]
IssuerDecode(ResourceCertificateDecodeError),
#[error("child CA certificate profile validation failed: {0} (RFC 6487 §4.8)")]
ChildProfile(ResourceCertificateProfileError),
#[error("issuer CA certificate profile validation failed: {0} (RFC 6487 §4.8)")]
IssuerProfile(ResourceCertificateProfileError),
#[error("issuer CRL decode failed: {0} (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1)")]
CrlDecode(#[from] CrlDecodeError),
#[error(
"child certificate must be a CA resource certificate (RFC 6487 §4.8.1; RFC 5280 §4.2.1.9)"
)]
ChildNotCa,
#[error(
"issuer certificate must be a CA resource certificate (RFC 6487 §4.8.1; RFC 5280 §4.2.1.9)"
)]
IssuerNotCa,
#[error(
"child issuer DN does not match issuer CA subject DN: child.issuer={child_issuer_dn} issuer.subject={issuer_subject_dn} (RFC 5280 §6.1)"
)]
IssuerSubjectMismatch {
child_issuer_dn: String,
issuer_subject_dn: String,
},
#[error("child CA certificate signature verification failed: {0} (RFC 5280 §6.1)")]
ChildSignatureInvalid(String),
#[error("issuer SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7)")]
IssuerSpkiParse(String),
#[error(
"trailing bytes after issuer SubjectPublicKeyInfo DER: {0} bytes (DER; RFC 5280 §4.1.2.7)"
)]
IssuerSpkiTrailingBytes(usize),
#[error("certificate not valid at validation_time (RFC 5280 §4.1.2.5; RFC 5280 §6.1)")]
CertificateNotValidAtTime,
#[error("child CA KeyUsage extension missing (RFC 6487 §4.8.4; RFC 5280 §4.2.1.3)")]
KeyUsageMissing,
#[error("child CA KeyUsage criticality must be critical (RFC 6487 §4.8.4; RFC 5280 §4.2.1.3)")]
KeyUsageNotCritical,
#[error("child CA KeyUsage must have only keyCertSign and cRLSign set (RFC 6487 §4.8.4)")]
KeyUsageInvalidBits,
#[error(
"CRL signature/binding verification failed: {0} (RFC 5280 §6.3.3; RFC 6487 §5; RFC 9829 §3.1)"
)]
CrlVerify(#[from] CrlVerifyError),
#[error(
"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)"
)]
CrlNotValidAtTime,
#[error("child CA certificate is revoked by issuer CRL (RFC 5280 §6.3.3; RFC 6487 §5)")]
ChildRevoked,
#[error(
"child CA certificate must contain at least one RFC 3779 resource extension (IP or AS) (RFC 6487 §4.8.10-§4.8.11)"
)]
ResourcesMissing,
#[error(
"resource extension inheritance cannot be resolved (parent missing resources) (RFC 6487 §7.2)"
)]
InheritWithoutParentResources,
#[error("child CA resources are not a subset of issuer resources (RFC 6487 §7.2)")]
ResourcesNotSubset,
#[error("issuer CA subjectKeyIdentifier missing (RFC 6487 §4.8.2)")]
IssuerSkiMissing,
#[error("child CA authorityKeyIdentifier missing (RFC 6487 §4.8.3; RFC 5280 §4.2.1.1)")]
ChildAkiMissing,
#[error(
"child CA authorityKeyIdentifier does not match issuer subjectKeyIdentifier (RFC 6487 §4.8.3)"
)]
ChildAkiMismatch,
#[error("child CA authorityInfoAccess missing (RFC 6487 §4.8.7; RFC 5280 §4.2.2.1)")]
ChildAiaMissing,
#[error(
"child CA authorityInfoAccess does not reference issuer certificate rsync URI (RFC 6487 §4.8.7)"
)]
ChildAiaIssuerUriMismatch,
#[error("child CA CRLDistributionPoints missing (RFC 6487 §4.8.6; RFC 5280 §4.2.1.13)")]
ChildCrlDpMissing,
#[error(
"child CA CRLDistributionPoints does not reference issuer CRL rsync URI (RFC 6487 §4.8.6)"
)]
ChildCrlDpUriMismatch,
}
pub fn validate_subordinate_ca_cert(
child_ca_der: &[u8],
issuer_ca_der: &[u8],
issuer_crl_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: &str,
issuer_effective_ip: Option<&IpResourceSet>,
issuer_effective_as: Option<&AsResourceSet>,
validation_time: time::OffsetDateTime,
) -> Result<ValidatedSubordinateCa, CaPathError> {
validate_subordinate_ca_cert_with_resource_validation_mode(
child_ca_der,
issuer_ca_der,
issuer_crl_der,
issuer_ca_rsync_uri,
issuer_crl_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
ResourceValidationMode::Rfc6487,
)
}
pub fn validate_subordinate_ca_cert_with_resource_validation_mode(
child_ca_der: &[u8],
issuer_ca_der: &[u8],
issuer_crl_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: &str,
issuer_effective_ip: Option<&IpResourceSet>,
issuer_effective_as: Option<&AsResourceSet>,
validation_time: time::OffsetDateTime,
resource_validation_mode: ResourceValidationMode,
) -> Result<ValidatedSubordinateCa, CaPathError> {
let child_ca = ResourceCertificate::decode_der(child_ca_der)?;
if child_ca.kind != ResourceCertKind::Ca {
return Err(CaPathError::ChildNotCa);
}
child_ca
.validate_rfc6487_profile(ResourceCertificateRole::Ca)
.map_err(CaPathError::ChildProfile)?;
let issuer_ca =
ResourceCertificate::decode_der(issuer_ca_der).map_err(CaPathError::IssuerDecode)?;
if issuer_ca.kind != ResourceCertKind::Ca {
return Err(CaPathError::IssuerNotCa);
}
issuer_ca
.validate_rfc6487_profile(ResourceCertificateRole::Ca)
.map_err(CaPathError::IssuerProfile)?;
let issuer_spki = parse_subject_pki_from_der(&issuer_ca.tbs.subject_public_key_info)?;
if !x509_names_equivalent(&child_ca.tbs.issuer_name, &issuer_ca.tbs.subject_name) {
return Err(CaPathError::IssuerSubjectMismatch {
child_issuer_dn: child_ca.tbs.issuer_name.to_string(),
issuer_subject_dn: issuer_ca.tbs.subject_name.to_string(),
});
}
validate_child_aki_matches_issuer_ski(&child_ca, &issuer_ca)?;
if let Some(expected_issuer_uri) = issuer_ca_rsync_uri {
validate_child_aia_points_to_issuer_uri(&child_ca, expected_issuer_uri)?;
}
validate_child_crldp_contains_issuer_crl_uri(&child_ca, issuer_crl_rsync_uri)?;
if !time_within_validity(
validation_time,
child_ca.tbs.validity_not_before,
child_ca.tbs.validity_not_after,
) || !time_within_validity(
validation_time,
issuer_ca.tbs.validity_not_before,
issuer_ca.tbs.validity_not_after,
) {
return Err(CaPathError::CertificateNotValidAtTime);
}
let child_x509 = parse_x509_cert(child_ca_der)?;
verify_child_signature(&child_x509, &issuer_spki)?;
validate_child_ca_key_usage(&child_x509)?;
let issuer_crl = RpkixCrl::decode_der(issuer_crl_der)?;
issuer_crl.verify_signature_with_issuer_certificate_der(issuer_ca_der)?;
if !crl_valid_at_time(&issuer_crl, validation_time) {
return Err(CaPathError::CrlNotValidAtTime);
}
if is_serial_revoked_by_crl(&child_ca, &issuer_crl) {
return Err(CaPathError::ChildRevoked);
}
let ResourceResolution {
effective_ip_resources,
effective_as_resources,
warnings: resource_warnings,
} = resolve_child_resources(
child_ca.tbs.extensions.ip_resources.as_ref(),
issuer_effective_ip,
child_ca.tbs.extensions.as_resources.as_ref(),
issuer_effective_as,
&IssuerEffectiveResourcesIndex::from_effective_resources(
issuer_effective_ip,
issuer_effective_as,
)?,
resource_validation_mode,
)?;
if effective_ip_resources.is_none() && effective_as_resources.is_none() {
return Err(CaPathError::ResourcesMissing);
}
Ok(ValidatedSubordinateCa {
child_ca,
issuer_ca,
issuer_crl,
effective_ip_resources,
effective_as_resources,
resource_warnings,
})
}
/// Validate a subordinate child CA using *pre-decoded issuer CA* and *pre-decoded+verified issuer CRL*.
///
/// This avoids repeating issuer CA decode and issuer CRL decode+signature verification for every
/// child CA certificate discovered in a publication point.
pub fn validate_subordinate_ca_cert_with_prevalidated_issuer(
child_ca_der: &[u8],
child_ca: ResourceCertificate,
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_crl: &RpkixCrl,
issuer_crl_revoked_serials: &HashSet<Vec<u8>>,
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: &str,
issuer_effective_ip: Option<&IpResourceSet>,
issuer_effective_as: Option<&AsResourceSet>,
validation_time: time::OffsetDateTime,
) -> Result<ValidatedSubordinateCaLite, CaPathError> {
let issuer_resources_index = IssuerEffectiveResourcesIndex::from_effective_resources(
issuer_effective_ip,
issuer_effective_as,
)?;
validate_subordinate_ca_cert_with_prevalidated_issuer_and_resources(
child_ca_der,
child_ca,
issuer_ca,
issuer_spki,
issuer_crl,
issuer_crl_revoked_serials,
issuer_ca_rsync_uri,
issuer_crl_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
&issuer_resources_index,
validation_time,
ResourceValidationMode::Rfc6487,
)
}
pub fn validate_subordinate_ca_cert_with_prevalidated_issuer_and_resources(
child_ca_der: &[u8],
child_ca: ResourceCertificate,
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_crl: &RpkixCrl,
issuer_crl_revoked_serials: &HashSet<Vec<u8>>,
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: &str,
issuer_effective_ip: Option<&IpResourceSet>,
issuer_effective_as: Option<&AsResourceSet>,
issuer_resources_index: &IssuerEffectiveResourcesIndex,
validation_time: time::OffsetDateTime,
resource_validation_mode: ResourceValidationMode,
) -> Result<ValidatedSubordinateCaLite, CaPathError> {
if child_ca.kind != ResourceCertKind::Ca {
return Err(CaPathError::ChildNotCa);
}
if issuer_ca.kind != ResourceCertKind::Ca {
return Err(CaPathError::IssuerNotCa);
}
child_ca
.validate_rfc6487_profile(ResourceCertificateRole::Ca)
.map_err(CaPathError::ChildProfile)?;
issuer_ca
.validate_rfc6487_profile(ResourceCertificateRole::Ca)
.map_err(CaPathError::IssuerProfile)?;
if !x509_names_equivalent(&child_ca.tbs.issuer_name, &issuer_ca.tbs.subject_name) {
return Err(CaPathError::IssuerSubjectMismatch {
child_issuer_dn: child_ca.tbs.issuer_name.to_string(),
issuer_subject_dn: issuer_ca.tbs.subject_name.to_string(),
});
}
validate_child_aki_matches_issuer_ski(&child_ca, issuer_ca)?;
if let Some(expected_issuer_uri) = issuer_ca_rsync_uri {
validate_child_aia_points_to_issuer_uri(&child_ca, expected_issuer_uri)?;
}
validate_child_crldp_contains_issuer_crl_uri(&child_ca, issuer_crl_rsync_uri)?;
if !time_within_validity(
validation_time,
child_ca.tbs.validity_not_before,
child_ca.tbs.validity_not_after,
) || !time_within_validity(
validation_time,
issuer_ca.tbs.validity_not_before,
issuer_ca.tbs.validity_not_after,
) {
return Err(CaPathError::CertificateNotValidAtTime);
}
let child_x509 = parse_x509_cert(child_ca_der)?;
verify_child_signature(&child_x509, issuer_spki)?;
validate_child_ca_key_usage(&child_x509)?;
if !crl_valid_at_time(issuer_crl, validation_time) {
return Err(CaPathError::CrlNotValidAtTime);
}
let serial = BigUnsigned::from_biguint(&child_ca.tbs.serial_number);
if issuer_crl_revoked_serials.contains(&serial.bytes_be) {
return Err(CaPathError::ChildRevoked);
}
let ResourceResolution {
effective_ip_resources,
effective_as_resources,
warnings: resource_warnings,
} = resolve_child_resources(
child_ca.tbs.extensions.ip_resources.as_ref(),
issuer_effective_ip,
child_ca.tbs.extensions.as_resources.as_ref(),
issuer_effective_as,
issuer_resources_index,
resource_validation_mode,
)?;
if effective_ip_resources.is_none() && effective_as_resources.is_none() {
return Err(CaPathError::ResourcesMissing);
}
Ok(ValidatedSubordinateCaLite {
child_ca,
effective_ip_resources,
effective_as_resources,
resource_warnings,
})
}

725
src/validation/cert_path.rs Normal file
View File

@@ -0,0 +1,725 @@
#![allow(clippy::too_many_arguments)]
use crate::model::common::BigUnsigned;
use crate::model::crl::{CrlDecodeError, CrlVerifyError, RpkixCrl};
use crate::model::rc::{
ResourceCertKind, ResourceCertificate, ResourceCertificateDecodeError,
ResourceCertificateProfileError, ResourceCertificateRole,
};
use crate::model::signed_object::{EeKeyUsageSummary, ResourceEeCertificate};
use ring::signature;
use x509_parser::prelude::{FromDer, X509Certificate};
use crate::validation::x509_name::x509_names_equivalent;
use std::collections::HashSet;
use x509_parser::x509::SubjectPublicKeyInfo;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ValidatedEeCertPath {
pub ee: ResourceCertificate,
pub issuer_ca: ResourceCertificate,
pub issuer_crl: RpkixCrl,
}
#[derive(Debug, thiserror::Error)]
pub enum CertPathError {
#[error("EE certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")]
EeDecode(#[from] ResourceCertificateDecodeError),
#[error("issuer CA certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")]
IssuerDecode(ResourceCertificateDecodeError),
#[error("issuer CRL decode failed: {0} (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1)")]
CrlDecode(#[from] CrlDecodeError),
#[error(
"issuer certificate must be a CA resource certificate (RFC 6487 §4.8.1; RFC 5280 §4.2.1.9)"
)]
IssuerNotCa,
#[error("issuer CA certificate profile validation failed: {0} (RFC 6487 §4.8)")]
IssuerProfile(ResourceCertificateProfileError),
#[error("EE certificate must be an EE resource certificate (RFC 6487 §4)")]
EeNotEe,
#[error(
"EE issuer DN does not match issuer CA subject DN: ee.issuer={ee_issuer_dn} issuer.subject={issuer_subject_dn} (RFC 5280 §6.1)"
)]
IssuerSubjectMismatch {
ee_issuer_dn: String,
issuer_subject_dn: String,
},
#[error("EE certificate signature verification failed: {0} (RFC 5280 §6.1)")]
EeSignatureInvalid(String),
#[error("issuer SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7)")]
IssuerSpkiParse(String),
#[error(
"trailing bytes after issuer SubjectPublicKeyInfo DER: {0} bytes (DER; RFC 5280 §4.1.2.7)"
)]
IssuerSpkiTrailingBytes(usize),
#[error("EE KeyUsage extension missing (RFC 6487 §4.8.4; RFC 5280 §4.2.1.3)")]
KeyUsageMissing,
#[error("EE KeyUsage criticality must be critical (RFC 6487 §4.8.4; RFC 5280 §4.2.1.3)")]
KeyUsageNotCritical,
#[error("EE KeyUsage must have only digitalSignature set (RFC 6487 §4.8.4)")]
KeyUsageInvalidBits,
#[error("EE KeyUsage parse failed: {0} (RFC 6487 §4.8.4; RFC 5280 §4.2.1.3)")]
KeyUsageParse(String),
#[error("issuer CA subjectKeyIdentifier missing (RFC 6487 §4.8.2)")]
IssuerSkiMissing,
#[error("EE authorityKeyIdentifier missing (RFC 6487 §4.8.3; RFC 5280 §4.2.1.1)")]
EeAkiMissing,
#[error(
"EE authorityKeyIdentifier does not match issuer subjectKeyIdentifier (RFC 6487 §4.8.3)"
)]
EeAkiMismatch,
#[error("EE authorityInfoAccess missing (RFC 6487 §4.8.7; RFC 5280 §4.2.2.1)")]
EeAiaMissing,
#[error(
"EE authorityInfoAccess does not reference issuer certificate rsync URI (RFC 6487 §4.8.7)"
)]
EeAiaIssuerUriMismatch,
#[error("EE CRLDistributionPoints missing (RFC 6487 §4.8.6; RFC 5280 §4.2.1.13)")]
EeCrlDpMissing,
#[error("EE CRLDistributionPoints does not reference issuer CRL rsync URI (RFC 6487 §4.8.6)")]
EeCrlDpUriMismatch,
#[error("certificate not valid at validation_time (RFC 5280 §4.1.2.5; RFC 5280 §6.1)")]
CertificateNotValidAtTime,
#[error(
"CRL signature/binding verification failed: {0} (RFC 5280 §6.3.3; RFC 6487 §5; RFC 9829 §3.1)"
)]
CrlVerify(#[from] CrlVerifyError),
#[error(
"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)"
)]
CrlNotValidAtTime,
#[error("EE certificate is revoked by issuer CRL (RFC 5280 §6.3.3; RFC 6487 §5)")]
EeRevoked,
}
pub fn validate_ee_cert_path(
ee_cert_der: &[u8],
issuer_ca_der: &[u8],
issuer_crl_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<ValidatedEeCertPath, CertPathError> {
let ee = ResourceCertificate::decode_der(ee_cert_der)?;
if ee.kind != ResourceCertKind::Ee {
return Err(CertPathError::EeNotEe);
}
let issuer_ca =
ResourceCertificate::decode_der(issuer_ca_der).map_err(CertPathError::IssuerDecode)?;
if issuer_ca.kind != ResourceCertKind::Ca {
return Err(CertPathError::IssuerNotCa);
}
issuer_ca
.validate_rfc6487_profile(ResourceCertificateRole::Ca)
.map_err(CertPathError::IssuerProfile)?;
let issuer_spki = parse_subject_pki_from_der(&issuer_ca.tbs.subject_public_key_info)?;
if !x509_names_equivalent(&ee.tbs.issuer_name, &issuer_ca.tbs.subject_name) {
return Err(CertPathError::IssuerSubjectMismatch {
ee_issuer_dn: ee.tbs.issuer_name.to_string(),
issuer_subject_dn: issuer_ca.tbs.subject_name.to_string(),
});
}
validate_ee_aki_matches_issuer_ski(&ee, &issuer_ca)?;
if let Some(expected_issuer_uri) = issuer_ca_rsync_uri {
validate_ee_aia_points_to_issuer_uri(&ee, expected_issuer_uri)?;
}
if let Some(expected_crl_uri) = issuer_crl_rsync_uri {
validate_ee_crldp_contains_issuer_crl_uri(&ee, expected_crl_uri)?;
}
if !time_within_validity(
validation_time,
ee.tbs.validity_not_before,
ee.tbs.validity_not_after,
) || !time_within_validity(
validation_time,
issuer_ca.tbs.validity_not_before,
issuer_ca.tbs.validity_not_after,
) {
return Err(CertPathError::CertificateNotValidAtTime);
}
let ee_x509 = parse_x509_cert(ee_cert_der)?;
verify_ee_cert_signature(&ee_x509, &issuer_spki)?;
validate_ee_key_usage(&ee_x509)?;
let issuer_crl = RpkixCrl::decode_der(issuer_crl_der)?;
issuer_crl.verify_signature_with_issuer_certificate_der(issuer_ca_der)?;
if !crl_valid_at_time(&issuer_crl, validation_time) {
return Err(CertPathError::CrlNotValidAtTime);
}
if is_serial_revoked_by_crl(&ee, &issuer_crl) {
return Err(CertPathError::EeRevoked);
}
Ok(ValidatedEeCertPath {
ee,
issuer_ca,
issuer_crl,
})
}
/// Validate the EE certificate path using a *pre-decoded issuer CA* and a *pre-decoded and
/// pre-verified issuer CRL*.
///
/// This is a performance-oriented helper for serial runs: it avoids repeating issuer CA
/// decode and issuer CRL decode+signature verification for every signed object in a publication point.
///
/// The caller must ensure:
/// - `issuer_ca` corresponds to `issuer_ca_der`
/// - `issuer_crl` cryptographic signature/binding has already been verified against `issuer_ca_der`
pub fn validate_ee_cert_path_with_prevalidated_issuer(
ee_cert_der: &[u8],
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_crl: &RpkixCrl,
issuer_crl_revoked_serials: &HashSet<Vec<u8>>,
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<ResourceCertificate, CertPathError> {
let ee = ResourceCertificate::decode_der(ee_cert_der)?;
validate_ee_cert_path_components(
&ee,
ee_cert_der,
issuer_ca,
issuer_spki,
issuer_crl,
issuer_crl_revoked_serials,
issuer_ca_rsync_uri,
issuer_crl_rsync_uri,
validation_time,
)?;
Ok(ee)
}
/// Validate the EE certificate path using a pre-decoded EE certificate and a pre-validated issuer.
///
/// This avoids re-decoding the embedded EE certificate when the caller already parsed it while
/// decoding a signed object (e.g. ROA / ASPA).
pub fn validate_ee_cert_path_with_predecoded_ee(
ee: &ResourceCertificate,
ee_cert_der: &[u8],
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_crl: &RpkixCrl,
issuer_crl_revoked_serials: &HashSet<Vec<u8>>,
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<(), CertPathError> {
validate_ee_cert_path_components(
ee,
ee_cert_der,
issuer_ca,
issuer_spki,
issuer_crl,
issuer_crl_revoked_serials,
issuer_ca_rsync_uri,
issuer_crl_rsync_uri,
validation_time,
)
}
/// Validate a signed-object embedded EE certificate path using signed-object cached fields.
///
/// This avoids reparsing the EE certificate as `X509Certificate` and bypasses the
/// x509-parser signature wrapper by verifying the cached TBS/signature bytes directly.
pub fn validate_signed_object_ee_cert_path_fast(
ee: &ResourceEeCertificate,
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_crl: &RpkixCrl,
issuer_crl_revoked_serials: &HashSet<Vec<u8>>,
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<(), CertPathError> {
validate_ee_cert_path_bindings_and_status(
&ee.resource_cert,
issuer_ca,
issuer_crl,
issuer_crl_revoked_serials,
issuer_ca_rsync_uri,
issuer_crl_rsync_uri,
validation_time,
)?;
validate_ee_key_usage_summary(&ee.key_usage_summary)?;
verify_ee_cert_signature_fast(&ee.tbs_certificate_der, &ee.signature_bytes, issuer_spki)?;
Ok(())
}
fn validate_ee_cert_path_components(
ee: &ResourceCertificate,
ee_cert_der: &[u8],
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_crl: &RpkixCrl,
issuer_crl_revoked_serials: &HashSet<Vec<u8>>,
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<(), CertPathError> {
validate_ee_cert_path_bindings_and_status(
ee,
issuer_ca,
issuer_crl,
issuer_crl_revoked_serials,
issuer_ca_rsync_uri,
issuer_crl_rsync_uri,
validation_time,
)?;
let ee_x509 = parse_x509_cert(ee_cert_der)?;
verify_ee_cert_signature(&ee_x509, issuer_spki)?;
validate_ee_key_usage(&ee_x509)?;
Ok(())
}
fn validate_ee_cert_path_bindings_and_status(
ee: &ResourceCertificate,
issuer_ca: &ResourceCertificate,
issuer_crl: &RpkixCrl,
issuer_crl_revoked_serials: &HashSet<Vec<u8>>,
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<(), CertPathError> {
if ee.kind != ResourceCertKind::Ee {
return Err(CertPathError::EeNotEe);
}
if issuer_ca.kind != ResourceCertKind::Ca {
return Err(CertPathError::IssuerNotCa);
}
issuer_ca
.validate_rfc6487_profile(ResourceCertificateRole::Ca)
.map_err(CertPathError::IssuerProfile)?;
if !x509_names_equivalent(&ee.tbs.issuer_name, &issuer_ca.tbs.subject_name) {
return Err(CertPathError::IssuerSubjectMismatch {
ee_issuer_dn: ee.tbs.issuer_name.to_string(),
issuer_subject_dn: issuer_ca.tbs.subject_name.to_string(),
});
}
validate_ee_aki_matches_issuer_ski(ee, issuer_ca)?;
if let Some(expected_issuer_uri) = issuer_ca_rsync_uri {
validate_ee_aia_points_to_issuer_uri(ee, expected_issuer_uri)?;
}
if let Some(expected_crl_uri) = issuer_crl_rsync_uri {
validate_ee_crldp_contains_issuer_crl_uri(ee, expected_crl_uri)?;
}
if !time_within_validity(
validation_time,
ee.tbs.validity_not_before,
ee.tbs.validity_not_after,
) || !time_within_validity(
validation_time,
issuer_ca.tbs.validity_not_before,
issuer_ca.tbs.validity_not_after,
) {
return Err(CertPathError::CertificateNotValidAtTime);
}
if !crl_valid_at_time(issuer_crl, validation_time) {
return Err(CertPathError::CrlNotValidAtTime);
}
let serial = BigUnsigned::from_biguint(&ee.tbs.serial_number);
if issuer_crl_revoked_serials.contains(&serial.bytes_be) {
return Err(CertPathError::EeRevoked);
}
Ok(())
}
fn parse_subject_pki_from_der(der: &[u8]) -> Result<SubjectPublicKeyInfo<'_>, CertPathError> {
let (rem, spki) = SubjectPublicKeyInfo::from_der(der)
.map_err(|e| CertPathError::IssuerSpkiParse(e.to_string()))?;
if !rem.is_empty() {
return Err(CertPathError::IssuerSpkiTrailingBytes(rem.len()));
}
Ok(spki)
}
fn parse_x509_cert(der: &[u8]) -> Result<X509Certificate<'_>, CertPathError> {
let (rem, cert) = X509Certificate::from_der(der)
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))?;
if !rem.is_empty() {
return Err(CertPathError::EeSignatureInvalid(
"trailing bytes after EE certificate".to_string(),
));
}
Ok(cert)
}
fn verify_ee_cert_signature(
ee: &X509Certificate<'_>,
issuer_spki: &SubjectPublicKeyInfo<'_>,
) -> Result<(), CertPathError> {
ee.verify_signature(Some(issuer_spki))
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
}
fn verify_ee_cert_signature_fast(
ee_tbs_der: &[u8],
ee_signature_bytes: &[u8],
issuer_spki: &SubjectPublicKeyInfo<'_>,
) -> Result<(), CertPathError> {
let key = signature::UnparsedPublicKey::new(
&signature::RSA_PKCS1_2048_8192_SHA256,
&issuer_spki.subject_public_key.data,
);
key.verify(ee_tbs_der, ee_signature_bytes)
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
}
fn validate_ee_aki_matches_issuer_ski(
ee: &ResourceCertificate,
issuer_ca: &ResourceCertificate,
) -> Result<(), CertPathError> {
let Some(issuer_ski) = issuer_ca.tbs.extensions.subject_key_identifier.as_deref() else {
return Err(CertPathError::IssuerSkiMissing);
};
let Some(ee_aki) = ee.tbs.extensions.authority_key_identifier.as_deref() else {
return Err(CertPathError::EeAkiMissing);
};
if ee_aki != issuer_ski {
return Err(CertPathError::EeAkiMismatch);
}
Ok(())
}
fn validate_ee_aia_points_to_issuer_uri(
ee: &ResourceCertificate,
issuer_ca_rsync_uri: &str,
) -> Result<(), CertPathError> {
let Some(uris) = ee.tbs.extensions.ca_issuers_uris.as_ref() else {
return Err(CertPathError::EeAiaMissing);
};
if uris.iter().any(|u| u.as_str() == issuer_ca_rsync_uri) {
return Ok(());
}
if uris.iter().any(|u| u.starts_with("rsync://")) {
return Ok(());
}
Err(CertPathError::EeAiaIssuerUriMismatch)
}
fn validate_ee_crldp_contains_issuer_crl_uri(
ee: &ResourceCertificate,
issuer_crl_rsync_uri: &str,
) -> Result<(), CertPathError> {
let Some(uris) = ee.tbs.extensions.crl_distribution_points_uris.as_ref() else {
return Err(CertPathError::EeCrlDpMissing);
};
if !uris.iter().any(|u| u.as_str() == issuer_crl_rsync_uri) {
return Err(CertPathError::EeCrlDpUriMismatch);
}
Ok(())
}
fn validate_ee_key_usage(cert: &X509Certificate<'_>) -> Result<(), CertPathError> {
let mut ku_critical: Option<bool> = None;
for ext in cert.extensions() {
if ext.oid.as_bytes() == crate::model::oid::OID_KEY_USAGE_RAW {
ku_critical = Some(ext.critical);
break;
}
}
let Some(critical) = ku_critical else {
return Err(CertPathError::KeyUsageMissing);
};
if !critical {
return Err(CertPathError::KeyUsageNotCritical);
}
let Some(ku) = cert
.key_usage()
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))?
else {
return Err(CertPathError::KeyUsageMissing);
};
let v = &ku.value;
let ok = v.digital_signature()
&& !v.key_cert_sign()
&& !v.crl_sign()
&& !v.non_repudiation()
&& !v.key_encipherment()
&& !v.data_encipherment()
&& !v.key_agreement()
&& !v.encipher_only()
&& !v.decipher_only();
if !ok {
return Err(CertPathError::KeyUsageInvalidBits);
}
Ok(())
}
fn validate_ee_key_usage_summary(summary: &EeKeyUsageSummary) -> Result<(), CertPathError> {
match summary {
EeKeyUsageSummary::DigitalSignatureOnly => Ok(()),
EeKeyUsageSummary::Missing => Err(CertPathError::KeyUsageMissing),
EeKeyUsageSummary::NotCritical => Err(CertPathError::KeyUsageNotCritical),
EeKeyUsageSummary::InvalidBits => Err(CertPathError::KeyUsageInvalidBits),
EeKeyUsageSummary::ParseError(err) => Err(CertPathError::KeyUsageParse(err.clone())),
}
}
fn time_within_validity(
t: time::OffsetDateTime,
not_before: time::OffsetDateTime,
not_after: time::OffsetDateTime,
) -> bool {
let t = t.to_offset(time::UtcOffset::UTC);
let not_before = not_before.to_offset(time::UtcOffset::UTC);
let not_after = not_after.to_offset(time::UtcOffset::UTC);
t >= not_before && t <= not_after
}
fn crl_valid_at_time(crl: &RpkixCrl, t: time::OffsetDateTime) -> bool {
let t = t.to_offset(time::UtcOffset::UTC);
let this_update = crl.this_update.utc.to_offset(time::UtcOffset::UTC);
let next_update = crl.next_update.utc.to_offset(time::UtcOffset::UTC);
t >= this_update && t < next_update
}
fn is_serial_revoked_by_crl(ee: &ResourceCertificate, crl: &RpkixCrl) -> bool {
let serial = BigUnsigned::from_biguint(&ee.tbs.serial_number);
crl.revoked_certs
.iter()
.any(|rc| rc.serial_number == serial)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::common::X509NameDer;
use crate::model::oid::OID_CP_IPADDR_ASNUMBER;
use crate::model::rc::{
BasicConstraintsProfile, CertificatePoliciesProfile, RcExtensions, ResourceCertKind,
ResourceCertificate, RpkixTbsCertificate,
};
use der_parser::num_bigint::BigUint;
fn dummy_cert(
kind: ResourceCertKind,
subject_dn: &str,
issuer_dn: &str,
ski: Option<Vec<u8>>,
aki: Option<Vec<u8>>,
aia: Option<Vec<&str>>,
crldp: Option<Vec<&str>>,
) -> ResourceCertificate {
let aia = aia.map(|v| v.into_iter().map(|s| s.to_string()).collect::<Vec<_>>());
let crldp = crldp.map(|v| v.into_iter().map(|s| s.to_string()).collect::<Vec<_>>());
ResourceCertificate {
raw_der: Vec::new(),
kind,
tbs: RpkixTbsCertificate {
version: 2,
serial_number: BigUint::from(1u8),
signature_algorithm: "1.2.840.113549.1.1.11".to_string(),
issuer_name: X509NameDer(issuer_dn.as_bytes().to_vec()),
subject_name: X509NameDer(subject_dn.as_bytes().to_vec()),
validity_not_before: time::OffsetDateTime::UNIX_EPOCH,
validity_not_after: time::OffsetDateTime::UNIX_EPOCH,
subject_public_key_info: Vec::new(),
extensions: RcExtensions {
basic_constraints_ca: kind == ResourceCertKind::Ca,
basic_constraints: (kind == ResourceCertKind::Ca).then_some(
BasicConstraintsProfile {
ca: true,
critical: true,
path_len_constraint: None,
},
),
subject_key_identifier: ski,
authority_key_identifier: aki,
crl_distribution_points_uris: crldp,
ca_issuers_uris: aia,
subject_info_access: None,
certificate_policies_oid: (kind == ResourceCertKind::Ca)
.then_some(OID_CP_IPADDR_ASNUMBER.to_string()),
certificate_policies: (kind == ResourceCertKind::Ca).then_some(
CertificatePoliciesProfile {
policy_oid: OID_CP_IPADDR_ASNUMBER.to_string(),
qualifier_oids: Vec::new(),
},
),
extension_oids: Vec::new(),
ip_resources: None,
as_resources: None,
},
},
}
}
#[test]
fn ee_aki_and_aia_and_crldp_binding_checks_report_errors() {
let issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let ee = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![9]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_ee_aki_matches_issuer_ski(&ee, &issuer).unwrap_err();
assert!(matches!(err, CertPathError::EeAkiMismatch), "{err}");
let issuer_missing_ski = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
None,
None,
None,
None,
);
let err = validate_ee_aki_matches_issuer_ski(&ee, &issuer_missing_ski).unwrap_err();
assert!(matches!(err, CertPathError::IssuerSkiMissing), "{err}");
let ee_missing_aki = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
None,
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_ee_aki_matches_issuer_ski(&ee_missing_aki, &issuer).unwrap_err();
assert!(matches!(err, CertPathError::EeAkiMissing), "{err}");
let ee_missing_aia = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
None,
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_ee_aia_points_to_issuer_uri(
&ee_missing_aia,
"rsync://example.test/issuer.cer",
)
.unwrap_err();
assert!(matches!(err, CertPathError::EeAiaMissing), "{err}");
let ee_wrong_aia = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/other.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
validate_ee_aia_points_to_issuer_uri(&ee_wrong_aia, "rsync://example.test/issuer.cer")
.expect("non-matching rsync AIA is currently accepted");
let ee_missing_crldp = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
None,
);
let err = validate_ee_crldp_contains_issuer_crl_uri(
&ee_missing_crldp,
"rsync://example.test/issuer.crl",
)
.unwrap_err();
assert!(matches!(err, CertPathError::EeCrlDpMissing), "{err}");
let ee_wrong_crldp = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/other.crl"]),
);
let err = validate_ee_crldp_contains_issuer_crl_uri(
&ee_wrong_crldp,
"rsync://example.test/issuer.crl",
)
.unwrap_err();
assert!(matches!(err, CertPathError::EeCrlDpUriMismatch), "{err}");
}
#[test]
fn ee_binding_checks_accept_when_matching() {
let issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let ee = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
validate_ee_aki_matches_issuer_ski(&ee, &issuer).expect("aki ok");
validate_ee_aia_points_to_issuer_uri(&ee, "rsync://example.test/issuer.cer")
.expect("aia ok");
validate_ee_crldp_contains_issuer_crl_uri(&ee, "rsync://example.test/issuer.crl")
.expect("crldp ok");
}
}

533
src/validation/from_tal.rs Normal file
View File

@@ -0,0 +1,533 @@
use url::Url;
use crate::model::ta::{TrustAnchor, TrustAnchorError};
use crate::model::tal::{Tal, TalDecodeError};
use crate::repository::fetch::rsync::RsyncFetcher;
use crate::repository::sync::rrdp::Fetcher;
use crate::validation::ca_instance::{
CaInstanceUris, CaInstanceUrisError, ca_instance_uris_from_ca_certificate,
};
use crate::validation::run::{RunError, RunOutput, run_publication_point_once};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiscoveredRootCaInstance {
pub tal_url: Option<String>,
pub trust_anchor: TrustAnchor,
pub ca_instance: CaInstanceUris,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunFromTalOutput {
pub discovery: DiscoveredRootCaInstance,
pub run: RunOutput,
}
#[derive(Debug, thiserror::Error)]
pub enum FromTalError {
#[error("TAL fetch failed: {0} (RFC 8630 §2.2)")]
TalFetch(String),
#[error("TAL decode failed: {0} (RFC 8630 §2.2)")]
TalDecode(#[from] TalDecodeError),
#[error("failed to fetch TA certificate from TAL: {0} (RFC 8630 §2.3)")]
TaFetch(String),
#[error("failed to bind TAL and TA certificate: {0} (RFC 8630 §2.3)")]
Bind(#[from] TrustAnchorError),
#[error("failed to discover CA instance URIs from TA certificate: {0}")]
CaInstanceUris(#[from] CaInstanceUrisError),
#[error("run failed: {0}")]
Run(#[from] RunError),
#[error("TAL contains no TA URIs (RFC 8630 §2.2)")]
NoTaUris,
}
pub fn discover_root_ca_instance_from_tal_url(
http_fetcher: &dyn Fetcher,
tal_url: &str,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
let tal_bytes = http_fetcher
.fetch(tal_url)
.map_err(FromTalError::TalFetch)?;
let tal = Tal::decode_bytes(&tal_bytes)?;
discover_root_ca_instance_from_tal(http_fetcher, tal, Some(tal_url.to_string()))
}
pub fn discover_root_ca_instance_from_tal_url_with_strict_name(
http_fetcher: &dyn Fetcher,
tal_url: &str,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
let tal_bytes = http_fetcher
.fetch(tal_url)
.map_err(FromTalError::TalFetch)?;
let tal = Tal::decode_bytes(&tal_bytes)?;
discover_root_ca_instance_from_tal_with_strict_name(
http_fetcher,
tal,
Some(tal_url.to_string()),
)
}
pub fn discover_root_ca_instance_from_tal_url_with_fetchers(
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn RsyncFetcher,
tal_url: &str,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
let tal_bytes = http_fetcher
.fetch(tal_url)
.map_err(FromTalError::TalFetch)?;
let tal = Tal::decode_bytes(&tal_bytes)?;
discover_root_ca_instance_from_tal_with_fetchers_impl(
http_fetcher,
rsync_fetcher,
tal,
Some(tal_url.to_string()),
false,
TaUriOrder::HttpFirst,
)
}
pub fn discover_root_ca_instance_from_tal_url_with_fetchers_strict_name(
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn RsyncFetcher,
tal_url: &str,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
let tal_bytes = http_fetcher
.fetch(tal_url)
.map_err(FromTalError::TalFetch)?;
let tal = Tal::decode_bytes(&tal_bytes)?;
discover_root_ca_instance_from_tal_with_fetchers_impl(
http_fetcher,
rsync_fetcher,
tal,
Some(tal_url.to_string()),
true,
TaUriOrder::HttpFirst,
)
}
pub fn discover_root_ca_instance_from_tal(
http_fetcher: &dyn Fetcher,
tal: Tal,
tal_url: Option<String>,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
discover_root_ca_instance_from_tal_impl(http_fetcher, tal, tal_url, false)
}
pub fn discover_root_ca_instance_from_tal_with_strict_name(
http_fetcher: &dyn Fetcher,
tal: Tal,
tal_url: Option<String>,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
discover_root_ca_instance_from_tal_impl(http_fetcher, tal, tal_url, true)
}
fn discover_root_ca_instance_from_tal_impl(
http_fetcher: &dyn Fetcher,
tal: Tal,
tal_url: Option<String>,
strict_name: bool,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
if tal.ta_uris.is_empty() {
return Err(FromTalError::NoTaUris);
}
let mut last_err: Option<String> = None;
for ta_uri in tal.ta_uris.iter() {
let ta_der = match http_fetcher.fetch(ta_uri.as_str()) {
Ok(b) => b,
Err(e) => {
last_err = Some(format!("fetch {ta_uri} failed: {e}"));
continue;
}
};
let trust_anchor =
match bind_trust_anchor_der(tal.clone(), &ta_der, Some(ta_uri), strict_name) {
Ok(ta) => ta,
Err(e) => {
last_err = Some(format!("bind {ta_uri} failed: {e}"));
continue;
}
};
let ca_instance =
match ca_instance_uris_from_ca_certificate(&trust_anchor.ta_certificate.rc_ca) {
Ok(v) => v,
Err(e) => {
last_err = Some(format!("CA instance discovery failed: {e}"));
continue;
}
};
return Ok(DiscoveredRootCaInstance {
tal_url,
trust_anchor,
ca_instance,
});
}
Err(FromTalError::TaFetch(last_err.unwrap_or_else(|| {
"unknown TA candidate error".to_string()
})))
}
pub fn discover_root_ca_instance_from_tal_with_fetchers(
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn RsyncFetcher,
tal: Tal,
tal_url: Option<String>,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
discover_root_ca_instance_from_tal_with_fetchers_impl(
http_fetcher,
rsync_fetcher,
tal,
tal_url,
false,
TaUriOrder::RsyncFirst,
)
}
pub fn discover_root_ca_instance_from_tal_with_fetchers_strict_name(
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn RsyncFetcher,
tal: Tal,
tal_url: Option<String>,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
discover_root_ca_instance_from_tal_with_fetchers_impl(
http_fetcher,
rsync_fetcher,
tal,
tal_url,
true,
TaUriOrder::RsyncFirst,
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TaUriOrder {
HttpFirst,
RsyncFirst,
}
fn discover_root_ca_instance_from_tal_with_fetchers_impl(
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn RsyncFetcher,
tal: Tal,
tal_url: Option<String>,
strict_name: bool,
ta_uri_order: TaUriOrder,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
if tal.ta_uris.is_empty() {
return Err(FromTalError::NoTaUris);
}
let mut last_err: Option<String> = None;
let mut ta_uris = tal.ta_uris.clone();
ta_uris.sort_by_key(|uri| ta_uri_priority(uri, ta_uri_order));
for ta_uri in ta_uris.iter() {
let ta_der = match fetch_ta_der(http_fetcher, rsync_fetcher, ta_uri) {
Ok(b) => b,
Err(e) => {
last_err = Some(format!("fetch {ta_uri} failed: {e}"));
continue;
}
};
let trust_anchor =
match bind_trust_anchor_der(tal.clone(), &ta_der, Some(ta_uri), strict_name) {
Ok(ta) => ta,
Err(e) => {
last_err = Some(format!("bind {ta_uri} failed: {e}"));
continue;
}
};
let ca_instance =
match ca_instance_uris_from_ca_certificate(&trust_anchor.ta_certificate.rc_ca) {
Ok(v) => v,
Err(e) => {
last_err = Some(format!("CA instance discovery failed: {e}"));
continue;
}
};
return Ok(DiscoveredRootCaInstance {
tal_url,
trust_anchor,
ca_instance,
});
}
Err(FromTalError::TaFetch(last_err.unwrap_or_else(|| {
"unknown TA candidate error".to_string()
})))
}
fn ta_uri_priority(uri: &Url, order: TaUriOrder) -> u8 {
match (order, uri.scheme()) {
(TaUriOrder::HttpFirst, "https" | "http") => 0,
(TaUriOrder::HttpFirst, "rsync") => 1,
(TaUriOrder::RsyncFirst, "rsync") => 0,
(TaUriOrder::RsyncFirst, "https" | "http") => 1,
_ => 2,
}
}
fn fetch_ta_der(
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn RsyncFetcher,
ta_uri: &Url,
) -> Result<Vec<u8>, String> {
match ta_uri.scheme() {
"https" | "http" => http_fetcher.fetch(ta_uri.as_str()),
"rsync" => fetch_ta_der_via_rsync(rsync_fetcher, ta_uri.as_str()),
scheme => Err(format!("unsupported TA URI scheme: {scheme}")),
}
}
fn bind_trust_anchor_der(
tal: Tal,
ta_der: &[u8],
resolved_uri: Option<&Url>,
strict_name: bool,
) -> Result<TrustAnchor, TrustAnchorError> {
if strict_name {
TrustAnchor::bind_der_with_strict_name(tal, ta_der, resolved_uri)
} else {
TrustAnchor::bind_der(tal, ta_der, resolved_uri)
}
}
fn fetch_ta_der_via_rsync(
rsync_fetcher: &dyn RsyncFetcher,
ta_rsync_uri: &str,
) -> Result<Vec<u8>, String> {
rsync_fetcher
.fetch_object(ta_rsync_uri)
.map_err(|e| e.to_string())
}
pub fn discover_root_ca_instance_from_tal_and_ta_der(
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&Url>,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
discover_root_ca_instance_from_tal_and_ta_der_impl(tal_bytes, ta_der, resolved_ta_uri, false)
}
pub fn discover_root_ca_instance_from_tal_and_ta_der_with_strict_name(
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&Url>,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
discover_root_ca_instance_from_tal_and_ta_der_impl(tal_bytes, ta_der, resolved_ta_uri, true)
}
fn discover_root_ca_instance_from_tal_and_ta_der_impl(
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&Url>,
strict_name: bool,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
let tal = Tal::decode_bytes(tal_bytes)?;
let trust_anchor = bind_trust_anchor_der(tal, ta_der, resolved_ta_uri, strict_name)?;
let ca_instance = ca_instance_uris_from_ca_certificate(&trust_anchor.ta_certificate.rc_ca)?;
Ok(DiscoveredRootCaInstance {
tal_url: None,
trust_anchor,
ca_instance,
})
}
pub fn canonical_tal_rsync_uri_from_bytes(tal_bytes: &[u8]) -> Result<Url, FromTalError> {
let tal = Tal::decode_bytes(tal_bytes)?;
tal.ta_uris
.iter()
.find(|uri| uri.scheme() == "rsync")
.cloned()
.ok_or_else(|| {
FromTalError::TaFetch("TAL contains no rsync TA URI for offline TA binding".to_string())
})
}
pub fn run_root_from_tal_url_once(
store: &crate::repository::storage::RocksStore,
policy: &crate::validation::policy::Policy,
tal_url: &str,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::repository::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
) -> Result<RunFromTalOutput, FromTalError> {
let discovery =
discover_root_ca_instance_from_tal_url_with_fetchers(http_fetcher, rsync_fetcher, tal_url)?;
let run = run_publication_point_once(
store,
policy,
discovery.ca_instance.rrdp_notification_uri.as_deref(),
&discovery.ca_instance.rsync_base_uri,
&discovery.ca_instance.manifest_rsync_uri,
&discovery.ca_instance.publication_point_rsync_uri,
http_fetcher,
rsync_fetcher,
&discovery.trust_anchor.ta_certificate.raw_der,
None,
discovery
.trust_anchor
.ta_certificate
.rc_ca
.tbs
.extensions
.ip_resources
.as_ref(),
discovery
.trust_anchor
.ta_certificate
.rc_ca
.tbs
.extensions
.as_resources
.as_ref(),
validation_time,
)?;
Ok(RunFromTalOutput { discovery, run })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repository::fetch::rsync::LocalDirRsyncFetcher;
use std::collections::HashMap;
struct MapHttpFetcher {
map: HashMap<String, Vec<u8>>,
}
impl Fetcher for MapHttpFetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
self.map
.get(uri)
.cloned()
.ok_or_else(|| format!("no fixture mapped for {uri}"))
}
}
struct FailingHttpFetcher;
impl Fetcher for FailingHttpFetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
Err(format!("blocked test HTTP fetch: {uri}"))
}
}
#[test]
fn discover_root_ca_instance_from_tal_with_fetchers_supports_rsync_ta_uri() {
let fixture = crate::test_support::synthetic_repository();
let tal_bytes = std::fs::read(fixture.tal()).unwrap();
let ta_der = std::fs::read(fixture.trust_anchor()).unwrap();
let tal = Tal::decode_bytes(&tal_bytes).unwrap();
let td = tempfile::tempdir().unwrap();
let mirror_root = td.path().join("mirror");
std::fs::create_dir_all(&mirror_root).unwrap();
std::fs::write(mirror_root.join("ta.cer"), ta_der).unwrap();
let rsync = LocalDirRsyncFetcher::new(mirror_root);
let discovery = discover_root_ca_instance_from_tal_with_fetchers(
&FailingHttpFetcher,
&rsync,
tal,
None,
)
.expect("discover via rsync TA fallback");
assert!(
discovery
.trust_anchor
.resolved_ta_uri
.unwrap()
.as_str()
.starts_with("rsync://")
);
}
struct PanicRsyncFetcher;
impl RsyncFetcher for PanicRsyncFetcher {
fn fetch_objects(
&self,
rsync_base_uri: &str,
) -> crate::repository::fetch::rsync::RsyncFetchResult<Vec<(String, Vec<u8>)>> {
panic!("rsync should not be used when HTTPS TA URI is available: {rsync_base_uri}")
}
}
#[test]
fn discover_root_ca_instance_from_tal_url_with_fetchers_prefers_https_ta_uri() {
let tal_url = "https://example.test/apnic.tal";
let fixture = crate::test_support::synthetic_repository();
let tal_bytes = std::fs::read(fixture.tal()).unwrap();
let ta_der = std::fs::read(fixture.trust_anchor()).unwrap();
let https_uri = Tal::decode_bytes(&tal_bytes)
.unwrap()
.ta_uris
.into_iter()
.find(|uri| uri.scheme() == "https")
.unwrap()
.to_string();
let mut map = HashMap::new();
map.insert(tal_url.to_string(), tal_bytes);
map.insert(https_uri.clone(), ta_der);
let discovery = discover_root_ca_instance_from_tal_url_with_fetchers(
&MapHttpFetcher { map },
&PanicRsyncFetcher,
tal_url,
)
.expect("discover via HTTPS TA URI");
assert_eq!(
discovery.trust_anchor.resolved_ta_uri.unwrap().as_str(),
https_uri
);
}
#[test]
fn discover_root_ca_instance_from_tal_url_with_fetchers_supports_rsync_only_tal() {
let tal_url = "https://example.test/apnic-rsync-only.tal";
let fixture = crate::test_support::synthetic_repository();
let tal_bytes = std::fs::read_to_string(fixture.tal()).unwrap();
let rsync_uri = Tal::decode_bytes(tal_bytes.as_bytes())
.unwrap()
.ta_uris
.into_iter()
.find(|uri| uri.scheme() == "rsync")
.unwrap()
.to_string();
let key_material = tal_bytes
.split_once("\n\n")
.map(|(_, key)| key)
.expect("fixture contains TAL key material");
let rsync_only_tal = format!("{rsync_uri}\n\n{key_material}");
let ta_der = std::fs::read(fixture.trust_anchor()).unwrap();
let td = tempfile::tempdir().unwrap();
let mirror_root = td.path().join("mirror");
std::fs::create_dir_all(&mirror_root).unwrap();
std::fs::write(mirror_root.join("ta.cer"), ta_der).unwrap();
let mut map = HashMap::new();
map.insert(tal_url.to_string(), rsync_only_tal.into_bytes());
let discovery = discover_root_ca_instance_from_tal_url_with_fetchers(
&MapHttpFetcher { map },
&LocalDirRsyncFetcher::new(mirror_root),
tal_url,
)
.expect("discover via rsync-only TAL");
assert_eq!(
discovery.trust_anchor.resolved_ta_uri.unwrap().as_str(),
rsync_uri
);
}
}

View File

@@ -0,0 +1,19 @@
#![allow(clippy::too_many_arguments)]
use crate::model::manifest::{ManifestDecodeError, ManifestObject, ManifestValidateError};
use crate::model::signed_object::SignedObjectVerifyError;
use crate::output::report::Warning;
use crate::repository::current_repo_index::CurrentRepoIndexHandle;
use crate::repository::storage::{PackFile, PackTime, RocksStore, StorageError};
use crate::validation::cert_path::{CertPathError, validate_signed_object_ee_cert_path_fast};
use crate::validation::policy::Policy;
use crate::validation::publication_point::PublicationPointSnapshot;
use sha2::Digest;
use std::cmp::Ordering;
use x509_parser::prelude::FromDer;
include!("manifest/models_and_process.rs");
include!("manifest/helpers.rs");
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,96 @@
// Manifest URI and embedded certificate validation helpers.
fn cmp_minimal_be_unsigned(a: &[u8], b: &[u8]) -> Ordering {
// Compare two minimal big-endian byte strings as unsigned integers.
// (Leading zeros are not expected; callers store minimal big-endian.)
a.len().cmp(&b.len()).then_with(|| a.cmp(b))
}
fn join_rsync_dir_and_file(base: &str, file_name: &str) -> String {
if base.ends_with('/') {
format!("{base}{file_name}")
} else {
format!("{base}/{file_name}")
}
}
fn rsync_uri_is_under_publication_point(uri: &str, publication_point_rsync_uri: &str) -> bool {
let pp = if publication_point_rsync_uri.ends_with('/') {
publication_point_rsync_uri.to_string()
} else {
format!("{publication_point_rsync_uri}/")
};
uri.starts_with(&pp)
}
fn validate_manifest_embedded_ee_cert_path(
manifest: &ManifestObject,
files: &[crate::repository::storage::PackFile],
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<(), ManifestFreshError> {
let ee = &manifest.signed_object.signed_data.certificates[0];
let crl_files = files
.iter()
.filter(|f| f.rsync_uri.ends_with(".crl"))
.collect::<Vec<_>>();
if crl_files.is_empty() {
return Err(ManifestFreshError::NoCrlFiles);
}
let Some(crldp_uris) = ee
.resource_cert
.tbs
.extensions
.crl_distribution_points_uris
.as_ref()
else {
return Err(ManifestFreshError::EeCrlDpMissing);
};
for u in crldp_uris {
let s = u.as_str();
if let Some(f) = crl_files.iter().find(|f| f.rsync_uri == s) {
let crl_bytes = f.bytes().map_err(|e| ManifestFreshError::MissingFile {
rsync_uri: format!("{s} ({e})"),
})?;
let issuer_ca = crate::model::rc::ResourceCertificate::decode_der(issuer_ca_der)
.map_err(CertPathError::IssuerDecode)?;
let (rem, issuer_spki) = x509_parser::x509::SubjectPublicKeyInfo::from_der(
&issuer_ca.tbs.subject_public_key_info,
)
.map_err(|e| CertPathError::IssuerSpkiParse(e.to_string()))?;
if !rem.is_empty() {
return Err(CertPathError::IssuerSpkiTrailingBytes(rem.len()).into());
}
let issuer_crl = crate::model::crl::RpkixCrl::decode_der(crl_bytes)
.map_err(CertPathError::from)?;
let revoked_serials = issuer_crl
.revoked_certs
.iter()
.map(|rc| rc.serial_number.bytes_be.clone())
.collect::<std::collections::HashSet<_>>();
validate_signed_object_ee_cert_path_fast(
ee,
&issuer_ca,
&issuer_spki,
&issuer_crl,
&revoked_serials,
issuer_ca_rsync_uri,
Some(f.rsync_uri.as_str()),
validation_time,
)?;
return Ok(());
}
}
Err(ManifestFreshError::EeCrlNotFound(
crldp_uris
.iter()
.map(|u| u.as_str())
.collect::<Vec<_>>()
.join(", "),
))
}

View File

@@ -0,0 +1,679 @@
// Publication-point models and manifest processing pipeline.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PublicationPointSource {
Fresh,
FailedFetchNoCache,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicationPointResult {
pub source: PublicationPointSource,
pub snapshot: PublicationPointSnapshot,
pub warnings: Vec<Warning>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FreshValidatedPublicationPoint {
pub manifest_rsync_uri: String,
pub publication_point_rsync_uri: String,
pub manifest_number_be: Vec<u8>,
pub this_update: PackTime,
pub next_update: PackTime,
pub verified_at: PackTime,
pub manifest_bytes: Vec<u8>,
pub files: Vec<PackFile>,
}
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("manifest freshness state unavailable: {0}")]
Storage(#[from] StorageError),
#[error("repo sync failed: {detail} (RFC 8182 §3.4.5; RFC 9286 §6.6)")]
RepoSyncFailed { detail: String },
#[error(
"manifest not found in current repository view: {manifest_rsync_uri} (RFC 9286 §6.2; RFC 9286 §6.6)"
)]
MissingManifest { manifest_rsync_uri: String },
#[error("manifest decode failed: {0} (RFC 9286 §4; RFC 9286 §6.2; RFC 9286 §6.6)")]
Decode(#[from] ManifestDecodeError),
#[error(
"manifest embedded EE certificate resources invalid: {0} (RFC 9286 §5.1; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
EeResources(#[from] ManifestValidateError),
#[error(
"manifest CMS signature verification failed: {0} (RFC 6488 §3; RFC 9589 §4; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
Signature(#[from] SignedObjectVerifyError),
#[error(
"manifest embedded EE certificate path validation failed: {0} (RFC 6488 §3; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
EeCertPath(#[from] CertPathError),
#[error(
"manifest embedded EE certificate CRLDistributionPoints missing (cannot validate EE certificate) (RFC 6487 §4.8.6; RFC 6488 §3; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
EeCrlDpMissing,
#[error(
"publication point contains no CRL files (cannot validate manifest EE certificate) (RFC 9286 §7; RFC 6487 §4.8.6; RFC 6488 §3; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
NoCrlFiles,
#[error(
"CRL referenced by manifest embedded EE certificate CRLDistributionPoints not found at publication point: {0} (RFC 6487 §4.8.6; RFC 9286 §4.2.1; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
EeCrlNotFound(String),
#[error(
"manifest is not valid at validation_time: this_update={this_update_rfc3339_utc} next_update={next_update_rfc3339_utc} validation_time={validation_time_rfc3339_utc} (RFC 9286 §6.3; RFC 9286 §6.6)"
)]
StaleOrEarly {
this_update_rfc3339_utc: String,
next_update_rfc3339_utc: String,
validation_time_rfc3339_utc: String,
},
#[error(
"manifest must reside at the same publication point as id-ad-caRepository: manifest={manifest_rsync_uri} publication_point={publication_point_rsync_uri} (RFC 9286 §6.1; RFC 9286 §6.6)"
)]
ManifestOutsidePublicationPoint {
manifest_rsync_uri: String,
publication_point_rsync_uri: String,
},
#[error(
"manifestNumber not higher than previously validated manifest: old={old_hex} new={new_hex} (RFC 9286 §4.2.1; RFC 9286 §6.6)"
)]
ManifestNumberNotIncreasing { old_hex: String, new_hex: String },
#[error(
"thisUpdate not more recent than previously validated manifest: old={old_rfc3339_utc} new={new_rfc3339_utc} (RFC 9286 §4.2.1; RFC 9286 §6.6)"
)]
ThisUpdateNotIncreasing {
old_rfc3339_utc: String,
new_rfc3339_utc: String,
},
#[error(
"manifest referenced file missing in current repository view: {rsync_uri} (RFC 9286 §6.4; RFC 9286 §6.6)"
)]
MissingFile { rsync_uri: String },
#[error("manifest file hash mismatch: {rsync_uri} (RFC 9286 §6.5; RFC 9286 §6.6)")]
HashMismatch { rsync_uri: String },
#[error("issuer CA certificate bytes unavailable: {detail} (RFC 6487 §4; RFC 9286 §6.2)")]
IssuerCaLoadFailed { detail: String },
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestProcessError {
#[error("manifest processing failed: {0}")]
StopAllOutput(#[from] ManifestFreshError),
#[error("storage error during manifest processing: {0}")]
Storage(#[from] StorageError),
}
pub fn process_manifest_publication_point(
store: &RocksStore,
policy: &Policy,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<PublicationPointResult, ManifestProcessError> {
process_manifest_publication_point_after_repo_sync(
store,
policy,
manifest_rsync_uri,
publication_point_rsync_uri,
issuer_ca_der,
issuer_ca_rsync_uri,
validation_time,
true,
None,
)
}
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<FreshValidatedPublicationPoint, ManifestFreshError> {
process_manifest_publication_point_fresh_after_repo_sync_with_timing(
store,
manifest_rsync_uri,
publication_point_rsync_uri,
None,
issuer_ca_der,
issuer_ca_rsync_uri,
validation_time,
repo_sync_ok,
repo_sync_error,
)
.map(|(fresh, _timing)| fresh)
}
#[derive(Clone, Debug, Default)]
pub struct FreshPublicationPointTimingBreakdown {
pub current_index_lock_ms: u64,
pub manifest_load_ms: u64,
pub manifest_index_lookup_ms: u64,
pub manifest_blob_load_ms: u64,
pub manifest_decode_ms: u64,
pub anti_rollback_guard_ms: u64,
pub anti_rollback_meta_hit: bool,
pub anti_rollback_meta_miss: bool,
pub manifest_entries_ms: u64,
pub pack_files_ms: u64,
pub pack_files_index_lookup_ms: u64,
pub pack_files_blob_load_ms: u64,
pub ee_path_validate_ms: u64,
pub manifest_file_count: usize,
}
pub fn process_manifest_publication_point_fresh_after_repo_sync_with_timing(
store: &RocksStore,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
current_repo_index: Option<&CurrentRepoIndexHandle>,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
repo_sync_ok: bool,
repo_sync_error: Option<&str>,
) -> Result<
(
FreshValidatedPublicationPoint,
FreshPublicationPointTimingBreakdown,
),
ManifestFreshError,
> {
if repo_sync_ok {
try_build_fresh_publication_point_with_timing(
store,
manifest_rsync_uri,
publication_point_rsync_uri,
current_repo_index,
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,
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<PublicationPointResult, ManifestProcessError> {
let fresh = 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(),
})
};
match fresh {
Ok(fresh_point) => Ok(PublicationPointResult {
source: PublicationPointSource::Fresh,
snapshot: fresh_point.to_publication_point_snapshot(),
warnings: Vec::new(),
}),
Err(fresh_err) => {
let _ = policy;
Err(ManifestProcessError::StopAllOutput(fresh_err))
}
}
}
fn decode_and_validate_manifest_with_current_time(
manifest_bytes: &[u8],
validation_time: time::OffsetDateTime,
) -> Result<ManifestObject, ManifestFreshError> {
let manifest = ManifestObject::decode_der(manifest_bytes)?;
manifest.validate_embedded_ee_cert()?;
manifest.signed_object.verify()?;
let this_update = manifest
.manifest
.this_update
.to_offset(time::UtcOffset::UTC);
let next_update = manifest
.manifest
.next_update
.to_offset(time::UtcOffset::UTC);
let now = validation_time.to_offset(time::UtcOffset::UTC);
if now < this_update || now > next_update {
return Err(ManifestFreshError::StaleOrEarly {
this_update_rfc3339_utc: this_update
.format(&time::format_description::well_known::Rfc3339)
.expect("format thisUpdate"),
next_update_rfc3339_utc: next_update
.format(&time::format_description::well_known::Rfc3339)
.expect("format nextUpdate"),
validation_time_rfc3339_utc: now
.format(&time::format_description::well_known::Rfc3339)
.expect("format validation_time"),
});
}
Ok(manifest)
}
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<FreshValidatedPublicationPoint, ManifestFreshError> {
try_build_fresh_publication_point_with_timing(
store,
manifest_rsync_uri,
publication_point_rsync_uri,
None,
issuer_ca_der,
issuer_ca_rsync_uri,
validation_time,
)
.map(|(fresh, _timing)| fresh)
}
pub(crate) fn try_build_fresh_publication_point_with_timing(
store: &RocksStore,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
current_repo_index: Option<&CurrentRepoIndexHandle>,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<
(
FreshValidatedPublicationPoint,
FreshPublicationPointTimingBreakdown,
),
ManifestFreshError,
> {
let mut timing = FreshPublicationPointTimingBreakdown::default();
let current_index_lock_started = std::time::Instant::now();
let current_index_guard = current_repo_index.and_then(|handle| handle.read().ok());
timing.current_index_lock_ms = current_index_lock_started.elapsed().as_millis() as u64;
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(),
publication_point_rsync_uri: publication_point_rsync_uri.to_string(),
});
}
let manifest_load_started = std::time::Instant::now();
let manifest_bytes = if let Some(index) = current_index_guard.as_ref() {
let manifest_lookup_started = std::time::Instant::now();
let current = index.get_by_uri(manifest_rsync_uri).ok_or_else(|| {
ManifestFreshError::MissingManifest {
manifest_rsync_uri: manifest_rsync_uri.to_string(),
}
})?;
timing.manifest_index_lookup_ms = manifest_lookup_started.elapsed().as_millis() as u64;
let manifest_blob_load_started = std::time::Instant::now();
store
.get_blob_bytes(&current.current_hash_hex)
.map_err(|e| ManifestFreshError::MissingManifest {
manifest_rsync_uri: format!("{manifest_rsync_uri} ({e})"),
})?
.ok_or_else(|| ManifestFreshError::MissingManifest {
manifest_rsync_uri: manifest_rsync_uri.to_string(),
})
.inspect(|_| {
timing.manifest_blob_load_ms =
manifest_blob_load_started.elapsed().as_millis() as u64;
})?
} else {
let manifest_blob_load_started = std::time::Instant::now();
store
.load_current_object_bytes_by_uri(manifest_rsync_uri)
.map_err(|e| ManifestFreshError::MissingManifest {
manifest_rsync_uri: format!("{manifest_rsync_uri} ({e})"),
})?
.ok_or_else(|| ManifestFreshError::MissingManifest {
manifest_rsync_uri: manifest_rsync_uri.to_string(),
})
.inspect(|_| {
timing.manifest_blob_load_ms =
manifest_blob_load_started.elapsed().as_millis() as u64;
})?
};
timing.manifest_load_ms = manifest_load_started.elapsed().as_millis() as u64;
let manifest_decode_started = std::time::Instant::now();
let manifest =
decode_and_validate_manifest_with_current_time(&manifest_bytes, validation_time)?;
timing.manifest_decode_ms = manifest_decode_started.elapsed().as_millis() as u64;
let this_update = manifest
.manifest
.this_update
.to_offset(time::UtcOffset::UTC);
let next_update = manifest
.manifest
.next_update
.to_offset(time::UtcOffset::UTC);
let now = validation_time.to_offset(time::UtcOffset::UTC);
// RFC 9286 §4.2.1: rollback detection for manifestNumber and thisUpdate.
//
// Important nuance for revalidation across runs:
// - 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 continue the current validation run with a clear failure if the
// repository cannot provide a valid manifest.
// - 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 anti_rollback_started = std::time::Instant::now();
if let Some(old_meta) = store
.get_manifest_anti_rollback_meta(manifest_rsync_uri)?
{
timing.anti_rollback_meta_hit = true;
if old_meta.manifest_rsync_uri == manifest_rsync_uri {
let new_num = manifest.manifest.manifest_number.bytes_be.as_slice();
let old_num = old_meta.manifest_number_be.as_slice();
match cmp_minimal_be_unsigned(new_num, old_num) {
Ordering::Greater => {
let old_this_update = old_meta
.manifest_this_update
.parse()
.expect("manifest anti-rollback metadata 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 => {
let new_manifest_hash = sha2::Sha256::digest(&manifest_bytes);
if old_meta.manifest_sha256.as_slice() != new_manifest_hash.as_slice() {
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),
});
}
}
}
} else {
timing.anti_rollback_meta_miss = true;
}
timing.anti_rollback_guard_ms = anti_rollback_started.elapsed().as_millis() as u64;
let manifest_entries_started = std::time::Instant::now();
let entries = manifest
.manifest
.parse_files()
.map_err(ManifestDecodeError::Validate)?;
timing.manifest_entries_ms = manifest_entries_started.elapsed().as_millis() as u64;
timing.manifest_file_count = entries.len();
let mut files = Vec::with_capacity(manifest.manifest.file_count());
let pack_files_started = std::time::Instant::now();
let external_raw_store = store
.external_raw_store_ref()
.cloned()
.map(std::sync::Arc::new);
let external_repo_bytes = store
.external_repo_bytes_ref()
.cloned()
.map(std::sync::Arc::new);
let mut pack_files_index_lookup_duration = std::time::Duration::ZERO;
let mut pack_files_blob_load_duration = std::time::Duration::ZERO;
for entry in &entries {
let rsync_uri =
join_rsync_dir_and_file(publication_point_rsync_uri, entry.file_name.as_str());
let current_object = if let Some(index) = current_index_guard.as_ref() {
let index_lookup_started = std::time::Instant::now();
let current =
index
.get_by_uri(&rsync_uri)
.ok_or_else(|| ManifestFreshError::MissingFile {
rsync_uri: rsync_uri.clone(),
})?;
pack_files_index_lookup_duration += index_lookup_started.elapsed();
crate::repository::storage::CurrentObjectWithHash {
current_hash_hex: current.current_hash_hex.clone(),
current_hash: current.current_hash,
bytes: Vec::new(),
}
} else {
let blob_load_started = std::time::Instant::now();
store
.load_current_object_with_hash_by_uri(&rsync_uri)
.map_err(|_e| ManifestFreshError::MissingFile {
rsync_uri: rsync_uri.clone(),
})?
.ok_or_else(|| ManifestFreshError::MissingFile {
rsync_uri: rsync_uri.clone(),
})
.inspect(|_| {
pack_files_blob_load_duration += blob_load_started.elapsed();
})?
};
if current_object.current_hash != entry.hash_bytes {
return Err(ManifestFreshError::HashMismatch { rsync_uri });
}
if let (Some(_), Some(repo_bytes)) =
(current_index_guard.as_ref(), external_repo_bytes.as_ref())
{
files.push(PackFile::from_lazy_repo_bytes(
rsync_uri,
current_object.current_hash_hex,
current_object.current_hash,
repo_bytes.clone(),
));
} else if let (Some(_), Some(raw_store)) =
(current_index_guard.as_ref(), external_raw_store.as_ref())
{
files.push(PackFile::from_lazy_external_raw_store(
rsync_uri,
current_object.current_hash_hex,
current_object.current_hash,
raw_store.clone(),
));
} else {
let bytes = if current_object.bytes.is_empty() {
let blob_load_started = std::time::Instant::now();
store
.get_blob_bytes(&current_object.current_hash_hex)
.map_err(|_e| ManifestFreshError::MissingFile {
rsync_uri: rsync_uri.clone(),
})?
.ok_or_else(|| ManifestFreshError::MissingFile {
rsync_uri: rsync_uri.clone(),
})
.inspect(|_| {
pack_files_blob_load_duration += blob_load_started.elapsed();
})?
} else {
current_object.bytes
};
files.push(PackFile::from_bytes_with_sha256(
rsync_uri,
bytes,
current_object.current_hash,
));
}
}
timing.pack_files_index_lookup_ms = pack_files_index_lookup_duration.as_millis() as u64;
timing.pack_files_blob_load_ms = pack_files_blob_load_duration.as_millis() as u64;
timing.pack_files_ms = pack_files_started.elapsed().as_millis() as u64;
// RFC 6488 §3: manifest (signed object) validity includes a valid EE cert path.
// We validate this after §6.4/§6.5 so the issuer CRL can be selected from the publication point.
let ee_path_validate_started = std::time::Instant::now();
validate_manifest_embedded_ee_cert_path(
&manifest,
&files,
issuer_ca_der,
issuer_ca_rsync_uri,
validation_time,
)?;
timing.ee_path_validate_ms = ee_path_validate_started.elapsed().as_millis() as u64;
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: 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,
},
timing,
))
}

View File

@@ -0,0 +1,606 @@
//! Manifest processing regression tests.
use super::*;
use crate::repository::current_repo_index::CurrentRepoIndex;
use crate::repository::storage::{RawByHashEntry, RocksStore};
use std::path::PathBuf;
fn fixture_repository() -> PathBuf {
crate::test_support::synthetic_repository().case_repository("baseline-v1")
}
fn manifest_fixture_path() -> PathBuf {
fixture_repository().join("child/child.mft")
}
fn issuer_ca_fixture_der() -> Vec<u8> {
std::fs::read(fixture_repository().join("root/child.cer")).expect("read synthetic issuer CA")
}
fn issuer_ca_rsync_uri() -> &'static str {
"rsync://rpki-local-rsync:873/custom/root/child.cer"
}
fn load_manifest_fixture() -> (
ManifestObject,
Vec<u8>,
String,
String,
time::OffsetDateTime,
) {
let manifest_path = manifest_fixture_path();
let manifest_bytes = std::fs::read(&manifest_path).expect("read synthetic manifest");
let manifest = ManifestObject::decode_der(&manifest_bytes).expect("decode synthetic manifest");
let manifest_rsync_uri = "rsync://rpki-local-rsync:873/custom/child/child.mft".to_string();
let publication_point_rsync_uri = "rsync://rpki-local-rsync:873/custom/child/".to_string();
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<u8>, 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 put_current_object(store: &RocksStore, rsync_uri: &str, bytes: Vec<u8>, object_type: &str) {
let hash = hex::encode(sha2::Sha256::digest(&bytes));
store
.put_raw_by_hash_entry(&raw_by_hash_entry(rsync_uri, bytes, object_type))
.expect("put raw_by_hash entry");
store
.put_repository_view_entry(&crate::repository::storage::RepositoryViewEntry {
rsync_uri: rsync_uri.to_string(),
current_hash: Some(hash),
repository_source: Some("https://example.test/notification.xml".to_string()),
object_type: Some(object_type.to_string()),
state: crate::repository::storage::RepositoryViewState::Present,
})
.expect("put repository view entry");
}
fn put_complete_publication_point_current_objects(
store: &RocksStore,
manifest: &ManifestObject,
manifest_rsync_uri: &str,
manifest_bytes: Vec<u8>,
publication_point_rsync_uri: &str,
) {
put_current_object(store, manifest_rsync_uri, manifest_bytes, "mft");
for entry in manifest.manifest.parse_files().expect("parse files") {
let file_path = manifest_fixture_path()
.parent()
.unwrap()
.join(entry.file_name.as_str());
let bytes = std::fs::read(&file_path).expect("read fixture file");
let rsync_uri = format!("{publication_point_rsync_uri}{}", entry.file_name);
let object_type = rsync_uri.rsplit('.').next().unwrap_or("bin");
put_current_object(store, &rsync_uri, bytes, object_type);
}
}
fn put_raw_only(store: &RocksStore, rsync_uri: &str, bytes: Vec<u8>, object_type: &str) {
store
.put_raw_by_hash_entry(&raw_by_hash_entry(rsync_uri, bytes, object_type))
.expect("put raw_by_hash entry");
}
fn locked_files_for_manifest(
manifest: &ManifestObject,
publication_point_rsync_uri: &str,
) -> Vec<PackFile> {
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();
put_current_object(&store, &manifest_rsync_uri, manifest_bytes, "mft");
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);
let object_type = rsync_uri.rsplit('.').next().unwrap_or("bin");
put_current_object(&store, &rsync_uri, bytes, object_type);
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 try_build_fresh_publication_point_detects_hash_mismatch_via_repository_view_hash() {
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();
put_current_object(&store, &manifest_rsync_uri, manifest_bytes, "mft");
let entries = manifest
.manifest
.parse_files()
.expect("parse files")
.into_iter()
.collect::<Vec<_>>();
let first = entries
.iter()
.find(|entry| !entry.file_name.ends_with(".crl"))
.expect("synthetic manifest non-CRL entry");
let first_uri = format!("{publication_point_rsync_uri}{}", first.file_name);
let object_type = first_uri.rsplit('.').next().unwrap_or("bin");
put_current_object(
&store,
&first_uri,
b"wrong object bytes".to_vec(),
object_type,
);
for entry in entries
.iter()
.filter(|entry| entry.file_name != first.file_name)
{
let file_path = manifest_fixture_path()
.parent()
.unwrap()
.join(entry.file_name.as_str());
let bytes = std::fs::read(&file_path).expect("read fixture file");
let rsync_uri = format!("{publication_point_rsync_uri}{}", entry.file_name);
let object_type = rsync_uri.rsplit('.').next().unwrap_or("bin");
put_current_object(&store, &rsync_uri, bytes, object_type);
}
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::HashMismatch { .. }),
"{err}"
);
}
#[test]
fn try_build_fresh_publication_point_uses_current_repo_index_without_repository_view() {
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();
put_raw_only(&store, &manifest_rsync_uri, manifest_bytes.clone(), "mft");
let current_index = CurrentRepoIndex::shared();
let mut entries = vec![crate::repository::storage::RepositoryViewEntry {
rsync_uri: manifest_rsync_uri.clone(),
current_hash: Some(hex::encode(sha2::Sha256::digest(&manifest_bytes))),
repository_source: Some("https://example.test/notification.xml".to_string()),
object_type: Some("mft".to_string()),
state: crate::repository::storage::RepositoryViewState::Present,
}];
for entry in manifest.manifest.parse_files().expect("parse files") {
let file_path = manifest_fixture_path()
.parent()
.unwrap()
.join(entry.file_name.as_str());
let bytes = std::fs::read(&file_path).expect("read fixture file");
let rsync_uri = format!("{publication_point_rsync_uri}{}", entry.file_name);
let object_type = rsync_uri.rsplit('.').next().unwrap_or("bin").to_string();
put_raw_only(&store, &rsync_uri, bytes.clone(), &object_type);
entries.push(crate::repository::storage::RepositoryViewEntry {
rsync_uri,
current_hash: Some(hex::encode(sha2::Sha256::digest(&bytes))),
repository_source: Some("https://example.test/notification.xml".to_string()),
object_type: Some(object_type),
state: crate::repository::storage::RepositoryViewState::Present,
});
}
current_index
.write()
.expect("index write lock")
.apply_repository_view_entries(&entries)
.expect("apply current index");
assert!(
store
.get_repository_view_entry(&manifest_rsync_uri)
.expect("get repository view")
.is_none()
);
let (fresh, _timing) = try_build_fresh_publication_point_with_timing(
&store,
&manifest_rsync_uri,
&publication_point_rsync_uri,
Some(&current_index),
&issuer_ca_fixture_der(),
Some(issuer_ca_rsync_uri()),
validation_time,
)
.expect("fresh publication point via current index");
assert_eq!(fresh.manifest_rsync_uri, manifest_rsync_uri);
assert_eq!(fresh.files.len(), manifest.manifest.file_count());
}
#[test]
fn try_build_fresh_publication_point_records_anti_rollback_meta_miss() {
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();
put_complete_publication_point_current_objects(
&store,
&manifest,
&manifest_rsync_uri,
manifest_bytes,
&publication_point_rsync_uri,
);
let (_fresh, timing) = try_build_fresh_publication_point_with_timing(
&store,
&manifest_rsync_uri,
&publication_point_rsync_uri,
None,
&issuer_ca_fixture_der(),
Some(issuer_ca_rsync_uri()),
validation_time,
)
.expect("fresh publication point without freshness metadata");
assert!(timing.anti_rollback_meta_miss);
assert!(!timing.anti_rollback_meta_hit);
}
#[test]
fn try_build_fresh_publication_point_uses_anti_rollback_meta_hit_for_same_manifest() {
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();
let previous_meta = sample_manifest_meta(
&manifest,
&manifest_rsync_uri,
&publication_point_rsync_uri,
&manifest_bytes,
validation_time,
);
store
.put_manifest_anti_rollback_meta(&previous_meta)
.expect("persist freshness");
put_complete_publication_point_current_objects(
&store,
&manifest,
&manifest_rsync_uri,
manifest_bytes,
&publication_point_rsync_uri,
);
let (_fresh, timing) = try_build_fresh_publication_point_with_timing(
&store,
&manifest_rsync_uri,
&publication_point_rsync_uri,
None,
&issuer_ca_fixture_der(),
Some(issuer_ca_rsync_uri()),
validation_time,
)
.expect("fresh publication point with matching freshness metadata");
assert!(timing.anti_rollback_meta_hit);
assert!(!timing.anti_rollback_meta_miss);
}
#[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::<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::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::<Vec<_>>();
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 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::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}"
);
}
fn sample_manifest_meta(
manifest: &ManifestObject,
uri: &str,
_base: &str,
bytes: &[u8],
now: time::OffsetDateTime,
) -> crate::repository::storage::ManifestAntiRollbackMeta {
crate::repository::storage::ManifestAntiRollbackMeta {
manifest_rsync_uri: uri.into(),
manifest_number_be: manifest.manifest.manifest_number.bytes_be.clone(),
manifest_this_update: PackTime::from_utc_offset_datetime(manifest.manifest.this_update),
manifest_sha256: sha2::Sha256::digest(bytes).to_vec(),
updated_at_validation_time: PackTime::from_utc_offset_datetime(now),
}
}
#[test]
fn history_survives_restart_and_rejects_rollback_without_advancing() {
for case in 0..4 {
let dir = tempfile::tempdir().unwrap();
let (manifest, bytes, uri, base, now) = load_manifest_fixture();
let mut meta = sample_manifest_meta(&manifest, &uri, &base, &bytes, now);
match case {
0 => {}
1 => {
meta.manifest_number_be = vec![0x7f; 20];
}
2 => {
meta.manifest_sha256 = vec![0x55; 32];
}
3 => {
meta.manifest_number_be = vec![0];
}
_ => unreachable!(),
}
{
let store = RocksStore::open(dir.path()).unwrap();
store.put_manifest_anti_rollback_meta(&meta).unwrap();
put_complete_publication_point_current_objects(
&store,
&manifest,
&uri,
bytes.clone(),
&base,
);
}
let store = RocksStore::open(dir.path()).unwrap();
assert_eq!(
store.get_manifest_anti_rollback_meta(&uri).unwrap(),
Some(meta.clone())
);
let result = try_build_fresh_publication_point_with_timing(
&store,
&uri,
&base,
None,
&issuer_ca_fixture_der(),
Some(issuer_ca_rsync_uri()),
now,
);
match case {
0 => {
assert!(result.unwrap().1.anti_rollback_meta_hit);
}
1 | 2 => {
assert!(matches!(
result,
Err(ManifestFreshError::ManifestNumberNotIncreasing { .. })
));
}
3 => {
assert!(matches!(
result,
Err(ManifestFreshError::ThisUpdateNotIncreasing { .. })
));
}
_ => unreachable!(),
}
assert_eq!(
store.get_manifest_anti_rollback_meta(&uri).unwrap(),
Some(meta)
);
}
}

14
src/validation/mod.rs Normal file
View File

@@ -0,0 +1,14 @@
pub mod ca_instance;
pub mod ca_path;
pub mod cert_path;
pub mod from_tal;
pub mod manifest;
pub mod objects;
pub mod policy;
pub mod publication_point;
pub mod run;
pub mod run_tree_from_tal;
pub mod tree;
pub mod tree_parallel;
pub mod tree_runner;
pub mod x509_name;

36
src/validation/objects.rs Normal file
View File

@@ -0,0 +1,36 @@
#![allow(clippy::too_many_arguments)]
use crate::model::aspa::{AspaDecodeError, AspaObject, AspaValidateError};
use crate::model::manifest::ManifestObject;
use crate::model::rc::{
AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpAddressOrRange, IpPrefix as RcIpPrefix,
ResourceCertificate,
};
use crate::model::roa::{IpPrefix, RoaAfi, RoaDecodeError, RoaObject, RoaValidateError};
use crate::model::signed_object::{RpkiSignedObject, SignedObjectVerifyError};
use crate::output::analysis::timing::TimingHandle;
use crate::output::audit::{
AuditObjectKind, AuditObjectResult, ObjectAuditEntry, sha256_hex_from_32,
};
use crate::output::report::{RfcRef, Warning, WarningCategory};
use crate::repository::storage::{PackFile, PackTime};
use crate::scheduler::config::ParallelPhase2Config;
use crate::scheduler::object_worker::{
ObjectTaskExecutor, ObjectWorkerPool, ObjectWorkerSubmitError,
};
use crate::validation::cert_path::{CertPathError, validate_signed_object_ee_cert_path_fast};
use crate::validation::manifest::PublicationPointData;
use crate::validation::policy::{Policy, ResourceValidationMode, SignedObjectFailurePolicy};
use crate::validation::publication_point::PublicationPointSnapshot;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use x509_parser::prelude::FromDer;
use x509_parser::x509::SubjectPublicKeyInfo;
include!("objects/outputs.rs");
include!("objects/serial_processing.rs");
include!("objects/parallel_processing.rs");
include!("objects/parallel_stage.rs");
include!("objects/object_validation.rs");
include!("objects/resource_validation.rs");

View File

@@ -0,0 +1,100 @@
fn process_roa_with_issuer(
file: &PackFile,
issuer_ca_der: &[u8],
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_ca_rsync_uri: Option<&str>,
crl_states: &mut std::collections::HashMap<String, IssuerCrlState>,
issuer_resources_index: &IssuerResourcesIndex,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
strict_cms_der: bool,
strict_name: bool,
resource_validation_mode: ResourceValidationMode,
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
) -> Result<Vec<Vrp>, ObjectValidateError> {
let roa = {
let _span = timing.as_ref().map(|t| t.span_phase("objects_roa_decode_and_validate_total"));
RoaObject::decode_der_with_strict_options(file.bytes().map_err(ObjectValidateError::BytesLoad)?, strict_cms_der, strict_name)?
};
roa.validate_embedded_ee_cert()?;
roa.signed_object.verify()?;
let ee = &roa.signed_object.signed_data.certificates[0];
let crl_uri = choose_crl_uri_for_certificate(ee.resource_cert.tbs.extensions.crl_distribution_points_uris.as_ref(), crl_states)?;
let verified_crl = ensure_issuer_crl_verified(crl_uri, crl_states, issuer_ca_der)?;
validate_signed_object_ee_cert_path_fast(ee, issuer_ca, issuer_spki, &verified_crl.crl, &verified_crl.revoked_serials, issuer_ca_rsync_uri, Some(crl_uri), validation_time)?;
let ee_vrs = validate_ee_resources_for_mode(&ee.resource_cert, issuer_effective_ip, issuer_effective_as, issuer_resources_index, resource_validation_mode)?;
if let Some(constraints) = ta_constraints { constraints.validate_ee_certificate(&ee.resource_cert)?; }
roa_to_vrps_with_vrs(&roa, ee_vrs.ip.as_ref())
}
fn process_roa_with_issuer_parallel(
file: &PackFile,
issuer_ca_der: &[u8],
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_ca_rsync_uri: Option<&str>,
crl_states: &Mutex<std::collections::HashMap<String, IssuerCrlState>>,
issuer_resources_index: &IssuerResourcesIndex,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
strict_cms_der: bool,
strict_name: bool,
resource_validation_mode: ResourceValidationMode,
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
) -> Result<Vec<Vrp>, ObjectValidateError> {
let roa = {
let _span = timing.as_ref().map(|t| t.span_phase("objects_roa_decode_and_validate_total"));
RoaObject::decode_der_with_strict_options(file.bytes().map_err(ObjectValidateError::BytesLoad)?, strict_cms_der, strict_name)?
};
roa.validate_embedded_ee_cert()?;
roa.signed_object.verify()?;
let ee = &roa.signed_object.signed_data.certificates[0];
let (crl_uri, verified_crl) = {
let mut states = crl_states.lock().expect("parallel issuer CRL state lock");
let uri = choose_crl_uri_for_certificate(ee.resource_cert.tbs.extensions.crl_distribution_points_uris.as_ref(), &states)?.to_string();
let value = ensure_issuer_crl_verified(&uri, &mut states, issuer_ca_der)?;
(uri, value)
};
validate_signed_object_ee_cert_path_fast(ee, issuer_ca, issuer_spki, &verified_crl.crl, &verified_crl.revoked_serials, issuer_ca_rsync_uri, Some(crl_uri.as_str()), validation_time)?;
let ee_vrs = validate_ee_resources_for_mode(&ee.resource_cert, issuer_effective_ip, issuer_effective_as, issuer_resources_index, resource_validation_mode)?;
if let Some(constraints) = ta_constraints { constraints.validate_ee_certificate(&ee.resource_cert)?; }
roa_to_vrps_with_vrs(&roa, ee_vrs.ip.as_ref())
}
fn process_aspa_with_issuer(
file: &PackFile,
issuer_ca_der: &[u8],
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_ca_rsync_uri: Option<&str>,
crl_states: &mut std::collections::HashMap<String, IssuerCrlState>,
issuer_resources_index: &IssuerResourcesIndex,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
strict_cms_der: bool,
strict_name: bool,
resource_validation_mode: ResourceValidationMode,
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
) -> Result<AspaAttestation, ObjectValidateError> {
let aspa = {
let _span = timing.as_ref().map(|t| t.span_phase("objects_aspa_decode_and_validate_total"));
AspaObject::decode_der_with_strict_options(file.bytes().map_err(ObjectValidateError::BytesLoad)?, strict_cms_der, strict_name)?
};
aspa.validate_embedded_ee_cert()?;
aspa.signed_object.verify()?;
let ee = &aspa.signed_object.signed_data.certificates[0];
let crl_uri = choose_crl_uri_for_certificate(ee.resource_cert.tbs.extensions.crl_distribution_points_uris.as_ref(), crl_states)?;
let verified_crl = ensure_issuer_crl_verified(crl_uri, crl_states, issuer_ca_der)?;
validate_signed_object_ee_cert_path_fast(ee, issuer_ca, issuer_spki, &verified_crl.crl, &verified_crl.revoked_serials, issuer_ca_rsync_uri, Some(crl_uri), validation_time)?;
let ee_vrs = validate_ee_resources_for_mode(&ee.resource_cert, issuer_effective_ip, issuer_effective_as, issuer_resources_index, resource_validation_mode)?;
if let Some(constraints) = ta_constraints { constraints.validate_ee_certificate(&ee.resource_cert)?; }
validate_aspa_customer_in_vrs(&aspa, ee_vrs.asn.as_ref())?;
Ok(AspaAttestation { customer_as_id: aspa.aspa.customer_as_id, provider_as_ids: aspa.aspa.provider_as_ids.clone() })
}

View File

@@ -0,0 +1,88 @@
// Shared in-run validation types used by the fresh validation pipeline.
const RFC_NONE: &[RfcRef] = &[];
const RFC_CRLDP: &[RfcRef] = &[RfcRef("RFC 6487 §4.8.6")];
const RFC_CRLDP_AND_LOCKED_PACK: &[RfcRef] = &[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §4.2.1")];
fn ber_compatible_cms_warning(der: &[u8], rsync_uri: &str, object_kind: &str) -> Option<Warning> {
let strict_error = RpkiSignedObject::strict_cms_der_error(der)?;
Some(Warning::new(format!("accepted BER-compatible CMS encoding for {object_kind}: {rsync_uri}: {strict_error}"))
.with_category(WarningCategory::BerCompatibleCmsEncoding)
.with_rfc_refs(&[RfcRef("X.690 §10"), RfcRef("RFC 6488 §2")])
.with_context(rsync_uri))
}
fn ber_compatible_cms_warning_for_file(file: &PackFile, object_kind: &str) -> Option<Warning> {
ber_compatible_cms_warning(file.bytes().ok()?, &file.rsync_uri, object_kind)
}
fn decode_resource_certificate_with_policy(der: &[u8], policy: &Policy) -> Result<ResourceCertificate, crate::model::rc::ResourceCertificateDecodeError> {
if policy.strict.name { ResourceCertificate::decode_der_with_strict_name(der) } else { ResourceCertificate::decode_der(der) }
}
#[derive(Clone, Debug)]
pub(crate) struct VerifiedIssuerCrl {
pub(crate) crl: crate::model::crl::RpkixCrl,
pub(crate) revoked_serials: std::collections::HashSet<Vec<u8>>,
}
#[derive(Clone, Debug)]
pub(crate) enum IssuerCrlState {
Pending { bytes: Vec<u8> },
Verified(Arc<VerifiedIssuerCrl>),
}
#[derive(Clone, Debug, Default)]
pub(crate) struct IssuerResourcesIndex {
pub(crate) ip_v4: Option<Vec<(Vec<u8>, Vec<u8>)>>,
pub(crate) ip_v6: Option<Vec<(Vec<u8>, Vec<u8>)>>,
pub(crate) asnum: Option<Vec<(u32, u32)>>,
pub(crate) rdi: Option<Vec<(u32, u32)>>,
}
fn extra_rfc_refs_for_crl_selection(error: &ObjectValidateError) -> &'static [RfcRef] {
match error { ObjectValidateError::MissingCrlDpUris => RFC_CRLDP, ObjectValidateError::CrlNotFound(_) => RFC_CRLDP_AND_LOCKED_PACK, _ => RFC_NONE }
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vrp { pub asn: u32, pub prefix: IpPrefix, pub max_length: u16 }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaAttestation { pub customer_as_id: u32, pub provider_as_ids: Vec<u32> }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RouterKeyPayload {
pub as_id: u32,
pub ski: Vec<u8>,
pub spki_der: Vec<u8>,
pub source_object_uri: String,
pub source_object_hash: String,
pub source_ee_cert_hash: String,
pub item_effective_until: PackTime,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ObjectsOutput {
pub vrps: Vec<Vrp>,
pub aspas: Vec<AspaAttestation>,
pub router_keys: Vec<RouterKeyPayload>,
pub warnings: Vec<Warning>,
pub stats: ObjectsStats,
pub audit: Vec<ObjectAuditEntry>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ObjectsStats {
pub roa_total: usize,
pub roa_ok: usize,
pub aspa_total: usize,
pub aspa_ok: usize,
pub publication_point_dropped: bool,
}
#[derive(Debug)]
pub(crate) struct RoaTaskOk { pub(crate) vrps: Vec<Vrp> }
#[derive(Debug)]
pub(crate) struct RoaTaskResult {
pub(crate) publication_point_id: u64,
pub(crate) index: usize,
pub(crate) worker_index: usize,
pub(crate) queue_wait_ms: u64,
pub(crate) worker_ms: u64,
pub(crate) outcome: Result<RoaTaskOk, ObjectValidateError>,
}

View File

@@ -0,0 +1,331 @@
/// Bounded parallel ROA validation.
///
/// Workers share only immutable certificate/resource state and an in-run CRL
/// verification map. Nothing in this path is persisted or reused by a later
/// validation run.
pub fn process_publication_point_for_issuer_parallel_roa<P: PublicationPointData>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
config: &ParallelPhase2Config,
) -> ObjectsOutput {
if config.object_workers <= 1
|| policy.signed_object_failure_policy == SignedObjectFailurePolicy::DropPublicationPoint
{
return process_publication_point_for_issuer_with_ta_constraints(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
None,
);
}
let Ok(pool) = ParallelRoaWorkerPool::new(config) else {
return process_publication_point_for_issuer_with_ta_constraints(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
None,
);
};
process_publication_point_for_issuer_parallel_roa_with_pool(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
&pool,
)
}
pub fn process_publication_point_for_issuer_parallel_roa_with_pool<P: PublicationPointData>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
pool: &ParallelRoaWorkerPool,
) -> ObjectsOutput {
if policy.signed_object_failure_policy == SignedObjectFailurePolicy::DropPublicationPoint {
return process_publication_point_for_issuer_with_ta_constraints(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
None,
);
}
process_publication_point_for_issuer_parallel_roa_inner(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
pool,
None,
)
.unwrap_or_else(|_| {
process_publication_point_for_issuer_with_ta_constraints(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
None,
)
})
}
#[derive(Clone)]
pub(crate) struct RoaTaskShared {
locked_files: Arc<[PackFile]>,
issuer_ca_der: Arc<[u8]>,
issuer_ca: Arc<ResourceCertificate>,
issuer_spki_der: Arc<[u8]>,
issuer_ca_rsync_uri: Option<Arc<str>>,
crl_states: Arc<Mutex<HashMap<String, IssuerCrlState>>>,
issuer_resources_index: Arc<IssuerResourcesIndex>,
issuer_effective_ip: Option<Arc<crate::model::rc::IpResourceSet>>,
issuer_effective_as: Option<Arc<crate::model::rc::AsResourceSet>>,
resource_validation_mode: ResourceValidationMode,
ta_constraints: Option<Arc<crate::ta_constraints::TaConstraints>>,
}
#[derive(Clone)]
pub(crate) struct OwnedRoaTask {
pub(crate) publication_point_id: u64,
index: usize,
shared: Arc<RoaTaskShared>,
validation_time: time::OffsetDateTime,
strict_cms_der: bool,
strict_name: bool,
pub(crate) submitted_at: Option<Instant>,
}
#[derive(Clone)]
struct RoaTaskExecutor;
impl ObjectTaskExecutor<OwnedRoaTask, RoaTaskResult> for RoaTaskExecutor {
fn execute(&self, worker_index: usize, task: OwnedRoaTask) -> RoaTaskResult {
validate_owned_roa_task(worker_index, task)
}
}
pub struct ParallelRoaWorkerPool {
pool: Mutex<ObjectWorkerPool<OwnedRoaTask, RoaTaskResult, RoaTaskExecutor>>,
}
pub(crate) enum ParallelObjectsPrepare {
Complete(ObjectsOutput),
Staged(ParallelObjectsStage),
}
pub(crate) struct ParallelObjectsStage {
pub(crate) publication_point_id: u64,
pub(crate) shared: Arc<RoaTaskShared>,
pub(crate) validation_time: time::OffsetDateTime,
pub(crate) strict_cms_der: bool,
pub(crate) strict_name: bool,
pub(crate) roa_task_indices: Vec<usize>,
pub(crate) ready_roa_results: Vec<RoaTaskResult>,
pub(crate) warnings: Vec<Warning>,
pub(crate) stats: ObjectsStats,
pub(crate) audit: Vec<ObjectAuditEntry>,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ObjectValidateError {
#[error("object bytes load failed: {0}")]
BytesLoad(String),
#[error("ROA decode failed: {0}")]
RoaDecode(#[from] RoaDecodeError),
#[error("ROA embedded EE resource validation failed: {0}")]
RoaEeResources(#[from] RoaValidateError),
#[error("ASPA decode failed: {0}")]
AspaDecode(#[from] AspaDecodeError),
#[error("ASPA embedded EE resource validation failed: {0}")]
AspaEeResources(#[from] AspaValidateError),
#[error("CMS signature verification failed: {0}")]
Signature(#[from] SignedObjectVerifyError),
#[error("EE certificate path validation failed: {0}")]
CertPath(#[from] CertPathError),
#[error("certificate CRLDistributionPoints URIs missing (RFC 6487 §4.8.6)")]
MissingCrlDpUris,
#[error("no CRL available in publication point snapshot (RFC 9286 §7)")]
MissingCrlInPack,
#[error("CRL referenced by CRLDistributionPoints not found in publication point snapshot: {0}")]
CrlNotFound(String),
#[error("issuer effective IP resources missing")]
MissingIssuerEffectiveIp,
#[error("issuer effective AS resources missing")]
MissingIssuerEffectiveAs,
#[error("EE certificate resources are not a subset of issuer effective resources")]
EeResourcesNotSubset,
#[error("EE certificate violates locally configured TA constraints: {0}")]
TaConstraints(#[from] crate::ta_constraints::TaConstraintsViolation),
}
impl ParallelRoaWorkerPool {
pub fn new(config: &ParallelPhase2Config) -> Result<Self, String> {
if config.object_workers <= 1 {
return Err("parallel ROA worker pool requires object_workers > 1".to_string());
}
Ok(Self {
pool: Mutex::new(ObjectWorkerPool::new(
config.object_workers,
config.worker_queue_capacity,
RoaTaskExecutor,
)?),
})
}
pub(crate) fn try_submit_round_robin(
&self,
task: OwnedRoaTask,
) -> Result<usize, ObjectWorkerSubmitError<OwnedRoaTask>> {
self.pool
.lock()
.expect("parallel ROA worker pool lock")
.try_submit_round_robin(task)
}
pub(crate) fn recv_result_timeout(
&self,
timeout: Duration,
) -> Result<Option<RoaTaskResult>, String> {
self.pool
.lock()
.expect("parallel ROA worker pool lock")
.recv_result_timeout(timeout)
}
}
fn validate_owned_roa_task(worker_index: usize, task: OwnedRoaTask) -> RoaTaskResult {
let worker_started = Instant::now();
let queue_wait_ms = task
.submitted_at
.map(|submitted_at| worker_started.saturating_duration_since(submitted_at))
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0);
let shared = task.shared.as_ref();
let file = shared
.locked_files
.get(task.index)
.expect("ROA task index must reference locked file");
let outcome = match SubjectPublicKeyInfo::from_der(shared.issuer_spki_der.as_ref()) {
Ok(([], issuer_spki)) => process_roa_with_issuer_parallel(
file,
shared.issuer_ca_der.as_ref(),
shared.issuer_ca.as_ref(),
&issuer_spki,
shared.issuer_ca_rsync_uri.as_deref(),
shared.crl_states.as_ref(),
shared.issuer_resources_index.as_ref(),
shared.issuer_effective_ip.as_deref(),
shared.issuer_effective_as.as_deref(),
task.validation_time,
None,
task.strict_cms_der,
task.strict_name,
shared.resource_validation_mode,
shared.ta_constraints.as_deref(),
)
.map(|vrps| RoaTaskOk { vrps }),
Ok((rem, _)) => Err(ObjectValidateError::CertPath(
CertPathError::IssuerSpkiTrailingBytes(rem.len()),
)),
Err(error) => Err(ObjectValidateError::CertPath(
CertPathError::IssuerSpkiParse(error.to_string()),
)),
};
RoaTaskResult {
publication_point_id: task.publication_point_id,
index: task.index,
worker_index,
queue_wait_ms,
worker_ms: worker_started.elapsed().as_millis() as u64,
outcome,
}
}
fn process_publication_point_for_issuer_parallel_roa_inner<P: PublicationPointData>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
pool: &ParallelRoaWorkerPool,
ta_constraints: Option<Arc<crate::ta_constraints::TaConstraints>>,
) -> Result<ObjectsOutput, String> {
let stage = match prepare_publication_point_for_parallel_roa_and_ta_constraints(
0,
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
ta_constraints,
) {
ParallelObjectsPrepare::Complete(output) => return Ok(output),
ParallelObjectsPrepare::Staged(stage) => stage,
};
let task_count = stage.roa_task_count();
let mut pending = std::collections::VecDeque::with_capacity(task_count);
stage.append_roa_tasks_to(&mut pending);
while let Some(mut task) = pending.pop_front() {
task.submitted_at = Some(Instant::now());
match pool.try_submit_round_robin(task) {
Ok(_) => {}
Err(ObjectWorkerSubmitError::QueueFull { task, .. }) => {
pending.push_front(task);
std::thread::yield_now();
}
Err(ObjectWorkerSubmitError::Disconnected { .. }) => {
return Err("parallel ROA worker queue disconnected".to_string());
}
}
}
let mut results = Vec::with_capacity(task_count);
while results.len() < task_count {
let Some(result) = pool.recv_result_timeout(Duration::from_secs(30))? else {
return Err("parallel ROA worker timed out".to_string());
};
results.push(result);
}
reduce_parallel_roa_stage(stage, results, timing)
}

View File

@@ -0,0 +1,377 @@
impl ParallelObjectsStage {
pub(crate) fn append_roa_tasks_to(
&self,
pending: &mut std::collections::VecDeque<OwnedRoaTask>,
) {
self.extend_roa_tasks(|task| pending.push_back(task));
}
fn extend_roa_tasks<F>(&self, mut push: F)
where
F: FnMut(OwnedRoaTask),
{
let shared = self.shared.clone();
for index in &self.roa_task_indices {
push(OwnedRoaTask {
publication_point_id: self.publication_point_id,
index: *index,
shared: shared.clone(),
validation_time: self.validation_time,
strict_cms_der: self.strict_cms_der,
strict_name: self.strict_name,
submitted_at: None,
});
}
}
pub(crate) fn roa_task_count(&self) -> usize {
self.roa_task_indices.len()
}
pub(crate) fn aspa_task_count(&self) -> usize {
self.stats.aspa_total
}
pub(crate) fn locked_file_count(&self) -> usize {
self.shared.locked_files.len()
}
}
pub(crate) fn prepare_publication_point_for_parallel_roa_and_ta_constraints<
P: PublicationPointData,
>(
publication_point_id: u64,
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
ta_constraints: Option<Arc<crate::ta_constraints::TaConstraints>>,
) -> ParallelObjectsPrepare {
let manifest_uri = publication_point.manifest_rsync_uri();
let locked_files = publication_point.files();
let mut stats = ObjectsStats {
roa_total: locked_files
.iter()
.filter(|f| f.rsync_uri.ends_with(".roa"))
.count(),
aspa_total: locked_files
.iter()
.filter(|f| f.rsync_uri.ends_with(".asa"))
.count(),
..ObjectsStats::default()
};
let mut warnings = Vec::new();
let audit = Vec::new();
let empty = |stats: ObjectsStats, warnings: Vec<Warning>| ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
warnings,
stats,
audit: Vec::new(),
};
if let Err(error) = ManifestObject::decode_der_with_strict_options(
publication_point.manifest_bytes(),
policy.strict.cms_der,
policy.strict.name,
) {
stats.publication_point_dropped = true;
warnings.push(
Warning::new(format!(
"dropping publication point: manifest decode failed: {error}"
))
.with_rfc_refs(&[RfcRef("RFC 9286 §4"), RfcRef("RFC 9286 §6.6")])
.with_context(manifest_uri),
);
return ParallelObjectsPrepare::Complete(empty(stats, warnings));
}
if let Some(warning) = ber_compatible_cms_warning(
publication_point.manifest_bytes(),
manifest_uri,
"manifest",
) {
warnings.push(warning);
}
let issuer_ca = match decode_resource_certificate_with_policy(issuer_ca_der, policy) {
Ok(value) => value,
Err(error) => {
stats.publication_point_dropped = true;
warnings.push(
Warning::new(format!(
"dropping publication point: issuer CA decode failed: {error}"
))
.with_rfc_refs(&[RfcRef("RFC 6487 §7.2"), RfcRef("RFC 5280 §6.1")])
.with_context(manifest_uri),
);
return ParallelObjectsPrepare::Complete(empty(stats, warnings));
}
};
let issuer_spki_der = issuer_ca.tbs.subject_public_key_info.clone();
match SubjectPublicKeyInfo::from_der(&issuer_spki_der) {
Ok(([], _)) => {}
Ok((remaining, _)) => {
stats.publication_point_dropped = true;
warnings.push(
Warning::new(format!(
"dropping publication point: issuer SPKI has {} trailing bytes",
remaining.len()
))
.with_context(manifest_uri),
);
return ParallelObjectsPrepare::Complete(empty(stats, warnings));
}
Err(error) => {
stats.publication_point_dropped = true;
warnings.push(
Warning::new(format!(
"dropping publication point: issuer SPKI parse failed: {error}"
))
.with_context(manifest_uri),
);
return ParallelObjectsPrepare::Complete(empty(stats, warnings));
}
}
let crl_states = locked_files
.iter()
.filter(|file| file.rsync_uri.ends_with(".crl"))
.filter_map(|file| {
file.bytes_cloned().ok().map(|bytes| {
(
file.rsync_uri.clone(),
IssuerCrlState::Pending { bytes },
)
})
})
.collect::<HashMap<_, _>>();
if crl_states.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 validated publication point",
)
.with_rfc_refs(&[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §7")])
.with_context(manifest_uri),
);
return ParallelObjectsPrepare::Complete(empty(stats, warnings));
}
let roa_task_indices = locked_files
.iter()
.enumerate()
.filter_map(|(index, file)| file.rsync_uri.ends_with(".roa").then_some(index))
.collect::<Vec<_>>();
ParallelObjectsPrepare::Staged(ParallelObjectsStage {
publication_point_id,
shared: Arc::new(RoaTaskShared {
locked_files: Arc::<[PackFile]>::from(locked_files.to_vec()),
issuer_ca_der: Arc::<[u8]>::from(issuer_ca_der.to_vec()),
issuer_ca: Arc::new(issuer_ca),
issuer_spki_der: Arc::<[u8]>::from(issuer_spki_der),
issuer_ca_rsync_uri: issuer_ca_rsync_uri.map(Arc::<str>::from),
crl_states: Arc::new(Mutex::new(crl_states)),
issuer_resources_index: Arc::new(build_issuer_resources_index(
issuer_effective_ip,
issuer_effective_as,
)),
issuer_effective_ip: issuer_effective_ip.cloned().map(Arc::new),
issuer_effective_as: issuer_effective_as.cloned().map(Arc::new),
resource_validation_mode: policy.resource_validation_mode,
ta_constraints,
}),
validation_time,
strict_cms_der: policy.strict.cms_der,
strict_name: policy.strict.name,
roa_task_indices,
ready_roa_results: Vec::new(),
warnings,
stats,
audit,
})
}
pub(crate) fn reduce_parallel_roa_stage(
stage: ParallelObjectsStage,
mut roa_results: Vec<RoaTaskResult>,
timing: Option<&TimingHandle>,
) -> Result<ObjectsOutput, String> {
roa_results.extend(stage.ready_roa_results);
roa_results.sort_by_key(|result| result.index);
let mut result_iter = roa_results.into_iter().peekable();
let shared = stage.shared.clone();
let strict_cms_der = stage.strict_cms_der;
let strict_name = stage.strict_name;
let issuer_spki = SubjectPublicKeyInfo::from_der(shared.issuer_spki_der.as_ref())
.map_err(|error| error.to_string())?
.1;
let mut stats = stage.stats;
let mut warnings = stage.warnings;
let mut audit = stage.audit;
let mut vrps = Vec::new();
let mut aspas = Vec::new();
let mut crl_states = shared
.crl_states
.lock()
.map_err(|_| "issuer CRL state lock poisoned")?;
for (index, file) in shared.locked_files.iter().enumerate() {
if file.rsync_uri.ends_with(".roa") {
let result = match result_iter.peek() {
Some(result) if result.index == index => {
result_iter.next().expect("peeked result")
}
Some(result) => {
return Err(format!(
"unexpected ROA task result index {} while reducing {}",
result.index, file.rsync_uri
));
}
None => return Err(format!("missing ROA task result for {}", file.rsync_uri)),
};
match result.outcome {
Ok(mut output) => {
stats.roa_ok += 1;
vrps.append(&mut output.vrps);
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Ok,
detail: None,
});
if let Some(warning) = ber_compatible_cms_warning_for_file(file, "ROA") {
warnings.push(warning);
}
}
Err(error) => {
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Error,
detail: Some(error.to_string()),
});
let mut refs = vec![RfcRef("RFC 6488 §3"), RfcRef("RFC 9582 §4-§5")];
refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&error));
warnings.push(
Warning::new(format!(
"dropping invalid ROA: {}: {error}",
file.rsync_uri
))
.with_rfc_refs(&refs)
.with_context(&file.rsync_uri),
);
}
}
} else if file.rsync_uri.ends_with(".asa") {
match process_aspa_with_issuer(
file,
shared.issuer_ca_der.as_ref(),
shared.issuer_ca.as_ref(),
&issuer_spki,
shared.issuer_ca_rsync_uri.as_deref(),
&mut crl_states,
shared.issuer_resources_index.as_ref(),
shared.issuer_effective_ip.as_deref(),
shared.issuer_effective_as.as_deref(),
stage.validation_time,
timing,
strict_cms_der,
strict_name,
shared.resource_validation_mode,
shared.ta_constraints.as_deref(),
) {
Ok(attestation) => {
stats.aspa_ok += 1;
aspas.push(attestation);
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Ok,
detail: None,
});
if let Some(warning) = ber_compatible_cms_warning_for_file(file, "ASPA") {
warnings.push(warning);
}
}
Err(error) => {
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Error,
detail: Some(error.to_string()),
});
warnings.push(
Warning::new(format!(
"dropping invalid ASPA: {}: {error}",
file.rsync_uri
))
.with_context(&file.rsync_uri),
);
}
}
}
}
if let Some(result) = result_iter.next() {
return Err(format!(
"unexpected trailing ROA task result at index {}",
result.index
));
}
Ok(ObjectsOutput {
vrps,
aspas,
router_keys: Vec::new(),
warnings,
stats,
audit,
})
}
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::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::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,
)
}
pub fn process_publication_point_snapshot_for_issuer_parallel_roa(
pack: &PublicationPointSnapshot,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
config: &ParallelPhase2Config,
) -> ObjectsOutput {
process_publication_point_for_issuer_parallel_roa(
pack,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
config,
)
}

View File

@@ -0,0 +1,642 @@
fn choose_crl_uri_for_certificate<'a>(
crldp_uris: Option<&'a Vec<String>>,
crl_states: &std::collections::HashMap<String, IssuerCrlState>,
) -> Result<&'a str, ObjectValidateError> {
if crl_states.is_empty() {
return Err(ObjectValidateError::MissingCrlInPack);
}
let Some(crldp_uris) = crldp_uris else {
return Err(ObjectValidateError::MissingCrlDpUris);
};
for u in crldp_uris {
let s = u.as_str();
if crl_states.contains_key(s) {
return Ok(s);
}
}
Err(ObjectValidateError::CrlNotFound(
crldp_uris
.iter()
.map(|u| u.as_str())
.collect::<Vec<_>>()
.join(", "),
))
}
fn ensure_issuer_crl_verified(
crl_rsync_uri: &str,
crl_states: &mut std::collections::HashMap<String, IssuerCrlState>,
issuer_ca_der: &[u8],
) -> Result<Arc<VerifiedIssuerCrl>, CertPathError> {
let entry = crl_states
.get_mut(crl_rsync_uri)
.expect("CRL must exist in snapshot");
match entry {
IssuerCrlState::Verified(v) => Ok(Arc::clone(v)),
IssuerCrlState::Pending { bytes } => {
let der = std::mem::take(bytes);
let crl = crate::model::crl::RpkixCrl::decode_der(&der)
.map_err(CertPathError::CrlDecode)?;
crl.verify_signature_with_issuer_certificate_der(issuer_ca_der)
.map_err(CertPathError::CrlVerify)?;
let mut revoked_serials: std::collections::HashSet<Vec<u8>> =
std::collections::HashSet::with_capacity(crl.revoked_certs.len());
for rc in &crl.revoked_certs {
revoked_serials.insert(rc.serial_number.bytes_be.clone());
}
*entry = IssuerCrlState::Verified(Arc::new(VerifiedIssuerCrl {
crl,
revoked_serials,
}));
match entry {
IssuerCrlState::Verified(v) => Ok(Arc::clone(v)),
_ => unreachable!(),
}
}
}
}
fn validate_ee_resources_subset(
ee: &ResourceCertificate,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
issuer_resources_index: &IssuerResourcesIndex,
) -> Result<(), ObjectValidateError> {
if let Some(child_ip) = ee.tbs.extensions.ip_resources.as_ref() {
let Some(parent_ip) = issuer_effective_ip else {
return Err(ObjectValidateError::MissingIssuerEffectiveIp);
};
if !ip_resources_is_subset_indexed(child_ip, parent_ip, issuer_resources_index) {
return Err(ObjectValidateError::EeResourcesNotSubset);
}
}
if let Some(child_as) = ee.tbs.extensions.as_resources.as_ref() {
let Some(parent_as) = issuer_effective_as else {
return Err(ObjectValidateError::MissingIssuerEffectiveAs);
};
if !as_resources_is_subset_indexed(child_as, parent_as, issuer_resources_index) {
return Err(ObjectValidateError::EeResourcesNotSubset);
}
}
Ok(())
}
#[derive(Debug)]
struct EeVerifiedResources {
ip: Option<crate::model::rc::IpResourceSet>,
asn: Option<crate::model::rc::AsResourceSet>,
}
fn validate_ee_resources_for_mode(
ee: &ResourceCertificate,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
issuer_resources_index: &IssuerResourcesIndex,
mode: ResourceValidationMode,
) -> Result<EeVerifiedResources, ObjectValidateError> {
match mode {
ResourceValidationMode::Rfc6487 => {
validate_ee_resources_subset(
ee,
issuer_effective_ip,
issuer_effective_as,
issuer_resources_index,
)?;
Ok(EeVerifiedResources {
ip: ee.tbs.extensions.ip_resources.clone(),
asn: ee.tbs.extensions.as_resources.clone(),
})
}
ResourceValidationMode::ValidationUpdate03 => {
let ip = match ee.tbs.extensions.ip_resources.as_ref() {
Some(child_ip) => Some(intersect_ee_ip_resources_vrs(
child_ip,
issuer_effective_ip,
issuer_resources_index,
)?),
None => None,
};
let asn = match ee.tbs.extensions.as_resources.as_ref() {
Some(child_as) => Some(intersect_ee_as_resources_vrs(
child_as,
issuer_effective_as,
issuer_resources_index,
)?),
None => None,
};
Ok(EeVerifiedResources { ip, asn })
}
}
}
fn intersect_ee_ip_resources_vrs(
child_ip: &crate::model::rc::IpResourceSet,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_resources_index: &IssuerResourcesIndex,
) -> Result<crate::model::rc::IpResourceSet, ObjectValidateError> {
if child_ip.has_any_inherit() {
return Err(ObjectValidateError::EeResourcesNotSubset);
}
let _ = issuer_effective_ip;
let mut families = Vec::new();
for fam in &child_ip.families {
let parent_intervals = match fam.afi {
crate::model::rc::Afi::Ipv4 => issuer_resources_index.ip_v4.as_deref(),
crate::model::rc::Afi::Ipv6 => issuer_resources_index.ip_v6.as_deref(),
}
.unwrap_or(&[]);
let items = match &fam.choice {
IpAddressChoice::Inherit => return Err(ObjectValidateError::EeResourcesNotSubset),
IpAddressChoice::AddressesOrRanges(items) => items,
};
let intersections = intersect_ip_items_with_parent_intervals(items, parent_intervals);
if !intersections.is_empty() {
families.push(crate::model::rc::IpAddressFamily {
afi: fam.afi,
choice: IpAddressChoice::AddressesOrRanges(ip_intervals_to_ranges(
fam.afi,
&intersections,
)),
});
}
}
Ok(crate::model::rc::IpResourceSet { families })
}
fn intersect_ee_as_resources_vrs(
child_as: &crate::model::rc::AsResourceSet,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
issuer_resources_index: &IssuerResourcesIndex,
) -> Result<crate::model::rc::AsResourceSet, ObjectValidateError> {
let _ = issuer_effective_as;
if matches!(child_as.asnum, Some(AsIdentifierChoice::Inherit))
|| matches!(child_as.rdi, Some(AsIdentifierChoice::Inherit))
{
return Err(ObjectValidateError::EeResourcesNotSubset);
}
let asnum = child_as.asnum.as_ref().map(|choice| {
let child_intervals = as_choice_to_merged_intervals(choice);
AsIdentifierChoice::AsIdsOrRanges(as_intervals_to_items(&intersect_as_intervals(
&child_intervals,
issuer_resources_index.asnum.as_deref().unwrap_or(&[]),
)))
});
let rdi = child_as.rdi.as_ref().map(|choice| {
let child_intervals = as_choice_to_merged_intervals(choice);
AsIdentifierChoice::AsIdsOrRanges(as_intervals_to_items(&intersect_as_intervals(
&child_intervals,
issuer_resources_index.rdi.as_deref().unwrap_or(&[]),
)))
});
Ok(crate::model::rc::AsResourceSet { asnum, rdi })
}
fn roa_to_vrps_with_vrs(
roa: &RoaObject,
ee_vrs_ip: Option<&crate::model::rc::IpResourceSet>,
) -> Result<Vec<Vrp>, ObjectValidateError> {
let vrps = roa_to_vrps(roa);
let Some(ee_vrs_ip) = ee_vrs_ip else {
return Err(ObjectValidateError::EeResourcesNotSubset);
};
for vrp in &vrps {
let rc_prefix = roa_prefix_to_rc_prefix(&vrp.prefix);
if !ee_vrs_ip.contains_prefix(&rc_prefix) {
return Err(ObjectValidateError::EeResourcesNotSubset);
}
}
Ok(vrps)
}
fn validate_aspa_customer_in_vrs(
aspa: &AspaObject,
ee_vrs_as: Option<&crate::model::rc::AsResourceSet>,
) -> Result<(), ObjectValidateError> {
let Some(ee_vrs_as) = ee_vrs_as else {
return Err(ObjectValidateError::EeResourcesNotSubset);
};
if !as_resource_set_contains_asn(ee_vrs_as, aspa.aspa.customer_as_id) {
return Err(ObjectValidateError::EeResourcesNotSubset);
}
Ok(())
}
fn as_resource_set_contains_asn(
resources: &crate::model::rc::AsResourceSet,
asn: u32,
) -> bool {
let Some(choice) = resources.asnum.as_ref() else {
return false;
};
match choice {
AsIdentifierChoice::Inherit => false,
AsIdentifierChoice::AsIdsOrRanges(items) => items.iter().any(|item| match item {
crate::model::rc::AsIdOrRange::Id(id) => *id == asn,
crate::model::rc::AsIdOrRange::Range { min, max } => *min <= asn && asn <= *max,
}),
}
}
fn roa_prefix_to_rc_prefix(prefix: &IpPrefix) -> RcIpPrefix {
let afi = match prefix.afi {
RoaAfi::Ipv4 => crate::model::rc::Afi::Ipv4,
RoaAfi::Ipv6 => crate::model::rc::Afi::Ipv6,
};
let mut addr = prefix.addr.to_vec();
addr.truncate(afi.octets_len());
RcIpPrefix {
afi,
prefix_len: prefix.prefix_len,
addr,
}
}
fn as_resources_is_subset_indexed(
child: &AsResourceSet,
parent: &AsResourceSet,
idx: &IssuerResourcesIndex,
) -> bool {
let _ = parent;
as_choice_subset_indexed(child.asnum.as_ref(), idx.asnum.as_deref())
&& as_choice_subset_indexed(child.rdi.as_ref(), idx.rdi.as_deref())
}
fn as_choice_subset_indexed(
child: Option<&AsIdentifierChoice>,
parent_intervals: Option<&[(u32, u32)]>,
) -> bool {
let Some(child) = child else {
return true;
};
let Some(parent_intervals) = parent_intervals else {
return false;
};
if matches!(child, AsIdentifierChoice::Inherit) {
return false;
}
let child_intervals = as_choice_to_merged_intervals(child);
for (cmin, cmax) in &child_intervals {
if !as_interval_is_covered(parent_intervals, *cmin, *cmax) {
return false;
}
}
true
}
fn as_choice_to_merged_intervals(choice: &AsIdentifierChoice) -> Vec<(u32, u32)> {
let mut v = Vec::new();
match choice {
AsIdentifierChoice::Inherit => {}
AsIdentifierChoice::AsIdsOrRanges(items) => {
for item in items {
match item {
crate::model::rc::AsIdOrRange::Id(id) => v.push((*id, *id)),
crate::model::rc::AsIdOrRange::Range { min, max } => v.push((*min, *max)),
}
}
}
}
v.sort_by_key(|(a, _)| *a);
merge_as_intervals(&v)
}
fn merge_as_intervals(v: &[(u32, u32)]) -> Vec<(u32, u32)> {
let mut out: Vec<(u32, u32)> = Vec::new();
for (min, max) in v {
let Some(last) = out.last_mut() else {
out.push((*min, *max));
continue;
};
if *min <= last.1.saturating_add(1) {
last.1 = last.1.max(*max);
continue;
}
out.push((*min, *max));
}
out
}
fn as_interval_is_covered(parent: &[(u32, u32)], min: u32, max: u32) -> bool {
for (pmin, pmax) in parent {
if *pmin <= min && max <= *pmax {
return true;
}
if *pmin > min {
break;
}
}
false
}
fn ip_resources_is_subset_indexed(
child: &crate::model::rc::IpResourceSet,
parent: &crate::model::rc::IpResourceSet,
idx: &IssuerResourcesIndex,
) -> bool {
let _ = parent;
for fam in &child.families {
let parent_intervals = match fam.afi {
crate::model::rc::Afi::Ipv4 => idx.ip_v4.as_deref(),
crate::model::rc::Afi::Ipv6 => idx.ip_v6.as_deref(),
};
let Some(parent_intervals) = parent_intervals else {
return false;
};
let items = match &fam.choice {
IpAddressChoice::Inherit => return false,
IpAddressChoice::AddressesOrRanges(items) => items,
};
let mut child_intervals: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for item in items {
match item {
IpAddressOrRange::Prefix(p) => child_intervals.push(prefix_to_range(p)),
IpAddressOrRange::Range(r) => child_intervals.push((r.min.clone(), r.max.clone())),
}
}
if child_intervals.is_empty() {
continue;
}
child_intervals.sort_by(|(a, _), (b, _)| a.cmp(b));
merge_ip_intervals_in_place(&mut child_intervals);
if !intervals_are_covered(parent_intervals, &child_intervals) {
return false;
}
}
true
}
fn ip_items_to_merged_intervals(
items: &[crate::model::rc::IpAddressOrRange],
) -> Vec<(Vec<u8>, Vec<u8>)> {
let mut intervals = Vec::new();
for item in items {
match item {
IpAddressOrRange::Prefix(p) => intervals.push(prefix_to_range(p)),
IpAddressOrRange::Range(r) => intervals.push((r.min.clone(), r.max.clone())),
}
}
intervals.sort_by(|(a, _), (b, _)| a.cmp(b));
merge_ip_intervals_in_place(&mut intervals);
intervals
}
fn intersect_ip_items_with_parent_intervals(
items: &[crate::model::rc::IpAddressOrRange],
parent_intervals: &[(Vec<u8>, Vec<u8>)],
) -> Vec<(Vec<u8>, Vec<u8>)> {
let child_intervals = ip_items_to_merged_intervals(items);
let mut out = Vec::new();
let mut parent_index = 0usize;
for (child_min, child_max) in &child_intervals {
while parent_index < parent_intervals.len()
&& parent_intervals[parent_index].1.as_slice() < child_min.as_slice()
{
parent_index += 1;
}
let mut scan = parent_index;
while scan < parent_intervals.len()
&& parent_intervals[scan].0.as_slice() <= child_max.as_slice()
{
let (parent_min, parent_max) = &parent_intervals[scan];
let min = if bytes_leq(child_min, parent_min) {
parent_min.clone()
} else {
child_min.clone()
};
let max = if bytes_leq(child_max, parent_max) {
child_max.clone()
} else {
parent_max.clone()
};
if bytes_leq(&min, &max) {
out.push((min, max));
}
scan += 1;
}
}
merge_ip_intervals_in_place(&mut out);
out
}
fn ip_intervals_to_ranges(
afi: crate::model::rc::Afi,
intervals: &[(Vec<u8>, Vec<u8>)],
) -> Vec<crate::model::rc::IpAddressOrRange> {
intervals
.iter()
.map(|(min, max)| {
IpAddressOrRange::Range(crate::model::rc::IpAddressRange {
min: normalize_ip_bytes(afi, min),
max: normalize_ip_bytes(afi, max),
})
})
.collect()
}
fn normalize_ip_bytes(afi: crate::model::rc::Afi, bytes: &[u8]) -> Vec<u8> {
let target_len = afi.octets_len();
if bytes.len() == target_len {
return bytes.to_vec();
}
let mut out = vec![0u8; target_len];
let copy_len = bytes.len().min(target_len);
out[..copy_len].copy_from_slice(&bytes[..copy_len]);
out
}
fn intersect_as_intervals(child: &[(u32, u32)], parent: &[(u32, u32)]) -> Vec<(u32, u32)> {
let mut out = Vec::new();
let mut parent_index = 0usize;
for (child_min, child_max) in child {
while parent_index < parent.len() && parent[parent_index].1 < *child_min {
parent_index += 1;
}
let mut scan = parent_index;
while scan < parent.len() && parent[scan].0 <= *child_max {
let min = (*child_min).max(parent[scan].0);
let max = (*child_max).min(parent[scan].1);
if min <= max {
out.push((min, max));
}
scan += 1;
}
}
merge_as_intervals(&out)
}
fn as_intervals_to_items(intervals: &[(u32, u32)]) -> Vec<crate::model::rc::AsIdOrRange> {
intervals
.iter()
.map(|(min, max)| {
if min == max {
crate::model::rc::AsIdOrRange::Id(*min)
} else {
crate::model::rc::AsIdOrRange::Range {
min: *min,
max: *max,
}
}
})
.collect()
}
fn merge_ip_intervals_in_place(v: &mut Vec<(Vec<u8>, Vec<u8>)>) {
if v.is_empty() {
return;
}
let mut out: Vec<(Vec<u8>, Vec<u8>)> = Vec::with_capacity(v.len());
for (min, max) in v.drain(..) {
let Some(last) = out.last_mut() else {
out.push((min, max));
continue;
};
if bytes_leq(&min, &last.1) || bytes_is_next(&min, &last.1) {
if bytes_leq(&last.1, &max) {
last.1 = max;
}
continue;
}
out.push((min, max));
}
*v = out;
}
fn intervals_are_covered(parent: &[(Vec<u8>, Vec<u8>)], child: &[(Vec<u8>, Vec<u8>)]) -> bool {
let mut i = 0usize;
for (cmin, cmax) in child {
while i < parent.len() && parent[i].1.as_slice() < cmin.as_slice() {
i += 1;
}
if i >= parent.len() {
return false;
}
let (pmin, pmax) = &parent[i];
if !bytes_leq(pmin, cmin) || !bytes_leq(cmax, pmax) {
return false;
}
}
true
}
fn prefix_to_range(prefix: &RcIpPrefix) -> (Vec<u8>, Vec<u8>) {
let mut min = prefix.addr.clone();
let mut max = prefix.addr.clone();
let bitlen = prefix.afi.ub();
let plen = prefix.prefix_len.min(bitlen);
for bit in plen..bitlen {
let byte = (bit / 8) as usize;
let offset = 7 - (bit % 8);
let mask = 1u8 << offset;
min[byte] &= !mask;
max[byte] |= mask;
}
(min, max)
}
fn bytes_leq(a: &[u8], b: &[u8]) -> bool {
a <= b
}
fn bytes_is_next(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut carry: u16 = 1;
for i in (0..b.len()).rev() {
let sum = (b[i] as u16) + carry;
let expected = (sum & 0xFF) as u8;
carry = sum >> 8;
if a[i] != expected {
return false;
}
}
true
}
fn build_issuer_resources_index(
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
) -> IssuerResourcesIndex {
let mut idx = IssuerResourcesIndex::default();
if let Some(ip) = issuer_effective_ip {
let mut v4: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
let mut v6: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for fam in &ip.families {
let ent = match fam.afi {
crate::model::rc::Afi::Ipv4 => &mut v4,
crate::model::rc::Afi::Ipv6 => &mut v6,
};
match &fam.choice {
IpAddressChoice::Inherit => {
// Effective resources should not contain inherit; leave empty so subset fails.
}
IpAddressChoice::AddressesOrRanges(items) => {
for item in items {
match item {
IpAddressOrRange::Prefix(p) => ent.push(prefix_to_range(p)),
IpAddressOrRange::Range(r) => ent.push((r.min.clone(), r.max.clone())),
}
}
}
}
}
if !v4.is_empty() {
v4.sort_by(|(a, _), (b, _)| a.cmp(b));
merge_ip_intervals_in_place(&mut v4);
idx.ip_v4 = Some(v4);
}
if !v6.is_empty() {
v6.sort_by(|(a, _), (b, _)| a.cmp(b));
merge_ip_intervals_in_place(&mut v6);
idx.ip_v6 = Some(v6);
}
}
if let Some(asr) = issuer_effective_as {
if let Some(choice) = asr.asnum.as_ref()
&& !matches!(choice, AsIdentifierChoice::Inherit) {
idx.asnum = Some(as_choice_to_merged_intervals(choice));
}
if let Some(choice) = asr.rdi.as_ref()
&& !matches!(choice, AsIdentifierChoice::Inherit) {
idx.rdi = Some(as_choice_to_merged_intervals(choice));
}
}
idx
}
fn roa_to_vrps(roa: &RoaObject) -> Vec<Vrp> {
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
}
#[allow(dead_code)]
fn roa_afi_to_string(afi: RoaAfi) -> &'static str {
match afi {
RoaAfi::Ipv4 => "ipv4",
RoaAfi::Ipv6 => "ipv6",
}
}

View File

@@ -0,0 +1,117 @@
pub fn process_publication_point_for_issuer<P: PublicationPointData>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
) -> ObjectsOutput {
process_publication_point_for_issuer_with_ta_constraints(
publication_point, policy, issuer_ca_der, issuer_ca_rsync_uri,
issuer_effective_ip, issuer_effective_as, validation_time, timing, None,
)
}
pub fn process_publication_point_for_issuer_with_ta_constraints<P: PublicationPointData>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
) -> ObjectsOutput {
let manifest_uri = publication_point.manifest_rsync_uri();
let files = publication_point.files();
let mut stats = ObjectsStats {
roa_total: files.iter().filter(|f| f.rsync_uri.ends_with(".roa")).count(),
aspa_total: files.iter().filter(|f| f.rsync_uri.ends_with(".asa")).count(),
..ObjectsStats::default()
};
let mut warnings = Vec::new();
let mut audit = Vec::new();
let empty = |stats: ObjectsStats, warnings: Vec<Warning>, audit: Vec<ObjectAuditEntry>| ObjectsOutput {
vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), warnings, stats, audit,
};
if let Err(error) = ManifestObject::decode_der_with_strict_options(
publication_point.manifest_bytes(), policy.strict.cms_der, policy.strict.name,
) {
stats.publication_point_dropped = true;
warnings.push(Warning::new(format!("dropping publication point: manifest decode failed: {error}"))
.with_rfc_refs(&[RfcRef("RFC 9286 §4"), RfcRef("RFC 9286 §6.6")]).with_context(manifest_uri));
return empty(stats, warnings, audit);
}
if let Some(warning) = ber_compatible_cms_warning(publication_point.manifest_bytes(), manifest_uri, "manifest") { warnings.push(warning); }
let issuer_ca = match decode_resource_certificate_with_policy(issuer_ca_der, policy) {
Ok(value) => value,
Err(error) => {
stats.publication_point_dropped = true;
warnings.push(Warning::new(format!("dropping publication point: issuer CA decode failed: {error}"))
.with_rfc_refs(&[RfcRef("RFC 6487 §7.2"), RfcRef("RFC 5280 §6.1")]).with_context(manifest_uri));
return empty(stats, warnings, audit);
}
};
let issuer_spki = match SubjectPublicKeyInfo::from_der(&issuer_ca.tbs.subject_public_key_info) {
Ok(([], spki)) => spki,
Ok((remaining, _)) => {
stats.publication_point_dropped = true;
warnings.push(Warning::new(format!("dropping publication point: issuer SPKI has {} trailing bytes", remaining.len())).with_context(manifest_uri));
return empty(stats, warnings, audit);
}
Err(error) => {
stats.publication_point_dropped = true;
warnings.push(Warning::new(format!("dropping publication point: issuer SPKI parse failed: {error}")).with_context(manifest_uri));
return empty(stats, warnings, audit);
}
};
let mut crl_states: std::collections::HashMap<String, IssuerCrlState> = match files.iter().filter(|f| f.rsync_uri.ends_with(".crl")).map(|file| {
Ok((file.rsync_uri.clone(), IssuerCrlState::Pending { bytes: file.bytes_cloned().map_err(|e| format!("CRL bytes load failed: {e}"))? }))
}).collect::<Result<_, String>>() { Ok(value) => value, Err(error) => { stats.publication_point_dropped = true; warnings.push(Warning::new(error).with_context(manifest_uri)); return empty(stats, warnings, audit); } };
let issuer_resources_index = build_issuer_resources_index(issuer_effective_ip, issuer_effective_as);
if crl_states.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 validated publication point").with_rfc_refs(&[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §7")]).with_context(manifest_uri));
return empty(stats, warnings, audit);
}
let mut vrps = Vec::new();
let mut aspas = Vec::new();
for (index, file) in files.iter().enumerate() {
if file.rsync_uri.ends_with(".roa") {
let result = process_roa_with_issuer(file, issuer_ca_der, &issuer_ca, &issuer_spki, issuer_ca_rsync_uri, &mut crl_states, &issuer_resources_index, issuer_effective_ip, issuer_effective_as, validation_time, timing, policy.strict.cms_der, policy.strict.name, policy.resource_validation_mode, ta_constraints);
match result {
Ok(mut values) => {
stats.roa_ok += 1;
vrps.append(&mut values);
audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Ok, detail: None });
if let Some(warning) = ber_compatible_cms_warning_for_file(file, "ROA") { warnings.push(warning); }
}
Err(error) => match policy.signed_object_failure_policy {
SignedObjectFailurePolicy::DropObject => {
audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Error, detail: Some(error.to_string()) });
let mut refs = vec![RfcRef("RFC 6488 §3"), RfcRef("RFC 9582 §4-§5")]; refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&error));
warnings.push(Warning::new(format!("dropping invalid ROA: {}: {error}", file.rsync_uri)).with_rfc_refs(&refs).with_context(&file.rsync_uri));
}
SignedObjectFailurePolicy::DropPublicationPoint => {
stats.publication_point_dropped = true;
warnings.push(Warning::new(format!("dropping publication point due to invalid ROA: {}: {error}", file.rsync_uri)).with_context(manifest_uri));
for later in files.iter().skip(index + 1) { if later.rsync_uri.ends_with(".roa") || later.rsync_uri.ends_with(".asa") { audit.push(ObjectAuditEntry { rsync_uri: later.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&later.sha256), kind: if later.rsync_uri.ends_with(".roa") { AuditObjectKind::Roa } else { AuditObjectKind::Aspa }, result: AuditObjectResult::Skipped, detail: Some("skipped due to signed_object_failure_policy=drop_publication_point".to_string()) }); } }
return empty(stats, warnings, audit);
}
},
}
} else if file.rsync_uri.ends_with(".asa") {
match process_aspa_with_issuer(file, issuer_ca_der, &issuer_ca, &issuer_spki, issuer_ca_rsync_uri, &mut crl_states, &issuer_resources_index, issuer_effective_ip, issuer_effective_as, validation_time, timing, policy.strict.cms_der, policy.strict.name, policy.resource_validation_mode, ta_constraints) {
Ok(attestation) => { stats.aspa_ok += 1; aspas.push(attestation); audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Ok, detail: None }); if let Some(warning) = ber_compatible_cms_warning_for_file(file, "ASPA") { warnings.push(warning); } }
Err(error) => match policy.signed_object_failure_policy {
SignedObjectFailurePolicy::DropObject => { audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Error, detail: Some(error.to_string()) }); warnings.push(Warning::new(format!("dropping invalid ASPA: {}: {error}", file.rsync_uri)).with_context(&file.rsync_uri)); }
SignedObjectFailurePolicy::DropPublicationPoint => { stats.publication_point_dropped = true; warnings.push(Warning::new(format!("dropping publication point due to invalid ASPA: {}: {error}", file.rsync_uri)).with_context(manifest_uri)); return empty(stats, warnings, audit); }
}
}
}
}
ObjectsOutput { vrps, aspas, router_keys: Vec::new(), warnings, stats, audit }
}

196
src/validation/policy.rs Normal file
View File

@@ -0,0 +1,196 @@
use serde::{Deserialize, Serialize};
use crate::ta_constraints::TaConstraintsByTal;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum SyncPreference {
#[default]
RrdpThenRsync,
RsyncOnly,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum SignedObjectFailurePolicy {
#[default]
DropObject,
DropPublicationPoint,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ResourceValidationMode {
#[serde(rename = "validation-update-03")]
#[default]
ValidationUpdate03,
#[serde(rename = "rfc6487")]
Rfc6487,
}
impl ResourceValidationMode {
pub fn parse_cli_value(raw: &str) -> Result<Self, String> {
match raw.trim() {
"validation-update-03" => Ok(Self::ValidationUpdate03),
"rfc6487" => Ok(Self::Rfc6487),
value => Err(format!(
"unknown resource validation mode: {value}; supported: validation-update-03,rfc6487"
)),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct StrictPolicy {
pub name: bool,
pub cms_der: bool,
pub signed_attrs: bool,
}
impl StrictPolicy {
pub fn none() -> Self {
Self::default()
}
pub fn all() -> Self {
Self {
name: true,
cms_der: true,
signed_attrs: true,
}
}
pub fn parse_cli_spec(spec: Option<&str>) -> Result<Self, String> {
let Some(spec) = spec else {
return Ok(Self::all());
};
let spec = spec.trim();
if spec.is_empty() || spec == "all" {
return Ok(Self::all());
}
if spec == "none" {
return Ok(Self::none());
}
let mut out = Self::none();
for raw in spec.split(',') {
let item = raw.trim();
match item {
"name" => out.name = true,
"cms-der" | "cms_der" => out.cms_der = true,
"signed-attrs" | "signed_attrs" => out.signed_attrs = true,
"all" => out = Self::all(),
"none" => out = Self::none(),
"" => return Err("empty strict policy name".to_string()),
_ => {
return Err(format!(
"unknown strict policy: {item}; supported: name,cms-der,signed-attrs,all,none"
));
}
}
}
Ok(out)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct Policy {
pub sync_preference: SyncPreference,
pub signed_object_failure_policy: SignedObjectFailurePolicy,
pub resource_validation_mode: ResourceValidationMode,
pub strict: StrictPolicy,
/// Locally configured, per-TAL EE certificate resource constraints. They
/// are intentionally CLI/runtime-only rather than policy-file input.
#[serde(skip)]
pub ta_constraints: TaConstraintsByTal,
}
#[derive(Debug, thiserror::Error)]
pub enum PolicyParseError {
#[error("policy TOML parse error: {0}")]
Toml(String),
}
impl Policy {
pub fn from_toml_str(s: &str) -> Result<Self, PolicyParseError> {
toml::from_str(s).map_err(|e| PolicyParseError::Toml(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strict_policy_parses_cli_specs() {
assert_eq!(
StrictPolicy::parse_cli_spec(None).unwrap(),
StrictPolicy::all()
);
assert_eq!(
StrictPolicy::parse_cli_spec(Some("name,signed-attrs")).unwrap(),
StrictPolicy {
name: true,
cms_der: false,
signed_attrs: true,
}
);
assert_eq!(
StrictPolicy::parse_cli_spec(Some("none")).unwrap(),
StrictPolicy::none()
);
assert!(StrictPolicy::parse_cli_spec(Some("bogus")).is_err());
}
#[test]
fn resource_validation_mode_parses_cli_values() {
assert_eq!(
ResourceValidationMode::parse_cli_value("validation-update-03").unwrap(),
ResourceValidationMode::ValidationUpdate03
);
assert_eq!(
ResourceValidationMode::parse_cli_value("rfc6487").unwrap(),
ResourceValidationMode::Rfc6487
);
assert!(ResourceValidationMode::parse_cli_value("bogus").is_err());
}
#[test]
fn policy_toml_accepts_strict_table() {
let policy = Policy::from_toml_str(
r#"
[strict]
name = true
cms_der = true
signed_attrs = false
"#,
)
.expect("parse policy");
assert_eq!(
policy.strict,
StrictPolicy {
name: true,
cms_der: true,
signed_attrs: false,
}
);
}
#[test]
fn policy_toml_accepts_resource_validation_mode() {
let policy = Policy::from_toml_str(
r#"
resource_validation_mode = "rfc6487"
"#,
)
.expect("parse policy");
assert_eq!(
policy.resource_validation_mode,
ResourceValidationMode::Rfc6487
);
}
}

View File

@@ -0,0 +1,18 @@
use crate::repository::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<u8>,
pub this_update: PackTime,
pub next_update: PackTime,
pub verified_at: PackTime,
pub manifest_bytes: Vec<u8>,
pub files: Vec<PackFile>,
}
impl PublicationPointSnapshot {
pub const FORMAT_VERSION_V1: u32 = 1;
}

86
src/validation/run.rs Normal file
View File

@@ -0,0 +1,86 @@
#![allow(clippy::too_many_arguments)]
use crate::model::rc::{AsResourceSet, IpResourceSet};
use crate::output::report::Warning;
use crate::repository::fetch::rsync::RsyncFetcher;
use crate::repository::storage::RocksStore;
use crate::repository::sync::rrdp::Fetcher as HttpFetcher;
use crate::validation::manifest::PublicationPointSource;
use crate::validation::objects::ObjectsOutput;
use crate::validation::policy::Policy;
use crate::validation::tree::{CaCertificateRef, CaInstanceHandle, PublicationPointRunner};
use crate::validation::tree_runner::Rpkiv1PublicationPointRunner;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunOutput {
pub publication_point_source: PublicationPointSource,
pub publication_point_warnings: Vec<Warning>,
pub objects: ObjectsOutput,
}
#[derive(Debug, thiserror::Error)]
pub enum RunError {
#[error("publication point runner failed: {0}")]
Runner(String),
}
/// Offline-friendly end-to-end execution for a single publication point.
///
/// 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 public report/CCR output
pub fn run_publication_point_once(
store: &RocksStore,
policy: &Policy,
rrdp_notification_uri: Option<&str>,
rsync_base_uri: &str,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
http_fetcher: &dyn HttpFetcher,
rsync_fetcher: &dyn RsyncFetcher,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&IpResourceSet>,
issuer_effective_as: Option<&AsResourceSet>,
validation_time: time::OffsetDateTime,
) -> Result<RunOutput, RunError> {
let handle = CaInstanceHandle {
depth: 0,
tal_id: "single-publication-point".to_string(),
parent_manifest_rsync_uri: None,
ca_certificate: CaCertificateRef::inline_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,
http_fetcher,
rsync_fetcher,
validation_time,
timing: None,
download_log: None,
current_repo_index: None,
repo_sync_runtime: None,
parallel_phase2_config: None,
parallel_roa_worker_pool: None,
ccr_accumulator: None,
};
let result = runner
.run_publication_point(&handle)
.map_err(RunError::Runner)?;
Ok(RunOutput {
publication_point_source: result.source,
publication_point_warnings: result.warnings,
objects: result.objects,
})
}

View File

@@ -0,0 +1,42 @@
#![allow(clippy::too_many_arguments, clippy::type_complexity)]
use url::Url;
use crate::ccr::CcrAccumulator;
use crate::model::ta::TrustAnchor;
use crate::output::analysis::timing::TimingHandle;
use crate::output::audit::PublicationPointAudit;
use crate::output::audit_downloads::DownloadLogHandle;
use crate::repository::current_repo_index::{CurrentRepoIndexHandle, CurrentRepoObject};
use crate::repository::sync::rrdp::Fetcher;
use crate::scheduler::config::{ParallelPhase1Config, ParallelPhase2Config};
use crate::scheduler::repo_runtime::{Phase1RepoSyncRuntime, RepoSyncRuntime};
use crate::scheduler::repo_worker::{
LiveRepoTransportExecutor, RepoTransportWorkerPool, RepoWorkerPoolConfig,
};
use crate::scheduler::run_coordinator::GlobalRunCoordinator;
use crate::scheduler::types::{TalInputSpec, TalSource};
use crate::validation::from_tal::{
DiscoveredRootCaInstance, FromTalError, canonical_tal_rsync_uri_from_bytes,
discover_root_ca_instance_from_tal_and_ta_der,
discover_root_ca_instance_from_tal_and_ta_der_with_strict_name,
discover_root_ca_instance_from_tal_url_with_fetchers,
discover_root_ca_instance_from_tal_url_with_fetchers_strict_name,
discover_root_ca_instance_from_tal_with_fetchers,
discover_root_ca_instance_from_tal_with_fetchers_strict_name,
};
use crate::validation::objects::ParallelRoaWorkerPool;
use crate::validation::tree::CaCertificateRef;
use crate::validation::tree::{
CaInstanceHandle, TreeRunAuditOutput, TreeRunConfig, TreeRunError, TreeRunOutput,
run_tree_serial, run_tree_serial_audit, run_tree_serial_audit_multi_root,
};
use crate::validation::tree_parallel::{
run_tree_parallel_phase2_audit, run_tree_parallel_phase2_audit_multi_root,
};
use crate::validation::tree_runner::Rpkiv1PublicationPointRunner;
use std::sync::{Arc, Mutex};
include!("run_tree_from_tal/discovery.rs");
include!("run_tree_from_tal/serial.rs");
include!("run_tree_from_tal/phase1.rs");

View File

@@ -0,0 +1,360 @@
fn tal_id_from_url_like(s: &str) -> Option<String> {
let url = Url::parse(s).ok()?;
if let Some(last) = url
.path_segments()
.and_then(|mut segments| segments.rfind(|seg| !seg.is_empty()))
{
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,
pub tree: TreeRunOutput,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunTreeFromTalAuditOutput {
pub discovery: DiscoveredRootCaInstance,
pub discoveries: Vec<DiscoveredRootCaInstance>,
pub successful_tal_inputs: Vec<TalInputSpec>,
pub tree: TreeRunOutput,
pub publication_points: Vec<PublicationPointAudit>,
pub downloads: Vec<crate::output::audit::AuditDownloadEvent>,
pub download_stats: crate::output::audit::AuditDownloadStats,
pub current_repo_objects: Vec<CurrentRepoObject>,
pub ccr_accumulator: Option<CcrAccumulator>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TalRootDiscovery {
pub tal_input: TalInputSpec,
pub discovery: DiscoveredRootCaInstance,
pub root_handle: CaInstanceHandle,
}
fn snapshot_current_repo_objects(
current_repo_index: Option<&CurrentRepoIndexHandle>,
collect: bool,
) -> Vec<CurrentRepoObject> {
if !collect {
return Vec::new();
}
current_repo_index
.and_then(|handle| handle.read().ok().map(|idx| idx.snapshot_objects()))
.unwrap_or_default()
}
fn make_live_runner<'a>(
store: &'a crate::repository::storage::RocksStore,
policy: &'a crate::validation::policy::Policy,
http_fetcher: &'a dyn Fetcher,
rsync_fetcher: &'a dyn crate::repository::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
timing: Option<TimingHandle>,
download_log: Option<DownloadLogHandle>,
current_repo_index: Option<CurrentRepoIndexHandle>,
repo_sync_runtime: Option<Arc<dyn RepoSyncRuntime>>,
parallel_phase2_config: Option<ParallelPhase2Config>,
ccr_accumulator: Option<CcrAccumulator>,
) -> Rpkiv1PublicationPointRunner<'a> {
let parallel_roa_worker_pool = parallel_phase2_config
.as_ref()
.and_then(|config| ParallelRoaWorkerPool::new(config).ok());
Rpkiv1PublicationPointRunner {
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
timing,
download_log,
current_repo_index,
repo_sync_runtime,
parallel_phase2_config,
parallel_roa_worker_pool,
ccr_accumulator: ccr_accumulator.map(Mutex::new),
}
}
fn build_phase1_repo_sync_runtime<H, R>(
store: Arc<crate::repository::storage::RocksStore>,
policy: &crate::validation::policy::Policy,
http_fetcher: &H,
rsync_fetcher: &R,
parallel_config: ParallelPhase1Config,
timing: Option<TimingHandle>,
download_log: Option<DownloadLogHandle>,
tal_inputs: Vec<crate::scheduler::types::TalInputSpec>,
) -> Result<(Arc<dyn RepoSyncRuntime>, CurrentRepoIndexHandle), RunTreeFromTalError>
where
H: Fetcher + Clone + 'static,
R: crate::repository::fetch::rsync::RsyncFetcher + Clone + 'static,
{
let coordinator = GlobalRunCoordinator::new(parallel_config.clone(), tal_inputs);
let current_repo_index = coordinator.current_repo_index_handle();
let rsync_fetcher_arc = Arc::new(rsync_fetcher.clone());
let executor = LiveRepoTransportExecutor::new(
Arc::clone(&store),
current_repo_index.clone(),
Arc::new(http_fetcher.clone()),
Arc::clone(&rsync_fetcher_arc),
timing.clone(),
download_log,
);
let pool = RepoTransportWorkerPool::new(RepoWorkerPoolConfig::from(&parallel_config), executor)
.map_err(RunTreeFromTalError::Setup)?;
let resolver_rsync_fetcher_arc = Arc::clone(&rsync_fetcher_arc);
let resolver: Arc<dyn Fn(&str) -> String + Send + Sync> =
Arc::new(move |base: &str| resolver_rsync_fetcher_arc.dedup_key(base));
let failure_rsync_fetcher_arc = Arc::clone(&rsync_fetcher_arc);
let failure_resolver: Arc<dyn Fn(&str) -> Option<String> + Send + Sync> =
Arc::new(move |base: &str| failure_rsync_fetcher_arc.failure_dedup_key(base));
let _ = policy; // policy reserved for later runtime-level decisions
let runtime = Arc::new(Phase1RepoSyncRuntime::new_with_failure_scope(
coordinator,
pool,
resolver,
failure_resolver,
policy.sync_preference,
));
Ok((runtime, current_repo_index))
}
fn root_discovery_from_tal_input(
tal_input: &TalInputSpec,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::repository::fetch::rsync::RsyncFetcher,
strict_name: bool,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
match &tal_input.source {
TalSource::Url(url) => {
if strict_name {
discover_root_ca_instance_from_tal_url_with_fetchers_strict_name(
http_fetcher,
rsync_fetcher,
url,
)
} else {
discover_root_ca_instance_from_tal_url_with_fetchers(
http_fetcher,
rsync_fetcher,
url,
)
}
}
TalSource::DerBytes {
tal_bytes, ta_der, ..
} => {
if strict_name {
discover_root_ca_instance_from_tal_and_ta_der_with_strict_name(
tal_bytes, ta_der, None,
)
} else {
discover_root_ca_instance_from_tal_and_ta_der(tal_bytes, ta_der, None)
}
}
TalSource::FilePath(path) => {
let tal_bytes = std::fs::read(path).map_err(|e| {
FromTalError::TalFetch(format!("read TAL file failed: {}: {e}", path.display()))
})?;
let tal = crate::model::tal::Tal::decode_bytes(&tal_bytes)
.map_err(FromTalError::from)?;
if strict_name {
discover_root_ca_instance_from_tal_with_fetchers_strict_name(
http_fetcher,
rsync_fetcher,
tal,
None,
)
} else {
discover_root_ca_instance_from_tal_with_fetchers(
http_fetcher,
rsync_fetcher,
tal,
None,
)
}
}
TalSource::FilePathWithTa { tal_path, ta_path } => {
let tal_bytes = std::fs::read(tal_path).map_err(|e| {
FromTalError::TalFetch(format!("read TAL file failed: {}: {e}", tal_path.display()))
})?;
let ta_der = std::fs::read(ta_path).map_err(|e| {
FromTalError::TaFetch(format!("read TA file failed: {}: {e}", ta_path.display()))
})?;
let resolved_ta_uri = canonical_tal_rsync_uri_from_bytes(&tal_bytes)?;
if strict_name {
discover_root_ca_instance_from_tal_and_ta_der_with_strict_name(
&tal_bytes,
&ta_der,
Some(&resolved_ta_uri),
)
} else {
discover_root_ca_instance_from_tal_and_ta_der(
&tal_bytes,
&ta_der,
Some(&resolved_ta_uri),
)
}
}
}
}
fn discover_root_ca_instance_from_tal_url_with_policy(
policy: &crate::validation::policy::Policy,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::repository::fetch::rsync::RsyncFetcher,
tal_url: &str,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
if policy.strict.name {
discover_root_ca_instance_from_tal_url_with_fetchers_strict_name(
http_fetcher,
rsync_fetcher,
tal_url,
)
} else {
discover_root_ca_instance_from_tal_url_with_fetchers(http_fetcher, rsync_fetcher, tal_url)
}
}
fn discover_root_ca_instance_from_tal_and_ta_der_with_policy(
policy: &crate::validation::policy::Policy,
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&Url>,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
if policy.strict.name {
discover_root_ca_instance_from_tal_and_ta_der_with_strict_name(
tal_bytes,
ta_der,
resolved_ta_uri,
)
} else {
discover_root_ca_instance_from_tal_and_ta_der(tal_bytes, ta_der, resolved_ta_uri)
}
}
fn discover_multiple_roots_from_tal_inputs(
tal_inputs: &[TalInputSpec],
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::repository::fetch::rsync::RsyncFetcher,
strict_name: bool,
) -> Result<Vec<TalRootDiscovery>, RunTreeFromTalError> {
let mut roots = Vec::with_capacity(tal_inputs.len());
for tal_input in tal_inputs {
let discovery = match root_discovery_from_tal_input(
tal_input,
http_fetcher,
rsync_fetcher,
strict_name,
) {
Ok(discovery) => discovery,
Err(error)
if should_isolate_multi_tal_strict_name_failure(
tal_inputs.len(),
strict_name,
&error,
) =>
{
crate::logging::warning!(
"warning: skipping TAL '{}' because strict name validation failed during trust anchor discovery: {error}",
tal_input.tal_id
);
continue;
}
Err(error) => return Err(error.into()),
};
let root_handle = root_handle_from_trust_anchor(
&discovery.trust_anchor,
tal_input.tal_id.clone(),
None,
&discovery.ca_instance,
);
roots.push(TalRootDiscovery {
tal_input: tal_input.clone(),
discovery,
root_handle,
});
}
if roots.is_empty() {
return Err(RunTreeFromTalError::Setup(
"multi-TAL root discovery returned no usable roots after strict name filtering"
.to_string(),
));
}
Ok(roots)
}
fn should_isolate_multi_tal_strict_name_failure(
tal_input_count: usize,
strict_name: bool,
error: &FromTalError,
) -> bool {
strict_name
&& tal_input_count > 1
&& error.to_string().contains("Name strict validation failed")
}
#[derive(Debug, thiserror::Error)]
pub enum RunTreeFromTalError {
#[error("{0}")]
FromTal(#[from] FromTalError),
#[error("validation setup failed: {0}")]
Setup(String),
#[error("{0}")]
Tree(#[from] TreeRunError),
}
pub fn root_handle_from_trust_anchor(
trust_anchor: &TrustAnchor,
tal_id: String,
ca_certificate_rsync_uri: Option<String>,
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: CaCertificateRef::inline_der(trust_anchor.ta_certificate.raw_der.clone()),
ca_certificate_rsync_uri,
effective_ip_resources: ta_rc.tbs.extensions.ip_resources.clone(),
effective_as_resources: ta_rc.tbs.extensions.as_resources.clone(),
rsync_base_uri: ca_instance.rsync_base_uri.clone(),
manifest_rsync_uri: ca_instance.manifest_rsync_uri.clone(),
publication_point_rsync_uri: ca_instance.publication_point_rsync_uri.clone(),
rrdp_notification_uri: ca_instance.rrdp_notification_uri.clone(),
}
}

View File

@@ -0,0 +1,365 @@
pub fn run_tree_from_tal_url_parallel_phase1_audit<H, R>(
store: Arc<crate::repository::storage::RocksStore>,
policy: &crate::validation::policy::Policy,
tal_url: &str,
http_fetcher: &H,
rsync_fetcher: &R,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
parallel_config: ParallelPhase1Config,
collect_current_repo_objects: bool,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError>
where
H: Fetcher + Clone + 'static,
R: crate::repository::fetch::rsync::RsyncFetcher + Clone + 'static,
{
let discovery = discover_root_ca_instance_from_tal_url_with_policy(
policy,
http_fetcher,
rsync_fetcher,
tal_url,
)?;
run_single_root_parallel_audit_inner(
store,
policy,
discovery,
vec![TalInputSpec::from_url(tal_url.to_string())],
http_fetcher,
rsync_fetcher,
validation_time,
config,
parallel_config,
None,
collect_current_repo_objects,
None,
)
}
pub fn run_tree_from_tal_and_ta_der_parallel_phase1_audit<H, R>(
store: Arc<crate::repository::storage::RocksStore>,
policy: &crate::validation::policy::Policy,
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&url::Url>,
http_fetcher: &H,
rsync_fetcher: &R,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
parallel_config: ParallelPhase1Config,
collect_current_repo_objects: bool,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError>
where
H: Fetcher + Clone + 'static,
R: crate::repository::fetch::rsync::RsyncFetcher + Clone + 'static,
{
let discovery = discover_root_ca_instance_from_tal_and_ta_der_with_policy(
policy,
tal_bytes,
ta_der,
resolved_ta_uri,
)?;
let derived_tal_id = derive_tal_id(&discovery);
let tal_inputs = vec![TalInputSpec {
tal_id: derived_tal_id.clone(),
rir_id: derived_tal_id,
source: TalSource::DerBytes {
tal_url: discovery
.tal_url
.clone()
.unwrap_or_else(|| "embedded-tal".to_string()),
tal_bytes: tal_bytes.to_vec(),
ta_der: ta_der.to_vec(),
},
}];
run_single_root_parallel_audit_inner(
store,
policy,
discovery,
tal_inputs,
http_fetcher,
rsync_fetcher,
validation_time,
config,
parallel_config,
None,
collect_current_repo_objects,
None,
)
}
pub fn run_tree_from_multiple_tals_parallel_phase1_audit<H, R>(
store: Arc<crate::repository::storage::RocksStore>,
policy: &crate::validation::policy::Policy,
tal_inputs: Vec<TalInputSpec>,
http_fetcher: &H,
rsync_fetcher: &R,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
parallel_config: ParallelPhase1Config,
collect_current_repo_objects: bool,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError>
where
H: Fetcher + Clone + 'static,
R: crate::repository::fetch::rsync::RsyncFetcher + Clone + 'static,
{
run_multi_root_parallel_audit_inner(
store,
policy,
tal_inputs,
http_fetcher,
rsync_fetcher,
validation_time,
config,
parallel_config,
None,
collect_current_repo_objects,
None,
)
}
pub fn run_tree_from_tal_url_parallel_phase2_audit<H, R>(
store: Arc<crate::repository::storage::RocksStore>,
policy: &crate::validation::policy::Policy,
tal_url: &str,
http_fetcher: &H,
rsync_fetcher: &R,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
parallel_config: ParallelPhase1Config,
phase2_config: ParallelPhase2Config,
collect_current_repo_objects: bool,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError>
where
H: Fetcher + Clone + 'static,
R: crate::repository::fetch::rsync::RsyncFetcher + Clone + 'static,
{
let discovery = discover_root_ca_instance_from_tal_url_with_policy(
policy,
http_fetcher,
rsync_fetcher,
tal_url,
)?;
run_single_root_parallel_audit_inner(
store,
policy,
discovery,
vec![TalInputSpec::from_url(tal_url.to_string())],
http_fetcher,
rsync_fetcher,
validation_time,
config,
parallel_config,
Some(phase2_config),
collect_current_repo_objects,
None,
)
}
pub fn run_tree_from_tal_url_parallel_phase2_audit_with_timing<H, R>(
store: Arc<crate::repository::storage::RocksStore>,
policy: &crate::validation::policy::Policy,
tal_url: &str,
http_fetcher: &H,
rsync_fetcher: &R,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
parallel_config: ParallelPhase1Config,
phase2_config: ParallelPhase2Config,
collect_current_repo_objects: bool,
timing: &TimingHandle,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError>
where
H: Fetcher + Clone + 'static,
R: crate::repository::fetch::rsync::RsyncFetcher + Clone + 'static,
{
let discovery = discover_root_ca_instance_from_tal_url_with_policy(
policy,
http_fetcher,
rsync_fetcher,
tal_url,
)?;
run_single_root_parallel_audit_inner(
store,
policy,
discovery,
vec![TalInputSpec::from_url(tal_url.to_string())],
http_fetcher,
rsync_fetcher,
validation_time,
config,
parallel_config,
Some(phase2_config),
collect_current_repo_objects,
Some(timing.clone()),
)
}
pub fn run_tree_from_tal_and_ta_der_parallel_phase2_audit<H, R>(
store: Arc<crate::repository::storage::RocksStore>,
policy: &crate::validation::policy::Policy,
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&url::Url>,
http_fetcher: &H,
rsync_fetcher: &R,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
parallel_config: ParallelPhase1Config,
phase2_config: ParallelPhase2Config,
collect_current_repo_objects: bool,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError>
where
H: Fetcher + Clone + 'static,
R: crate::repository::fetch::rsync::RsyncFetcher + Clone + 'static,
{
let discovery = discover_root_ca_instance_from_tal_and_ta_der_with_policy(
policy,
tal_bytes,
ta_der,
resolved_ta_uri,
)?;
let derived_tal_id = derive_tal_id(&discovery);
let tal_inputs = vec![TalInputSpec {
tal_id: derived_tal_id.clone(),
rir_id: derived_tal_id,
source: TalSource::DerBytes {
tal_url: discovery
.tal_url
.clone()
.unwrap_or_else(|| "embedded-tal".to_string()),
tal_bytes: tal_bytes.to_vec(),
ta_der: ta_der.to_vec(),
},
}];
run_single_root_parallel_audit_inner(
store,
policy,
discovery,
tal_inputs,
http_fetcher,
rsync_fetcher,
validation_time,
config,
parallel_config,
Some(phase2_config),
collect_current_repo_objects,
None,
)
}
pub fn run_tree_from_tal_and_ta_der_parallel_phase2_audit_with_timing<H, R>(
store: Arc<crate::repository::storage::RocksStore>,
policy: &crate::validation::policy::Policy,
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&url::Url>,
http_fetcher: &H,
rsync_fetcher: &R,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
parallel_config: ParallelPhase1Config,
phase2_config: ParallelPhase2Config,
collect_current_repo_objects: bool,
timing: &TimingHandle,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError>
where
H: Fetcher + Clone + 'static,
R: crate::repository::fetch::rsync::RsyncFetcher + Clone + 'static,
{
let discovery = discover_root_ca_instance_from_tal_and_ta_der_with_policy(
policy,
tal_bytes,
ta_der,
resolved_ta_uri,
)?;
let derived_tal_id = derive_tal_id(&discovery);
let tal_inputs = vec![TalInputSpec {
tal_id: derived_tal_id.clone(),
rir_id: derived_tal_id,
source: TalSource::DerBytes {
tal_url: discovery
.tal_url
.clone()
.unwrap_or_else(|| "embedded-tal".to_string()),
tal_bytes: tal_bytes.to_vec(),
ta_der: ta_der.to_vec(),
},
}];
run_single_root_parallel_audit_inner(
store,
policy,
discovery,
tal_inputs,
http_fetcher,
rsync_fetcher,
validation_time,
config,
parallel_config,
Some(phase2_config),
collect_current_repo_objects,
Some(timing.clone()),
)
}
pub fn run_tree_from_multiple_tals_parallel_phase2_audit<H, R>(
store: Arc<crate::repository::storage::RocksStore>,
policy: &crate::validation::policy::Policy,
tal_inputs: Vec<TalInputSpec>,
http_fetcher: &H,
rsync_fetcher: &R,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
parallel_config: ParallelPhase1Config,
phase2_config: ParallelPhase2Config,
collect_current_repo_objects: bool,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError>
where
H: Fetcher + Clone + 'static,
R: crate::repository::fetch::rsync::RsyncFetcher + Clone + 'static,
{
run_multi_root_parallel_audit_inner(
store,
policy,
tal_inputs,
http_fetcher,
rsync_fetcher,
validation_time,
config,
parallel_config,
Some(phase2_config),
collect_current_repo_objects,
None,
)
}
pub fn run_tree_from_multiple_tals_parallel_phase2_audit_with_timing<H, R>(
store: Arc<crate::repository::storage::RocksStore>,
policy: &crate::validation::policy::Policy,
tal_inputs: Vec<TalInputSpec>,
http_fetcher: &H,
rsync_fetcher: &R,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
parallel_config: ParallelPhase1Config,
phase2_config: ParallelPhase2Config,
collect_current_repo_objects: bool,
timing: &TimingHandle,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError>
where
H: Fetcher + Clone + 'static,
R: crate::repository::fetch::rsync::RsyncFetcher + Clone + 'static,
{
run_multi_root_parallel_audit_inner(
store,
policy,
tal_inputs,
http_fetcher,
rsync_fetcher,
validation_time,
config,
parallel_config,
Some(phase2_config),
collect_current_repo_objects,
Some(timing.clone()),
)
}

View File

@@ -0,0 +1,344 @@
pub fn run_tree_from_tal_url_serial(
store: &crate::repository::storage::RocksStore,
policy: &crate::validation::policy::Policy,
tal_url: &str,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::repository::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
) -> Result<RunTreeFromTalOutput, RunTreeFromTalError> {
let discovery = discover_root_ca_instance_from_tal_url_with_policy(
policy,
http_fetcher,
rsync_fetcher,
tal_url,
)?;
let runner = make_live_runner(
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
None,
None,
None,
None,
None,
None,
);
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 })
}
pub fn run_tree_from_tal_url_serial_audit(
store: &crate::repository::storage::RocksStore,
policy: &crate::validation::policy::Policy,
tal_url: &str,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::repository::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError> {
let discovery = discover_root_ca_instance_from_tal_url_with_policy(
policy,
http_fetcher,
rsync_fetcher,
tal_url,
)?;
let download_log = DownloadLogHandle::new();
let runner = make_live_runner(
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
None,
Some(download_log.clone()),
None,
None,
None,
None,
);
let root = root_handle_from_trust_anchor(
&discovery.trust_anchor,
derive_tal_id(&discovery),
None,
&discovery.ca_instance,
);
let TreeRunAuditOutput {
tree,
publication_points,
} = run_tree_serial_audit(root, &runner, config)?;
let downloads = download_log.snapshot_events();
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
Ok(RunTreeFromTalAuditOutput {
discovery: discovery.clone(),
discoveries: vec![discovery],
successful_tal_inputs: Vec::new(),
tree,
publication_points,
downloads,
download_stats,
current_repo_objects: Vec::new(),
ccr_accumulator: None,
})
}
pub fn run_tree_from_tal_url_serial_audit_with_timing(
store: &crate::repository::storage::RocksStore,
policy: &crate::validation::policy::Policy,
tal_url: &str,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::repository::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
timing: &TimingHandle,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError> {
let _tal = timing.span_phase("tal_bootstrap");
let discovery = discover_root_ca_instance_from_tal_url_with_policy(
policy,
http_fetcher,
rsync_fetcher,
tal_url,
)?;
drop(_tal);
let download_log = DownloadLogHandle::new();
let runner = make_live_runner(
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
Some(timing.clone()),
Some(download_log.clone()),
None,
None,
None,
None,
);
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,
publication_points,
} = run_tree_serial_audit(root, &runner, config)?;
let downloads = download_log.snapshot_events();
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
Ok(RunTreeFromTalAuditOutput {
discovery: discovery.clone(),
discoveries: vec![discovery],
successful_tal_inputs: Vec::new(),
tree,
publication_points,
downloads,
download_stats,
current_repo_objects: Vec::new(),
ccr_accumulator: None,
})
}
fn run_single_root_parallel_audit_inner<H, R>(
store: Arc<crate::repository::storage::RocksStore>,
policy: &crate::validation::policy::Policy,
discovery: DiscoveredRootCaInstance,
tal_inputs: Vec<TalInputSpec>,
http_fetcher: &H,
rsync_fetcher: &R,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
parallel_config: ParallelPhase1Config,
phase2_config: Option<ParallelPhase2Config>,
collect_current_repo_objects: bool,
timing: Option<TimingHandle>,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError>
where
H: Fetcher + Clone + 'static,
R: crate::repository::fetch::rsync::RsyncFetcher + Clone + 'static,
{
let phase2_enabled = phase2_config.is_some();
let download_log = DownloadLogHandle::new();
let (runtime, current_repo_index) = build_phase1_repo_sync_runtime(
Arc::clone(&store),
policy,
http_fetcher,
rsync_fetcher,
parallel_config,
timing.clone(),
Some(download_log.clone()),
tal_inputs,
)?;
let current_repo_index_for_output = current_repo_index.clone();
let runner = make_live_runner(
store.as_ref(),
policy,
http_fetcher,
rsync_fetcher,
validation_time,
timing.clone(),
Some(download_log.clone()),
Some(current_repo_index),
Some(Arc::clone(&runtime)),
phase2_config,
(phase2_enabled && config.build_ccr_accumulator)
.then(|| CcrAccumulator::new(vec![discovery.trust_anchor.clone()])),
);
let root = root_handle_from_trust_anchor(
&discovery.trust_anchor,
derive_tal_id(&discovery),
None,
&discovery.ca_instance,
);
let TreeRunAuditOutput {
tree,
publication_points,
} = if phase2_enabled {
run_tree_parallel_phase2_audit(root, &runner, config)?
} else {
run_tree_serial_audit(root, &runner, config)?
};
let downloads = download_log.snapshot_events();
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
Ok(RunTreeFromTalAuditOutput {
discovery: discovery.clone(),
discoveries: vec![discovery],
successful_tal_inputs: Vec::new(),
tree,
publication_points,
downloads,
download_stats,
current_repo_objects: snapshot_current_repo_objects(
Some(&current_repo_index_for_output),
collect_current_repo_objects,
),
ccr_accumulator: runner.ccr_accumulator_snapshot(),
})
}
fn run_multi_root_parallel_audit_inner<H, R>(
store: Arc<crate::repository::storage::RocksStore>,
policy: &crate::validation::policy::Policy,
tal_inputs: Vec<TalInputSpec>,
http_fetcher: &H,
rsync_fetcher: &R,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
parallel_config: ParallelPhase1Config,
phase2_config: Option<ParallelPhase2Config>,
collect_current_repo_objects: bool,
timing: Option<TimingHandle>,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError>
where
H: Fetcher + Clone + 'static,
R: crate::repository::fetch::rsync::RsyncFetcher + Clone + 'static,
{
// Constraints are an immutable per-run policy snapshot. The phase-2
// ready-stage binds the snapshot to each CA's TAL and moves an Arc into
// ROA/ASPA worker state, so constrained multi-TAL runs retain the same
// parallel scheduler as unconstrained runs.
let phase2_enabled = phase2_config.is_some();
if tal_inputs.is_empty() {
return Err(RunTreeFromTalError::Setup(
"multi-TAL run requires at least one TAL input".to_string(),
));
}
let roots = discover_multiple_roots_from_tal_inputs(
&tal_inputs,
http_fetcher,
rsync_fetcher,
policy.strict.name,
)?;
let primary = roots.first().cloned().ok_or_else(|| {
RunTreeFromTalError::Setup("multi-TAL root discovery returned no roots".to_string())
})?;
let discoveries = roots
.iter()
.map(|item| item.discovery.clone())
.collect::<Vec<_>>();
let successful_tal_inputs = roots
.iter()
.map(|item| item.tal_input.clone())
.collect::<Vec<_>>();
let root_handles = roots
.iter()
.map(|item| item.root_handle.clone())
.collect::<Vec<_>>();
let download_log = DownloadLogHandle::new();
let (runtime, current_repo_index) = build_phase1_repo_sync_runtime(
Arc::clone(&store),
policy,
http_fetcher,
rsync_fetcher,
parallel_config,
timing.clone(),
Some(download_log.clone()),
successful_tal_inputs.clone(),
)?;
let current_repo_index_for_output = current_repo_index.clone();
let runner = make_live_runner(
store.as_ref(),
policy,
http_fetcher,
rsync_fetcher,
validation_time,
timing.clone(),
Some(download_log.clone()),
Some(current_repo_index),
Some(Arc::clone(&runtime)),
phase2_config,
(phase2_enabled && config.build_ccr_accumulator).then(|| {
CcrAccumulator::new(
discoveries
.iter()
.map(|item| item.trust_anchor.clone())
.collect::<Vec<_>>(),
)
}),
);
let TreeRunAuditOutput {
tree,
publication_points,
} = if phase2_enabled {
run_tree_parallel_phase2_audit_multi_root(root_handles, &runner, config)?
} else {
run_tree_serial_audit_multi_root(root_handles, &runner, config)?
};
let downloads = download_log.snapshot_events();
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
Ok(RunTreeFromTalAuditOutput {
discovery: primary.discovery.clone(),
discoveries,
successful_tal_inputs,
tree,
publication_points,
downloads,
download_stats,
current_repo_objects: snapshot_current_repo_objects(
Some(&current_repo_index_for_output),
collect_current_repo_objects,
),
ccr_accumulator: runner.ccr_accumulator_snapshot(),
})
}

363
src/validation/tree.rs Normal file
View File

@@ -0,0 +1,363 @@
use crate::model::rc::{AsResourceSet, IpResourceSet};
use crate::output::audit::{DiscoveredFrom, PublicationPointAudit};
use crate::output::report::Warning;
use crate::validation::manifest::PublicationPointSource;
use crate::validation::objects::{AspaAttestation, ObjectsOutput, RouterKeyPayload, Vrp};
use crate::validation::publication_point::PublicationPointSnapshot;
use std::borrow::Cow;
use std::sync::{Arc, OnceLock};
pub const DEFAULT_MAX_CA_DEPTH: usize = 32;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRunConfig {
/// Max CA instance depth to process (0 = root only).
pub max_depth: Option<usize>,
/// Max number of CA instances to process.
pub max_instances: Option<usize>,
/// Drop per-object audit payload when the caller only needs validation outputs.
pub compact_audit: bool,
/// Build online CCR manifest projections during phase2 validation.
pub build_ccr_accumulator: bool,
}
impl Default for TreeRunConfig {
fn default() -> Self {
Self {
max_depth: Some(DEFAULT_MAX_CA_DEPTH),
max_instances: None,
compact_audit: false,
build_ccr_accumulator: true,
}
}
}
pub(crate) fn ca_depth_is_allowed(config: &TreeRunConfig, depth: usize) -> bool {
config.max_depth.is_none_or(|max_depth| depth <= max_depth)
}
pub(crate) fn next_allowed_ca_depth(config: &TreeRunConfig, parent_depth: usize) -> Option<usize> {
let child_depth = parent_depth.checked_add(1)?;
ca_depth_is_allowed(config, child_depth).then_some(child_depth)
}
#[derive(Clone, Debug)]
pub enum CaCertificateRef {
InlineDer(Vec<u8>),
RepoBytes {
sha256_hex: String,
cached_der: Arc<OnceLock<Arc<[u8]>>>,
},
}
impl PartialEq for CaCertificateRef {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::InlineDer(left), Self::InlineDer(right)) => left == right,
(
Self::RepoBytes {
sha256_hex: left, ..
},
Self::RepoBytes {
sha256_hex: right, ..
},
) => left == right,
_ => false,
}
}
}
impl Eq for CaCertificateRef {}
impl CaCertificateRef {
pub fn inline_der(bytes: Vec<u8>) -> Self {
Self::InlineDer(bytes)
}
pub fn repo_bytes(sha256_hex: String) -> Self {
Self::RepoBytes {
sha256_hex,
cached_der: Arc::new(OnceLock::new()),
}
}
pub fn sha256_hex(&self) -> Option<&str> {
match self {
Self::InlineDer(_) => None,
Self::RepoBytes { sha256_hex, .. } => Some(sha256_hex.as_str()),
}
}
pub fn der<'a>(
&'a self,
store: &crate::repository::storage::RocksStore,
) -> Result<Cow<'a, [u8]>, String> {
match self {
Self::InlineDer(bytes) => Ok(Cow::Borrowed(bytes.as_slice())),
Self::RepoBytes {
sha256_hex,
cached_der,
} => {
if cached_der.get().is_none() {
let bytes = store
.get_blob_bytes(sha256_hex)
.map_err(|e| format!("load CA certificate bytes failed: {e}"))?
.ok_or_else(|| {
format!("missing CA certificate repo bytes for sha256={sha256_hex}")
})?;
let _ = cached_der.set(Arc::from(bytes));
}
let bytes = cached_der.get().ok_or_else(|| {
format!("missing cached CA certificate bytes for sha256={sha256_hex}")
})?;
Ok(Cow::Borrowed(bytes.as_ref()))
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CaInstanceHandle {
pub depth: usize,
pub tal_id: String,
pub parent_manifest_rsync_uri: Option<String>,
/// CA certificate bytes or a lazy repo-bytes reference for this CA instance.
pub ca_certificate: CaCertificateRef,
/// rsync URI of this CA certificate object (where it is published).
///
/// This is used for strict AIA binding checks (RFC 6487 §4.8.7) when validating
/// subordinate CA and EE certificates. Trust anchor certificates downloaded from
/// TAL URIs may not have an rsync publication URI, so this can be `None`.
pub ca_certificate_rsync_uri: Option<String>,
/// Effective (fully resolved) resources for this CA instance, used for resource-path
/// validation of subordinate CA certificates.
pub effective_ip_resources: Option<IpResourceSet>,
pub effective_as_resources: Option<AsResourceSet>,
pub rsync_base_uri: String,
pub manifest_rsync_uri: String,
pub publication_point_rsync_uri: String,
pub rrdp_notification_uri: Option<String>,
}
impl CaInstanceHandle {
pub fn with_depth(mut self, depth: usize) -> Self {
self.depth = depth;
self
}
pub fn ca_certificate_der<'a>(
&'a self,
store: &crate::repository::storage::RocksStore,
) -> Result<Cow<'a, [u8]>, String> {
self.ca_certificate.der(store)
}
pub fn ca_certificate_sha256_hex(&self) -> Option<&str> {
self.ca_certificate.sha256_hex()
}
pub fn ca_certificate_sha256_32(&self) -> Option<[u8; 32]> {
match &self.ca_certificate {
CaCertificateRef::InlineDer(bytes) => {
use sha2::Digest as _;
let digest = sha2::Sha256::digest(bytes);
Some(digest.into())
}
CaCertificateRef::RepoBytes { sha256_hex, .. } => {
let mut out = [0u8; 32];
hex::decode_to_slice(sha256_hex, &mut out).ok()?;
Some(out)
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicationPointRunResult {
pub source: PublicationPointSource,
pub snapshot: Option<PublicationPointSnapshot>,
pub warnings: Vec<Warning>,
pub objects: ObjectsOutput,
pub audit: PublicationPointAudit,
/// Candidate child CA instances to enqueue after this publication point completes.
///
/// - For `Fresh`, these are discovered from the current validated publication point.
/// - For failed synchronization, the result contains no child instances;
/// callers may retry the repository on a later run.
pub discovered_children: Vec<DiscoveredChildCaInstance>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiscoveredChildEntryProjection {
pub child_ski: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiscoveredChildCaInstance {
pub handle: CaInstanceHandle,
pub discovered_from: DiscoveredFrom,
pub child_entry_projection: Option<DiscoveredChildEntryProjection>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRunOutput {
pub instances_processed: usize,
pub instances_failed: usize,
pub warnings: Vec<Warning>,
pub vrps: Vec<Vrp>,
pub aspas: Vec<AspaAttestation>,
pub router_keys: Vec<RouterKeyPayload>,
}
#[derive(Debug, thiserror::Error)]
pub enum TreeRunError {
#[error("publication point runner failed: {0}")]
Runner(String),
}
pub trait PublicationPointRunner {
fn run_publication_point(
&self,
ca: &CaInstanceHandle,
) -> Result<PublicationPointRunResult, String>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRunAuditOutput {
pub tree: TreeRunOutput,
pub publication_points: Vec<PublicationPointAudit>,
}
pub fn run_tree_serial(
root: CaInstanceHandle,
runner: &dyn PublicationPointRunner,
config: &TreeRunConfig,
) -> Result<TreeRunOutput, TreeRunError> {
Ok(run_tree_serial_audit(root, runner, config)?.tree)
}
pub fn run_tree_serial_audit(
root: CaInstanceHandle,
runner: &dyn PublicationPointRunner,
config: &TreeRunConfig,
) -> Result<TreeRunAuditOutput, TreeRunError> {
run_tree_serial_audit_multi_root(vec![root], runner, config)
}
pub fn run_tree_serial_audit_multi_root(
roots: Vec<CaInstanceHandle>,
runner: &dyn PublicationPointRunner,
config: &TreeRunConfig,
) -> Result<TreeRunAuditOutput, TreeRunError> {
#[derive(Clone, Debug)]
struct QueuedCaInstance {
id: u64,
handle: CaInstanceHandle,
parent_id: Option<u64>,
discovered_from: Option<DiscoveredFrom>,
}
let mut next_id: u64 = 0;
let mut queue: std::collections::VecDeque<QueuedCaInstance> = std::collections::VecDeque::new();
for root in roots {
queue.push_back(QueuedCaInstance {
id: next_id,
handle: root,
parent_id: None,
discovered_from: None,
});
next_id += 1;
}
let mut visited_manifest_uris: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut instances_processed = 0usize;
let mut instances_failed = 0usize;
let mut warnings: Vec<Warning> = Vec::new();
let mut vrps: Vec<Vrp> = Vec::new();
let mut aspas: Vec<AspaAttestation> = Vec::new();
let mut router_keys: Vec<RouterKeyPayload> = Vec::new();
let mut publication_points: Vec<PublicationPointAudit> = Vec::new();
while let Some(node) = queue.pop_front() {
let ca = &node.handle;
if !visited_manifest_uris.insert(ca.manifest_rsync_uri.clone()) {
continue;
}
if let Some(max_instances) = config.max_instances
&& instances_processed >= max_instances
{
break;
}
if !ca_depth_is_allowed(config, ca.depth) {
continue;
}
let res = match runner.run_publication_point(ca) {
Ok(v) => v,
Err(e) => {
instances_failed += 1;
warnings.push(
Warning::new(format!("publication point failed: {e}"))
.with_context(&ca.manifest_rsync_uri),
);
continue;
}
};
instances_processed += 1;
warnings.extend(res.warnings);
warnings.extend(res.objects.warnings);
vrps.extend(res.objects.vrps);
aspas.extend(res.objects.aspas);
router_keys.extend(res.objects.router_keys);
let mut audit = res.audit;
audit.node_id = Some(node.id);
audit.parent_node_id = node.parent_id;
audit.discovered_from = node.discovered_from.clone();
if config.compact_audit {
audit.objects.clear();
audit.warnings.clear();
}
publication_points.push(audit);
let Some(child_depth) = next_allowed_ca_depth(config, ca.depth) else {
continue;
};
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(child_depth),
parent_id: Some(node.id),
discovered_from: Some(child.discovered_from),
});
next_id += 1;
}
}
Ok(TreeRunAuditOutput {
tree: TreeRunOutput {
instances_processed,
instances_failed,
warnings,
vrps,
aspas,
router_keys,
},
publication_points,
})
}

View File

@@ -0,0 +1,33 @@
#![allow(clippy::too_many_arguments, clippy::large_enum_variant)]
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError, TrySendError};
use std::time::{Duration, Instant};
use crate::output::audit::{DiscoveredFrom, PublicationPointAudit};
use crate::output::report::Warning;
use crate::scheduler::object_worker::{
ObjectTaskExecutor, ObjectWorkerSubmitError, ScopedObjectWorkerPool,
};
use crate::scheduler::repo_runtime::{RepoSyncRequestStatus, RepoSyncRuntimeOutcome};
use crate::scheduler::types::RepoIdentity;
use crate::validation::manifest::PublicationPointData;
use crate::validation::objects::{
ObjectsOutput, OwnedRoaTask, ParallelObjectsPrepare, ParallelObjectsStage,
prepare_publication_point_for_parallel_roa_and_ta_constraints, reduce_parallel_roa_stage,
};
use crate::validation::policy::SignedObjectFailurePolicy;
use crate::validation::tree::{
CaInstanceHandle, DiscoveredChildCaInstance, PublicationPointRunResult, PublicationPointRunner,
TreeRunAuditOutput, TreeRunConfig, TreeRunError, TreeRunOutput, ca_depth_is_allowed,
next_allowed_ca_depth, run_tree_serial_audit_multi_root,
};
use crate::validation::tree_runner::{
FreshPublicationPointFinalizeOutput, FreshPublicationPointStage, Rpkiv1PublicationPointRunner,
};
include!("tree_parallel/state.rs");
include!("tree_parallel/phase2.rs");
include!("tree_parallel/ready_stage.rs");
include!("tree_parallel/dispatch.rs");
include!("tree_parallel/finalize.rs");

View File

@@ -0,0 +1,450 @@
fn flush_pending_roa_dispatch(
runner: &Rpkiv1PublicationPointRunner<'_>,
pending_roa_dispatch: &mut VecDeque<OwnedRoaTask>,
inflight_publication_points: &mut HashMap<u64, InflightPublicationPoint>,
) -> Result<RoaDispatchMetrics, TreeRunError> {
let started = Instant::now();
let mut metrics = RoaDispatchMetrics::default();
let Some(pool) = runner.parallel_roa_worker_pool.as_ref() else {
return Ok(metrics);
};
while let Some(mut task) = pending_roa_dispatch.pop_front() {
metrics.attempted += 1;
let pp_id = task.publication_point_id;
task.submitted_at = Some(Instant::now());
match pool.try_submit_round_robin(task) {
Ok(_) => {
metrics.submitted += 1;
if let Some(state) = inflight_publication_points.get_mut(&pp_id) {
let now = Instant::now();
state.tasks_submitted += 1;
if state.first_task_submitted_at.is_none() {
state.first_task_submitted_at = Some(now);
}
state.last_task_submitted_at = Some(now);
}
}
Err(ObjectWorkerSubmitError::QueueFull { task, .. }) => {
pending_roa_dispatch.push_front(task);
metrics.queue_full = true;
break;
}
Err(ObjectWorkerSubmitError::Disconnected { .. }) => {
return Err(TreeRunError::Runner(
"parallel ROA worker queue disconnected".to_string(),
));
}
}
}
metrics.pending_remaining = pending_roa_dispatch.len();
metrics.duration_ms = elapsed_ms(started);
Ok(metrics)
}
fn flush_pending_roa_dispatch_with_progress(
runner: &Rpkiv1PublicationPointRunner<'_>,
pending_roa_dispatch: &mut VecDeque<OwnedRoaTask>,
inflight_publication_points: &mut HashMap<u64, InflightPublicationPoint>,
pending_finalization: &VecDeque<FinalizeTask>,
) -> Result<(), TreeRunError> {
let dispatch_metrics =
flush_pending_roa_dispatch(runner, pending_roa_dispatch, inflight_publication_points)?;
if dispatch_metrics.attempted > 0 || dispatch_metrics.queue_full {
crate::logging::progress::emit!(
"phase2_roa_dispatch_batch",
serde_json::json!({
"attempted": dispatch_metrics.attempted,
"submitted": dispatch_metrics.submitted,
"queue_full": dispatch_metrics.queue_full,
"pending_remaining": dispatch_metrics.pending_remaining,
"duration_ms": dispatch_metrics.duration_ms,
"inflight_publication_points": inflight_publication_points.len(),
"pending_finalization_len": pending_finalization.len(),
}),
);
}
Ok(())
}
fn drain_object_results(
runner: &Rpkiv1PublicationPointRunner<'_>,
inflight_publication_points: &mut HashMap<u64, InflightPublicationPoint>,
pending_finalization: &mut VecDeque<FinalizeTask>,
result_budget: usize,
) -> Result<ObjectDrainMetrics, TreeRunError> {
let started = Instant::now();
let mut metrics = ObjectDrainMetrics::default();
let Some(pool) = runner.parallel_roa_worker_pool.as_ref() else {
return Ok(metrics);
};
let result_budget = result_budget.max(1);
while metrics.results_drained < result_budget {
let Some(result) = pool
.recv_result_timeout(Duration::from_millis(0))
.map_err(TreeRunError::Runner)?
else {
break;
};
metrics.results_drained += 1;
let pp_id = result.publication_point_id;
let _worker_index = result.worker_index;
metrics.worker_ms_total += result.worker_ms;
metrics.worker_ms_max = metrics.worker_ms_max.max(result.worker_ms);
metrics.queue_wait_ms_total += result.queue_wait_ms;
metrics.queue_wait_ms_max = metrics.queue_wait_ms_max.max(result.queue_wait_ms);
let should_finalize = if let Some(state) = inflight_publication_points.get_mut(&pp_id) {
let now = Instant::now();
if state.first_result_at.is_none() {
state.first_result_at = Some(now);
}
state.last_result_at = Some(now);
state.worker_ms_total += result.worker_ms;
state.worker_ms_max = state.worker_ms_max.max(result.worker_ms);
state.queue_wait_ms_total += result.queue_wait_ms;
state.queue_wait_ms_max = state.queue_wait_ms_max.max(result.queue_wait_ms);
state.results.push(result);
state.results.len() == state.task_count
} else {
false
};
if should_finalize {
let mut state = inflight_publication_points
.remove(&pp_id)
.expect("inflight publication point must exist");
state.finalize_enqueued_at = Some(Instant::now());
metrics.publication_points_completed += 1;
pending_finalization.push_back(FinalizeTask { state });
}
}
metrics.result_budget_exhausted = metrics.results_drained == result_budget;
metrics.duration_ms = elapsed_ms(started);
Ok(metrics)
}
fn drain_object_results_with_progress(
runner: &Rpkiv1PublicationPointRunner<'_>,
inflight_publication_points: &mut HashMap<u64, InflightPublicationPoint>,
pending_finalization: &mut VecDeque<FinalizeTask>,
pending_roa_dispatch_len: usize,
result_budget: usize,
) -> Result<(), TreeRunError> {
let drain_metrics = drain_object_results(
runner,
inflight_publication_points,
pending_finalization,
result_budget,
)?;
if drain_metrics.results_drained > 0 || drain_metrics.result_budget_exhausted {
crate::logging::progress::emit!(
"phase2_object_results_drain",
serde_json::json!({
"results_drained": drain_metrics.results_drained,
"publication_points_completed": drain_metrics.publication_points_completed,
"result_budget_exhausted": drain_metrics.result_budget_exhausted,
"result_drain_batch_size": result_budget,
"worker_ms_total": drain_metrics.worker_ms_total,
"worker_ms_max": drain_metrics.worker_ms_max,
"queue_wait_ms_total": drain_metrics.queue_wait_ms_total,
"queue_wait_ms_max": drain_metrics.queue_wait_ms_max,
"duration_ms": drain_metrics.duration_ms,
"pending_roa_dispatch_len": pending_roa_dispatch_len,
"inflight_publication_points": inflight_publication_points.len(),
"pending_finalization_len": pending_finalization.len(),
}),
);
}
Ok(())
}
/// Submission endpoint of the stage pool, abstracted so the dispatch loop can
/// be tested with a deterministic backpressure source.
trait ReadyStageSubmitter {
// The error must hand the task back so the dispatch loop can requeue it;
// boxing it away would only shuffle the same bytes around.
#[allow(clippy::result_large_err)]
fn try_submit_ready_stage(
&mut self,
task: ReadyStageTask,
) -> Result<(), ObjectWorkerSubmitError<ReadyStageTask>>;
}
impl ReadyStageSubmitter for ReadyStagePool<'_, '_> {
fn try_submit_ready_stage(
&mut self,
task: ReadyStageTask,
) -> Result<(), ObjectWorkerSubmitError<ReadyStageTask>> {
self.try_submit_round_robin(task).map(|_| ())
}
}
/// Dispatch loop of the pool-enabled ready batch: pop ready publication
/// points and submit their compute tasks to the stage pool, following the
/// `flush_pending_roa_dispatch` backpressure pattern — on a full worker queue
/// the publication point goes back to the head of the ready queue so the next
/// turn retries it first (nothing lost, nothing duplicated).
fn submit_ready_batch_to_stage_pool(
stage_pool: &mut impl ReadyStageSubmitter,
ready_queue: &mut VecDeque<ReadyCaInstance>,
staging_inflight: &mut usize,
ready_batch_size: usize,
ready_batch_wall_time_budget: Duration,
) -> Result<StageDispatchMetrics, TreeRunError> {
let started = Instant::now();
let mut metrics = StageDispatchMetrics::default();
while metrics.submitted < ready_batch_size {
let Some(ready) = ready_queue.pop_front() else {
break;
};
let task = ReadyStageTask {
ready_queue_len_after_pop: ready_queue.len(),
ready,
submitted_at: Instant::now(),
};
match stage_pool.try_submit_ready_stage(task) {
Ok(_) => {
*staging_inflight += 1;
metrics.submitted += 1;
}
Err(ObjectWorkerSubmitError::QueueFull { task, .. }) => {
ready_queue.push_front(task.ready);
metrics.queue_full = true;
break;
}
Err(ObjectWorkerSubmitError::Disconnected { .. }) => {
return Err(TreeRunError::Runner(
"ready stage worker queue disconnected".to_string(),
));
}
}
if metrics.submitted > 0 && started.elapsed() >= ready_batch_wall_time_budget {
break;
}
}
metrics.duration_ms = elapsed_ms(started);
Ok(metrics)
}
/// Collect every available stage result without blocking and run the apply
/// phase for each on the control thread, exactly like the inline path would
/// have done right after compute. Drained metrics aggregate into the same
/// `ReadyStageBatchMetrics`, keeping the `phase2_ready_queue_*` events
/// unchanged.
#[allow(clippy::too_many_arguments)]
fn drain_stage_results(
stage_pool: &ReadyStagePool<'_, '_>,
runner: &Rpkiv1PublicationPointRunner<'_>,
next_id: &mut u64,
ca_queue: &mut VecDeque<QueuedCaInstance>,
pending_roa_dispatch: &mut VecDeque<OwnedRoaTask>,
inflight_publication_points: &mut HashMap<u64, InflightPublicationPoint>,
pending_finalization: &mut VecDeque<FinalizeTask>,
finished: &mut Vec<FinishedPublicationPoint>,
staging_inflight: &mut usize,
batch_metrics: &mut ReadyStageBatchMetrics,
pool_metrics: &mut StageDrainMetrics,
config: &TreeRunConfig,
) -> Result<(), TreeRunError> {
let started = Instant::now();
loop {
let Some(result) = stage_pool
.recv_result_timeout(Duration::from_millis(0))
.map_err(TreeRunError::Runner)?
else {
break;
};
pool_metrics.results_drained += 1;
pool_metrics.queue_wait_ms_total += result.queue_wait_ms;
pool_metrics.queue_wait_ms_max = pool_metrics.queue_wait_ms_max.max(result.queue_wait_ms);
pool_metrics.worker_ms_total += result.worker_ms;
pool_metrics.worker_ms_max = pool_metrics.worker_ms_max.max(result.worker_ms);
*staging_inflight = staging_inflight.saturating_sub(1);
let metrics = apply_ready_publication_point_stage(
runner,
next_id,
ca_queue,
pending_roa_dispatch,
inflight_publication_points,
pending_finalization,
finished,
result.outcome,
result.metrics,
config,
config.compact_audit,
);
batch_metrics.record(metrics);
}
pool_metrics.duration_ms += elapsed_ms(started);
Ok(())
}
fn submit_pending_finalization(
finalize_task_tx: &SyncSender<FinalizeTask>,
pending_finalization: &mut VecDeque<FinalizeTask>,
finalize_inflight: &mut usize,
) -> Result<FinalizeSubmitMetrics, TreeRunError> {
let started = Instant::now();
let mut metrics = FinalizeSubmitMetrics::default();
while let Some(task) = pending_finalization.pop_front() {
match finalize_task_tx.try_send(task) {
Ok(()) => {
metrics.submitted += 1;
*finalize_inflight += 1;
}
Err(TrySendError::Full(task)) => {
pending_finalization.push_front(task);
metrics.queue_full = true;
break;
}
Err(TrySendError::Disconnected(_task)) => {
return Err(TreeRunError::Runner(
"phase2 finalize worker queue disconnected".to_string(),
));
}
}
}
metrics.duration_ms = elapsed_ms(started);
Ok(metrics)
}
fn submit_pending_finalization_with_progress(
finalize_task_tx: &SyncSender<FinalizeTask>,
pending_finalization: &mut VecDeque<FinalizeTask>,
finalize_inflight: &mut usize,
finalize_queue_capacity: usize,
pending_roa_dispatch_len: usize,
inflight_publication_points_len: usize,
) -> Result<(), TreeRunError> {
let submit_metrics =
submit_pending_finalization(finalize_task_tx, pending_finalization, finalize_inflight)?;
if submit_metrics.submitted > 0 || submit_metrics.queue_full {
crate::logging::progress::emit!(
"phase2_finalize_task_submit",
serde_json::json!({
"submitted": submit_metrics.submitted,
"queue_full": submit_metrics.queue_full,
"duration_ms": submit_metrics.duration_ms,
"finalize_queue_capacity": finalize_queue_capacity,
"pending_finalization_len": pending_finalization.len(),
"finalize_inflight": *finalize_inflight,
"pending_roa_dispatch_len": pending_roa_dispatch_len,
"inflight_publication_points": inflight_publication_points_len,
}),
);
}
Ok(())
}
fn drain_finalize_results(
finalize_result_rx: &Receiver<FinalizeWorkerResult>,
finished: &mut Vec<FinishedPublicationPoint>,
finalize_inflight: &mut usize,
) -> Result<FinalizeResultsDrainMetrics, TreeRunError> {
let started = Instant::now();
let mut metrics = FinalizeResultsDrainMetrics::default();
loop {
match finalize_result_rx.try_recv() {
Ok(result) => {
metrics.results_drained += 1;
metrics.reduce_ms_total += result.metrics.reduce_ms;
metrics.reduce_ms_max = metrics.reduce_ms_max.max(result.metrics.reduce_ms);
metrics.finalize_ms_total += result.metrics.finalize_ms;
metrics.finalize_ms_max = metrics.finalize_ms_max.max(result.metrics.finalize_ms);
metrics.finalize_queue_wait_ms_max = metrics
.finalize_queue_wait_ms_max
.max(result.metrics.finalize_queue_wait_ms.unwrap_or(0));
metrics.finalize_worker_ms_total += result.metrics.finalize_worker_ms;
metrics.finalize_worker_ms_max = metrics
.finalize_worker_ms_max
.max(result.metrics.finalize_worker_ms);
metrics.snapshot_pack_ms_total += result.metrics.snapshot_pack_ms;
metrics.snapshot_pack_ms_max = metrics
.snapshot_pack_ms_max
.max(result.metrics.snapshot_pack_ms);
metrics.ccr_projection_build_ms_total += result.metrics.ccr_projection_build_ms;
metrics.ccr_projection_build_ms_max = metrics
.ccr_projection_build_ms_max
.max(result.metrics.ccr_projection_build_ms);
metrics.ccr_append_ms_total += result.metrics.ccr_append_ms;
metrics.ccr_append_ms_max =
metrics.ccr_append_ms_max.max(result.metrics.ccr_append_ms);
metrics.audit_build_ms_total += result.metrics.audit_build_ms;
metrics.audit_build_ms_max = metrics
.audit_build_ms_max
.max(result.metrics.audit_build_ms);
*finalize_inflight = finalize_inflight.saturating_sub(1);
finished.push(result.finished);
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
if *finalize_inflight == 0 {
break;
}
return Err(TreeRunError::Runner(
"phase2 finalize result channel disconnected".to_string(),
));
}
}
}
metrics.duration_ms = elapsed_ms(started);
Ok(metrics)
}
fn drain_finalize_results_with_progress(
finalize_result_rx: &Receiver<FinalizeWorkerResult>,
finished: &mut Vec<FinishedPublicationPoint>,
finalize_inflight: &mut usize,
pending_finalization_len: usize,
pending_roa_dispatch_len: usize,
inflight_publication_points_len: usize,
) -> Result<(), TreeRunError> {
let drain_metrics = drain_finalize_results(finalize_result_rx, finished, finalize_inflight)?;
if drain_metrics.results_drained >= 64
|| (drain_metrics.results_drained > 0
&& (*finalize_inflight == 0 || pending_finalization_len > 0))
{
crate::logging::progress::emit!(
"phase2_finalize_results_drain",
serde_json::json!({
"results_drained": drain_metrics.results_drained,
"reduce_ms_total": drain_metrics.reduce_ms_total,
"reduce_ms_max": drain_metrics.reduce_ms_max,
"finalize_ms_total": drain_metrics.finalize_ms_total,
"finalize_ms_max": drain_metrics.finalize_ms_max,
"finalize_queue_wait_ms_max": drain_metrics.finalize_queue_wait_ms_max,
"finalize_worker_ms_total": drain_metrics.finalize_worker_ms_total,
"finalize_worker_ms_max": drain_metrics.finalize_worker_ms_max,
"snapshot_pack_ms_total": drain_metrics.snapshot_pack_ms_total,
"snapshot_pack_ms_max": drain_metrics.snapshot_pack_ms_max,
"ccr_projection_build_ms_total": drain_metrics.ccr_projection_build_ms_total,
"ccr_projection_build_ms_max": drain_metrics.ccr_projection_build_ms_max,
"ccr_append_ms_total": drain_metrics.ccr_append_ms_total,
"ccr_append_ms_max": drain_metrics.ccr_append_ms_max,
"audit_build_ms_total": drain_metrics.audit_build_ms_total,
"audit_build_ms_max": drain_metrics.audit_build_ms_max,
"duration_ms": drain_metrics.duration_ms,
"pending_finalization_len": pending_finalization_len,
"finalize_inflight": *finalize_inflight,
"pending_roa_dispatch_len": pending_roa_dispatch_len,
"inflight_publication_points": inflight_publication_points_len,
}),
);
}
Ok(())
}
fn run_finalize_worker(
runner: &Rpkiv1PublicationPointRunner<'_>,
finalize_task_rx: Receiver<FinalizeTask>,
finalize_result_tx: mpsc::Sender<FinalizeWorkerResult>,
compact_audit: bool,
) -> Result<(), TreeRunError> {
while let Ok(task) = finalize_task_rx.recv() {
let result = finalize_publication_point_state(runner, task.state, compact_audit);
if finalize_result_tx.send(result).is_err() {
return Err(TreeRunError::Runner(
"phase2 finalize result receiver disconnected".to_string(),
));
}
}
Ok(())
}

View File

@@ -0,0 +1,488 @@
fn finalize_publication_point_state(
runner: &Rpkiv1PublicationPointRunner<'_>,
state: InflightPublicationPoint,
compact_audit: bool,
) -> FinalizeWorkerResult {
let finalize_worker_started = Instant::now();
let InflightPublicationPoint {
node,
fresh_stage,
objects_prepare,
repo_outcome,
warnings,
started_at,
objects_started_at,
task_count,
tasks_submitted,
first_task_submitted_at,
last_task_submitted_at,
first_result_at,
last_result_at,
worker_ms_total,
worker_ms_max,
queue_wait_ms_total,
queue_wait_ms_max,
finalize_enqueued_at,
results,
} = state;
let finalize_queue_wait_ms = finalize_enqueued_at.or(last_result_at).map(|ready_at| {
Instant::now()
.saturating_duration_since(ready_at)
.as_millis() as u64
});
let objects_processing_ms = objects_started_at.elapsed().as_millis() as u64;
runner.record_publication_point_step_ms(
&node.handle.manifest_rsync_uri,
"fresh_objects_processing_lifetime",
objects_processing_ms,
);
let (result, mut metrics, reduce_ms) = match objects_prepare {
ParallelObjectsPrepare::Staged(objects_stage) => {
let reduce_started = Instant::now();
let locked_files = objects_stage.locked_file_count();
let reduce_result =
reduce_parallel_roa_stage(objects_stage, results, runner.timing.as_ref());
let reduce_ms = elapsed_ms(reduce_started);
runner.record_publication_point_step_ms(
&node.handle.manifest_rsync_uri,
"fresh_roa_reduce",
reduce_ms,
);
let (result, metrics) = match reduce_result {
Ok(mut objects) => {
let finalize_started = Instant::now();
objects
.router_keys
.extend(fresh_stage.discovered_router_keys.clone());
let finalized = runner.finalize_fresh_publication_point_from_reducer(
&node.handle,
&fresh_stage.fresh_point,
warnings,
objects,
fresh_stage.child_audits,
fresh_stage.discovered_children,
repo_outcome.repo_sync_source.as_deref(),
repo_outcome.repo_sync_phase.as_deref(),
repo_outcome.repo_sync_duration_ms,
repo_outcome.repo_sync_err.as_deref(),
);
let finalize_ms = elapsed_ms(finalize_started);
match finalized {
Ok(output) => {
let metrics = finalize_metrics_from_output(
&output,
reduce_ms,
finalize_ms,
finalize_queue_wait_ms,
0,
locked_files,
);
(
compact_phase2_finished_result(output.result, compact_audit),
metrics,
)
}
Err(err) => (
FinishedPublicationPointResult::Err(err),
FinalizePublicationPointMetrics {
reduce_ms,
finalize_ms,
finalize_queue_wait_ms,
locked_files,
..FinalizePublicationPointMetrics::default()
},
),
}
}
Err(err) => (
FinishedPublicationPointResult::Err(err),
FinalizePublicationPointMetrics {
reduce_ms,
finalize_queue_wait_ms,
locked_files,
..FinalizePublicationPointMetrics::default()
},
),
};
(result, metrics, reduce_ms)
}
ParallelObjectsPrepare::Complete(objects) => {
// ROA prepare already produced complete objects for this publication
// point, so there is nothing to reduce; finalize directly. This is
// the former control-thread "direct finalize", now running on the
// finalize worker through the regular task queue.
let locked_files = fresh_stage.fresh_point.files().len();
let finalize_started = Instant::now();
let finalized = runner.finalize_fresh_publication_point_from_reducer(
&node.handle,
&fresh_stage.fresh_point,
warnings,
objects,
fresh_stage.child_audits,
fresh_stage.discovered_children,
repo_outcome.repo_sync_source.as_deref(),
repo_outcome.repo_sync_phase.as_deref(),
repo_outcome.repo_sync_duration_ms,
repo_outcome.repo_sync_err.as_deref(),
);
let finalize_ms = elapsed_ms(finalize_started);
let (result, metrics) = match finalized {
Ok(output) => {
let metrics = finalize_metrics_from_output(
&output,
0,
finalize_ms,
finalize_queue_wait_ms,
0,
locked_files,
);
(
compact_phase2_finished_result(output.result, compact_audit),
metrics,
)
}
Err(err) => (
FinishedPublicationPointResult::Err(err),
FinalizePublicationPointMetrics {
finalize_ms,
finalize_queue_wait_ms,
locked_files,
..FinalizePublicationPointMetrics::default()
},
),
};
(result, metrics, 0)
}
};
let finalize_worker_ms = elapsed_ms(finalize_worker_started);
metrics.finalize_worker_ms = finalize_worker_ms;
runner.record_publication_point_step_ms(
&node.handle.manifest_rsync_uri,
"fresh_finalize_worker",
finalize_worker_ms,
);
runner.record_publication_point_step_ms(
&node.handle.manifest_rsync_uri,
"fresh_finalize_queue_wait",
finalize_queue_wait_ms.unwrap_or(0),
);
runner.record_publication_point_step_ms(
&node.handle.manifest_rsync_uri,
"fresh_finalize",
metrics.finalize_ms,
);
runner.record_publication_point_total_ms(
&node.handle.manifest_rsync_uri,
started_at.elapsed().as_millis() as u64,
);
emit_finalize_breakdown(
"phase2_finalize_worker_breakdown",
node.handle.manifest_rsync_uri.as_str(),
node.handle.publication_point_rsync_uri.as_str(),
&metrics,
);
crate::logging::progress::emit!(
"phase2_publication_point_reduced",
serde_json::json!({
"manifest_rsync_uri": node.handle.manifest_rsync_uri.as_str(),
"publication_point_rsync_uri": node.handle.publication_point_rsync_uri.as_str(),
"objects_processing_ms": objects_processing_ms,
"task_count": task_count,
"tasks_submitted": tasks_submitted,
"first_task_submitted_ms": first_task_submitted_at.map(|t| t.saturating_duration_since(objects_started_at).as_millis() as u64),
"last_task_submitted_ms": last_task_submitted_at.map(|t| t.saturating_duration_since(objects_started_at).as_millis() as u64),
"task_submit_span_ms": match (first_task_submitted_at, last_task_submitted_at) {
(Some(first), Some(last)) => Some(last.saturating_duration_since(first).as_millis() as u64),
_ => None,
},
"first_result_ms": first_result_at.map(|t| t.saturating_duration_since(objects_started_at).as_millis() as u64),
"last_result_ms": last_result_at.map(|t| t.saturating_duration_since(objects_started_at).as_millis() as u64),
"all_results_ready_ms": last_result_at.map(|t| t.saturating_duration_since(objects_started_at).as_millis() as u64),
"finalize_queue_wait_ms": finalize_queue_wait_ms,
"result_span_ms": match (first_result_at, last_result_at) {
(Some(first), Some(last)) => Some(last.saturating_duration_since(first).as_millis() as u64),
_ => None,
},
"worker_ms_total": worker_ms_total,
"worker_ms_max": worker_ms_max,
"worker_ms_avg": if task_count > 0 { worker_ms_total / task_count as u64 } else { 0 },
"queue_wait_ms_total": queue_wait_ms_total,
"queue_wait_ms_max": queue_wait_ms_max,
"queue_wait_ms_avg": if task_count > 0 { queue_wait_ms_total / task_count as u64 } else { 0 },
"reduce_ms": reduce_ms,
"finalize_ms": metrics.finalize_ms,
"finalize_worker_ms": finalize_worker_ms,
"snapshot_pack_ms": metrics.snapshot_pack_ms,
"ccr_projection_build_ms": metrics.ccr_projection_build_ms,
"ccr_append_ms": metrics.ccr_append_ms,
"audit_build_ms": metrics.audit_build_ms,
"locked_files": metrics.locked_files,
"child_count": metrics.child_count,
"warning_count": metrics.warning_count,
"vrp_count": metrics.vrp_count,
"vap_count": metrics.vap_count,
"router_key_count": metrics.router_key_count,
"audit_object_count": metrics.audit_object_count,
"total_duration_ms": started_at.elapsed().as_millis() as u64,
}),
);
FinalizeWorkerResult {
finished: FinishedPublicationPoint {
node: FinishedPublicationPointNode::from_queued(node),
result,
},
metrics,
}
}
fn drain_repo_events(
repo_runtime: &dyn crate::scheduler::repo_runtime::RepoSyncRuntime,
ca_waiting_repo_by_identity: &mut HashMap<RepoIdentity, Vec<QueuedCaInstance>>,
ready_queue: &mut VecDeque<ReadyCaInstance>,
timeout: Duration,
) -> Result<RepoDrainMetrics, TreeRunError> {
let started = Instant::now();
let mut metrics = RepoDrainMetrics::default();
let events = repo_runtime
.drain_repo_results_timeout(timeout, REPO_RESULT_DRAIN_MAX_EVENTS)
.map_err(TreeRunError::Runner)?;
for event in events {
metrics.event_count += 1;
metrics.completions += event.completions.len();
for completion in event.completions {
let mut outcome = completion.outcome;
if completion.identity != event.transport_identity {
// Shared RRDP/rsync transports release many publication points, but the transport
// wall time should only be counted once in per-PP stage timing aggregation.
outcome.repo_sync_duration_ms = 0;
}
if let Some(waiters) = ca_waiting_repo_by_identity.remove(&completion.identity) {
metrics.ready_enqueued += waiters.len();
for node in waiters {
ready_queue.push_back(ReadyCaInstance {
node,
repo_outcome: outcome.clone(),
ready_enqueued_at: Instant::now(),
});
}
}
}
}
metrics.duration_ms = elapsed_ms(started);
Ok(metrics)
}
fn event_poll_timeout(
ca_queue: &VecDeque<QueuedCaInstance>,
ready_queue: &VecDeque<ReadyCaInstance>,
pending_roa_dispatch: &VecDeque<OwnedRoaTask>,
inflight_publication_points: &HashMap<u64, InflightPublicationPoint>,
pending_finalization: &VecDeque<FinalizeTask>,
finalize_inflight: usize,
staging_inflight: usize,
instances_started: usize,
config: &TreeRunConfig,
) -> Duration {
// Stage results pending collection must not let the loop sleep: with the
// stage pool enabled a 50ms nap per turn would collapse throughput.
if !ready_queue.is_empty()
|| !pending_roa_dispatch.is_empty()
|| !inflight_publication_points.is_empty()
|| !pending_finalization.is_empty()
|| staging_inflight > 0
|| (!ca_queue.is_empty() && can_start_more(instances_started, config))
{
Duration::from_millis(0)
} else if finalize_inflight > 0 {
Duration::from_millis(10)
} else {
Duration::from_millis(50)
}
}
fn is_complete(
ca_queue: &VecDeque<QueuedCaInstance>,
ready_queue: &VecDeque<ReadyCaInstance>,
ca_waiting_repo_by_identity: &HashMap<RepoIdentity, Vec<QueuedCaInstance>>,
pending_roa_dispatch: &VecDeque<OwnedRoaTask>,
inflight_publication_points: &HashMap<u64, InflightPublicationPoint>,
pending_finalization: &VecDeque<FinalizeTask>,
finalize_inflight: usize,
staging_inflight: usize,
instances_started: usize,
config: &TreeRunConfig,
) -> bool {
// `staging_inflight == 0` additionally implies the stage result channel is
// drained empty: the drain loop collects every available result each turn
// and only decrements the counter while collecting.
let ca_queue_done = ca_queue.is_empty() || !can_start_more(instances_started, config);
ca_queue_done
&& ready_queue.is_empty()
&& ca_waiting_repo_by_identity.is_empty()
&& pending_roa_dispatch.is_empty()
&& inflight_publication_points.is_empty()
&& pending_finalization.is_empty()
&& finalize_inflight == 0
&& staging_inflight == 0
}
/// Minimum publication points per reduction shard; smaller runs stay
/// single-threaded to avoid thread-spawn overhead dominating the reduction.
const TREE_OUTPUT_MIN_SHARD_LEN: usize = 4096;
/// Hard cap on reduction shards regardless of core count.
const TREE_OUTPUT_MAX_SHARDS: usize = 8;
fn tree_output_shard_count(len: usize) -> usize {
if len < TREE_OUTPUT_MIN_SHARD_LEN * 2 {
return 1;
}
let parallel = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1);
parallel
.min(TREE_OUTPUT_MAX_SHARDS)
.min(len / TREE_OUTPUT_MIN_SHARD_LEN)
.max(1)
}
/// Per-shard reduction result of the phase2 output merge. Shards cover
/// contiguous ranges of the id-sorted finished list, so concatenating shard
/// outputs in shard order reproduces the sequential single-threaded order
/// exactly.
#[derive(Default)]
struct TreeOutputShardReduction {
instances_processed: usize,
instances_failed: usize,
warnings: Vec<Warning>,
vrps: Vec<crate::validation::objects::Vrp>,
aspas: Vec<crate::validation::objects::AspaAttestation>,
router_keys: Vec<crate::validation::objects::RouterKeyPayload>,
publication_points: Vec<PublicationPointAudit>,
}
fn reduce_finished_shard(items: Vec<FinishedPublicationPoint>) -> TreeOutputShardReduction {
let mut reduction = TreeOutputShardReduction::default();
for item in items {
match item.result {
FinishedPublicationPointResult::Ok {
warnings: result_warnings,
objects,
audit,
} => {
reduction.instances_processed += 1;
reduction.warnings.extend(result_warnings);
reduction.warnings.extend(objects.warnings);
reduction.vrps.extend(objects.vrps);
reduction.aspas.extend(objects.aspas);
reduction.router_keys.extend(objects.router_keys);
let mut audit: PublicationPointAudit = audit;
audit.node_id = Some(item.node.id);
audit.parent_node_id = item.node.parent_id;
audit.discovered_from = item.node.discovered_from;
reduction.publication_points.push(audit);
}
FinishedPublicationPointResult::Err(err) => {
reduction.instances_failed += 1;
reduction.warnings.push(
Warning::new(format!("publication point failed: {err}"))
.with_context(&item.node.manifest_rsync_uri),
);
}
}
}
reduction
}
fn merge_shard_reductions(reductions: Vec<TreeOutputShardReduction>) -> TreeOutputShardReduction {
let mut iter = reductions.into_iter();
let mut merged = iter.next().unwrap_or_default();
for mut other in iter {
merged.instances_processed += other.instances_processed;
merged.instances_failed += other.instances_failed;
merged.warnings.append(&mut other.warnings);
merged.vrps.append(&mut other.vrps);
merged.aspas.append(&mut other.aspas);
merged.router_keys.append(&mut other.router_keys);
merged
.publication_points
.append(&mut other.publication_points);
}
merged
}
fn build_tree_output(mut finished: Vec<FinishedPublicationPoint>) -> TreeRunAuditOutput {
let total_started = Instant::now();
finished.sort_by_key(|item| item.node.id);
let sort_ms = total_started.elapsed().as_millis() as u64;
let shard_count = tree_output_shard_count(finished.len());
let reduce_started = Instant::now();
let reductions = if shard_count <= 1 {
vec![reduce_finished_shard(finished)]
} else {
let chunk_len = finished.len().div_ceil(shard_count);
let mut shards: Vec<Vec<FinishedPublicationPoint>> = Vec::new();
let mut rest = finished;
while !rest.is_empty() {
let split_at = chunk_len.min(rest.len());
let tail = rest.split_off(split_at);
shards.push(std::mem::replace(&mut rest, tail));
}
std::thread::scope(|scope| {
let handles: Vec<_> = shards
.into_iter()
.map(|shard| scope.spawn(move || reduce_finished_shard(shard)))
.collect();
handles
.into_iter()
.map(|handle| handle.join().expect("tree output reduction shard panicked"))
.collect()
})
};
let reduce_ms = reduce_started.elapsed().as_millis() as u64;
let merge_started = Instant::now();
let merged = merge_shard_reductions(reductions);
let merge_ms = merge_started.elapsed().as_millis() as u64;
let finalize_started = Instant::now();
let output = TreeRunAuditOutput {
tree: TreeRunOutput {
instances_processed: merged.instances_processed,
instances_failed: merged.instances_failed,
warnings: merged.warnings,
vrps: merged.vrps,
aspas: merged.aspas,
router_keys: merged.router_keys,
},
publication_points: merged.publication_points,
};
let finalize_ms = finalize_started.elapsed().as_millis() as u64;
crate::logging::progress::emit!(
"phase2_build_tree_output",
serde_json::json!({
"sort_ms": sort_ms,
"shard_count": shard_count,
"reduce_ms": reduce_ms,
"merge_ms": merge_ms,
"finalize_ms": finalize_ms,
"total_ms": total_started.elapsed().as_millis() as u64,
"publication_points": output.publication_points.len(),
"instances_processed": output.tree.instances_processed,
"instances_failed": output.tree.instances_failed,
}),
);
output
}
pub fn run_tree_parallel_phase2_audit(
root: CaInstanceHandle,
runner: &Rpkiv1PublicationPointRunner<'_>,
config: &TreeRunConfig,
) -> Result<TreeRunAuditOutput, TreeRunError> {
run_tree_parallel_phase2_audit_multi_root(vec![root], runner, config)
}

View File

@@ -0,0 +1,445 @@
pub fn run_tree_parallel_phase2_audit_multi_root(
roots: Vec<CaInstanceHandle>,
runner: &Rpkiv1PublicationPointRunner<'_>,
config: &TreeRunConfig,
) -> Result<TreeRunAuditOutput, TreeRunError> {
if runner.policy.signed_object_failure_policy == SignedObjectFailurePolicy::DropPublicationPoint
{
return run_tree_serial_audit_multi_root(roots, runner, config);
}
let Some(repo_runtime) = runner.repo_sync_runtime.as_ref() else {
return run_tree_serial_audit_multi_root(roots, runner, config);
};
if runner.parallel_roa_worker_pool.is_none() {
return run_tree_serial_audit_multi_root(roots, runner, config);
}
let mut next_id: u64 = 0;
let mut ca_queue: VecDeque<QueuedCaInstance> = VecDeque::new();
for root in roots {
ca_queue.push_back(QueuedCaInstance {
id: next_id,
handle: root,
parent_id: None,
discovered_from: None,
});
next_id += 1;
}
let mut visited_manifest_uris: HashSet<String> = HashSet::new();
let mut ca_waiting_repo_by_identity: HashMap<RepoIdentity, Vec<QueuedCaInstance>> =
HashMap::new();
let mut ready_queue: VecDeque<ReadyCaInstance> = VecDeque::new();
let mut inflight_publication_points: HashMap<u64, InflightPublicationPoint> = HashMap::new();
let mut pending_finalization: VecDeque<FinalizeTask> = VecDeque::new();
let mut pending_roa_dispatch: VecDeque<OwnedRoaTask> = VecDeque::new();
let mut finished: Vec<FinishedPublicationPoint> = Vec::new();
let mut instances_started = 0usize;
let phase2_config = runner.parallel_phase2_config.as_ref();
let ready_batch_size = phase2_config
.map(|cfg| cfg.ready_batch_size)
.unwrap_or(256)
.max(1);
let ready_batch_wall_time_budget_ms = phase2_config
.map(|cfg| cfg.ready_batch_wall_time_budget_ms)
.unwrap_or(100)
.max(1);
let ready_batch_wall_time_budget = Duration::from_millis(ready_batch_wall_time_budget_ms);
let object_result_drain_batch_size = phase2_config
.map(|cfg| cfg.object_result_drain_batch_size)
.unwrap_or(2048)
.max(1);
let publication_point_finalize_queue_capacity = phase2_config
.map(|cfg| cfg.publication_point_finalize_queue_capacity)
.unwrap_or(32768)
.max(1);
// Experimental ready-stage pool: `stage_workers == 0` keeps the inline
// compute+apply staging path byte-for-byte; any positive value moves the
// compute phase to a scoped worker pool.
let stage_worker_count = phase2_config.map(|cfg| cfg.stage_workers).unwrap_or(0);
let stage_queue_capacity = phase2_config
.map(|cfg| cfg.worker_queue_capacity)
.unwrap_or(256)
.max(1);
let (finalize_task_tx, finalize_task_rx) =
mpsc::sync_channel::<FinalizeTask>(publication_point_finalize_queue_capacity);
let (finalize_result_tx, finalize_result_rx) = mpsc::channel::<FinalizeWorkerResult>();
let mut finalize_inflight = 0usize;
std::thread::scope(|scope| {
let finalize_worker = scope.spawn(move || {
run_finalize_worker(
runner,
finalize_task_rx,
finalize_result_tx,
config.compact_audit,
)
});
// The stage pool borrows the runner like the finalize worker does; it
// lives inside this scope and is dropped before the scope joins.
let mut stage_pool = if stage_worker_count > 0 {
Some(
ReadyStagePool::new(
scope,
stage_worker_count,
stage_queue_capacity,
ReadyStageTaskExecutor { runner },
)
.map_err(TreeRunError::Runner)?,
)
} else {
None
};
// Submitted-but-not-yet-drained stage tasks. The drain loop collects
// every available result each turn, so `staging_inflight == 0` also
// implies the stage result channel is empty.
let mut staging_inflight = 0usize;
let run_result: Result<(), TreeRunError> = (|| {
loop {
let control_loop_started = Instant::now();
// With the stage pool enabled the batch wall clock covers the
// whole turn (turn-head drain + dispatch + apply); the inline
// path keeps its historical start point at the ready batch.
let turn_stage_started = Instant::now();
let mut ready_batch_metrics = ReadyStageBatchMetrics::default();
let mut stage_drain_metrics = StageDrainMetrics::default();
drain_finalize_results_with_progress(
&finalize_result_rx,
&mut finished,
&mut finalize_inflight,
pending_finalization.len(),
pending_roa_dispatch.len(),
inflight_publication_points.len(),
)?;
flush_pending_roa_dispatch_with_progress(
runner,
&mut pending_roa_dispatch,
&mut inflight_publication_points,
&pending_finalization,
)?;
drain_object_results_with_progress(
runner,
&mut inflight_publication_points,
&mut pending_finalization,
pending_roa_dispatch.len(),
object_result_drain_batch_size,
)?;
if let Some(pool) = stage_pool.as_ref() {
drain_stage_results(
pool,
runner,
&mut next_id,
&mut ca_queue,
&mut pending_roa_dispatch,
&mut inflight_publication_points,
&mut pending_finalization,
&mut finished,
&mut staging_inflight,
&mut ready_batch_metrics,
&mut stage_drain_metrics,
config,
)?;
}
submit_pending_finalization_with_progress(
&finalize_task_tx,
&mut pending_finalization,
&mut finalize_inflight,
publication_point_finalize_queue_capacity,
pending_roa_dispatch.len(),
inflight_publication_points.len(),
)?;
start_queued_ca_instances(
repo_runtime.as_ref(),
&mut ca_queue,
&mut ready_queue,
&mut ca_waiting_repo_by_identity,
&mut finished,
&mut visited_manifest_uris,
&mut instances_started,
config,
);
let repo_poll_timeout = event_poll_timeout(
&ca_queue,
&ready_queue,
&pending_roa_dispatch,
&inflight_publication_points,
&pending_finalization,
finalize_inflight,
staging_inflight,
instances_started,
config,
);
let repo_metrics = drain_repo_events(
repo_runtime.as_ref(),
&mut ca_waiting_repo_by_identity,
&mut ready_queue,
repo_poll_timeout,
)?;
if repo_metrics.event_count > 0 {
crate::logging::progress::emit!(
"phase2_repo_events_drain",
serde_json::json!({
"event_count": repo_metrics.event_count,
"completions": repo_metrics.completions,
"ready_enqueued": repo_metrics.ready_enqueued,
"duration_ms": repo_metrics.duration_ms,
"ready_queue_len": ready_queue.len(),
"ca_waiting_repo_identities": ca_waiting_repo_by_identity.len(),
}),
);
}
let ready_batch_started = Instant::now();
let mut ready_time_budget_exhausted = false;
let mut stage_dispatch_metrics = StageDispatchMetrics::default();
if let Some(pool) = stage_pool.as_mut() {
// Pool path: the ready batch becomes a dispatch loop that
// submits compute tasks and applies whatever results are
// already available; backpressure requeues for next turn.
stage_dispatch_metrics = submit_ready_batch_to_stage_pool(
pool,
&mut ready_queue,
&mut staging_inflight,
ready_batch_size,
ready_batch_wall_time_budget,
)?;
ready_time_budget_exhausted = stage_dispatch_metrics.queue_full
|| (!ready_queue.is_empty()
&& ready_batch_started.elapsed() >= ready_batch_wall_time_budget);
drain_stage_results(
pool,
runner,
&mut next_id,
&mut ca_queue,
&mut pending_roa_dispatch,
&mut inflight_publication_points,
&mut pending_finalization,
&mut finished,
&mut staging_inflight,
&mut ready_batch_metrics,
&mut stage_drain_metrics,
config,
)?;
} else {
while ready_batch_metrics.ready_count < ready_batch_size {
let Some(ready) = ready_queue.pop_front() else {
break;
};
let ready_queue_len_after_pop = ready_queue.len();
let (outcome, metrics) = compute_ready_publication_point_stage(
runner,
ready,
ready_queue_len_after_pop,
);
let metrics = apply_ready_publication_point_stage(
runner,
&mut next_id,
&mut ca_queue,
&mut pending_roa_dispatch,
&mut inflight_publication_points,
&mut pending_finalization,
&mut finished,
outcome,
metrics,
config,
config.compact_audit,
);
ready_batch_metrics.record(metrics);
if ready_batch_metrics.ready_count > 0
&& ready_batch_started.elapsed() >= ready_batch_wall_time_budget
{
ready_time_budget_exhausted = !ready_queue.is_empty();
break;
}
}
}
if ready_batch_metrics.ready_count > 0 {
ready_batch_metrics.total_ms = if stage_pool.is_some() {
elapsed_ms(turn_stage_started)
} else {
elapsed_ms(ready_batch_started)
};
let ready_count_budget_exhausted = ready_batch_metrics.ready_count
>= ready_batch_size
&& !ready_queue.is_empty();
ready_time_budget_exhausted = ready_time_budget_exhausted
|| (!ready_queue.is_empty()
&& ready_batch_metrics.total_ms >= ready_batch_wall_time_budget_ms);
emit_ready_queue_batch_progress(
&ready_batch_metrics,
ready_batch_size,
ready_batch_wall_time_budget_ms,
ready_queue.len(),
ready_count_budget_exhausted,
ready_time_budget_exhausted,
ca_queue.len(),
pending_roa_dispatch.len(),
inflight_publication_points.len(),
pending_finalization.len(),
finalize_inflight,
);
crate::logging::progress::emit!(
"phase2_ready_queue_stage_fresh_breakdown",
serde_json::json!({
"ready_count": ready_batch_metrics.ready_count,
"stage_fresh_ms_total": ready_batch_metrics.stage_fresh_ms_total,
"stage_fresh_ms_max": ready_batch_metrics.stage_fresh_ms_max,
"stage_fresh_ms_max_manifest_rsync_uri": ready_batch_metrics.stage_fresh_ms_max_manifest_rsync_uri,
"stage_fresh_ms_max_publication_point_rsync_uri": ready_batch_metrics.stage_fresh_ms_max_publication_point_rsync_uri,
"snapshot_prepare_ms_total": ready_batch_metrics.snapshot_prepare_ms_total,
"snapshot_prepare_ms_max": ready_batch_metrics.snapshot_prepare_ms_max,
"snapshot_current_index_lock_ms_total": ready_batch_metrics.snapshot_current_index_lock_ms_total,
"snapshot_current_index_lock_ms_max": ready_batch_metrics.snapshot_current_index_lock_ms_max,
"snapshot_manifest_load_ms_total": ready_batch_metrics.snapshot_manifest_load_ms_total,
"snapshot_manifest_load_ms_max": ready_batch_metrics.snapshot_manifest_load_ms_max,
"snapshot_manifest_index_lookup_ms_total": ready_batch_metrics.snapshot_manifest_index_lookup_ms_total,
"snapshot_manifest_index_lookup_ms_max": ready_batch_metrics.snapshot_manifest_index_lookup_ms_max,
"snapshot_manifest_blob_load_ms_total": ready_batch_metrics.snapshot_manifest_blob_load_ms_total,
"snapshot_manifest_blob_load_ms_max": ready_batch_metrics.snapshot_manifest_blob_load_ms_max,
"snapshot_manifest_decode_ms_total": ready_batch_metrics.snapshot_manifest_decode_ms_total,
"snapshot_manifest_decode_ms_max": ready_batch_metrics.snapshot_manifest_decode_ms_max,
"snapshot_anti_rollback_ms_total": ready_batch_metrics.snapshot_anti_rollback_ms_total,
"snapshot_anti_rollback_ms_max": ready_batch_metrics.snapshot_anti_rollback_ms_max,
"anti_rollback_meta_hit_count": ready_batch_metrics.anti_rollback_meta_hit_count,
"anti_rollback_meta_miss_count": ready_batch_metrics.anti_rollback_meta_miss_count,
"snapshot_manifest_entries_ms_total": ready_batch_metrics.snapshot_manifest_entries_ms_total,
"snapshot_manifest_entries_ms_max": ready_batch_metrics.snapshot_manifest_entries_ms_max,
"snapshot_pack_files_ms_total": ready_batch_metrics.snapshot_pack_files_ms_total,
"snapshot_pack_files_ms_max": ready_batch_metrics.snapshot_pack_files_ms_max,
"snapshot_pack_files_index_lookup_ms_total": ready_batch_metrics.snapshot_pack_files_index_lookup_ms_total,
"snapshot_pack_files_index_lookup_ms_max": ready_batch_metrics.snapshot_pack_files_index_lookup_ms_max,
"snapshot_pack_files_blob_load_ms_total": ready_batch_metrics.snapshot_pack_files_blob_load_ms_total,
"snapshot_pack_files_blob_load_ms_max": ready_batch_metrics.snapshot_pack_files_blob_load_ms_max,
"snapshot_ee_path_validate_ms_total": ready_batch_metrics.snapshot_ee_path_validate_ms_total,
"snapshot_ee_path_validate_ms_max": ready_batch_metrics.snapshot_ee_path_validate_ms_max,
"snapshot_manifest_file_count_total": ready_batch_metrics.snapshot_manifest_file_count_total,
"snapshot_manifest_file_count_max": ready_batch_metrics.snapshot_manifest_file_count_max,
"child_discovery_ms_total": ready_batch_metrics.child_discovery_ms_total,
"child_discovery_ms_max": ready_batch_metrics.child_discovery_ms_max,
"batch_duration_ms": ready_batch_metrics.total_ms,
}),
);
}
if stage_pool.is_some()
&& (stage_dispatch_metrics.submitted > 0
|| stage_drain_metrics.results_drained > 0
|| stage_dispatch_metrics.queue_full)
{
crate::logging::progress::emit!(
"phase2_stage_pool_stats",
serde_json::json!({
"stage_workers": stage_worker_count,
"submitted": stage_dispatch_metrics.submitted,
"results_drained": stage_drain_metrics.results_drained,
"queue_full": stage_dispatch_metrics.queue_full,
"staging_inflight": staging_inflight,
"ready_queue_len": ready_queue.len(),
"queue_wait_ms_total": stage_drain_metrics.queue_wait_ms_total,
"queue_wait_ms_max": stage_drain_metrics.queue_wait_ms_max,
"worker_ms_total": stage_drain_metrics.worker_ms_total,
"worker_ms_max": stage_drain_metrics.worker_ms_max,
"dispatch_duration_ms": stage_dispatch_metrics.duration_ms,
"drain_duration_ms": stage_drain_metrics.duration_ms,
}),
);
}
flush_pending_roa_dispatch_with_progress(
runner,
&mut pending_roa_dispatch,
&mut inflight_publication_points,
&pending_finalization,
)?;
drain_object_results_with_progress(
runner,
&mut inflight_publication_points,
&mut pending_finalization,
pending_roa_dispatch.len(),
object_result_drain_batch_size,
)?;
submit_pending_finalization_with_progress(
&finalize_task_tx,
&mut pending_finalization,
&mut finalize_inflight,
publication_point_finalize_queue_capacity,
pending_roa_dispatch.len(),
inflight_publication_points.len(),
)?;
drain_finalize_results_with_progress(
&finalize_result_rx,
&mut finished,
&mut finalize_inflight,
pending_finalization.len(),
pending_roa_dispatch.len(),
inflight_publication_points.len(),
)?;
emit_control_loop_slow(
elapsed_ms(control_loop_started),
repo_poll_timeout,
&repo_metrics,
&ready_batch_metrics,
ca_queue.len(),
ready_queue.len(),
ca_waiting_repo_by_identity.len(),
pending_roa_dispatch.len(),
inflight_publication_points.len(),
pending_finalization.len(),
finalize_inflight,
);
if is_complete(
&ca_queue,
&ready_queue,
&ca_waiting_repo_by_identity,
&pending_roa_dispatch,
&inflight_publication_points,
&pending_finalization,
finalize_inflight,
staging_inflight,
instances_started,
config,
) {
break;
}
}
repo_runtime
.reset_run_state()
.map_err(TreeRunError::Runner)?;
Ok(())
})();
drop(finalize_task_tx);
// Dropping the pool closes the stage task queues so the scoped stage
// workers exit before the scope joins them below.
drop(stage_pool);
let worker_result = finalize_worker
.join()
.map_err(|_| TreeRunError::Runner("phase2 finalize worker panicked".to_string()))?;
run_result?;
worker_result?;
drain_finalize_results_with_progress(
&finalize_result_rx,
&mut finished,
&mut finalize_inflight,
pending_finalization.len(),
pending_roa_dispatch.len(),
inflight_publication_points.len(),
)?;
if finalize_inflight != 0 || !pending_finalization.is_empty() {
return Err(TreeRunError::Runner(format!(
"phase2 finalize worker stopped with pending work: queued={} inflight={}",
pending_finalization.len(),
finalize_inflight
)));
}
Ok(build_tree_output(finished))
})
}

View File

@@ -0,0 +1,728 @@
fn emit_ready_queue_batch_progress(
metrics: &ReadyStageBatchMetrics,
ready_batch_size: usize,
ready_batch_wall_time_budget_ms: u64,
ready_queue_len_after_batch: usize,
ready_count_budget_exhausted: bool,
ready_time_budget_exhausted: bool,
ca_queue_len_after_batch: usize,
pending_roa_dispatch_len_after_batch: usize,
inflight_publication_points_after_batch: usize,
pending_finalization_len_after_batch: usize,
finalize_inflight_after_batch: usize,
) {
crate::logging::progress::emit!(
"phase2_ready_queue_batch",
serde_json::json!({
"ready_count": metrics.ready_count,
"fallback_count": metrics.fallback_count,
"complete_count": metrics.complete_count,
"staged_count": metrics.staged_count,
"zero_task_count": metrics.zero_task_count,
"error_count": metrics.error_count,
"discovered_children": metrics.discovered_children,
"locked_files": metrics.locked_files,
"roa_tasks": metrics.roa_tasks,
"aspa_objects": metrics.aspa_objects,
"stage_fresh_ms_total": metrics.stage_fresh_ms_total,
"stage_fresh_ms_max": metrics.stage_fresh_ms_max,
"stage_fresh_ms_max_manifest_rsync_uri": metrics.stage_fresh_ms_max_manifest_rsync_uri,
"stage_fresh_ms_max_publication_point_rsync_uri": metrics.stage_fresh_ms_max_publication_point_rsync_uri,
"prepare_ms_total": metrics.prepare_ms_total,
"prepare_ms_max": metrics.prepare_ms_max,
"build_roa_tasks_ms_total": metrics.build_roa_tasks_ms_total,
"build_roa_tasks_ms_max": metrics.build_roa_tasks_ms_max,
"batch_duration_ms": metrics.total_ms,
"ready_batch_size": ready_batch_size,
"ready_batch_wall_time_budget_ms": ready_batch_wall_time_budget_ms,
"ready_queue_len_after_batch": ready_queue_len_after_batch,
"ready_queue_budget_exhausted": ready_queue_len_after_batch > 0,
"ready_count_budget_exhausted": ready_count_budget_exhausted,
"ready_time_budget_exhausted": ready_time_budget_exhausted,
"ca_queue_len_after_batch": ca_queue_len_after_batch,
"pending_roa_dispatch_len_after_batch": pending_roa_dispatch_len_after_batch,
"inflight_publication_points_after_batch": inflight_publication_points_after_batch,
"pending_finalization_len_after_batch": pending_finalization_len_after_batch,
"finalize_inflight_after_batch": finalize_inflight_after_batch,
}),
);
crate::logging::progress::emit!(
"phase2_ready_queue_control_breakdown",
serde_json::json!({
"ready_count": metrics.ready_count,
"ready_queue_wait_ms_total": metrics.ready_queue_wait_ms_total,
"ready_queue_wait_ms_max": metrics.ready_queue_wait_ms_max,
"child_enqueue_ms_total": metrics.child_enqueue_ms_total,
"child_enqueue_ms_max": metrics.child_enqueue_ms_max,
"roa_presence_scan_ms_total": metrics.roa_presence_scan_ms_total,
"roa_presence_scan_ms_max": metrics.roa_presence_scan_ms_max,
"direct_finalize_ms_total": metrics.direct_finalize_ms_total,
"direct_finalize_ms_max": metrics.direct_finalize_ms_max,
"fallback_full_run_ms_total": metrics.fallback_full_run_ms_total,
"fallback_full_run_ms_max": metrics.fallback_full_run_ms_max,
"batch_duration_ms": metrics.total_ms,
}),
);
}
fn can_start_more(instances_started: usize, config: &TreeRunConfig) -> bool {
config
.max_instances
.map(|max| instances_started < max)
.unwrap_or(true)
}
fn start_queued_ca_instances(
repo_runtime: &dyn crate::scheduler::repo_runtime::RepoSyncRuntime,
ca_queue: &mut VecDeque<QueuedCaInstance>,
ready_queue: &mut VecDeque<ReadyCaInstance>,
ca_waiting_repo_by_identity: &mut HashMap<RepoIdentity, Vec<QueuedCaInstance>>,
finished: &mut Vec<FinishedPublicationPoint>,
visited_manifest_uris: &mut HashSet<String>,
instances_started: &mut usize,
config: &TreeRunConfig,
) {
while can_start_more(*instances_started, config) {
let Some(node) = ca_queue.pop_front() else {
break;
};
if !visited_manifest_uris.insert(node.handle.manifest_rsync_uri.clone()) {
continue;
}
if !ca_depth_is_allowed(config, node.handle.depth) {
continue;
}
*instances_started += 1;
match repo_runtime.request_publication_point_repo(&node.handle, 0) {
Ok(RepoSyncRequestStatus::Ready { mut outcome, .. }) => {
// Ready here means this CA is reusing repo work that has already completed
// (often due to child request scheduling). Do not add the transport duration
// again.
outcome.repo_sync_duration_ms = 0;
ready_queue.push_back(ReadyCaInstance {
node,
repo_outcome: outcome,
ready_enqueued_at: Instant::now(),
});
}
Ok(RepoSyncRequestStatus::Pending { identity, .. }) => {
ca_waiting_repo_by_identity
.entry(identity)
.or_default()
.push(node);
}
Err(err) => {
finished.push(FinishedPublicationPoint {
node: FinishedPublicationPointNode::from_queued(node),
result: FinishedPublicationPointResult::Err(err),
});
}
}
}
}
fn compute_ready_publication_point_stage(
runner: &Rpkiv1PublicationPointRunner<'_>,
ready: ReadyCaInstance,
ready_queue_len_after_pop: usize,
) -> (StageOutcome, ReadyStageMetrics) {
let publication_point_started = Instant::now();
let ready_queue_wait_ms = publication_point_started
.saturating_duration_since(ready.ready_enqueued_at)
.as_millis() as u64;
let mut metrics = ReadyStageMetrics {
ready_count: 1,
manifest_rsync_uri: Some(ready.node.handle.manifest_rsync_uri.clone()),
publication_point_rsync_uri: Some(ready.node.handle.publication_point_rsync_uri.clone()),
ready_queue_wait_ms,
ready_queue_len_after_pop,
..ReadyStageMetrics::default()
};
let mut warnings = ready.repo_outcome.warnings.clone();
let repo_outcome = ready.repo_outcome.clone();
let stage_fresh_started = Instant::now();
let stage = runner.stage_fresh_publication_point_after_repo_ready(
&ready.node.handle,
repo_outcome.repo_sync_ok,
repo_outcome.repo_sync_err.as_deref(),
);
metrics.stage_fresh_ms = elapsed_ms(stage_fresh_started);
let fresh_stage = match stage {
Ok(stage) => stage,
Err(err) => {
if metrics.stage_fresh_ms >= crate::logging::progress::stage_fresh_slow_threshold_ms() {
crate::logging::progress::emit!(
"phase2_stage_fresh_slow",
serde_json::json!({
"manifest_rsync_uri": ready.node.handle.manifest_rsync_uri.as_str(),
"publication_point_rsync_uri": ready.node.handle.publication_point_rsync_uri.as_str(),
"status": "error",
"error": err.error.to_string(),
"stage_fresh_ms": metrics.stage_fresh_ms,
"snapshot_prepare_ms": err.snapshot_prepare_ms,
"repo_sync_source": repo_outcome.repo_sync_source.as_deref(),
"repo_sync_phase": repo_outcome.repo_sync_phase.as_deref(),
"repo_sync_duration_ms": repo_outcome.repo_sync_duration_ms,
}),
);
}
// The blocking `run_publication_point` fallback stays on the control
// thread; it is executed by the apply phase for this outcome.
return (
StageOutcome::FreshError(Box::new(FreshErrorOutcome {
ready,
publication_point_started,
})),
metrics,
);
}
};
metrics.snapshot_prepare_ms = fresh_stage.snapshot_prepare_ms;
metrics.snapshot_current_index_lock_ms =
fresh_stage.snapshot_prepare_timing.current_index_lock_ms;
metrics.snapshot_manifest_load_ms = fresh_stage.snapshot_prepare_timing.manifest_load_ms;
metrics.snapshot_manifest_index_lookup_ms =
fresh_stage.snapshot_prepare_timing.manifest_index_lookup_ms;
metrics.snapshot_manifest_blob_load_ms =
fresh_stage.snapshot_prepare_timing.manifest_blob_load_ms;
metrics.snapshot_manifest_decode_ms = fresh_stage.snapshot_prepare_timing.manifest_decode_ms;
metrics.snapshot_anti_rollback_ms = fresh_stage.snapshot_prepare_timing.anti_rollback_guard_ms;
metrics.anti_rollback_meta_hit_count =
fresh_stage.snapshot_prepare_timing.anti_rollback_meta_hit as usize;
metrics.anti_rollback_meta_miss_count =
fresh_stage.snapshot_prepare_timing.anti_rollback_meta_miss as usize;
metrics.snapshot_manifest_entries_ms = fresh_stage.snapshot_prepare_timing.manifest_entries_ms;
metrics.snapshot_pack_files_ms = fresh_stage.snapshot_prepare_timing.pack_files_ms;
metrics.snapshot_pack_files_index_lookup_ms = fresh_stage
.snapshot_prepare_timing
.pack_files_index_lookup_ms;
metrics.snapshot_pack_files_blob_load_ms =
fresh_stage.snapshot_prepare_timing.pack_files_blob_load_ms;
metrics.snapshot_ee_path_validate_ms = fresh_stage.snapshot_prepare_timing.ee_path_validate_ms;
metrics.snapshot_manifest_file_count = fresh_stage.snapshot_prepare_timing.manifest_file_count;
metrics.child_discovery_ms = fresh_stage.child_discovery_ms;
if metrics.stage_fresh_ms >= crate::logging::progress::stage_fresh_slow_threshold_ms() {
crate::logging::progress::emit!(
"phase2_stage_fresh_slow",
serde_json::json!({
"manifest_rsync_uri": ready.node.handle.manifest_rsync_uri.as_str(),
"publication_point_rsync_uri": ready.node.handle.publication_point_rsync_uri.as_str(),
"status": "ok",
"stage_fresh_ms": metrics.stage_fresh_ms,
"snapshot_prepare_ms": fresh_stage.snapshot_prepare_ms,
"snapshot_current_index_lock_ms": fresh_stage.snapshot_prepare_timing.current_index_lock_ms,
"snapshot_manifest_load_ms": fresh_stage.snapshot_prepare_timing.manifest_load_ms,
"snapshot_manifest_index_lookup_ms": fresh_stage.snapshot_prepare_timing.manifest_index_lookup_ms,
"snapshot_manifest_blob_load_ms": fresh_stage.snapshot_prepare_timing.manifest_blob_load_ms,
"snapshot_manifest_decode_ms": fresh_stage.snapshot_prepare_timing.manifest_decode_ms,
"snapshot_anti_rollback_ms": fresh_stage.snapshot_prepare_timing.anti_rollback_guard_ms,
"anti_rollback_meta_hit": fresh_stage.snapshot_prepare_timing.anti_rollback_meta_hit,
"anti_rollback_meta_miss": fresh_stage.snapshot_prepare_timing.anti_rollback_meta_miss,
"snapshot_manifest_entries_ms": fresh_stage.snapshot_prepare_timing.manifest_entries_ms,
"snapshot_pack_files_ms": fresh_stage.snapshot_prepare_timing.pack_files_ms,
"snapshot_pack_files_index_lookup_ms": fresh_stage.snapshot_prepare_timing.pack_files_index_lookup_ms,
"snapshot_pack_files_blob_load_ms": fresh_stage.snapshot_prepare_timing.pack_files_blob_load_ms,
"snapshot_ee_path_validate_ms": fresh_stage.snapshot_prepare_timing.ee_path_validate_ms,
"snapshot_manifest_file_count": fresh_stage.snapshot_prepare_timing.manifest_file_count,
"child_discovery_ms": fresh_stage.child_discovery_ms,
"child_count": fresh_stage.discovered_children.len(),
"repo_sync_source": repo_outcome.repo_sync_source.as_deref(),
"repo_sync_phase": repo_outcome.repo_sync_phase.as_deref(),
"repo_sync_duration_ms": repo_outcome.repo_sync_duration_ms,
}),
);
}
warnings.extend(fresh_stage.warnings.clone());
metrics.discovered_children = fresh_stage.discovered_children.len();
let prepare_started = Instant::now();
let ta_constraints = runner
.policy
.ta_constraints
.shared_for_tal(&ready.node.handle.tal_id);
if ta_constraints.is_some()
&& let Some(timing) = runner.timing.as_ref() {
timing.record_count("ta_constraints_parallel_publication_points", 1);
}
match prepare_publication_point_for_parallel_roa_and_ta_constraints(
ready.node.id,
&fresh_stage.fresh_point,
runner.policy,
fresh_stage.issuer_ca_der.as_ref(),
ready.node.handle.ca_certificate_rsync_uri.as_deref(),
ready.node.handle.effective_ip_resources.as_ref(),
ready.node.handle.effective_as_resources.as_ref(),
runner.validation_time,
ta_constraints,
) {
ParallelObjectsPrepare::Complete(mut objects) => {
metrics.prepare_ms = elapsed_ms(prepare_started);
runner.record_publication_point_step_ms(
&ready.node.handle.manifest_rsync_uri,
"fresh_objects_prepare",
metrics.prepare_ms,
);
metrics.complete_count = 1;
metrics.roa_tasks = objects.stats.roa_total;
metrics.aspa_objects = objects.stats.aspa_total;
objects
.router_keys
.extend(fresh_stage.discovered_router_keys.clone());
(
StageOutcome::Complete(Box::new(CompleteOutcome {
ready,
publication_point_started,
fresh_stage,
warnings,
objects,
})),
metrics,
)
}
ParallelObjectsPrepare::Staged(objects_stage) => {
metrics.prepare_ms = elapsed_ms(prepare_started);
runner.record_publication_point_step_ms(
&ready.node.handle.manifest_rsync_uri,
"fresh_objects_prepare",
metrics.prepare_ms,
);
metrics.staged_count = 1;
metrics.locked_files = objects_stage.locked_file_count();
metrics.aspa_objects = objects_stage.aspa_task_count();
let task_count = objects_stage.roa_task_count();
metrics.roa_tasks = task_count;
let outcome = StagedOutcome {
ready,
publication_point_started,
fresh_stage,
warnings,
objects_stage,
};
if task_count == 0 {
metrics.zero_task_count = 1;
(StageOutcome::ZeroTask(Box::new(outcome)), metrics)
} else {
(StageOutcome::Fresh(Box::new(outcome)), metrics)
}
}
}
}
fn apply_ready_publication_point_stage(
runner: &Rpkiv1PublicationPointRunner<'_>,
next_id: &mut u64,
ca_queue: &mut VecDeque<QueuedCaInstance>,
pending_roa_dispatch: &mut VecDeque<OwnedRoaTask>,
inflight_publication_points: &mut HashMap<u64, InflightPublicationPoint>,
pending_finalization: &mut VecDeque<FinalizeTask>,
finished: &mut Vec<FinishedPublicationPoint>,
outcome: StageOutcome,
mut metrics: ReadyStageMetrics,
config: &TreeRunConfig,
compact_audit: bool,
) -> ReadyStageMetrics {
match outcome {
StageOutcome::FreshError(outcome) => {
let FreshErrorOutcome {
ready,
publication_point_started,
} = *outcome;
let repo_outcome = ready.repo_outcome.clone();
metrics.fallback_count = 1;
let fallback_started = Instant::now();
let fallback = runner.run_publication_point(&ready.node.handle);
metrics.fallback_full_run_ms = elapsed_ms(fallback_started);
if let Ok(result) = fallback.as_ref() {
metrics.discovered_children = result.discovered_children.len();
let child_enqueue_started = Instant::now();
enqueue_discovered_children(
next_id,
ca_queue,
&ready.node,
config,
result.discovered_children.clone(),
);
metrics.child_enqueue_ms = elapsed_ms(child_enqueue_started);
}
finished.push(FinishedPublicationPoint {
node: FinishedPublicationPointNode::from_queued(ready.node),
result: compact_phase2_finished_result_result(fallback, compact_audit),
});
metrics.total_ms = elapsed_ms(publication_point_started);
emit_ready_publication_point_control_slow(
metrics.manifest_rsync_uri.as_deref().unwrap_or_default(),
metrics
.publication_point_rsync_uri
.as_deref()
.unwrap_or_default(),
&repo_outcome,
&metrics,
"fallback",
true,
);
metrics
}
StageOutcome::Complete(outcome) => {
let CompleteOutcome {
ready,
publication_point_started,
fresh_stage,
warnings,
objects,
} = *outcome;
let repo_outcome = ready.repo_outcome.clone();
let child_enqueue_started = Instant::now();
enqueue_discovered_children(
next_id,
ca_queue,
&ready.node,
config,
fresh_stage.discovered_children.clone(),
);
metrics.child_enqueue_ms = elapsed_ms(child_enqueue_started);
// The finalize no longer runs synchronously here: the publication
// point is queued for the shared finalize worker through the same
// pending_finalization path (queue capacity backpressure and
// finalize_inflight accounting included) as zero-task staging. The
// per-publication-point total timing is recorded by the finalize
// worker, exactly like the zero-task and staged paths.
let direct_finalize_started = Instant::now();
pending_finalization.push_back(FinalizeTask {
state: InflightPublicationPoint {
node: ready.node,
fresh_stage,
objects_prepare: ParallelObjectsPrepare::Complete(objects),
repo_outcome: repo_outcome.clone(),
warnings,
started_at: publication_point_started,
objects_started_at: Instant::now(),
task_count: 0,
tasks_submitted: 0,
first_task_submitted_at: None,
last_task_submitted_at: None,
first_result_at: None,
last_result_at: None,
worker_ms_total: 0,
worker_ms_max: 0,
queue_wait_ms_total: 0,
queue_wait_ms_max: 0,
finalize_enqueued_at: Some(Instant::now()),
results: Vec::new(),
},
});
metrics.direct_finalize_ms = elapsed_ms(direct_finalize_started);
runner.record_publication_point_step_ms(
&metrics.manifest_rsync_uri.clone().unwrap_or_default(),
"fresh_direct_finalize",
metrics.direct_finalize_ms,
);
metrics.total_ms = elapsed_ms(publication_point_started);
emit_ready_publication_point_control_slow(
metrics.manifest_rsync_uri.as_deref().unwrap_or_default(),
metrics
.publication_point_rsync_uri
.as_deref()
.unwrap_or_default(),
&repo_outcome,
&metrics,
"complete",
false,
);
metrics
}
StageOutcome::ZeroTask(outcome) => {
let StagedOutcome {
ready,
publication_point_started,
fresh_stage,
warnings,
objects_stage,
} = *outcome;
let repo_outcome = ready.repo_outcome.clone();
let child_enqueue_started = Instant::now();
enqueue_discovered_children(
next_id,
ca_queue,
&ready.node,
config,
fresh_stage.discovered_children.clone(),
);
metrics.child_enqueue_ms = elapsed_ms(child_enqueue_started);
let build_tasks_started = Instant::now();
objects_stage.append_roa_tasks_to(pending_roa_dispatch);
metrics.build_roa_tasks_ms = elapsed_ms(build_tasks_started);
runner.record_publication_point_step_ms(
&ready.node.handle.manifest_rsync_uri,
"fresh_build_roa_tasks",
metrics.build_roa_tasks_ms,
);
let task_count = objects_stage.roa_task_count();
pending_finalization.push_back(FinalizeTask {
state: InflightPublicationPoint {
node: ready.node,
fresh_stage,
objects_prepare: ParallelObjectsPrepare::Staged(objects_stage),
repo_outcome: repo_outcome.clone(),
warnings,
started_at: publication_point_started,
objects_started_at: Instant::now(),
task_count,
tasks_submitted: 0,
first_task_submitted_at: None,
last_task_submitted_at: None,
first_result_at: None,
last_result_at: None,
worker_ms_total: 0,
worker_ms_max: 0,
queue_wait_ms_total: 0,
queue_wait_ms_max: 0,
finalize_enqueued_at: Some(Instant::now()),
results: Vec::new(),
},
});
metrics.total_ms = elapsed_ms(publication_point_started);
emit_ready_publication_point_control_slow(
metrics.manifest_rsync_uri.as_deref().unwrap_or_default(),
metrics
.publication_point_rsync_uri
.as_deref()
.unwrap_or_default(),
&repo_outcome,
&metrics,
"zero_task",
false,
);
metrics
}
StageOutcome::Fresh(outcome) => {
let StagedOutcome {
ready,
publication_point_started,
fresh_stage,
warnings,
objects_stage,
} = *outcome;
let repo_outcome = ready.repo_outcome.clone();
let child_enqueue_started = Instant::now();
enqueue_discovered_children(
next_id,
ca_queue,
&ready.node,
config,
fresh_stage.discovered_children.clone(),
);
metrics.child_enqueue_ms = elapsed_ms(child_enqueue_started);
let build_tasks_started = Instant::now();
objects_stage.append_roa_tasks_to(pending_roa_dispatch);
metrics.build_roa_tasks_ms = elapsed_ms(build_tasks_started);
runner.record_publication_point_step_ms(
&ready.node.handle.manifest_rsync_uri,
"fresh_build_roa_tasks",
metrics.build_roa_tasks_ms,
);
let task_count = objects_stage.roa_task_count();
inflight_publication_points.insert(
ready.node.id,
InflightPublicationPoint {
node: ready.node,
fresh_stage,
objects_prepare: ParallelObjectsPrepare::Staged(objects_stage),
repo_outcome: repo_outcome.clone(),
warnings,
started_at: publication_point_started,
objects_started_at: Instant::now(),
task_count,
tasks_submitted: 0,
first_task_submitted_at: None,
last_task_submitted_at: None,
first_result_at: None,
last_result_at: None,
worker_ms_total: 0,
worker_ms_max: 0,
queue_wait_ms_total: 0,
queue_wait_ms_max: 0,
finalize_enqueued_at: None,
results: Vec::with_capacity(task_count),
},
);
metrics.total_ms = elapsed_ms(publication_point_started);
emit_ready_publication_point_control_slow(
metrics.manifest_rsync_uri.as_deref().unwrap_or_default(),
metrics
.publication_point_rsync_uri
.as_deref()
.unwrap_or_default(),
&repo_outcome,
&metrics,
"staged",
false,
);
metrics
}
}
}
fn emit_ready_publication_point_control_slow(
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
repo_outcome: &RepoSyncRuntimeOutcome,
metrics: &ReadyStageMetrics,
status: &str,
force_error_path: bool,
) {
let threshold_ms = crate::logging::progress::pp_control_slow_threshold_ms();
if !force_error_path && metrics.total_ms < threshold_ms {
return;
}
crate::logging::progress::emit!(
"phase2_ready_publication_point_control_slow",
serde_json::json!({
"manifest_rsync_uri": manifest_rsync_uri,
"publication_point_rsync_uri": publication_point_rsync_uri,
"status": status,
"repo_sync_source": repo_outcome.repo_sync_source.as_deref(),
"repo_sync_phase": repo_outcome.repo_sync_phase.as_deref(),
"repo_sync_duration_ms": repo_outcome.repo_sync_duration_ms,
"repo_sync_ok": repo_outcome.repo_sync_ok,
"repo_sync_err": repo_outcome.repo_sync_err.as_deref(),
"ready_queue_wait_ms": metrics.ready_queue_wait_ms,
"ready_queue_len_after_pop": metrics.ready_queue_len_after_pop,
"stage_fresh_ms": metrics.stage_fresh_ms,
"child_discovery_ms": metrics.child_discovery_ms,
"child_enqueue_ms": metrics.child_enqueue_ms,
"discovered_children": metrics.discovered_children,
"roa_presence_scan_ms": metrics.roa_presence_scan_ms,
"prepare_ms": metrics.prepare_ms,
"build_roa_tasks_ms": metrics.build_roa_tasks_ms,
"direct_finalize_ms": metrics.direct_finalize_ms,
"fallback_full_run_ms": metrics.fallback_full_run_ms,
"locked_files": metrics.locked_files,
"roa_tasks": metrics.roa_tasks,
"aspa_objects": metrics.aspa_objects,
"complete_count": metrics.complete_count,
"staged_count": metrics.staged_count,
"zero_task_count": metrics.zero_task_count,
"fallback_count": metrics.fallback_count,
"total_ms": metrics.total_ms,
"slow_threshold_ms": threshold_ms,
}),
);
crate::logging::progress::emit!(
"phase2_ready_publication_point_control_snapshot_breakdown",
serde_json::json!({
"manifest_rsync_uri": manifest_rsync_uri,
"publication_point_rsync_uri": publication_point_rsync_uri,
"status": status,
"snapshot_prepare_ms": metrics.snapshot_prepare_ms,
"snapshot_current_index_lock_ms": metrics.snapshot_current_index_lock_ms,
"snapshot_manifest_load_ms": metrics.snapshot_manifest_load_ms,
"snapshot_manifest_index_lookup_ms": metrics.snapshot_manifest_index_lookup_ms,
"snapshot_manifest_blob_load_ms": metrics.snapshot_manifest_blob_load_ms,
"snapshot_manifest_decode_ms": metrics.snapshot_manifest_decode_ms,
"snapshot_anti_rollback_ms": metrics.snapshot_anti_rollback_ms,
"anti_rollback_meta_hit_count": metrics.anti_rollback_meta_hit_count,
"anti_rollback_meta_miss_count": metrics.anti_rollback_meta_miss_count,
"snapshot_manifest_entries_ms": metrics.snapshot_manifest_entries_ms,
"snapshot_pack_files_ms": metrics.snapshot_pack_files_ms,
"snapshot_pack_files_index_lookup_ms": metrics.snapshot_pack_files_index_lookup_ms,
"snapshot_pack_files_blob_load_ms": metrics.snapshot_pack_files_blob_load_ms,
"snapshot_ee_path_validate_ms": metrics.snapshot_ee_path_validate_ms,
"snapshot_manifest_file_count": metrics.snapshot_manifest_file_count,
"total_ms": metrics.total_ms,
"slow_threshold_ms": threshold_ms,
}),
);
}
fn enqueue_discovered_children(
next_id: &mut u64,
ca_queue: &mut VecDeque<QueuedCaInstance>,
parent: &QueuedCaInstance,
config: &TreeRunConfig,
mut children: Vec<DiscoveredChildCaInstance>,
) {
let Some(child_depth) = next_allowed_ca_depth(config, parent.handle.depth) else {
return;
};
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 {
let mut handle = child.handle.with_depth(child_depth);
handle.parent_manifest_rsync_uri = Some(parent.handle.manifest_rsync_uri.clone());
ca_queue.push_back(QueuedCaInstance {
id: *next_id,
handle,
parent_id: Some(parent.id),
discovered_from: Some(child.discovered_from),
});
*next_id += 1;
}
}
fn finalize_metrics_from_output(
output: &FreshPublicationPointFinalizeOutput,
reduce_ms: u64,
finalize_ms: u64,
finalize_queue_wait_ms: Option<u64>,
finalize_worker_ms: u64,
locked_files: usize,
) -> FinalizePublicationPointMetrics {
FinalizePublicationPointMetrics {
reduce_ms,
finalize_ms,
finalize_queue_wait_ms,
finalize_worker_ms,
snapshot_pack_ms: output.snapshot_pack_ms,
ccr_projection_build_ms: output.ccr_projection_build_ms,
ccr_append_ms: output.ccr_append_ms,
audit_build_ms: output.audit_build_ms,
locked_files,
child_count: output.result.discovered_children.len(),
warning_count: output.result.warnings.len(),
vrp_count: output.result.objects.vrps.len(),
vap_count: output.result.objects.aspas.len(),
router_key_count: output.result.objects.router_keys.len(),
audit_object_count: output.result.audit.objects.len(),
}
}
fn emit_finalize_breakdown(
event_name: &str,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
metrics: &FinalizePublicationPointMetrics,
) {
crate::logging::progress::emit!(
event_name,
serde_json::json!({
"manifest_rsync_uri": manifest_rsync_uri,
"publication_point_rsync_uri": publication_point_rsync_uri,
"reduce_ms": metrics.reduce_ms,
"finalize_ms": metrics.finalize_ms,
"finalize_queue_wait_ms": metrics.finalize_queue_wait_ms,
"finalize_worker_ms": metrics.finalize_worker_ms,
"snapshot_pack_ms": metrics.snapshot_pack_ms,
"ccr_projection_build_ms": metrics.ccr_projection_build_ms,
"ccr_append_ms": metrics.ccr_append_ms,
"audit_build_ms": metrics.audit_build_ms,
"locked_files": metrics.locked_files,
"child_count": metrics.child_count,
"warning_count": metrics.warning_count,
"vrp_count": metrics.vrp_count,
"vap_count": metrics.vap_count,
"router_key_count": metrics.router_key_count,
"audit_object_count": metrics.audit_object_count,
}),
);
}

View File

@@ -0,0 +1,564 @@
#[derive(Clone, Debug)]
struct QueuedCaInstance {
id: u64,
handle: CaInstanceHandle,
parent_id: Option<u64>,
discovered_from: Option<DiscoveredFrom>,
}
#[derive(Clone, Debug)]
struct ReadyCaInstance {
node: QueuedCaInstance,
repo_outcome: RepoSyncRuntimeOutcome,
ready_enqueued_at: Instant,
}
struct InflightPublicationPoint {
node: QueuedCaInstance,
fresh_stage: FreshPublicationPointStage,
objects_prepare: ParallelObjectsPrepare,
repo_outcome: RepoSyncRuntimeOutcome,
warnings: Vec<Warning>,
started_at: Instant,
objects_started_at: Instant,
task_count: usize,
tasks_submitted: usize,
first_task_submitted_at: Option<Instant>,
last_task_submitted_at: Option<Instant>,
first_result_at: Option<Instant>,
last_result_at: Option<Instant>,
worker_ms_total: u64,
worker_ms_max: u64,
queue_wait_ms_total: u64,
queue_wait_ms_max: u64,
finalize_enqueued_at: Option<Instant>,
results: Vec<crate::validation::objects::RoaTaskResult>,
}
struct FinishedPublicationPoint {
node: FinishedPublicationPointNode,
result: FinishedPublicationPointResult,
}
#[derive(Clone, Debug)]
struct FinishedPublicationPointNode {
id: u64,
parent_id: Option<u64>,
discovered_from: Option<DiscoveredFrom>,
manifest_rsync_uri: String,
}
impl FinishedPublicationPointNode {
fn from_queued(node: QueuedCaInstance) -> Self {
Self {
id: node.id,
parent_id: node.parent_id,
discovered_from: node.discovered_from,
manifest_rsync_uri: node.handle.manifest_rsync_uri,
}
}
}
#[derive(Debug)]
enum FinishedPublicationPointResult {
Ok {
warnings: Vec<Warning>,
objects: ObjectsOutput,
audit: PublicationPointAudit,
},
Err(String),
}
struct FinalizeTask {
state: InflightPublicationPoint,
}
/// Outcome of the pure compute phase for one ready publication point.
///
/// `compute_ready_publication_point_stage` only performs read-only validation
/// work (repository-view lookup, fresh snapshot staging, ROA prepare)
/// and returns this enum. `apply_ready_publication_point_stage` then performs
/// every write to control-loop state (`ca_queue`/`next_id`, `finished`,
/// `pending_roa_dispatch`, `pending_finalization`, `inflight_publication_points`)
/// in the same per-publication-point order the monolithic staging function did.
/// Each variant payload is boxed so the enum itself stays small.
enum StageOutcome {
/// A repository-view request was already completed during this run and only
/// needs child enqueueing plus a `finished` entry.
/// Fresh staging failed: apply runs the existing blocking
/// `run_publication_point` fallback inline on the control thread.
FreshError(Box<FreshErrorOutcome>),
/// Fresh staging succeeded and ROA prepare returned complete objects (no
/// ROA tasks to dispatch): apply hands the publication point to the
/// finalize worker through `pending_finalization` instead of running the
/// finalize synchronously on the control thread.
Complete(Box<CompleteOutcome>),
/// Fresh staging succeeded with a staged objects plan that contains zero
/// ROA tasks: apply queues the finalize task directly.
ZeroTask(Box<StagedOutcome>),
/// Fresh staging succeeded with ROA tasks to dispatch: apply appends the
/// tasks to `pending_roa_dispatch` and registers the inflight publication
/// point.
Fresh(Box<StagedOutcome>),
}
struct FreshErrorOutcome {
ready: ReadyCaInstance,
publication_point_started: Instant,
}
struct CompleteOutcome {
ready: ReadyCaInstance,
publication_point_started: Instant,
fresh_stage: FreshPublicationPointStage,
warnings: Vec<Warning>,
objects: ObjectsOutput,
}
struct StagedOutcome {
ready: ReadyCaInstance,
publication_point_started: Instant,
fresh_stage: FreshPublicationPointStage,
warnings: Vec<Warning>,
objects_stage: ParallelObjectsStage,
}
struct FinalizeWorkerResult {
finished: FinishedPublicationPoint,
metrics: FinalizePublicationPointMetrics,
}
/// Task submitted to the experimental ready-stage worker pool: everything
/// `compute_ready_publication_point_stage` needs for one ready publication
/// point.
struct ReadyStageTask {
ready: ReadyCaInstance,
ready_queue_len_after_pop: usize,
submitted_at: Instant,
}
/// Result drained from the ready-stage worker pool: the compute outcome and
/// its metrics plus per-task pool timing for the `phase2_stage_pool_stats`
/// observability event.
struct ReadyStageWorkerResult {
outcome: StageOutcome,
metrics: ReadyStageMetrics,
queue_wait_ms: u64,
worker_ms: u64,
}
/// Executor borrowing the publication point runner so stage workers can run
/// the read-only compute phase off the control thread. The runner is shared
/// with the finalize worker and the ROA pool in the same way; the scoped pool
/// guarantees all borrows end before the enclosing `std::thread::scope`.
struct ReadyStageTaskExecutor<'a> {
runner: &'a Rpkiv1PublicationPointRunner<'a>,
}
impl<'a> ObjectTaskExecutor<ReadyStageTask, ReadyStageWorkerResult> for ReadyStageTaskExecutor<'a> {
fn execute(&self, _worker_index: usize, task: ReadyStageTask) -> ReadyStageWorkerResult {
let worker_started = Instant::now();
let queue_wait_ms = worker_started
.saturating_duration_since(task.submitted_at)
.as_millis() as u64;
let (outcome, metrics) = compute_ready_publication_point_stage(
self.runner,
task.ready,
task.ready_queue_len_after_pop,
);
ReadyStageWorkerResult {
outcome,
metrics,
queue_wait_ms,
worker_ms: elapsed_ms(worker_started),
}
}
}
type ReadyStagePool<'scope, 'env> = ScopedObjectWorkerPool<
'scope,
'env,
ReadyStageTask,
ReadyStageWorkerResult,
ReadyStageTaskExecutor<'env>,
>;
#[derive(Default)]
struct StageDispatchMetrics {
submitted: usize,
queue_full: bool,
duration_ms: u64,
}
#[derive(Default)]
struct StageDrainMetrics {
results_drained: usize,
queue_wait_ms_total: u64,
queue_wait_ms_max: u64,
worker_ms_total: u64,
worker_ms_max: u64,
duration_ms: u64,
}
#[derive(Default)]
struct ReadyStageMetrics {
manifest_rsync_uri: Option<String>,
publication_point_rsync_uri: Option<String>,
ready_count: usize,
fallback_count: usize,
complete_count: usize,
staged_count: usize,
zero_task_count: usize,
error_count: usize,
discovered_children: usize,
locked_files: usize,
roa_tasks: usize,
aspa_objects: usize,
stage_fresh_ms: u64,
snapshot_prepare_ms: u64,
snapshot_current_index_lock_ms: u64,
snapshot_manifest_load_ms: u64,
snapshot_manifest_index_lookup_ms: u64,
snapshot_manifest_blob_load_ms: u64,
snapshot_manifest_decode_ms: u64,
snapshot_anti_rollback_ms: u64,
anti_rollback_meta_hit_count: usize,
anti_rollback_meta_miss_count: usize,
snapshot_manifest_entries_ms: u64,
snapshot_pack_files_ms: u64,
snapshot_pack_files_index_lookup_ms: u64,
snapshot_pack_files_blob_load_ms: u64,
snapshot_ee_path_validate_ms: u64,
snapshot_manifest_file_count: usize,
child_discovery_ms: u64,
child_enqueue_ms: u64,
ready_queue_wait_ms: u64,
ready_queue_len_after_pop: usize,
roa_presence_scan_ms: u64,
direct_finalize_ms: u64,
fallback_full_run_ms: u64,
prepare_ms: u64,
build_roa_tasks_ms: u64,
total_ms: u64,
}
#[derive(Default)]
struct ReadyStageBatchMetrics {
ready_count: usize,
fallback_count: usize,
complete_count: usize,
staged_count: usize,
zero_task_count: usize,
error_count: usize,
discovered_children: usize,
locked_files: usize,
roa_tasks: usize,
aspa_objects: usize,
stage_fresh_ms_total: u64,
stage_fresh_ms_max: u64,
stage_fresh_ms_max_manifest_rsync_uri: Option<String>,
stage_fresh_ms_max_publication_point_rsync_uri: Option<String>,
snapshot_prepare_ms_total: u64,
snapshot_prepare_ms_max: u64,
snapshot_current_index_lock_ms_total: u64,
snapshot_current_index_lock_ms_max: u64,
snapshot_manifest_load_ms_total: u64,
snapshot_manifest_load_ms_max: u64,
snapshot_manifest_index_lookup_ms_total: u64,
snapshot_manifest_index_lookup_ms_max: u64,
snapshot_manifest_blob_load_ms_total: u64,
snapshot_manifest_blob_load_ms_max: u64,
snapshot_manifest_decode_ms_total: u64,
snapshot_manifest_decode_ms_max: u64,
snapshot_anti_rollback_ms_total: u64,
snapshot_anti_rollback_ms_max: u64,
anti_rollback_meta_hit_count: usize,
anti_rollback_meta_miss_count: usize,
snapshot_manifest_entries_ms_total: u64,
snapshot_manifest_entries_ms_max: u64,
snapshot_pack_files_ms_total: u64,
snapshot_pack_files_ms_max: u64,
snapshot_pack_files_index_lookup_ms_total: u64,
snapshot_pack_files_index_lookup_ms_max: u64,
snapshot_pack_files_blob_load_ms_total: u64,
snapshot_pack_files_blob_load_ms_max: u64,
snapshot_ee_path_validate_ms_total: u64,
snapshot_ee_path_validate_ms_max: u64,
snapshot_manifest_file_count_total: usize,
snapshot_manifest_file_count_max: usize,
child_discovery_ms_total: u64,
child_discovery_ms_max: u64,
child_enqueue_ms_total: u64,
child_enqueue_ms_max: u64,
ready_queue_wait_ms_total: u64,
ready_queue_wait_ms_max: u64,
roa_presence_scan_ms_total: u64,
roa_presence_scan_ms_max: u64,
direct_finalize_ms_total: u64,
direct_finalize_ms_max: u64,
fallback_full_run_ms_total: u64,
fallback_full_run_ms_max: u64,
prepare_ms_total: u64,
prepare_ms_max: u64,
build_roa_tasks_ms_total: u64,
build_roa_tasks_ms_max: u64,
total_ms: u64,
}
impl ReadyStageBatchMetrics {
fn record(&mut self, metrics: ReadyStageMetrics) {
self.ready_count += metrics.ready_count;
self.fallback_count += metrics.fallback_count;
self.complete_count += metrics.complete_count;
self.staged_count += metrics.staged_count;
self.zero_task_count += metrics.zero_task_count;
self.error_count += metrics.error_count;
self.discovered_children += metrics.discovered_children;
self.locked_files += metrics.locked_files;
self.roa_tasks += metrics.roa_tasks;
self.aspa_objects += metrics.aspa_objects;
if metrics.stage_fresh_ms >= self.stage_fresh_ms_max {
self.stage_fresh_ms_max_manifest_rsync_uri = metrics.manifest_rsync_uri.clone();
self.stage_fresh_ms_max_publication_point_rsync_uri =
metrics.publication_point_rsync_uri.clone();
}
self.stage_fresh_ms_total += metrics.stage_fresh_ms;
self.stage_fresh_ms_max = self.stage_fresh_ms_max.max(metrics.stage_fresh_ms);
self.snapshot_prepare_ms_total += metrics.snapshot_prepare_ms;
self.snapshot_prepare_ms_max = self
.snapshot_prepare_ms_max
.max(metrics.snapshot_prepare_ms);
self.snapshot_current_index_lock_ms_total += metrics.snapshot_current_index_lock_ms;
self.snapshot_current_index_lock_ms_max = self
.snapshot_current_index_lock_ms_max
.max(metrics.snapshot_current_index_lock_ms);
self.snapshot_manifest_load_ms_total += metrics.snapshot_manifest_load_ms;
self.snapshot_manifest_load_ms_max = self
.snapshot_manifest_load_ms_max
.max(metrics.snapshot_manifest_load_ms);
self.snapshot_manifest_index_lookup_ms_total += metrics.snapshot_manifest_index_lookup_ms;
self.snapshot_manifest_index_lookup_ms_max = self
.snapshot_manifest_index_lookup_ms_max
.max(metrics.snapshot_manifest_index_lookup_ms);
self.snapshot_manifest_blob_load_ms_total += metrics.snapshot_manifest_blob_load_ms;
self.snapshot_manifest_blob_load_ms_max = self
.snapshot_manifest_blob_load_ms_max
.max(metrics.snapshot_manifest_blob_load_ms);
self.snapshot_manifest_decode_ms_total += metrics.snapshot_manifest_decode_ms;
self.snapshot_manifest_decode_ms_max = self
.snapshot_manifest_decode_ms_max
.max(metrics.snapshot_manifest_decode_ms);
self.snapshot_anti_rollback_ms_total += metrics.snapshot_anti_rollback_ms;
self.snapshot_anti_rollback_ms_max = self
.snapshot_anti_rollback_ms_max
.max(metrics.snapshot_anti_rollback_ms);
self.anti_rollback_meta_hit_count += metrics.anti_rollback_meta_hit_count;
self.anti_rollback_meta_miss_count += metrics.anti_rollback_meta_miss_count;
self.snapshot_manifest_entries_ms_total += metrics.snapshot_manifest_entries_ms;
self.snapshot_manifest_entries_ms_max = self
.snapshot_manifest_entries_ms_max
.max(metrics.snapshot_manifest_entries_ms);
self.snapshot_pack_files_ms_total += metrics.snapshot_pack_files_ms;
self.snapshot_pack_files_ms_max = self
.snapshot_pack_files_ms_max
.max(metrics.snapshot_pack_files_ms);
self.snapshot_pack_files_index_lookup_ms_total +=
metrics.snapshot_pack_files_index_lookup_ms;
self.snapshot_pack_files_index_lookup_ms_max = self
.snapshot_pack_files_index_lookup_ms_max
.max(metrics.snapshot_pack_files_index_lookup_ms);
self.snapshot_pack_files_blob_load_ms_total += metrics.snapshot_pack_files_blob_load_ms;
self.snapshot_pack_files_blob_load_ms_max = self
.snapshot_pack_files_blob_load_ms_max
.max(metrics.snapshot_pack_files_blob_load_ms);
self.snapshot_ee_path_validate_ms_total += metrics.snapshot_ee_path_validate_ms;
self.snapshot_ee_path_validate_ms_max = self
.snapshot_ee_path_validate_ms_max
.max(metrics.snapshot_ee_path_validate_ms);
self.snapshot_manifest_file_count_total += metrics.snapshot_manifest_file_count;
self.snapshot_manifest_file_count_max = self
.snapshot_manifest_file_count_max
.max(metrics.snapshot_manifest_file_count);
self.child_discovery_ms_total += metrics.child_discovery_ms;
self.child_discovery_ms_max = self.child_discovery_ms_max.max(metrics.child_discovery_ms);
self.child_enqueue_ms_total += metrics.child_enqueue_ms;
self.child_enqueue_ms_max = self.child_enqueue_ms_max.max(metrics.child_enqueue_ms);
self.ready_queue_wait_ms_total += metrics.ready_queue_wait_ms;
self.ready_queue_wait_ms_max = self
.ready_queue_wait_ms_max
.max(metrics.ready_queue_wait_ms);
self.roa_presence_scan_ms_total += metrics.roa_presence_scan_ms;
self.roa_presence_scan_ms_max = self
.roa_presence_scan_ms_max
.max(metrics.roa_presence_scan_ms);
self.direct_finalize_ms_total += metrics.direct_finalize_ms;
self.direct_finalize_ms_max = self.direct_finalize_ms_max.max(metrics.direct_finalize_ms);
self.fallback_full_run_ms_total += metrics.fallback_full_run_ms;
self.fallback_full_run_ms_max = self
.fallback_full_run_ms_max
.max(metrics.fallback_full_run_ms);
self.prepare_ms_total += metrics.prepare_ms;
self.prepare_ms_max = self.prepare_ms_max.max(metrics.prepare_ms);
self.build_roa_tasks_ms_total += metrics.build_roa_tasks_ms;
self.build_roa_tasks_ms_max = self.build_roa_tasks_ms_max.max(metrics.build_roa_tasks_ms);
self.total_ms += metrics.total_ms;
}
}
#[derive(Default)]
struct RoaDispatchMetrics {
attempted: usize,
submitted: usize,
queue_full: bool,
pending_remaining: usize,
duration_ms: u64,
}
#[derive(Default)]
struct ObjectDrainMetrics {
results_drained: usize,
publication_points_completed: usize,
worker_ms_total: u64,
worker_ms_max: u64,
queue_wait_ms_total: u64,
queue_wait_ms_max: u64,
result_budget_exhausted: bool,
duration_ms: u64,
}
#[derive(Default)]
struct FinalizeSubmitMetrics {
submitted: usize,
queue_full: bool,
duration_ms: u64,
}
#[derive(Default)]
struct FinalizePublicationPointMetrics {
reduce_ms: u64,
finalize_ms: u64,
finalize_queue_wait_ms: Option<u64>,
finalize_worker_ms: u64,
snapshot_pack_ms: u64,
ccr_projection_build_ms: u64,
ccr_append_ms: u64,
audit_build_ms: u64,
locked_files: usize,
child_count: usize,
warning_count: usize,
vrp_count: usize,
vap_count: usize,
router_key_count: usize,
audit_object_count: usize,
}
#[derive(Default)]
struct FinalizeResultsDrainMetrics {
results_drained: usize,
reduce_ms_total: u64,
reduce_ms_max: u64,
finalize_ms_total: u64,
finalize_ms_max: u64,
finalize_queue_wait_ms_max: u64,
finalize_worker_ms_total: u64,
finalize_worker_ms_max: u64,
snapshot_pack_ms_total: u64,
snapshot_pack_ms_max: u64,
ccr_projection_build_ms_total: u64,
ccr_projection_build_ms_max: u64,
ccr_append_ms_total: u64,
ccr_append_ms_max: u64,
audit_build_ms_total: u64,
audit_build_ms_max: u64,
duration_ms: u64,
}
#[derive(Default)]
struct RepoDrainMetrics {
event_count: usize,
completions: usize,
ready_enqueued: usize,
duration_ms: u64,
}
const REPO_RESULT_DRAIN_MAX_EVENTS: usize = 64;
fn elapsed_ms(started: Instant) -> u64 {
started.elapsed().as_millis() as u64
}
fn emit_control_loop_slow(
duration_ms: u64,
repo_poll_timeout: Duration,
repo_metrics: &RepoDrainMetrics,
ready_batch_metrics: &ReadyStageBatchMetrics,
ca_queue_len: usize,
ready_queue_len: usize,
ca_waiting_repo_identities: usize,
pending_roa_dispatch_len: usize,
inflight_publication_points_len: usize,
pending_finalization_len: usize,
finalize_inflight: usize,
) {
let threshold_ms = crate::logging::progress::control_loop_slow_threshold_ms();
if duration_ms < threshold_ms {
return;
}
crate::logging::progress::emit!(
"phase2_control_loop_slow",
serde_json::json!({
"duration_ms": duration_ms,
"slow_threshold_ms": threshold_ms,
"repo_poll_timeout_ms": repo_poll_timeout.as_millis() as u64,
"repo_event_count": repo_metrics.event_count,
"repo_completions": repo_metrics.completions,
"repo_ready_enqueued": repo_metrics.ready_enqueued,
"repo_drain_duration_ms": repo_metrics.duration_ms,
"ready_count": ready_batch_metrics.ready_count,
"ready_batch_duration_ms": ready_batch_metrics.total_ms,
"ready_batch_stage_fresh_ms_total": ready_batch_metrics.stage_fresh_ms_total,
"ready_batch_stage_fresh_ms_max": ready_batch_metrics.stage_fresh_ms_max,
"ready_batch_stage_fresh_ms_max_manifest_rsync_uri": ready_batch_metrics.stage_fresh_ms_max_manifest_rsync_uri,
"ready_batch_child_discovery_ms_total": ready_batch_metrics.child_discovery_ms_total,
"ready_batch_child_discovery_ms_max": ready_batch_metrics.child_discovery_ms_max,
"ready_batch_prepare_ms_total": ready_batch_metrics.prepare_ms_total,
"ready_batch_prepare_ms_max": ready_batch_metrics.prepare_ms_max,
"ready_batch_direct_finalize_ms_total": ready_batch_metrics.direct_finalize_ms_total,
"ready_batch_direct_finalize_ms_max": ready_batch_metrics.direct_finalize_ms_max,
"ca_queue_len": ca_queue_len,
"ready_queue_len": ready_queue_len,
"ca_waiting_repo_identities": ca_waiting_repo_identities,
"pending_roa_dispatch_len": pending_roa_dispatch_len,
"inflight_publication_points_len": inflight_publication_points_len,
"pending_finalization_len": pending_finalization_len,
"finalize_inflight": finalize_inflight,
}),
);
}
fn compact_phase2_finished_result(
mut result: PublicationPointRunResult,
compact_audit: bool,
) -> FinishedPublicationPointResult {
result.objects.audit.clear();
if compact_audit {
result.audit.objects.clear();
result.audit.warnings.clear();
}
FinishedPublicationPointResult::Ok {
warnings: result.warnings,
objects: result.objects,
audit: result.audit,
}
}
fn compact_phase2_finished_result_result(
result: Result<PublicationPointRunResult, String>,
compact_audit: bool,
) -> FinishedPublicationPointResult {
match result {
Ok(result) => compact_phase2_finished_result(result, compact_audit),
Err(err) => FinishedPublicationPointResult::Err(err),
}
}

View File

@@ -0,0 +1,68 @@
#![allow(
clippy::large_enum_variant,
clippy::too_many_arguments,
clippy::type_complexity
)]
mod labels;
use crate::ccr::CcrAccumulator;
use crate::model::rc::ResourceCertificate;
use crate::model::router_cert::{
BgpsecRouterCertificate, BgpsecRouterCertificateDecodeError, BgpsecRouterCertificatePathError,
BgpsecRouterCertificateProfileError,
};
use crate::output::analysis::timing::TimingHandle;
use crate::output::audit::{
AuditObjectKind, AuditObjectResult, AuditWarning, DiscoveredFrom, ObjectAuditEntry,
PublicationPointAudit, sha256_hex, sha256_hex_from_32,
};
use crate::output::audit_downloads::DownloadLogHandle;
use crate::output::report::{RfcRef, Warning};
use crate::repository::current_repo_index::CurrentRepoIndexHandle;
use crate::repository::fetch::rsync::RsyncFetcher;
use crate::repository::storage::{CcrManifestProjection, PackFile, PackTime, RocksStore};
use crate::repository::sync::repo::sync_publication_point;
use crate::repository::sync::rrdp::Fetcher;
use crate::scheduler::config::ParallelPhase2Config;
use crate::scheduler::repo_runtime::RepoSyncRuntime;
use crate::validation::ca_instance::ca_instance_uris_from_ca_certificate;
use crate::validation::ca_path::{
CaPathError, IssuerEffectiveResourcesIndex, ValidatedSubordinateCaLite,
validate_subordinate_ca_cert_with_prevalidated_issuer_and_resources,
};
use crate::validation::manifest::{
FreshPublicationPointTimingBreakdown, FreshValidatedPublicationPoint, ManifestFreshError,
PublicationPointData, PublicationPointSource,
process_manifest_publication_point_fresh_after_repo_sync_with_timing,
};
use crate::validation::objects::{
ParallelRoaWorkerPool, RouterKeyPayload, process_publication_point_for_issuer_parallel_roa,
process_publication_point_for_issuer_parallel_roa_with_pool,
};
use crate::validation::policy::{Policy, ResourceValidationMode};
use crate::validation::publication_point::PublicationPointSnapshot;
use crate::validation::tree::{
CaCertificateRef, CaInstanceHandle, DiscoveredChildCaInstance, DiscoveredChildEntryProjection,
PublicationPointRunResult, PublicationPointRunner,
};
use labels::{
effective_repo_sync_duration_ms, kind_from_rsync_uri, repo_sync_failure_phase_label,
repo_sync_phase_label, repo_sync_source_label, source_label, terminal_state_label,
};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use x509_parser::prelude::FromDer;
use x509_parser::x509::SubjectPublicKeyInfo;
include!("tree_runner/types.rs");
include!("tree_runner/timing.rs");
include!("tree_runner/fresh_pipeline.rs");
include!("tree_runner/publication_point_runner.rs");
include!("tree_runner/ephemeral_state.rs");
include!("tree_runner/discovery.rs");
include!("tree_runner/child_validation.rs");
include!("tree_runner/audit_projection.rs");

View File

@@ -0,0 +1,164 @@
fn build_publication_point_audit_from_snapshot(
ca: &CaInstanceHandle,
source: PublicationPointSource,
repo_sync_source: Option<&str>,
repo_sync_phase: Option<&str>,
repo_sync_duration_ms: Option<u64>,
repo_sync_error: Option<&str>,
pack: &PublicationPointSnapshot,
runner_warnings: &[Warning],
objects: &crate::validation::objects::ObjectsOutput,
child_audits: &[ObjectAuditEntry],
) -> PublicationPointAudit {
use crate::model::crl::RpkixCrl;
use std::collections::HashMap;
let mut audit_by_uri: HashMap<String, ObjectAuditEntry> = pack
.files
.iter()
.map(|file| {
(
file.rsync_uri.clone(),
ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: kind_from_rsync_uri(&file.rsync_uri),
result: AuditObjectResult::Skipped,
detail: Some("skipped: not processed in this run".to_string()),
},
)
})
.collect();
for file in &pack.files {
if !file.rsync_uri.ends_with(".crl") {
continue;
}
let decoded = file
.bytes()
.ok()
.and_then(|bytes| RpkixCrl::decode_der(bytes).ok())
.is_some();
audit_by_uri.insert(
file.rsync_uri.clone(),
ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Crl,
result: if decoded {
AuditObjectResult::Ok
} else {
AuditObjectResult::Error
},
detail: if decoded {
None
} else {
Some("CRL decode failed".to_string())
},
},
);
}
for entry in child_audits {
audit_by_uri.insert(entry.rsync_uri.clone(), entry.clone());
}
for entry in &objects.audit {
audit_by_uri.insert(entry.rsync_uri.clone(), entry.clone());
}
let mut objects_out = Vec::with_capacity(pack.files.len() + 1);
objects_out.push(ObjectAuditEntry {
rsync_uri: pack.manifest_rsync_uri.clone(),
sha256_hex: sha256_hex(&pack.manifest_bytes),
kind: AuditObjectKind::Manifest,
result: AuditObjectResult::Ok,
detail: None,
});
for file in &pack.files {
objects_out.push(audit_by_uri.remove(&file.rsync_uri).unwrap_or_else(|| {
ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: kind_from_rsync_uri(&file.rsync_uri),
result: AuditObjectResult::Skipped,
detail: Some("skipped: no audit entry".to_string()),
}
}));
}
let mut warnings = runner_warnings.iter().map(AuditWarning::from).collect::<Vec<_>>();
warnings.extend(objects.warnings.iter().map(AuditWarning::from));
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: 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_failed_fetch(
ca: &CaInstanceHandle,
repo_sync_source: Option<&str>,
repo_sync_phase: Option<&str>,
repo_sync_duration_ms: Option<u64>,
repo_sync_error: Option<&str>,
runner_warnings: &[Warning],
fresh_error: &ManifestFreshError,
) -> PublicationPointAudit {
let mut warnings = runner_warnings.iter().map(AuditWarning::from).collect::<Vec<_>>();
warnings.push(AuditWarning::from(
&Warning::new(fresh_error.to_string()).with_context(&ca.manifest_rsync_uri),
));
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(PublicationPointSource::FailedFetchNoCache),
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(PublicationPointSource::FailedFetchNoCache)
.to_string(),
this_update_rfc3339_utc: String::new(),
next_update_rfc3339_utc: String::new(),
verified_at_rfc3339_utc: String::new(),
warnings,
objects: vec![ObjectAuditEntry {
rsync_uri: ca.manifest_rsync_uri.clone(),
sha256_hex: String::new(),
kind: AuditObjectKind::Manifest,
result: AuditObjectResult::Error,
detail: Some(fresh_error.to_string()),
}],
}
}
fn empty_objects_output() -> crate::validation::objects::ObjectsOutput {
crate::validation::objects::ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
warnings: Vec::new(),
stats: crate::validation::objects::ObjectsStats::default(),
audit: Vec::new(),
}
}

View File

@@ -0,0 +1,43 @@
fn is_non_router_certificate(err: &BgpsecRouterCertificatePathError) -> bool {
matches!(
err,
BgpsecRouterCertificatePathError::Decode(BgpsecRouterCertificateDecodeError::Validate(
BgpsecRouterCertificateProfileError::NotEe
| BgpsecRouterCertificateProfileError::MissingExtendedKeyUsage
| BgpsecRouterCertificateProfileError::MissingBgpsecRouterEku
))
)
}
fn validate_subordinate_ca_cert_with_issuer(
child_ca_der: &[u8],
child_ca: crate::model::rc::ResourceCertificate,
issuer_ca_der: &[u8],
issuer_ca: &crate::model::rc::ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_crl_uri: &str,
crl_states: &mut HashMap<String, IssuerCrlState>,
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
issuer_resources_index: &IssuerEffectiveResourcesIndex,
validation_time: time::OffsetDateTime,
resource_validation_mode: ResourceValidationMode,
) -> Result<ValidatedSubordinateCaLite, CaPathError> {
let verified_crl = ensure_issuer_crl_verified(issuer_crl_uri, crl_states, issuer_ca_der)?;
validate_subordinate_ca_cert_with_prevalidated_issuer_and_resources(
child_ca_der,
child_ca,
issuer_ca,
issuer_spki,
&verified_crl.crl,
&verified_crl.revoked_serials,
issuer_ca_rsync_uri,
issuer_crl_uri,
issuer_effective_ip,
issuer_effective_as,
issuer_resources_index,
validation_time,
resource_validation_mode,
)
}

View File

@@ -0,0 +1,329 @@
// Fresh child-certificate and router-certificate validation for one
// publication-point snapshot. The CRL map below is deliberately scoped to
// this call: it only avoids decoding the same CRL twice within one snapshot.
fn discover_children_from_fresh_snapshot_with_audit_with_issuer_der<P: PublicationPointData>(
issuer: &CaInstanceHandle,
issuer_ca_der: &[u8],
publication_point: &P,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
policy: &Policy,
) -> Result<ChildDiscoveryOutput, String> {
let locked_files = publication_point.files();
let issuer_ca = match crate::model::rc::ResourceCertificate::decode_der(issuer_ca_der) {
Ok(ca) => match ca.validate_rfc6487_profile(crate::model::rc::ResourceCertificateRole::Ca) {
Ok(()) => Some(ca),
Err(error) => {
crate::logging::emit(crate::logging::Level::Debug, "issuer_ca_profile_error", || serde_json::json!({"error": error.to_string()}));
None
}
},
Err(error) => {
crate::logging::emit(crate::logging::Level::Debug, "issuer_ca_decode_error", || serde_json::json!({"error": error.to_string()}));
None
}
};
let issuer_spki = issuer_ca
.as_ref()
.and_then(|ca| SubjectPublicKeyInfo::from_der(&ca.tbs.subject_public_key_info).ok())
.and_then(|(remaining, spki)| remaining.is_empty().then_some(spki));
let mut crl_states: HashMap<String, IssuerCrlState> = locked_files
.iter()
.filter(|file| file.rsync_uri.ends_with(".crl"))
.map(|file| {
let bytes = file
.bytes_cloned()
.map_err(|error| format!("snapshot CRL bytes load failed: {error}"))?;
Ok((
file.rsync_uri.clone(),
IssuerCrlState::Pending { bytes },
))
})
.collect::<Result<_, String>>()?;
let issuer_resources_index = IssuerEffectiveResourcesIndex::from_effective_resources(
issuer.effective_ip_resources.as_ref(),
issuer.effective_as_resources.as_ref(),
)
.map_err(|error| format!("build issuer effective resources index failed: {error}"))?;
let mut children = Vec::new();
let mut audits = Vec::new();
let mut router_keys = Vec::new();
let mut ca_ok = 0u64;
let mut ca_error = 0u64;
let mut router_ok = 0u64;
let mut router_error = 0u64;
let mut uri_errors = 0u64;
let mut decode_nanos = 0u64;
let mut validate_nanos = 0u64;
let mut uri_nanos = 0u64;
let mut child_load_nanos = 0u64;
let mut child_load_count = 0u64;
for file in locked_files.iter().filter(|file| file.rsync_uri.ends_with(".cer")) {
let child_der = load_child_certificate_der_for_discovery(
file,
&mut child_load_nanos,
&mut child_load_count,
)?;
let decode_started = std::time::Instant::now();
let child_cert = match crate::model::rc::ResourceCertificate::decode_der(child_der) {
Ok(value) => value,
Err(error) => {
ca_error = ca_error.saturating_add(1);
audits.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Certificate,
result: AuditObjectResult::Error,
detail: Some(format!("child certificate decode failed: {error}")),
});
continue;
}
};
decode_nanos = decode_nanos.saturating_add(
decode_started
.elapsed()
.as_nanos()
.min(u128::from(u64::MAX)) as u64,
);
let issuer_crl_uri = match select_issuer_crl_uri_for_child(&child_cert, &crl_states) {
Ok(uri) => uri.to_string(),
Err(error) => {
ca_error = ca_error.saturating_add(1);
audits.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Certificate,
result: AuditObjectResult::Error,
detail: Some(format!("cannot select issuer CRL: {error}")),
});
continue;
}
};
let (Some(issuer_ca), Some(issuer_spki)) = (issuer_ca.as_ref(), issuer_spki.as_ref()) else {
ca_error = ca_error.saturating_add(1);
audits.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Certificate,
result: AuditObjectResult::Error,
detail: Some("issuer CA or SubjectPublicKeyInfo validation failed".to_string()),
});
continue;
};
let validate_started = std::time::Instant::now();
let validated = match validate_subordinate_ca_cert_with_issuer(
child_der,
child_cert,
issuer_ca_der,
issuer_ca,
issuer_spki,
&issuer_crl_uri,
&mut crl_states,
issuer.ca_certificate_rsync_uri.as_deref(),
issuer.effective_ip_resources.as_ref(),
issuer.effective_as_resources.as_ref(),
&issuer_resources_index,
validation_time,
policy.resource_validation_mode,
) {
Ok(value) => value,
Err(CaPathError::ChildNotCa) => {
let router_result = match ensure_issuer_crl_verified(
&issuer_crl_uri,
&mut crl_states,
issuer_ca_der,
) {
Ok(verified_crl) => BgpsecRouterCertificate::validate_path_with_prevalidated_issuer(
child_der,
issuer_ca,
issuer_spki,
&verified_crl.crl,
&verified_crl.revoked_serials,
issuer.ca_certificate_rsync_uri.as_deref(),
Some(issuer_crl_uri.as_str()),
validation_time,
),
Err(error) => {
router_error = router_error.saturating_add(1);
audits.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::RouterCertificate,
result: AuditObjectResult::Error,
detail: Some(format!("router certificate issuer CRL validation failed: {error}")),
});
continue;
}
};
match router_result {
Ok(router) => {
if let Some(constraints) = policy.ta_constraints.for_tal(&issuer.tal_id)
&& let Err(error) = constraints.validate_ee_certificate(&router.resource_cert) {
router_error = router_error.saturating_add(1);
audits.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::RouterCertificate,
result: AuditObjectResult::Error,
detail: Some(format!("router certificate violates TA constraints: {error}")),
});
continue;
}
let asns = match router_asns_for_resource_mode(
&router.asns,
issuer.effective_as_resources.as_ref(),
policy.resource_validation_mode,
) {
Ok(asns) => asns,
Err(error) => {
router_error = router_error.saturating_add(1);
audits.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::RouterCertificate,
result: AuditObjectResult::Error,
detail: Some(error),
});
continue;
}
};
let source_hash = sha256_hex_from_32(&file.sha256);
let until = PackTime::from_utc_offset_datetime(router.resource_cert.tbs.validity_not_after);
for as_id in asns {
router_keys.push(RouterKeyPayload {
as_id,
ski: router.subject_key_identifier.clone(),
spki_der: router.spki_der.clone(),
source_object_uri: file.rsync_uri.clone(),
source_object_hash: source_hash.clone(),
source_ee_cert_hash: source_hash.clone(),
item_effective_until: until.clone(),
});
}
router_ok = router_ok.saturating_add(1);
audits.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: source_hash,
kind: AuditObjectKind::RouterCertificate,
result: AuditObjectResult::Ok,
detail: Some("validated BGPsec router certificate (RFC 8209)".to_string()),
});
}
Err(error) if is_non_router_certificate(&error) => {
audits.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Certificate,
result: AuditObjectResult::Skipped,
detail: Some("certificate is neither a CA nor a BGPsec router certificate".to_string()),
});
}
Err(error) => {
router_error = router_error.saturating_add(1);
audits.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::RouterCertificate,
result: AuditObjectResult::Error,
detail: Some(format!("router certificate validation failed: {error}")),
});
}
}
continue;
}
Err(error) => {
ca_error = ca_error.saturating_add(1);
audits.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Certificate,
result: AuditObjectResult::Error,
detail: Some(format!("child CA validation failed: {error}")),
});
continue;
}
};
validate_nanos = validate_nanos.saturating_add(
validate_started
.elapsed()
.as_nanos()
.min(u128::from(u64::MAX)) as u64,
);
let uri_started = std::time::Instant::now();
let uris = match ca_instance_uris_from_ca_certificate(&validated.child_ca) {
Ok(uris) => uris,
Err(error) => {
uri_errors = uri_errors.saturating_add(1);
audits.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Certificate,
result: AuditObjectResult::Error,
detail: Some(format!("CA instance URI discovery failed: {error}")),
});
continue;
}
};
uri_nanos = uri_nanos.saturating_add(
uri_started
.elapsed()
.as_nanos()
.min(u128::from(u64::MAX)) as u64,
);
let child_ski = validated.child_ca.tbs.extensions.subject_key_identifier.clone();
let child_hash = sha256_hex_from_32(&file.sha256);
children.push(DiscoveredChildCaInstance {
handle: CaInstanceHandle {
depth: 0,
tal_id: issuer.tal_id.clone(),
parent_manifest_rsync_uri: Some(issuer.manifest_rsync_uri.clone()),
ca_certificate: CaCertificateRef::inline_der(child_der.to_vec()),
ca_certificate_rsync_uri: Some(file.rsync_uri.clone()),
effective_ip_resources: validated.effective_ip_resources.clone(),
effective_as_resources: validated.effective_as_resources.clone(),
rsync_base_uri: uris.rsync_base_uri.clone(),
manifest_rsync_uri: uris.manifest_rsync_uri.clone(),
publication_point_rsync_uri: uris.publication_point_rsync_uri.clone(),
rrdp_notification_uri: uris.rrdp_notification_uri.clone(),
},
discovered_from: DiscoveredFrom {
parent_manifest_rsync_uri: issuer.manifest_rsync_uri.clone(),
child_ca_certificate_rsync_uri: file.rsync_uri.clone(),
child_ca_certificate_sha256_hex: child_hash.clone(),
},
child_entry_projection: child_ski.as_ref().map(|ski| DiscoveredChildEntryProjection {
child_ski: hex::encode(ski),
}),
});
ca_ok = ca_ok.saturating_add(1);
audits.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: child_hash,
kind: AuditObjectKind::Certificate,
result: AuditObjectResult::Ok,
detail: Some("validated subordinate CA certificate; enqueued CA instance".to_string()),
});
}
if let Some(timing) = timing {
timing.record_count("child_cer_seen", locked_files.iter().filter(|file| file.rsync_uri.ends_with(".cer")).count() as u64);
timing.record_count("child_ca_ok", ca_ok);
timing.record_count("child_ca_error", ca_error);
timing.record_count("child_router_ok", router_ok);
timing.record_count("child_router_error", router_error);
timing.record_count("child_uri_discovery_error", uri_errors);
timing.record_phase_nanos("child_decode_certificate_total", decode_nanos);
timing.record_phase_nanos("child_validate_subordinate_total", validate_nanos);
timing.record_phase_nanos("child_ca_instance_uri_discovery_total", uri_nanos);
timing.record_phase_nanos("child_certificate_der_load_total", child_load_nanos);
timing.record_count("child_certificate_der_load_count", child_load_count);
}
Ok(ChildDiscoveryOutput { children, audits, router_keys })
}

View File

@@ -0,0 +1,152 @@
// Per-publication-point validation state.
//
// This module intentionally contains only ephemeral state needed while a
// snapshot is being validated. Nothing here is serialized or reused across
// runs; in particular, it is not a validation cache.
struct ChildDiscoveryOutput {
children: Vec<DiscoveredChildCaInstance>,
audits: Vec<ObjectAuditEntry>,
router_keys: Vec<RouterKeyPayload>,
}
#[derive(Clone, Debug)]
struct VerifiedIssuerCrl {
crl: crate::model::crl::RpkixCrl,
revoked_serials: std::collections::HashSet<Vec<u8>>,
}
#[derive(Clone, Debug)]
enum IssuerCrlState {
Pending { bytes: Vec<u8> },
Verified(VerifiedIssuerCrl),
}
fn load_child_certificate_der_for_discovery<'a>(
file: &'a PackFile,
elapsed_nanos: &mut u64,
count: &mut u64,
) -> Result<&'a [u8], String> {
let started = std::time::Instant::now();
let bytes = file
.bytes()
.map_err(|e| format!("child certificate bytes load failed: {e}"))?;
*elapsed_nanos = elapsed_nanos.saturating_add(
started
.elapsed()
.as_nanos()
.min(u128::from(u64::MAX)) as u64,
);
*count = count.saturating_add(1);
Ok(bytes)
}
fn ca_certificate_der_for_validation<'a>(
ca: &'a CaInstanceHandle,
store: &RocksStore,
timing: Option<&TimingHandle>,
) -> Result<std::borrow::Cow<'a, [u8]>, String> {
let started = std::time::Instant::now();
let was_lazy = ca.ca_certificate_sha256_hex().is_some();
let der = ca.ca_certificate_der(store)?;
if was_lazy {
let elapsed = started
.elapsed()
.as_nanos()
.min(u128::from(u64::MAX)) as u64;
if let Some(timing) = timing {
timing.record_count("ca_certificate_lazy_load_count", 1);
timing.record_count("ca_certificate_lazy_load_bytes", der.len() as u64);
timing.record_phase_nanos("ca_certificate_lazy_load_total", elapsed);
}
}
Ok(der)
}
fn select_issuer_crl_uri_for_child<'a>(
child: &'a crate::model::rc::ResourceCertificate,
crl_states: &HashMap<String, IssuerCrlState>,
) -> Result<&'a str, String> {
if crl_states.is_empty() {
return Err(
"no CRL available in publication point snapshot (cannot validate certificates) (RFC 9286 §7; RFC 6487 §4.8.6)"
.to_string(),
);
}
let Some(crldp_uris) = child.tbs.extensions.crl_distribution_points_uris.as_ref() else {
return Err(
"child certificate CRLDistributionPoints missing (RFC 6487 §4.8.6)".to_string(),
);
};
for uri in crldp_uris {
if crl_states.contains_key(uri.as_str()) {
return Ok(uri.as_str());
}
}
Err(format!(
"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(|uri| uri.as_str()).collect::<Vec<_>>().join(", ")
))
}
fn ensure_issuer_crl_verified<'a>(
crl_uri: &str,
crl_states: &'a mut HashMap<String, IssuerCrlState>,
issuer_ca_der: &[u8],
) -> Result<&'a VerifiedIssuerCrl, CaPathError> {
let state = crl_states
.get_mut(crl_uri)
.expect("selected CRL must be present in snapshot");
match state {
IssuerCrlState::Verified(verified) => Ok(verified),
IssuerCrlState::Pending { bytes } => {
let der = std::mem::take(bytes);
let crl = crate::model::crl::RpkixCrl::decode_der(&der)?;
crl.verify_signature_with_issuer_certificate_der(issuer_ca_der)?;
let mut revoked_serials = std::collections::HashSet::with_capacity(crl.revoked_certs.len());
for revoked in &crl.revoked_certs {
revoked_serials.insert(revoked.serial_number.bytes_be.clone());
}
*state = IssuerCrlState::Verified(VerifiedIssuerCrl {
crl,
revoked_serials,
});
match state {
IssuerCrlState::Verified(verified) => Ok(verified),
IssuerCrlState::Pending { .. } => unreachable!(),
}
}
}
}
fn router_asns_for_resource_mode(
router_asns: &[u32],
issuer_effective_as: Option<&crate::model::rc::AsResourceSet>,
mode: ResourceValidationMode,
) -> Result<Vec<u32>, String> {
let Some(resources) = issuer_effective_as else {
return Err("issuer has no effective AS resources".to_string());
};
let contains = |asn: u32| {
resources.asnum.as_ref().is_some_and(|choice| match choice {
crate::model::rc::AsIdentifierChoice::Inherit => false,
crate::model::rc::AsIdentifierChoice::AsIdsOrRanges(items) => items.iter().any(|item| match item {
crate::model::rc::AsIdOrRange::Id(id) => *id == asn,
crate::model::rc::AsIdOrRange::Range { min, max } => *min <= asn && asn <= *max,
}),
})
};
match mode {
ResourceValidationMode::Rfc6487 => {
let outside = router_asns.iter().copied().filter(|asn| !contains(*asn)).collect::<Vec<_>>();
if outside.is_empty() { Ok(router_asns.to_vec()) } else {
Err(format!("router AS resources are not a subset of issuer effective AS resources: {outside:?}"))
}
}
ResourceValidationMode::ValidationUpdate03 => {
let filtered = router_asns.iter().copied().filter(|asn| contains(*asn)).collect::<Vec<_>>();
if filtered.is_empty() { Err("router AS resources have empty validated resource set".to_string()) } else { Ok(filtered) }
}
}
}

View File

@@ -0,0 +1,324 @@
impl<'a> Rpkiv1PublicationPointRunner<'a> {
pub(crate) fn ccr_accumulator_snapshot(&self) -> Option<CcrAccumulator> {
self.ccr_accumulator
.as_ref()
.and_then(|accumulator| accumulator.lock().ok().map(|guard| guard.clone()))
}
pub(crate) fn append_ccr_manifest_projection(
&self,
projection: &CcrManifestProjection,
) -> Result<(), String> {
if let Some(accumulator) = self.ccr_accumulator.as_ref() {
accumulator
.lock()
.map_err(|_| "lock CCR accumulator failed".to_string())?
.append_manifest_projection(projection)?;
}
Ok(())
}
pub(crate) fn stage_fresh_publication_point_after_repo_ready(
&self,
ca: &CaInstanceHandle,
repo_sync_ok: bool,
repo_sync_err: Option<&str>,
) -> Result<FreshPublicationPointStage, FreshPublicationPointStageError> {
let snapshot_prepare_started = std::time::Instant::now();
let issuer_ca_der = ca_certificate_der_for_validation(ca, self.store, self.timing.as_ref())
.map_err(|detail| FreshPublicationPointStageError {
error: ManifestFreshError::IssuerCaLoadFailed { detail },
snapshot_prepare_ms: snapshot_prepare_started.elapsed().as_millis() as u64,
})?;
let issuer_ca_der: Arc<[u8]> = Arc::from(issuer_ca_der.as_ref());
let fresh_publication_point = {
let _manifest_total = self
.timing
.as_ref()
.map(|t| t.span_phase("manifest_processing_total"));
process_manifest_publication_point_fresh_after_repo_sync_with_timing(
self.store,
&ca.manifest_rsync_uri,
&ca.publication_point_rsync_uri,
self.current_repo_index.as_ref(),
issuer_ca_der.as_ref(),
ca.ca_certificate_rsync_uri.as_deref(),
self.validation_time,
repo_sync_ok,
repo_sync_err,
)
};
let snapshot_prepare_ms = snapshot_prepare_started.elapsed().as_millis() as u64;
let (fresh_point, snapshot_prepare_timing) =
fresh_publication_point.map_err(|error| FreshPublicationPointStageError {
error,
snapshot_prepare_ms,
})?;
if let Some(timing) = self.timing.as_ref() {
timing.record_phase_nanos(
"fresh_snapshot_prepare_total",
snapshot_prepare_ms.saturating_mul(1_000_000),
);
timing.record_phase_nanos(
"fresh_snapshot_manifest_load_total",
snapshot_prepare_timing
.manifest_load_ms
.saturating_mul(1_000_000),
);
timing.record_phase_nanos(
"fresh_snapshot_manifest_decode_total",
snapshot_prepare_timing
.manifest_decode_ms
.saturating_mul(1_000_000),
);
timing.record_phase_nanos(
"fresh_snapshot_anti_rollback_total",
snapshot_prepare_timing
.anti_rollback_guard_ms
.saturating_mul(1_000_000),
);
timing.record_phase_nanos(
"fresh_snapshot_manifest_entries_total",
snapshot_prepare_timing
.manifest_entries_ms
.saturating_mul(1_000_000),
);
timing.record_phase_nanos(
"fresh_snapshot_pack_files_total",
snapshot_prepare_timing
.pack_files_ms
.saturating_mul(1_000_000),
);
timing.record_phase_nanos(
"fresh_snapshot_ee_path_validate_total",
snapshot_prepare_timing
.ee_path_validate_ms
.saturating_mul(1_000_000),
);
timing.record_count("fresh_publication_points", 1);
timing.record_count(
"fresh_manifest_files_total",
snapshot_prepare_timing.manifest_file_count as u64,
);
}
self.record_publication_point_step_ms(
&ca.manifest_rsync_uri,
"fresh_snapshot_prepare",
snapshot_prepare_ms,
);
self.record_publication_point_step_ms(
&ca.manifest_rsync_uri,
"fresh_snapshot_manifest_load",
snapshot_prepare_timing.manifest_load_ms,
);
self.record_publication_point_step_ms(
&ca.manifest_rsync_uri,
"fresh_snapshot_manifest_decode",
snapshot_prepare_timing.manifest_decode_ms,
);
self.record_publication_point_step_ms(
&ca.manifest_rsync_uri,
"fresh_snapshot_anti_rollback",
snapshot_prepare_timing.anti_rollback_guard_ms,
);
self.record_publication_point_step_ms(
&ca.manifest_rsync_uri,
"fresh_snapshot_manifest_entries",
snapshot_prepare_timing.manifest_entries_ms,
);
self.record_publication_point_step_ms(
&ca.manifest_rsync_uri,
"fresh_snapshot_pack_files",
snapshot_prepare_timing.pack_files_ms,
);
self.record_publication_point_step_ms(
&ca.manifest_rsync_uri,
"fresh_snapshot_ee_path_validate",
snapshot_prepare_timing.ee_path_validate_ms,
);
let child_discovery_started = std::time::Instant::now();
let out = {
let _child_disc_total = self
.timing
.as_ref()
.map(|t| t.span_phase("child_discovery_total"));
discover_children_from_fresh_snapshot_with_audit_with_issuer_der(
ca,
issuer_ca_der.as_ref(),
&fresh_point,
self.validation_time,
self.timing.as_ref(),
self.policy,
)
};
let (discovered_children, child_audits, discovered_router_keys, warnings) = match out {
Ok(out) => (out.children, out.audits, out.router_keys, Vec::new()),
Err(e) => (
Vec::new(),
Vec::new(),
Vec::new(),
vec![
Warning::new(format!("child CA discovery failed: {e}"))
.with_rfc_refs(&[RfcRef("RFC 6487 §7.2")])
.with_context(&ca.manifest_rsync_uri),
],
),
};
let child_discovery_ms = child_discovery_started.elapsed().as_millis() as u64;
if let Some(timing) = self.timing.as_ref() {
timing.record_phase_nanos(
"fresh_child_discovery_total",
child_discovery_ms.saturating_mul(1_000_000),
);
timing.record_count(
"fresh_children_discovered",
discovered_children.len() as u64,
);
timing.record_count("fresh_child_audits", child_audits.len() as u64);
timing.record_count(
"fresh_router_keys_discovered",
discovered_router_keys.len() as u64,
);
}
self.record_publication_point_step_ms(
&ca.manifest_rsync_uri,
"fresh_child_discovery",
child_discovery_ms,
);
Ok(FreshPublicationPointStage {
fresh_point,
issuer_ca_der,
snapshot_prepare_timing,
snapshot_prepare_ms,
discovered_children,
child_audits,
discovered_router_keys,
child_discovery_ms,
warnings,
})
}
pub(crate) fn finalize_fresh_publication_point_from_reducer(
&self,
ca: &CaInstanceHandle,
fresh_point: &FreshValidatedPublicationPoint,
warnings: Vec<Warning>,
objects: crate::validation::objects::ObjectsOutput,
child_audits: Vec<ObjectAuditEntry>,
discovered_children: Vec<DiscoveredChildCaInstance>,
repo_sync_source: Option<&str>,
repo_sync_phase: Option<&str>,
repo_sync_duration_ms: u64,
repo_sync_err: Option<&str>,
) -> Result<FreshPublicationPointFinalizeOutput, String> {
let snapshot_pack_started = std::time::Instant::now();
let pack = fresh_point.to_publication_point_snapshot();
let snapshot_pack_ms = snapshot_pack_started.elapsed().as_millis() as u64;
if let Some(timing) = self.timing.as_ref() {
timing.record_phase_nanos(
"fresh_snapshot_pack_total",
snapshot_pack_ms.saturating_mul(1_000_000),
);
}
self.record_publication_point_step_ms(
&ca.manifest_rsync_uri,
"fresh_snapshot_pack",
snapshot_pack_ms,
);
let mut ccr_projection_build_ms = 0;
let mut ccr_append_ms = 0;
if self.ccr_accumulator.is_some() {
let ccr_projection_build_started = std::time::Instant::now();
let subordinate_skis = discovered_children.iter().map(|child| {
if let Some(projection) = &child.child_entry_projection {
return hex::decode(&projection.child_ski).map_err(|error| error.to_string());
}
let der = child.handle.ca_certificate_der(self.store)?;
let certificate = ResourceCertificate::decode_der(der.as_ref()).map_err(|error| error.to_string())?;
certificate.tbs.extensions.subject_key_identifier.clone()
.ok_or_else(|| "child certificate missing SubjectKeyIdentifier".to_string())
}).collect::<Result<Vec<_>, String>>()?;
let ccr_manifest_projection = crate::ccr::projection::from_snapshot(ca, &pack, subordinate_skis)?;
ccr_projection_build_ms = ccr_projection_build_started.elapsed().as_millis() as u64;
let ccr_append_started = std::time::Instant::now();
self.append_ccr_manifest_projection(&ccr_manifest_projection)?;
ccr_append_ms = ccr_append_started.elapsed().as_millis() as u64;
}
if let Some(timing) = self.timing.as_ref() {
timing.record_phase_nanos(
"fresh_ccr_projection_build_total",
ccr_projection_build_ms.saturating_mul(1_000_000),
);
timing.record_phase_nanos(
"fresh_ccr_append_total",
ccr_append_ms.saturating_mul(1_000_000),
);
}
self.record_publication_point_step_ms(
&ca.manifest_rsync_uri,
"fresh_ccr_projection_build",
ccr_projection_build_ms,
);
self.record_publication_point_step_ms(
&ca.manifest_rsync_uri,
"fresh_ccr_append",
ccr_append_ms,
);
let audit_build_started = std::time::Instant::now();
let audit = build_publication_point_audit_from_snapshot(
ca,
PublicationPointSource::Fresh,
repo_sync_source,
repo_sync_phase,
Some(repo_sync_duration_ms),
repo_sync_err,
&pack,
&warnings,
&objects,
&child_audits,
);
let audit_build_ms = audit_build_started.elapsed().as_millis() as u64;
if let Some(timing) = self.timing.as_ref() {
timing.record_phase_nanos(
"fresh_audit_build_total",
audit_build_ms.saturating_mul(1_000_000),
);
}
self.record_publication_point_step_ms(
&ca.manifest_rsync_uri,
"fresh_audit_build",
audit_build_ms,
);
// Commit protocol freshness only after successful publication-point
// validation/finalization, independently of optional CCR output.
self.store.put_manifest_anti_rollback_meta(
&crate::repository::storage::ManifestAntiRollbackMeta {
manifest_rsync_uri: pack.manifest_rsync_uri.clone(),
manifest_number_be: pack.manifest_number_be.clone(),
manifest_this_update: pack.this_update.clone(),
manifest_sha256: <sha2::Sha256 as sha2::Digest>::digest(&pack.manifest_bytes).to_vec(),
updated_at_validation_time: pack.verified_at.clone(),
},
).map_err(|error| format!("persist manifest freshness failed: {error}"))?;
Ok(FreshPublicationPointFinalizeOutput {
result: PublicationPointRunResult {
source: PublicationPointSource::Fresh,
snapshot: Some(pack),
warnings,
objects,
audit,
discovered_children,
},
snapshot_pack_ms,
ccr_projection_build_ms,
ccr_append_ms,
audit_build_ms,
})
}
}

View File

@@ -0,0 +1,86 @@
//! Stable labels used by validation audit output.
//!
//! These helpers are pure mappings. Keeping them separate from publication
//! point traversal makes the output vocabulary easy to review without
//! changing traversal or validation control flow.
use crate::output::audit::AuditObjectKind;
use crate::validation::manifest::PublicationPointSource;
pub(super) fn kind_from_rsync_uri(uri: &str) -> AuditObjectKind {
if uri.ends_with(".crl") {
AuditObjectKind::Crl
} else if uri.ends_with(".cer") {
AuditObjectKind::Certificate
} else if uri.ends_with(".roa") {
AuditObjectKind::Roa
} else if uri.ends_with(".asa") {
AuditObjectKind::Aspa
} else {
AuditObjectKind::Other
}
}
pub(super) fn source_label(source: PublicationPointSource) -> String {
match source {
PublicationPointSource::Fresh => "fresh".to_string(),
PublicationPointSource::FailedFetchNoCache => "failed_fetch_no_cache".to_string(),
}
}
pub(super) fn repo_sync_phase_label(
phase: crate::repository::sync::repo::RepoSyncPhase,
) -> &'static str {
match phase {
crate::repository::sync::repo::RepoSyncPhase::RrdpOk => "rrdp_ok",
crate::repository::sync::repo::RepoSyncPhase::RrdpFailedRsyncOk => "rrdp_failed_rsync_ok",
crate::repository::sync::repo::RepoSyncPhase::RsyncOnlyOk => "rsync_only_ok",
}
}
pub(super) fn repo_sync_failure_phase_label(
attempted_rrdp: bool,
original_notification_uri: Option<&str>,
effective_notification_uri: Option<&str>,
) -> &'static str {
if attempted_rrdp && original_notification_uri.is_some() && effective_notification_uri.is_some()
{
"rrdp_failed_rsync_failed"
} else if attempted_rrdp
&& original_notification_uri.is_some()
&& effective_notification_uri.is_none()
{
"rsync_only_failed_after_rrdp_dedup"
} else {
"rsync_only_failed"
}
}
pub(super) fn terminal_state_label(source: PublicationPointSource) -> &'static str {
match source {
PublicationPointSource::Fresh => "fresh",
PublicationPointSource::FailedFetchNoCache => "failed_no_cache",
}
}
pub(super) fn repo_sync_source_label(
source: crate::repository::sync::repo::RepoSyncSource,
) -> &'static str {
match source {
crate::repository::sync::repo::RepoSyncSource::Rrdp => "rrdp",
crate::repository::sync::repo::RepoSyncSource::Rsync => "rsync",
}
}
pub(super) fn effective_repo_sync_duration_ms(
elapsed_ms: u64,
runtime_reported_duration_ms: Option<u64>,
repo_sync_ok: bool,
) -> u64 {
if repo_sync_ok {
return elapsed_ms;
}
runtime_reported_duration_ms
.map(|runtime_ms| elapsed_ms.max(runtime_ms))
.unwrap_or(elapsed_ms)
}

View File

@@ -0,0 +1,223 @@
impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> {
fn run_publication_point(
&self,
ca: &CaInstanceHandle,
) -> Result<PublicationPointRunResult, String> {
let started = std::time::Instant::now();
let _span = self
.timing
.as_ref()
.map(|timing| timing.span_publication_point(&ca.manifest_rsync_uri));
if let Some(timing) = self.timing.as_ref() {
timing.record_count("publication_points_seen", 1);
}
crate::logging::progress::emit!(
"publication_point_start",
serde_json::json!({
"manifest_rsync_uri": ca.manifest_rsync_uri,
"publication_point_rsync_uri": ca.publication_point_rsync_uri,
"rsync_base_uri": ca.rsync_base_uri,
"rrdp_notification_uri": ca.rrdp_notification_uri,
}),
);
let attempted_rrdp =
self.policy.sync_preference == crate::validation::policy::SyncPreference::RrdpThenRsync;
let repo_started = std::time::Instant::now();
let (repo_sync_ok, repo_sync_error, repo_sync_source, repo_sync_phase, runtime_duration) =
if let Some(runtime) = self.repo_sync_runtime.as_ref() {
let outcome = runtime.sync_publication_point_repo(ca)?;
(
outcome.repo_sync_ok,
outcome.repo_sync_err,
outcome.repo_sync_source,
outcome.repo_sync_phase,
Some(outcome.repo_sync_duration_ms),
)
} else {
match sync_publication_point(
self.store,
self.policy,
if attempted_rrdp {
ca.rrdp_notification_uri.as_deref()
} else {
None
},
&ca.rsync_base_uri,
self.http_fetcher,
self.rsync_fetcher,
self.timing.as_ref(),
self.download_log.as_ref(),
) {
Ok(result) => (
true,
None,
Some(repo_sync_source_label(result.source).to_string()),
Some(repo_sync_phase_label(result.phase).to_string()),
None,
),
Err(error) => (
false,
Some(error.to_string()),
None,
Some(
repo_sync_failure_phase_label(
attempted_rrdp,
ca.rrdp_notification_uri.as_deref(),
ca.rrdp_notification_uri.as_deref(),
)
.to_string(),
),
None,
),
}
};
let repo_sync_duration_ms = effective_repo_sync_duration_ms(
repo_started.elapsed().as_millis() as u64,
runtime_duration,
repo_sync_ok,
);
crate::logging::progress::emit!(
"publication_point_repo_sync_done",
serde_json::json!({
"manifest_rsync_uri": ca.manifest_rsync_uri,
"repo_sync_ok": repo_sync_ok,
"repo_sync_source": repo_sync_source,
"repo_sync_phase": repo_sync_phase,
"repo_sync_error": repo_sync_error,
"repo_sync_duration_ms": repo_sync_duration_ms,
}),
);
let stage = self.stage_fresh_publication_point_after_repo_ready(
ca,
repo_sync_ok,
repo_sync_error.as_deref(),
);
let stage = match stage {
Ok(stage) => stage,
Err(stage_error) => {
let mut warnings = Vec::new();
warnings.push(
Warning::new(format!("publication point processing failed: {}", stage_error.error))
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")])
.with_context(&ca.manifest_rsync_uri),
);
let audit = build_publication_point_audit_from_failed_fetch(
ca,
repo_sync_source.as_deref(),
repo_sync_phase.as_deref(),
Some(repo_sync_duration_ms),
repo_sync_error.as_deref(),
&warnings,
&stage_error.error,
);
crate::logging::progress::emit!(
"repo_terminal_failure",
serde_json::json!({
"manifest_rsync_uri": ca.manifest_rsync_uri,
"repo_sync_source": repo_sync_source,
"repo_sync_phase": repo_sync_phase,
"repo_sync_error": repo_sync_error,
"repo_sync_duration_ms": repo_sync_duration_ms,
"terminal_state": "failed_no_cache",
"error": stage_error.error.to_string(),
}),
);
return Ok(PublicationPointRunResult {
source: PublicationPointSource::FailedFetchNoCache,
snapshot: None,
warnings,
objects: empty_objects_output(),
audit,
discovered_children: Vec::new(),
});
}
};
let ta_constraints = self.policy.ta_constraints.for_tal(&ca.tal_id);
let object_started = std::time::Instant::now();
let mut objects = if let Some(constraints) = ta_constraints {
crate::validation::objects::process_publication_point_for_issuer_with_ta_constraints(
&stage.fresh_point,
self.policy,
stage.issuer_ca_der.as_ref(),
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(),
Some(constraints),
)
} else if let Some(pool) = self.parallel_roa_worker_pool.as_ref() {
process_publication_point_for_issuer_parallel_roa_with_pool(
&stage.fresh_point,
self.policy,
stage.issuer_ca_der.as_ref(),
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(),
pool,
)
} else if let Some(config) = self.parallel_phase2_config.as_ref() {
process_publication_point_for_issuer_parallel_roa(
&stage.fresh_point,
self.policy,
stage.issuer_ca_der.as_ref(),
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(),
config,
)
} else {
crate::validation::objects::process_publication_point_for_issuer(
&stage.fresh_point,
self.policy,
stage.issuer_ca_der.as_ref(),
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 object_ms = object_started.elapsed().as_millis() as u64;
self.record_publication_point_step_ms(&ca.manifest_rsync_uri, "objects_processing", object_ms);
objects.router_keys.extend(stage.discovered_router_keys.clone());
let finalized = self.finalize_fresh_publication_point_from_reducer(
ca,
&stage.fresh_point,
stage.warnings,
objects,
stage.child_audits,
stage.discovered_children,
repo_sync_source.as_deref(),
repo_sync_phase.as_deref(),
repo_sync_duration_ms,
repo_sync_error.as_deref(),
)?;
let result = finalized.result;
let total_ms = started.elapsed().as_millis() as u64;
crate::logging::progress::emit!(
"publication_point_finish",
serde_json::json!({
"manifest_rsync_uri": ca.manifest_rsync_uri,
"repo_sync_source": repo_sync_source,
"repo_sync_phase": repo_sync_phase,
"repo_sync_duration_ms": repo_sync_duration_ms,
"total_duration_ms": total_ms,
"objects_processing_ms": object_ms,
"warning_count": result.warnings.len(),
"vrp_count": result.objects.vrps.len(),
"aspa_count": result.objects.aspas.len(),
"child_count": result.discovered_children.len(),
}),
);
Ok(result)
}
}

View File

@@ -0,0 +1,23 @@
impl<'a> Rpkiv1PublicationPointRunner<'a> {
pub(crate) fn record_publication_point_total_ms(&self, manifest_rsync_uri: &str, ms: u64) {
if let Some(timing) = self.timing.as_ref() {
timing.record_publication_point_nanos(manifest_rsync_uri, ms.saturating_mul(1_000_000));
}
}
pub(crate) fn record_publication_point_step_ms(
&self,
manifest_rsync_uri: &str,
step: &'static str,
ms: u64,
) {
if let Some(timing) = self.timing.as_ref() {
timing.record_publication_point_step_nanos(
manifest_rsync_uri,
step,
ms.saturating_mul(1_000_000),
);
}
}
}

View File

@@ -0,0 +1,42 @@
#[derive(Clone, Debug)]
pub(crate) struct FreshPublicationPointStage {
pub(crate) fresh_point: FreshValidatedPublicationPoint,
pub(crate) issuer_ca_der: Arc<[u8]>,
pub(crate) snapshot_prepare_timing: FreshPublicationPointTimingBreakdown,
pub(crate) snapshot_prepare_ms: u64,
pub(crate) discovered_children: Vec<DiscoveredChildCaInstance>,
pub(crate) child_audits: Vec<ObjectAuditEntry>,
pub(crate) discovered_router_keys: Vec<RouterKeyPayload>,
pub(crate) child_discovery_ms: u64,
pub(crate) warnings: Vec<Warning>,
}
#[derive(Debug)]
pub(crate) struct FreshPublicationPointStageError {
pub(crate) error: ManifestFreshError,
pub(crate) snapshot_prepare_ms: u64,
}
#[derive(Clone, Debug)]
pub(crate) struct FreshPublicationPointFinalizeOutput {
pub(crate) result: PublicationPointRunResult,
pub(crate) snapshot_pack_ms: u64,
pub(crate) ccr_projection_build_ms: u64,
pub(crate) ccr_append_ms: u64,
pub(crate) audit_build_ms: u64,
}
pub struct Rpkiv1PublicationPointRunner<'a> {
pub store: &'a RocksStore,
pub policy: &'a Policy,
pub http_fetcher: &'a dyn Fetcher,
pub rsync_fetcher: &'a dyn RsyncFetcher,
pub validation_time: time::OffsetDateTime,
pub timing: Option<TimingHandle>,
pub download_log: Option<DownloadLogHandle>,
pub current_repo_index: Option<CurrentRepoIndexHandle>,
pub repo_sync_runtime: Option<Arc<dyn RepoSyncRuntime>>,
pub parallel_phase2_config: Option<ParallelPhase2Config>,
pub parallel_roa_worker_pool: Option<ParallelRoaWorkerPool>,
pub ccr_accumulator: Option<Mutex<CcrAccumulator>>,
}

View File

@@ -0,0 +1,85 @@
use crate::model::common::X509NameDer;
use x509_parser::prelude::FromDer;
fn canonicalize(name: &X509NameDer) -> Option<String> {
let (rem, parsed) = x509_parser::x509::X509Name::from_der(name.as_raw()).ok()?;
if !rem.is_empty() {
return None;
}
Some(parsed.to_string())
}
/// Compare two X.509 distinguished names using a tolerant semantic comparison.
///
/// RPKI repositories in the wild sometimes encode the same name using different
/// ASN.1 string types (e.g., PrintableString vs UTF8String) while remaining
/// semantically equivalent. RFC 5280 path validation requires name matching, but
/// DER byte equality is too strict for interoperability.
pub fn x509_names_equivalent(a: &X509NameDer, b: &X509NameDer) -> bool {
let Some(ca) = canonicalize(a) else {
return a == b;
};
let Some(cb) = canonicalize(b) else {
return a == b;
};
ca == cb
}
#[cfg(test)]
mod tests {
use super::*;
fn synthetic_certificate_der() -> Vec<u8> {
std::fs::read(
crate::test_support::synthetic_repository()
.case_repository("baseline-v1")
.join("root/child.cer"),
)
.expect("read synthetic child certificate")
}
#[test]
fn x509_names_equivalent_falls_back_to_der_equality_when_parse_fails() {
// Invalid tag (not a SEQUENCE) makes x509-parser fail and forces DER equality fallback.
let a = X509NameDer(vec![0x01, 0x00]);
let b = X509NameDer(vec![0x01, 0x00]);
assert!(x509_names_equivalent(&a, &b));
}
#[test]
fn x509_names_equivalent_compares_semantic_names_when_parse_succeeds() {
let cert_der = synthetic_certificate_der();
let (_rem, cert) =
x509_parser::parse_x509_certificate(&cert_der).expect("parse certificate fixture");
let subject = X509NameDer(cert.tbs_certificate.subject.as_raw().to_vec());
let issuer = X509NameDer(cert.tbs_certificate.issuer.as_raw().to_vec());
assert!(x509_names_equivalent(&subject, &subject));
assert!(!x509_names_equivalent(&subject, &issuer));
}
#[test]
fn x509_names_equivalent_falls_back_when_name_has_trailing_bytes() {
// Use a real name DER and append a trailing byte so parsing yields leftover `rem`.
let cert_der = synthetic_certificate_der();
let (_rem, cert) =
x509_parser::parse_x509_certificate(&cert_der).expect("parse certificate fixture");
let mut name = cert.tbs_certificate.subject.as_raw().to_vec();
name.push(0x00);
let a = X509NameDer(name.clone());
let b = X509NameDer(name);
assert!(x509_names_equivalent(&a, &b));
}
#[test]
fn x509_names_equivalent_falls_back_when_one_side_fails_to_parse() {
let cert_der = synthetic_certificate_der();
let (_rem, cert) =
x509_parser::parse_x509_certificate(&cert_der).expect("parse certificate fixture");
let good = X509NameDer(cert.tbs_certificate.subject.as_raw().to_vec());
let bad = X509NameDer(vec![0x01, 0x00]);
assert!(!x509_names_equivalent(&good, &bad));
}
}