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