Skip to main content

ic_query/subnet_catalog/model/
validation.rs

1//! Module: subnet_catalog::model::validation
2//!
3//! Responsibility: validate raw structural and host authority catalog evidence.
4//! Does not own: Registry transport, cache policy, or report rendering.
5//! Boundary: only this module constructs `ValidatedSubnetCatalog`.
6
7#[cfg(feature = "subnet-catalog-host")]
8use super::UncertifiedCatalogCollection;
9#[cfg(feature = "subnet-catalog-host")]
10use super::{
11    CatalogAssurance, CatalogValidationContext, ValidatedSubnetCatalog,
12    policy::{RESOLVER_BACKEND, apply_mainnet_classification_policy, classification_policy_digest},
13};
14use super::{RawSubnetCatalog, RoutingRange, SubnetInfo};
15use crate::subnet_catalog::{
16    CATALOG_SCHEMA_VERSION, CatalogError, MAINNET_NETWORK, MAINNET_REGISTRY_CANISTER_ID,
17    parse_principal, principal_bytes, resolver::routing_range_sorts_after,
18};
19#[cfg(feature = "subnet-catalog-host")]
20use crate::{
21    hex::{hex_bytes, is_lowercase_hex},
22    http_endpoint::parse_http_endpoint,
23    subnet_catalog::{
24        CLASSIFICATION_SCHEMA_VERSION, MAX_SUBNET_CATALOG_AGREEMENT_ENDPOINTS,
25        MIN_SUBNET_CATALOG_AGREEMENT_ENDPOINTS, RESOLVER_SCHEMA_VERSION,
26    },
27};
28#[cfg(feature = "subnet-catalog-host")]
29use sha2::{Digest, Sha256};
30use std::{cmp::Ordering, collections::BTreeSet};
31
32impl RawSubnetCatalog {
33    /// Build, canonicalize, classify, and seal one uncertified mainnet source snapshot.
34    #[cfg(feature = "subnet-catalog-host")]
35    pub fn new_mainnet_uncertified(
36        collection: UncertifiedCatalogCollection,
37        subnets: Vec<SubnetInfo>,
38        routing_ranges: Vec<RoutingRange>,
39    ) -> Result<Self, CatalogError> {
40        let mut raw = Self {
41            catalog_schema_version: CATALOG_SCHEMA_VERSION,
42            provenance: super::SubnetCatalogProvenance {
43                network: MAINNET_NETWORK.to_string(),
44                registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
45                registry_version: collection.registry_version,
46                assurance: CatalogAssurance::UncertifiedQuery,
47                source_endpoints: vec![collection.source_endpoint],
48                agreement_digest: None,
49                registry_query_call_count: collection.registry_query_call_count,
50                fetched_at: collection.fetched_at,
51                certificate_time: None,
52                root_key_digest: None,
53                fetched_by: collection.fetched_by,
54                collector_version: collection.collector_version,
55                classification_schema_version: CLASSIFICATION_SCHEMA_VERSION,
56                classification_policy_digest: classification_policy_digest(),
57                resolver_schema_version: RESOLVER_SCHEMA_VERSION,
58                resolver_backend: RESOLVER_BACKEND.to_string(),
59            },
60            catalog_digest: String::new(),
61            subnets,
62            routing_ranges,
63        };
64        raw.canonicalize_and_seal()?;
65        Ok(raw)
66    }
67
68    /// Canonicalize source rows, apply the current policy, and replace the catalog digest.
69    #[cfg(feature = "subnet-catalog-host")]
70    pub fn canonicalize_and_seal(&mut self) -> Result<(), CatalogError> {
71        canonicalize_subnet_catalog_content(&mut self.subnets, &mut self.routing_ranges)?;
72        self.provenance.classification_schema_version = CLASSIFICATION_SCHEMA_VERSION;
73        self.provenance.classification_policy_digest = classification_policy_digest();
74        self.provenance.resolver_schema_version = RESOLVER_SCHEMA_VERSION;
75        self.provenance.resolver_backend = RESOLVER_BACKEND.to_string();
76        self.catalog_digest = hex_bytes(&canonical_catalog_digest(self)?);
77        self.validate()
78    }
79
80    /// Promote matching single-endpoint evidence into one sealed agreement snapshot.
81    #[cfg(feature = "subnet-catalog-host")]
82    pub(in crate::subnet_catalog) fn promote_to_multi_endpoint_agreement(
83        &mut self,
84        source_endpoints: Vec<String>,
85        registry_query_call_count: u64,
86    ) -> Result<(), CatalogError> {
87        self.provenance.assurance = CatalogAssurance::MultiEndpointAgreement;
88        self.provenance.source_endpoints = source_endpoints;
89        self.provenance.registry_query_call_count = registry_query_call_count;
90        self.provenance.agreement_digest = Some(hex_bytes(&catalog_agreement_digest(self)?));
91        self.canonicalize_and_seal()
92    }
93
94    /// Validate schema, fixed mainnet identity, raw classifications, and routing structure.
95    pub fn validate(&self) -> Result<(), CatalogError> {
96        if self.catalog_schema_version != CATALOG_SCHEMA_VERSION {
97            return Err(CatalogError::UnsupportedSchemaVersion {
98                found: self.catalog_schema_version,
99                supported: CATALOG_SCHEMA_VERSION,
100            });
101        }
102        if self.provenance.network != MAINNET_NETWORK {
103            return Err(CatalogError::NetworkMismatch {
104                expected: MAINNET_NETWORK.to_string(),
105                actual: self.provenance.network.clone(),
106            });
107        }
108        if self.provenance.registry_canister_id != MAINNET_REGISTRY_CANISTER_ID {
109            return Err(CatalogError::RegistryCanisterMismatch {
110                expected: MAINNET_REGISTRY_CANISTER_ID.to_string(),
111                actual: self.provenance.registry_canister_id.clone(),
112            });
113        }
114        if self.provenance.registry_version == 0 {
115            return Err(CatalogError::InvalidRegistryVersion);
116        }
117        parse_principal(
118            &self.provenance.registry_canister_id,
119            "provenance.registry_canister_id",
120        )?;
121
122        validate_subnet_catalog_content(&self.subnets, &self.routing_ranges)
123    }
124
125    /// Find one raw Subnet row by canonical principal text.
126    #[must_use]
127    pub fn subnet_by_principal(&self, subnet_principal: &str) -> Option<&SubnetInfo> {
128        self.subnets
129            .iter()
130            .find(|subnet| subnet.subnet_principal == subnet_principal)
131    }
132
133    /// Return raw routing ranges assigned to one Subnet.
134    #[must_use]
135    pub fn routing_ranges_for_subnet(&self, subnet_principal: &str) -> Vec<&RoutingRange> {
136        self.routing_ranges
137            .iter()
138            .filter(|range| range.subnet_principal == subnet_principal)
139            .collect()
140    }
141}
142
143#[cfg(feature = "subnet-catalog-host")]
144pub fn canonicalize_subnet_catalog_content(
145    subnets: &mut [SubnetInfo],
146    routing_ranges: &mut Vec<RoutingRange>,
147) -> Result<(), CatalogError> {
148    subnets.sort_by(|left, right| left.subnet_principal.cmp(&right.subnet_principal));
149    let mut keyed_ranges = routing_ranges
150        .drain(..)
151        .map(|range| {
152            Ok::<_, CatalogError>((
153                principal_bytes(&range.start_canister_id, "start_canister_id")?,
154                principal_bytes(&range.end_canister_id, "end_canister_id")?,
155                range.subnet_principal.clone(),
156                range,
157            ))
158        })
159        .collect::<Result<Vec<_>, _>>()?;
160    keyed_ranges.sort_by(|left, right| {
161        compare_routing_keys(&left.0, &left.1, &left.2, &right.0, &right.1, &right.2)
162    });
163    *routing_ranges = keyed_ranges
164        .into_iter()
165        .map(|(_, _, _, range)| range)
166        .collect();
167    apply_mainnet_classification_policy(subnets);
168    validate_subnet_catalog_content(subnets, routing_ranges)
169}
170
171fn validate_subnet_catalog_content(
172    subnets: &[SubnetInfo],
173    routing_ranges: &[RoutingRange],
174) -> Result<(), CatalogError> {
175    if subnets.is_empty() {
176        return Err(CatalogError::EmptySubnets);
177    }
178    if routing_ranges.is_empty() {
179        return Err(CatalogError::EmptyRoutingRanges);
180    }
181    let mut subnet_principals = BTreeSet::new();
182    let mut previous_subnet: Option<&str> = None;
183    for subnet in subnets {
184        parse_principal(&subnet.subnet_principal, "subnet_principal")?;
185        if let Some(previous) = previous_subnet
186            && previous >= subnet.subnet_principal.as_str()
187        {
188            return Err(CatalogError::NonCanonicalSubnetOrder {
189                previous: previous.to_string(),
190                current: subnet.subnet_principal.clone(),
191            });
192        }
193        previous_subnet = Some(subnet.subnet_principal.as_str());
194        if !subnet_principals.insert(subnet.subnet_principal.clone()) {
195            return Err(CatalogError::DuplicateSubnet {
196                subnet_principal: subnet.subnet_principal.clone(),
197            });
198        }
199        validate_raw_subnet_classification(subnet)?;
200    }
201
202    let mut validated_ranges = Vec::with_capacity(routing_ranges.len());
203    for range in routing_ranges {
204        if !subnet_principals.contains(&range.subnet_principal) {
205            return Err(CatalogError::UnknownRoutingSubnet {
206                subnet_principal: range.subnet_principal.clone(),
207            });
208        }
209        let start = principal_bytes(&range.start_canister_id, "start_canister_id")?;
210        let end = principal_bytes(&range.end_canister_id, "end_canister_id")?;
211        parse_principal(&range.subnet_principal, "routing_range.subnet_principal")?;
212        if routing_range_sorts_after(&start, &end) {
213            return Err(CatalogError::InvalidRoutingRange {
214                subnet_principal: range.subnet_principal.clone(),
215                start_canister_id: range.start_canister_id.clone(),
216                end_canister_id: range.end_canister_id.clone(),
217            });
218        }
219        validated_ranges.push((range, start, end));
220    }
221    for pair in validated_ranges.windows(2) {
222        let (first, first_start, first_end) = &pair[0];
223        let (second, second_start, second_end) = &pair[1];
224        if compare_routing_keys(
225            first_start,
226            first_end,
227            &first.subnet_principal,
228            second_start,
229            second_end,
230            &second.subnet_principal,
231        ) != Ordering::Less
232        {
233            return Err(CatalogError::NonCanonicalRoutingOrder {
234                previous: Box::new((*first).clone()),
235                current: Box::new((*second).clone()),
236            });
237        }
238        if second_start <= first_end {
239            return Err(CatalogError::OverlappingRoutingRanges {
240                first: Box::new((*first).clone()),
241                second: Box::new((*second).clone()),
242            });
243        }
244    }
245
246    Ok(())
247}
248
249#[cfg(feature = "subnet-catalog-host")]
250impl ValidatedSubnetCatalog {
251    /// Validate raw authority evidence against caller-owned identity and time policy.
252    pub fn try_from_raw(
253        raw: RawSubnetCatalog,
254        context: &CatalogValidationContext,
255    ) -> Result<Self, CatalogError> {
256        raw.validate()?;
257        validate_expected_identity(&raw, context)?;
258        validate_provenance(&raw, context)?;
259        validate_classification_policy(&raw)?;
260        let catalog_digest = validate_catalog_digest(&raw)?;
261        Ok(Self::from_validated_parts(raw, catalog_digest))
262    }
263}
264
265fn validate_raw_subnet_classification(subnet: &SubnetInfo) -> Result<(), CatalogError> {
266    let expected_kind = super::SubnetKind::from_registry_subnet_type(subnet.registry_subnet_type);
267    if subnet.subnet_kind != expected_kind {
268        return Err(CatalogError::SubnetKindMismatch {
269            subnet_principal: subnet.subnet_principal.clone(),
270            registry_subnet_type: subnet.registry_subnet_type,
271            expected: expected_kind.as_str().to_string(),
272            actual: subnet.subnet_kind.as_str().to_string(),
273        });
274    }
275    if subnet.subnet_kind_source != super::ClassificationSource::Registry {
276        return Err(CatalogError::ClassificationMismatch {
277            subnet_principal: subnet.subnet_principal.clone(),
278            field: "subnet_kind_source",
279            reason: "raw Registry subnet kind must have registry source".to_string(),
280        });
281    }
282    let expected_charges = expected_kind.charges_apply_by_default();
283    if subnet.charges_apply_by_default != expected_charges {
284        return Err(CatalogError::ChargingPolicyMismatch {
285            subnet_principal: subnet.subnet_principal.clone(),
286            expected: expected_charges,
287            actual: subnet.charges_apply_by_default,
288        });
289    }
290    Ok(())
291}
292
293#[cfg(feature = "subnet-catalog-host")]
294fn validate_expected_identity(
295    raw: &RawSubnetCatalog,
296    context: &CatalogValidationContext,
297) -> Result<(), CatalogError> {
298    if raw.provenance.network != context.expected_network {
299        return Err(CatalogError::NetworkMismatch {
300            expected: context.expected_network.clone(),
301            actual: raw.provenance.network.clone(),
302        });
303    }
304    if raw.provenance.registry_canister_id != context.expected_registry_canister_id {
305        return Err(CatalogError::RegistryCanisterMismatch {
306            expected: context.expected_registry_canister_id.clone(),
307            actual: raw.provenance.registry_canister_id.clone(),
308        });
309    }
310    Ok(())
311}
312
313#[cfg(feature = "subnet-catalog-host")]
314fn validate_provenance(
315    raw: &RawSubnetCatalog,
316    context: &CatalogValidationContext,
317) -> Result<(), CatalogError> {
318    validate_collection_time(raw, context)?;
319    validate_collector_identity(raw)?;
320    let parsed_endpoints = validated_source_endpoints(raw)?;
321    validate_assurance(raw, &parsed_endpoints)?;
322    validate_policy_identity(raw)
323}
324
325#[cfg(feature = "subnet-catalog-host")]
326fn validate_collection_time(
327    raw: &RawSubnetCatalog,
328    context: &CatalogValidationContext,
329) -> Result<(), CatalogError> {
330    let invalid_timestamp = || CatalogError::InvalidTimestamp {
331        field: "provenance.fetched_at",
332        value: raw.provenance.fetched_at.clone(),
333    };
334    let fetched_at_unix_secs =
335        crate::subnet_catalog::parse_utc_timestamp_secs(&raw.provenance.fetched_at)
336            .ok_or_else(invalid_timestamp)?;
337    if crate::subnet_catalog::format_utc_timestamp_secs(fetched_at_unix_secs)
338        != raw.provenance.fetched_at
339    {
340        return Err(invalid_timestamp());
341    }
342    let latest_allowed = context
343        .now_unix_secs
344        .saturating_add(context.max_future_skew_seconds);
345    if fetched_at_unix_secs > latest_allowed {
346        return Err(CatalogError::FutureTimestamp {
347            field: "provenance.fetched_at",
348            value: raw.provenance.fetched_at.clone(),
349            latest_allowed_unix_secs: latest_allowed,
350        });
351    }
352    Ok(())
353}
354
355#[cfg(feature = "subnet-catalog-host")]
356fn validate_collector_identity(raw: &RawSubnetCatalog) -> Result<(), CatalogError> {
357    if raw.provenance.fetched_by.trim().is_empty() {
358        return Err(CatalogError::InvalidProvenance {
359            field: "provenance.fetched_by",
360            reason: "collector identity must not be empty".to_string(),
361        });
362    }
363    if raw.provenance.collector_version.trim().is_empty() {
364        return Err(CatalogError::InvalidProvenance {
365            field: "provenance.collector_version",
366            reason: "collector version must not be empty".to_string(),
367        });
368    }
369    if raw.provenance.registry_query_call_count == 0 {
370        return Err(CatalogError::InvalidProvenance {
371            field: "provenance.registry_query_call_count",
372            reason: "live collection must record at least one Registry query call".to_string(),
373        });
374    }
375    Ok(())
376}
377
378#[cfg(feature = "subnet-catalog-host")]
379fn validated_source_endpoints(raw: &RawSubnetCatalog) -> Result<Vec<url::Url>, CatalogError> {
380    if raw.provenance.source_endpoints.is_empty() {
381        return Err(CatalogError::InvalidProvenance {
382            field: "provenance.source_endpoints",
383            reason: "at least one source endpoint is required".to_string(),
384        });
385    }
386    raw.provenance
387        .source_endpoints
388        .iter()
389        .map(|endpoint| {
390            parse_http_endpoint(endpoint).map_err(|reason| CatalogError::InvalidSourceEndpoint {
391                endpoint: endpoint.clone(),
392                reason,
393            })
394        })
395        .collect()
396}
397
398#[cfg(feature = "subnet-catalog-host")]
399fn validate_assurance(
400    raw: &RawSubnetCatalog,
401    parsed_endpoints: &[url::Url],
402) -> Result<(), CatalogError> {
403    match raw.provenance.assurance {
404        CatalogAssurance::UncertifiedQuery => {
405            if raw.provenance.source_endpoints.len() != 1 {
406                return Err(CatalogError::InvalidProvenance {
407                    field: "provenance.source_endpoints",
408                    reason: "uncertified query assurance requires exactly one source endpoint"
409                        .to_string(),
410                });
411            }
412            if raw.provenance.certificate_time.is_some() || raw.provenance.root_key_digest.is_some()
413            {
414                return Err(CatalogError::InvalidProvenance {
415                    field: "provenance.assurance",
416                    reason: "uncertified query must not carry certificate evidence".to_string(),
417                });
418            }
419            if raw.provenance.agreement_digest.is_some() {
420                return Err(CatalogError::InvalidProvenance {
421                    field: "provenance.agreement_digest",
422                    reason: "uncertified query must not claim endpoint agreement".to_string(),
423                });
424            }
425        }
426        CatalogAssurance::MultiEndpointAgreement => {
427            let endpoint_count = raw.provenance.source_endpoints.len();
428            if !(MIN_SUBNET_CATALOG_AGREEMENT_ENDPOINTS..=MAX_SUBNET_CATALOG_AGREEMENT_ENDPOINTS)
429                .contains(&endpoint_count)
430            {
431                return Err(CatalogError::InvalidProvenance {
432                    field: "provenance.source_endpoints",
433                    reason: format!(
434                        "multi-endpoint agreement requires {MIN_SUBNET_CATALOG_AGREEMENT_ENDPOINTS}..={MAX_SUBNET_CATALOG_AGREEMENT_ENDPOINTS} endpoints"
435                    ),
436                });
437            }
438            if raw
439                .provenance
440                .source_endpoints
441                .windows(2)
442                .any(|pair| pair[0] >= pair[1])
443            {
444                return Err(CatalogError::InvalidProvenance {
445                    field: "provenance.source_endpoints",
446                    reason: "agreement endpoints must be unique and canonically ordered"
447                        .to_string(),
448                });
449            }
450            let mut hostnames = BTreeSet::new();
451            for endpoint in parsed_endpoints {
452                let hostname =
453                    endpoint
454                        .host_str()
455                        .ok_or_else(|| CatalogError::InvalidProvenance {
456                            field: "provenance.source_endpoints",
457                            reason: "agreement endpoint is missing a hostname".to_string(),
458                        })?;
459                if !hostnames.insert(hostname.to_ascii_lowercase()) {
460                    return Err(CatalogError::InvalidProvenance {
461                        field: "provenance.source_endpoints",
462                        reason: "agreement endpoints must use distinct hostnames".to_string(),
463                    });
464                }
465            }
466            if raw.provenance.certificate_time.is_some() || raw.provenance.root_key_digest.is_some()
467            {
468                return Err(CatalogError::InvalidProvenance {
469                    field: "provenance.assurance",
470                    reason: "multi-endpoint agreement must not carry certificate evidence"
471                        .to_string(),
472                });
473            }
474            validate_agreement_digest(raw)?;
475        }
476        CatalogAssurance::Certified => {
477            return Err(CatalogError::UnsupportedAssurance {
478                assurance: CatalogAssurance::Certified.as_str().to_string(),
479            });
480        }
481    }
482    Ok(())
483}
484
485#[cfg(feature = "subnet-catalog-host")]
486fn validate_policy_identity(raw: &RawSubnetCatalog) -> Result<(), CatalogError> {
487    if raw.provenance.classification_schema_version != CLASSIFICATION_SCHEMA_VERSION {
488        return Err(CatalogError::ClassificationPolicyVersionMismatch {
489            found: raw.provenance.classification_schema_version,
490            supported: CLASSIFICATION_SCHEMA_VERSION,
491        });
492    }
493    let expected_policy_digest = classification_policy_digest();
494    if raw.provenance.classification_policy_digest != expected_policy_digest {
495        return Err(CatalogError::ClassificationPolicyDigestMismatch {
496            expected: expected_policy_digest,
497            actual: raw.provenance.classification_policy_digest.clone(),
498        });
499    }
500    if raw.provenance.resolver_schema_version != RESOLVER_SCHEMA_VERSION
501        || raw.provenance.resolver_backend != RESOLVER_BACKEND
502    {
503        return Err(CatalogError::ResolverPolicyMismatch {
504            expected_version: RESOLVER_SCHEMA_VERSION,
505            actual_version: raw.provenance.resolver_schema_version,
506            expected_backend: RESOLVER_BACKEND.to_string(),
507            actual_backend: raw.provenance.resolver_backend.clone(),
508        });
509    }
510    Ok(())
511}
512
513#[cfg(feature = "subnet-catalog-host")]
514fn validate_classification_policy(raw: &RawSubnetCatalog) -> Result<(), CatalogError> {
515    let mut expected = raw.clone();
516    apply_mainnet_classification_policy(&mut expected.subnets);
517    for (actual, expected) in raw.subnets.iter().zip(&expected.subnets) {
518        if actual.subnet_specialization != expected.subnet_specialization
519            || actual.subnet_specialization_source != expected.subnet_specialization_source
520            || actual.geographic_scope != expected.geographic_scope
521            || actual.geographic_scope_source != expected.geographic_scope_source
522            || actual.subnet_label != expected.subnet_label
523            || actual.subnet_label_source != expected.subnet_label_source
524        {
525            return Err(CatalogError::ClassificationMismatch {
526                subnet_principal: actual.subnet_principal.clone(),
527                field: "curated_or_computed_annotations",
528                reason: "annotations do not match the recorded classification policy".to_string(),
529            });
530        }
531    }
532    Ok(())
533}
534
535#[cfg(feature = "subnet-catalog-host")]
536fn validate_catalog_digest(raw: &RawSubnetCatalog) -> Result<[u8; 32], CatalogError> {
537    if raw.catalog_digest.len() != 64 || !is_lowercase_hex(&raw.catalog_digest) {
538        return Err(CatalogError::InvalidCatalogDigest {
539            value: raw.catalog_digest.clone(),
540        });
541    }
542    let expected = canonical_catalog_digest(raw)?;
543    if raw.catalog_digest != hex_bytes(&expected) {
544        return Err(CatalogError::CatalogDigestMismatch {
545            expected: hex_bytes(&expected),
546            actual: raw.catalog_digest.clone(),
547        });
548    }
549    Ok(expected)
550}
551
552#[cfg(feature = "subnet-catalog-host")]
553fn canonical_catalog_digest(raw: &RawSubnetCatalog) -> Result<[u8; 32], CatalogError> {
554    let mut payload = raw.clone();
555    payload.catalog_digest.clear();
556    let serialized = serde_json::to_vec(&payload)?;
557    Ok(Sha256::digest(serialized).into())
558}
559
560#[cfg(feature = "subnet-catalog-host")]
561pub(in crate::subnet_catalog) fn catalog_agreement_digest(
562    raw: &RawSubnetCatalog,
563) -> Result<[u8; 32], CatalogError> {
564    #[derive(serde::Serialize)]
565    struct AgreementPayload<'a> {
566        catalog_schema_version: u32,
567        network: &'a str,
568        registry_canister_id: &'a str,
569        registry_version: u64,
570        subnets: &'a [SubnetInfo],
571        routing_ranges: &'a [RoutingRange],
572    }
573
574    let payload = AgreementPayload {
575        catalog_schema_version: raw.catalog_schema_version,
576        network: &raw.provenance.network,
577        registry_canister_id: &raw.provenance.registry_canister_id,
578        registry_version: raw.provenance.registry_version,
579        subnets: &raw.subnets,
580        routing_ranges: &raw.routing_ranges,
581    };
582    Ok(Sha256::digest(serde_json::to_vec(&payload)?).into())
583}
584
585#[cfg(feature = "subnet-catalog-host")]
586fn validate_agreement_digest(raw: &RawSubnetCatalog) -> Result<(), CatalogError> {
587    let actual = raw
588        .provenance
589        .agreement_digest
590        .as_deref()
591        .unwrap_or_default();
592    if actual.len() != 64 || !is_lowercase_hex(actual) {
593        return Err(CatalogError::InvalidAgreementDigest {
594            value: actual.to_string(),
595        });
596    }
597    let expected = hex_bytes(&catalog_agreement_digest(raw)?);
598    if actual != expected {
599        return Err(CatalogError::AgreementDigestMismatch {
600            expected,
601            actual: actual.to_string(),
602        });
603    }
604    Ok(())
605}
606
607fn compare_routing_keys(
608    left_start: &[u8],
609    left_end: &[u8],
610    left_subnet: &str,
611    right_start: &[u8],
612    right_end: &[u8],
613    right_subnet: &str,
614) -> Ordering {
615    left_start
616        .cmp(right_start)
617        .then_with(|| left_end.cmp(right_end))
618        .then_with(|| left_subnet.cmp(right_subnet))
619}