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 = "certified-subnet-catalog-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    cache_file::canonical_json_sha256,
24    hex::{hex_bytes, is_lowercase_hex},
25    http_endpoint::parse_http_endpoint,
26    subnet_catalog::{
27        CLASSIFICATION_SCHEMA_VERSION, MAX_SUBNET_CATALOG_AGREEMENT_ENDPOINTS,
28        MIN_SUBNET_CATALOG_AGREEMENT_ENDPOINTS, RESOLVER_SCHEMA_VERSION,
29    },
30};
31use std::{cmp::Ordering, collections::BTreeSet};
32
33impl RawSubnetCatalog {
34    /// Build, canonicalize, classify, and seal one uncertified mainnet source snapshot.
35    #[cfg(feature = "subnet-catalog-host")]
36    pub fn new_mainnet_uncertified(
37        collection: UncertifiedCatalogCollection,
38        subnets: Vec<SubnetInfo>,
39        routing_ranges: Vec<RoutingRange>,
40    ) -> Result<Self, CatalogError> {
41        let mut raw = Self {
42            catalog_schema_version: CATALOG_SCHEMA_VERSION,
43            provenance: super::SubnetCatalogProvenance {
44                network: MAINNET_NETWORK.to_string(),
45                registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
46                registry_version: collection.registry_version,
47                assurance: CatalogAssurance::UncertifiedQuery,
48                source_endpoints: vec![collection.source_endpoint],
49                agreement_digest: None,
50                registry_query_call_count: collection.registry_query_call_count,
51                routing_source: collection.routing_source,
52                registry_records: collection.registry_records,
53                fetched_at: collection.fetched_at,
54                certified_registry: None,
55                fetched_by: collection.fetched_by,
56                collector_version: collection.collector_version,
57                classification_schema_version: CLASSIFICATION_SCHEMA_VERSION,
58                classification_policy_digest: classification_policy_digest(),
59                resolver_schema_version: RESOLVER_SCHEMA_VERSION,
60                resolver_backend: RESOLVER_BACKEND.to_string(),
61            },
62            catalog_digest: String::new(),
63            subnets,
64            routing_ranges,
65        };
66        raw.canonicalize_and_seal()?;
67        Ok(raw)
68    }
69
70    /// Canonicalize source rows, apply the current policy, and replace the catalog digest.
71    #[cfg(feature = "subnet-catalog-host")]
72    pub fn canonicalize_and_seal(&mut self) -> Result<(), CatalogError> {
73        canonicalize_subnet_catalog_content(&mut self.subnets, &mut self.routing_ranges)?;
74        self.provenance.registry_records.sort_by(|left, right| {
75            left.source_endpoint
76                .cmp(&right.source_endpoint)
77                .then_with(|| left.record.key.cmp(&right.record.key))
78                .then_with(|| left.record.kind.as_str().cmp(right.record.kind.as_str()))
79                .then_with(|| {
80                    left.returned_registry_version
81                        .cmp(&right.returned_registry_version)
82                })
83        });
84        self.provenance.classification_schema_version = CLASSIFICATION_SCHEMA_VERSION;
85        self.provenance.classification_policy_digest = classification_policy_digest();
86        self.provenance.resolver_schema_version = RESOLVER_SCHEMA_VERSION;
87        self.provenance.resolver_backend = RESOLVER_BACKEND.to_string();
88        self.catalog_digest = hex_bytes(&canonical_catalog_digest(self)?);
89        self.validate()
90    }
91
92    /// Promote matching single-endpoint evidence into one sealed agreement snapshot.
93    #[cfg(feature = "subnet-catalog-host")]
94    pub(in crate::subnet_catalog) fn promote_to_multi_endpoint_agreement(
95        &mut self,
96        source_endpoints: Vec<String>,
97        registry_query_call_count: u64,
98    ) -> Result<(), CatalogError> {
99        self.provenance.assurance = CatalogAssurance::MultiEndpointAgreement;
100        self.provenance.source_endpoints = source_endpoints;
101        self.provenance.registry_query_call_count = registry_query_call_count;
102        self.provenance.agreement_digest = Some(hex_bytes(&catalog_agreement_digest(self)?));
103        self.canonicalize_and_seal()
104    }
105
106    /// Validate schema, fixed mainnet identity, raw classifications, and routing structure.
107    pub fn validate(&self) -> Result<(), CatalogError> {
108        if self.catalog_schema_version != CATALOG_SCHEMA_VERSION {
109            return Err(CatalogError::UnsupportedSchemaVersion {
110                found: self.catalog_schema_version,
111                supported: CATALOG_SCHEMA_VERSION,
112            });
113        }
114        if self.provenance.network != MAINNET_NETWORK {
115            return Err(CatalogError::NetworkMismatch {
116                expected: MAINNET_NETWORK.to_string(),
117                actual: self.provenance.network.clone(),
118            });
119        }
120        if self.provenance.registry_canister_id != MAINNET_REGISTRY_CANISTER_ID {
121            return Err(CatalogError::RegistryCanisterMismatch {
122                expected: MAINNET_REGISTRY_CANISTER_ID.to_string(),
123                actual: self.provenance.registry_canister_id.clone(),
124            });
125        }
126        if self.provenance.registry_version == 0 {
127            return Err(CatalogError::InvalidRegistryVersion);
128        }
129        parse_principal(
130            &self.provenance.registry_canister_id,
131            "provenance.registry_canister_id",
132        )?;
133
134        validate_subnet_catalog_content(&self.subnets, &self.routing_ranges)
135    }
136
137    /// Find one raw Subnet row by canonical principal text.
138    #[must_use]
139    pub fn subnet_by_principal(&self, subnet_principal: &str) -> Option<&SubnetInfo> {
140        self.subnets
141            .iter()
142            .find(|subnet| subnet.subnet_principal == subnet_principal)
143    }
144
145    /// Return raw routing ranges assigned to one Subnet.
146    #[must_use]
147    pub fn routing_ranges_for_subnet(&self, subnet_principal: &str) -> Vec<&RoutingRange> {
148        self.routing_ranges
149            .iter()
150            .filter(|range| range.subnet_principal == subnet_principal)
151            .collect()
152    }
153}
154
155#[cfg(feature = "subnet-catalog-host")]
156pub fn canonicalize_subnet_catalog_content(
157    subnets: &mut [SubnetInfo],
158    routing_ranges: &mut Vec<RoutingRange>,
159) -> Result<(), CatalogError> {
160    subnets.sort_by(|left, right| left.subnet_principal.cmp(&right.subnet_principal));
161    let mut keyed_ranges = routing_ranges
162        .drain(..)
163        .map(|range| {
164            Ok::<_, CatalogError>((
165                principal_bytes(&range.start_canister_id, "start_canister_id")?,
166                principal_bytes(&range.end_canister_id, "end_canister_id")?,
167                range.subnet_principal.clone(),
168                range,
169            ))
170        })
171        .collect::<Result<Vec<_>, _>>()?;
172    keyed_ranges.sort_by(|left, right| {
173        compare_routing_keys(&left.0, &left.1, &left.2, &right.0, &right.1, &right.2)
174    });
175    *routing_ranges = keyed_ranges
176        .into_iter()
177        .map(|(_, _, _, range)| range)
178        .collect();
179    apply_mainnet_classification_policy(subnets);
180    validate_subnet_catalog_content(subnets, routing_ranges)
181}
182
183fn validate_subnet_catalog_content(
184    subnets: &[SubnetInfo],
185    routing_ranges: &[RoutingRange],
186) -> Result<(), CatalogError> {
187    if subnets.is_empty() {
188        return Err(CatalogError::EmptySubnets);
189    }
190    if routing_ranges.is_empty() {
191        return Err(CatalogError::EmptyRoutingRanges);
192    }
193    let mut subnet_principals = BTreeSet::new();
194    let mut previous_subnet: Option<&str> = None;
195    for subnet in subnets {
196        parse_principal(&subnet.subnet_principal, "subnet_principal")?;
197        if let Some(previous) = previous_subnet
198            && previous >= subnet.subnet_principal.as_str()
199        {
200            return Err(CatalogError::NonCanonicalSubnetOrder {
201                previous: previous.to_string(),
202                current: subnet.subnet_principal.clone(),
203            });
204        }
205        previous_subnet = Some(subnet.subnet_principal.as_str());
206        if !subnet_principals.insert(subnet.subnet_principal.clone()) {
207            return Err(CatalogError::DuplicateSubnet {
208                subnet_principal: subnet.subnet_principal.clone(),
209            });
210        }
211        validate_raw_subnet_classification(subnet)?;
212    }
213
214    let mut validated_ranges = Vec::with_capacity(routing_ranges.len());
215    for range in routing_ranges {
216        if !subnet_principals.contains(&range.subnet_principal) {
217            return Err(CatalogError::UnknownRoutingSubnet {
218                subnet_principal: range.subnet_principal.clone(),
219            });
220        }
221        let start = principal_bytes(&range.start_canister_id, "start_canister_id")?;
222        let end = principal_bytes(&range.end_canister_id, "end_canister_id")?;
223        parse_principal(&range.subnet_principal, "routing_range.subnet_principal")?;
224        if routing_range_sorts_after(&start, &end) {
225            return Err(CatalogError::InvalidRoutingRange {
226                subnet_principal: range.subnet_principal.clone(),
227                start_canister_id: range.start_canister_id.clone(),
228                end_canister_id: range.end_canister_id.clone(),
229            });
230        }
231        validated_ranges.push((range, start, end));
232    }
233    for pair in validated_ranges.windows(2) {
234        let (first, first_start, first_end) = &pair[0];
235        let (second, second_start, second_end) = &pair[1];
236        if compare_routing_keys(
237            first_start,
238            first_end,
239            &first.subnet_principal,
240            second_start,
241            second_end,
242            &second.subnet_principal,
243        ) != Ordering::Less
244        {
245            return Err(CatalogError::NonCanonicalRoutingOrder {
246                previous: Box::new((*first).clone()),
247                current: Box::new((*second).clone()),
248            });
249        }
250        if second_start <= first_end {
251            return Err(CatalogError::OverlappingRoutingRanges {
252                first: Box::new((*first).clone()),
253                second: Box::new((*second).clone()),
254            });
255        }
256    }
257
258    Ok(())
259}
260
261#[cfg(feature = "subnet-catalog-host")]
262impl ValidatedSubnetCatalog {
263    /// Validate raw authority evidence against caller-owned identity and time policy.
264    pub fn try_from_raw(
265        raw: RawSubnetCatalog,
266        context: &CatalogValidationContext,
267    ) -> Result<Self, CatalogError> {
268        Self::try_from_raw_with_certified_admission(raw, context, false)
269    }
270
271    #[cfg(feature = "certified-subnet-catalog-host")]
272    pub(crate) fn try_from_authenticated_archive(
273        raw: RawSubnetCatalog,
274        context: &CatalogValidationContext,
275        archive: &NnsAuthenticatedRegistryArchive,
276    ) -> Result<Self, CatalogError> {
277        validate_certified_archive_binding(&raw, archive)?;
278        Self::try_from_raw_with_certified_admission(raw, context, true)
279    }
280
281    fn try_from_raw_with_certified_admission(
282        raw: RawSubnetCatalog,
283        context: &CatalogValidationContext,
284        admit_certified: bool,
285    ) -> Result<Self, CatalogError> {
286        raw.validate()?;
287        validate_expected_identity(&raw, context)?;
288        validate_provenance(&raw, context, admit_certified)?;
289        validate_classification_policy(&raw)?;
290        let catalog_digest = validate_catalog_digest(&raw)?;
291        Ok(Self::from_validated_parts(raw, catalog_digest))
292    }
293}
294
295fn validate_raw_subnet_classification(subnet: &SubnetInfo) -> Result<(), CatalogError> {
296    let expected_kind = super::SubnetKind::from_registry_subnet_type(subnet.registry_subnet_type);
297    if subnet.subnet_kind != expected_kind {
298        return Err(CatalogError::SubnetKindMismatch {
299            subnet_principal: subnet.subnet_principal.clone(),
300            registry_subnet_type: subnet.registry_subnet_type,
301            expected: expected_kind.as_str().to_string(),
302            actual: subnet.subnet_kind.as_str().to_string(),
303        });
304    }
305    if subnet.subnet_kind_source != super::ClassificationSource::Registry {
306        return Err(CatalogError::ClassificationMismatch {
307            subnet_principal: subnet.subnet_principal.clone(),
308            field: "subnet_kind_source",
309            reason: "raw Registry subnet kind must have registry source".to_string(),
310        });
311    }
312    let expected_charges = expected_kind.charges_apply_by_default();
313    if subnet.charges_apply_by_default != expected_charges {
314        return Err(CatalogError::ChargingPolicyMismatch {
315            subnet_principal: subnet.subnet_principal.clone(),
316            expected: expected_charges,
317            actual: subnet.charges_apply_by_default,
318        });
319    }
320    Ok(())
321}
322
323#[cfg(feature = "subnet-catalog-host")]
324fn validate_expected_identity(
325    raw: &RawSubnetCatalog,
326    context: &CatalogValidationContext,
327) -> Result<(), CatalogError> {
328    if raw.provenance.network != context.expected_network {
329        return Err(CatalogError::NetworkMismatch {
330            expected: context.expected_network.clone(),
331            actual: raw.provenance.network.clone(),
332        });
333    }
334    if raw.provenance.registry_canister_id != context.expected_registry_canister_id {
335        return Err(CatalogError::RegistryCanisterMismatch {
336            expected: context.expected_registry_canister_id.clone(),
337            actual: raw.provenance.registry_canister_id.clone(),
338        });
339    }
340    Ok(())
341}
342
343#[cfg(feature = "subnet-catalog-host")]
344fn validate_provenance(
345    raw: &RawSubnetCatalog,
346    context: &CatalogValidationContext,
347    admit_certified: bool,
348) -> Result<(), CatalogError> {
349    validate_collection_time(raw, context)?;
350    validate_collector_identity(raw)?;
351    let parsed_endpoints = validated_source_endpoints(raw)?;
352    validate_assurance(raw, &parsed_endpoints, admit_certified)?;
353    validate_registry_record_evidence(raw)?;
354    validate_policy_identity(raw)
355}
356
357#[cfg(feature = "subnet-catalog-host")]
358fn validate_registry_record_evidence(raw: &RawSubnetCatalog) -> Result<(), CatalogError> {
359    if raw.provenance.registry_records.is_empty() {
360        if raw.provenance.assurance == CatalogAssurance::Certified {
361            return Ok(());
362        }
363        return Err(invalid_registry_record_evidence(
364            "catalog has no Registry record evidence".to_string(),
365        ));
366    }
367
368    let expected_subnets = raw
369        .subnets
370        .iter()
371        .map(|subnet| subnet.subnet_principal.clone())
372        .collect::<BTreeSet<_>>();
373    let mut subnet_list_endpoints = BTreeSet::new();
374    let mut routing_endpoints = BTreeSet::new();
375    let mut subnet_record_identities = BTreeSet::new();
376    let mut previous_record = None;
377    for evidence in &raw.provenance.registry_records {
378        if evidence.requested_registry_version != raw.provenance.registry_version {
379            return Err(invalid_registry_record_evidence(
380                "record request version does not match the pinned catalog version".to_string(),
381            ));
382        }
383        if evidence.returned_registry_version > evidence.requested_registry_version {
384            return Err(invalid_registry_record_evidence(
385                "record value version is newer than its requested version".to_string(),
386            ));
387        }
388        if !raw
389            .provenance
390            .source_endpoints
391            .contains(&evidence.source_endpoint)
392        {
393            return Err(invalid_registry_record_evidence(
394                "record source endpoint is not a catalog source endpoint".to_string(),
395            ));
396        }
397        if evidence.assurance != CatalogAssurance::UncertifiedQuery {
398            return Err(invalid_registry_record_evidence(
399                "individual get_value evidence must have uncertified-query assurance".to_string(),
400            ));
401        }
402
403        let record_identity = (
404            evidence.source_endpoint.as_str(),
405            evidence.record.key.as_str(),
406        );
407        if previous_record.is_some_and(|previous| previous >= record_identity) {
408            return Err(invalid_registry_record_evidence(
409                "record evidence is duplicated or not in canonical endpoint/key order".to_string(),
410            ));
411        }
412        previous_record = Some(record_identity);
413
414        match validated_registry_record_subject(
415            evidence,
416            raw.provenance.routing_source,
417            &expected_subnets,
418        )? {
419            RegistryRecordEvidenceSubject::SubnetList => {
420                subnet_list_endpoints.insert(evidence.source_endpoint.as_str());
421            }
422            RegistryRecordEvidenceSubject::Routing => {
423                routing_endpoints.insert(evidence.source_endpoint.as_str());
424            }
425            RegistryRecordEvidenceSubject::Subnet(subnet) => {
426                subnet_record_identities.insert((evidence.source_endpoint.as_str(), subnet));
427            }
428        }
429    }
430
431    for endpoint in &raw.provenance.source_endpoints {
432        if !subnet_list_endpoints.contains(endpoint.as_str()) {
433            return Err(invalid_registry_record_evidence(format!(
434                "source endpoint {endpoint:?} has no Subnet-list record evidence"
435            )));
436        }
437        if !routing_endpoints.contains(endpoint.as_str()) {
438            return Err(invalid_registry_record_evidence(format!(
439                "source endpoint {endpoint:?} has no selected routing record evidence"
440            )));
441        }
442        for subnet in &expected_subnets {
443            if !subnet_record_identities.contains(&(endpoint.as_str(), subnet.clone())) {
444                return Err(invalid_registry_record_evidence(format!(
445                    "source endpoint {endpoint:?} has no record evidence for Subnet {subnet}"
446                )));
447            }
448        }
449    }
450    Ok(())
451}
452
453#[cfg(feature = "subnet-catalog-host")]
454enum RegistryRecordEvidenceSubject {
455    SubnetList,
456    Routing,
457    Subnet(String),
458}
459
460#[cfg(feature = "subnet-catalog-host")]
461fn validated_registry_record_subject(
462    evidence: &super::SubnetCatalogRegistryRecordEvidence,
463    routing_source: super::SubnetCatalogRoutingSource,
464    expected_subnets: &BTreeSet<String>,
465) -> Result<RegistryRecordEvidenceSubject, CatalogError> {
466    match evidence.record.kind {
467        super::SubnetCatalogRegistryRecordKind::SubnetList => {
468            if evidence.record.key != crate::ic_registry::SUBNET_LIST_KEY
469                || evidence.record.subnet.is_some()
470                || evidence.record.canister_range_start.is_some()
471            {
472                return Err(invalid_registry_record_evidence(
473                    "Subnet-list evidence does not match its exact Registry subject".to_string(),
474                ));
475            }
476            Ok(RegistryRecordEvidenceSubject::SubnetList)
477        }
478        super::SubnetCatalogRegistryRecordKind::RoutingTable => {
479            if evidence.record.subnet.is_some() {
480                return Err(invalid_registry_record_evidence(
481                    "routing evidence unexpectedly names a Subnet-record subject".to_string(),
482                ));
483            }
484            let is_modern = if evidence.record.key == crate::ic_registry::ROUTING_TABLE_KEY {
485                if evidence.record.canister_range_start.is_some() {
486                    return Err(invalid_registry_record_evidence(
487                        "legacy routing evidence has a shard lower-bound subject".to_string(),
488                    ));
489                }
490                false
491            } else {
492                let parsed = crate::ic_registry::routing_shards::canister_range_start_from_key(
493                    crate::ic_registry::CANISTER_RANGES_KEY_PREFIX,
494                    &evidence.record.key,
495                )
496                .map_err(invalid_registry_record_evidence)?;
497                if evidence.record.canister_range_start.as_ref() != Some(&parsed) {
498                    return Err(invalid_registry_record_evidence(
499                        "routing shard subject does not match the lower bound encoded by its key"
500                            .to_string(),
501                    ));
502                }
503                true
504            };
505            let selected_modern =
506                routing_source == super::SubnetCatalogRoutingSource::CanisterRanges;
507            if is_modern != selected_modern {
508                return Err(invalid_registry_record_evidence(
509                    "routing record evidence contradicts the selected routing source".to_string(),
510                ));
511            }
512            Ok(RegistryRecordEvidenceSubject::Routing)
513        }
514        super::SubnetCatalogRegistryRecordKind::SubnetRecord => {
515            let Some(subnet) = evidence.record.subnet else {
516                return Err(invalid_registry_record_evidence(
517                    "Subnet-record evidence has no Subnet subject".to_string(),
518                ));
519            };
520            let subnet = subnet.to_text();
521            if evidence.record.canister_range_start.is_some()
522                || evidence.record.key != crate::ic_registry::subnet_record_key(&subnet)
523                || !expected_subnets.contains(&subnet)
524            {
525                return Err(invalid_registry_record_evidence(
526                    "Subnet-record evidence does not match a current catalog Subnet".to_string(),
527                ));
528            }
529            Ok(RegistryRecordEvidenceSubject::Subnet(subnet))
530        }
531    }
532}
533
534#[cfg(feature = "subnet-catalog-host")]
535const fn invalid_registry_record_evidence(reason: String) -> CatalogError {
536    CatalogError::InvalidProvenance {
537        field: "provenance.registry_records",
538        reason,
539    }
540}
541
542#[cfg(feature = "subnet-catalog-host")]
543fn validate_collection_time(
544    raw: &RawSubnetCatalog,
545    context: &CatalogValidationContext,
546) -> Result<(), CatalogError> {
547    let invalid_timestamp = || CatalogError::InvalidTimestamp {
548        field: "provenance.fetched_at",
549        value: raw.provenance.fetched_at.clone(),
550    };
551    let fetched_at_unix_secs =
552        crate::subnet_catalog::parse_utc_timestamp_secs(&raw.provenance.fetched_at)
553            .ok_or_else(invalid_timestamp)?;
554    let latest_allowed = context
555        .now_unix_secs
556        .saturating_add(context.max_future_skew_seconds);
557    if fetched_at_unix_secs > latest_allowed {
558        return Err(CatalogError::FutureTimestamp {
559            field: "provenance.fetched_at",
560            value: raw.provenance.fetched_at.clone(),
561            latest_allowed_unix_secs: latest_allowed,
562        });
563    }
564    Ok(())
565}
566
567#[cfg(feature = "subnet-catalog-host")]
568fn validate_collector_identity(raw: &RawSubnetCatalog) -> Result<(), CatalogError> {
569    if raw.provenance.fetched_by.trim().is_empty() {
570        return Err(CatalogError::InvalidProvenance {
571            field: "provenance.fetched_by",
572            reason: "collector identity must not be empty".to_string(),
573        });
574    }
575    if raw.provenance.collector_version.trim().is_empty() {
576        return Err(CatalogError::InvalidProvenance {
577            field: "provenance.collector_version",
578            reason: "collector version must not be empty".to_string(),
579        });
580    }
581    if raw.provenance.registry_query_call_count == 0 {
582        return Err(CatalogError::InvalidProvenance {
583            field: "provenance.registry_query_call_count",
584            reason: "catalog evidence must record at least one Registry query call".to_string(),
585        });
586    }
587    Ok(())
588}
589
590#[cfg(feature = "subnet-catalog-host")]
591fn validated_source_endpoints(raw: &RawSubnetCatalog) -> Result<Vec<url::Url>, CatalogError> {
592    if raw.provenance.source_endpoints.is_empty() {
593        return Err(CatalogError::InvalidProvenance {
594            field: "provenance.source_endpoints",
595            reason: "at least one source endpoint is required".to_string(),
596        });
597    }
598    raw.provenance
599        .source_endpoints
600        .iter()
601        .map(|endpoint| {
602            parse_http_endpoint(endpoint).map_err(|reason| CatalogError::InvalidSourceEndpoint {
603                endpoint: endpoint.clone(),
604                reason,
605            })
606        })
607        .collect()
608}
609
610#[cfg(feature = "subnet-catalog-host")]
611fn validate_assurance(
612    raw: &RawSubnetCatalog,
613    parsed_endpoints: &[url::Url],
614    admit_certified: bool,
615) -> Result<(), CatalogError> {
616    match raw.provenance.assurance {
617        CatalogAssurance::UncertifiedQuery => {
618            if raw.provenance.source_endpoints.len() != 1 {
619                return Err(CatalogError::InvalidProvenance {
620                    field: "provenance.source_endpoints",
621                    reason: "uncertified query assurance requires exactly one source endpoint"
622                        .to_string(),
623                });
624            }
625            if raw.provenance.certified_registry.is_some() {
626                return Err(CatalogError::InvalidProvenance {
627                    field: "provenance.assurance",
628                    reason: "uncertified query must not carry certificate evidence".to_string(),
629                });
630            }
631            if raw.provenance.agreement_digest.is_some() {
632                return Err(CatalogError::InvalidProvenance {
633                    field: "provenance.agreement_digest",
634                    reason: "uncertified query must not claim endpoint agreement".to_string(),
635                });
636            }
637        }
638        CatalogAssurance::MultiEndpointAgreement => {
639            let endpoint_count = raw.provenance.source_endpoints.len();
640            if !(MIN_SUBNET_CATALOG_AGREEMENT_ENDPOINTS..=MAX_SUBNET_CATALOG_AGREEMENT_ENDPOINTS)
641                .contains(&endpoint_count)
642            {
643                return Err(CatalogError::InvalidProvenance {
644                    field: "provenance.source_endpoints",
645                    reason: format!(
646                        "multi-endpoint agreement requires {MIN_SUBNET_CATALOG_AGREEMENT_ENDPOINTS}..={MAX_SUBNET_CATALOG_AGREEMENT_ENDPOINTS} endpoints"
647                    ),
648                });
649            }
650            if raw
651                .provenance
652                .source_endpoints
653                .windows(2)
654                .any(|pair| pair[0] >= pair[1])
655            {
656                return Err(CatalogError::InvalidProvenance {
657                    field: "provenance.source_endpoints",
658                    reason: "agreement endpoints must be unique and canonically ordered"
659                        .to_string(),
660                });
661            }
662            let mut hostnames = BTreeSet::new();
663            for endpoint in parsed_endpoints {
664                let hostname =
665                    endpoint
666                        .host_str()
667                        .ok_or_else(|| CatalogError::InvalidProvenance {
668                            field: "provenance.source_endpoints",
669                            reason: "agreement endpoint is missing a hostname".to_string(),
670                        })?;
671                if !hostnames.insert(hostname.to_ascii_lowercase()) {
672                    return Err(CatalogError::InvalidProvenance {
673                        field: "provenance.source_endpoints",
674                        reason: "agreement endpoints must use distinct hostnames".to_string(),
675                    });
676                }
677            }
678            if raw.provenance.certified_registry.is_some() {
679                return Err(CatalogError::InvalidProvenance {
680                    field: "provenance.assurance",
681                    reason: "multi-endpoint agreement must not carry certificate evidence"
682                        .to_string(),
683                });
684            }
685            validate_agreement_digest(raw)?;
686        }
687        CatalogAssurance::Certified => {
688            if !admit_certified {
689                return Err(CatalogError::UnsupportedAssurance {
690                    assurance: CatalogAssurance::Certified.as_str().to_string(),
691                });
692            }
693            validate_certified_assurance(raw)?;
694        }
695    }
696    Ok(())
697}
698
699#[cfg(feature = "certified-subnet-catalog-host")]
700fn validate_certified_archive_binding(
701    raw: &RawSubnetCatalog,
702    archive: &NnsAuthenticatedRegistryArchive,
703) -> Result<(), CatalogError> {
704    let manifest = archive.manifest();
705    let expected_evidence = super::CertifiedRegistryCatalogEvidence {
706        archive_manifest_schema_version: manifest.schema_version,
707        delta_report_schema_version: manifest.delta_report_schema_version,
708        replay_provenance_schema_version: manifest.replay_provenance_schema_version,
709        root_key_digest: manifest.root_key_digest.clone(),
710        evidence_chain_digest: manifest.evidence_chain_digest.clone(),
711        complete_state_digest: manifest.complete_state_digest.clone(),
712        minimum_certificate_time_nanos: manifest.minimum_certificate_time_nanos,
713        maximum_certificate_time_nanos: manifest.maximum_certificate_time_nanos,
714    };
715    if raw.provenance.assurance != CatalogAssurance::Certified
716        || raw.provenance.network != manifest.network
717        || raw.provenance.registry_canister_id != manifest.registry_canister_id
718        || raw.provenance.registry_version != manifest.selected_version
719        || raw.provenance.source_endpoints != manifest.source_endpoints
720        || raw.provenance.registry_query_call_count != manifest.query_call_count
721        || raw.provenance.certified_registry.as_ref() != Some(&expected_evidence)
722    {
723        return Err(CatalogError::InvalidProvenance {
724            field: "provenance",
725            reason: "certified catalog provenance does not match its authenticated archive"
726                .to_string(),
727        });
728    }
729    Ok(())
730}
731
732#[cfg(feature = "subnet-catalog-host")]
733fn validate_certified_assurance(raw: &RawSubnetCatalog) -> Result<(), CatalogError> {
734    if raw.provenance.agreement_digest.is_some() {
735        return Err(CatalogError::InvalidProvenance {
736            field: "provenance.agreement_digest",
737            reason: "certified assurance must not claim endpoint agreement".to_string(),
738        });
739    }
740    if raw
741        .provenance
742        .source_endpoints
743        .windows(2)
744        .any(|pair| pair[0] >= pair[1])
745    {
746        return Err(CatalogError::InvalidProvenance {
747            field: "provenance.source_endpoints",
748            reason: "certified archive endpoints must be unique and canonically ordered"
749                .to_string(),
750        });
751    }
752    let evidence = raw.provenance.certified_registry.as_ref().ok_or_else(|| {
753        CatalogError::InvalidProvenance {
754            field: "provenance.certified_registry",
755            reason: "certified assurance requires authenticated archive commitments".to_string(),
756        }
757    })?;
758    for (field, digest) in [
759        ("root_key_digest", &evidence.root_key_digest),
760        ("evidence_chain_digest", &evidence.evidence_chain_digest),
761        ("complete_state_digest", &evidence.complete_state_digest),
762    ] {
763        if digest.len() != 64 || !is_lowercase_hex(digest) {
764            return Err(CatalogError::InvalidProvenance {
765                field: "provenance.certified_registry",
766                reason: format!("{field} must be exactly 32 lowercase hexadecimal bytes"),
767            });
768        }
769    }
770    if evidence.archive_manifest_schema_version == 0
771        || evidence.delta_report_schema_version == 0
772        || evidence.replay_provenance_schema_version == 0
773    {
774        return Err(CatalogError::InvalidProvenance {
775            field: "provenance.certified_registry",
776            reason: "certified evidence schema versions must be greater than zero".to_string(),
777        });
778    }
779    if evidence.minimum_certificate_time_nanos > evidence.maximum_certificate_time_nanos {
780        return Err(CatalogError::InvalidProvenance {
781            field: "provenance.certified_registry",
782            reason: "minimum certificate time exceeds maximum certificate time".to_string(),
783        });
784    }
785    let maximum_certificate_time = crate::subnet_catalog::format_utc_timestamp_secs(
786        evidence.maximum_certificate_time_nanos / 1_000_000_000,
787    );
788    if raw.provenance.fetched_at != maximum_certificate_time {
789        return Err(CatalogError::InvalidProvenance {
790            field: "provenance.fetched_at",
791            reason: "certified catalog time must equal the latest archive certificate time"
792                .to_string(),
793        });
794    }
795    Ok(())
796}
797
798#[cfg(feature = "subnet-catalog-host")]
799fn validate_policy_identity(raw: &RawSubnetCatalog) -> Result<(), CatalogError> {
800    if raw.provenance.classification_schema_version != CLASSIFICATION_SCHEMA_VERSION {
801        return Err(CatalogError::ClassificationPolicyVersionMismatch {
802            found: raw.provenance.classification_schema_version,
803            supported: CLASSIFICATION_SCHEMA_VERSION,
804        });
805    }
806    let expected_policy_digest = classification_policy_digest();
807    if raw.provenance.classification_policy_digest != expected_policy_digest {
808        return Err(CatalogError::ClassificationPolicyDigestMismatch {
809            expected: expected_policy_digest,
810            actual: raw.provenance.classification_policy_digest.clone(),
811        });
812    }
813    if raw.provenance.resolver_schema_version != RESOLVER_SCHEMA_VERSION
814        || raw.provenance.resolver_backend != RESOLVER_BACKEND
815    {
816        return Err(CatalogError::ResolverPolicyMismatch {
817            expected_version: RESOLVER_SCHEMA_VERSION,
818            actual_version: raw.provenance.resolver_schema_version,
819            expected_backend: RESOLVER_BACKEND.to_string(),
820            actual_backend: raw.provenance.resolver_backend.clone(),
821        });
822    }
823    Ok(())
824}
825
826#[cfg(feature = "subnet-catalog-host")]
827fn validate_classification_policy(raw: &RawSubnetCatalog) -> Result<(), CatalogError> {
828    let mut expected = raw.subnets.clone();
829    apply_mainnet_classification_policy(&mut expected);
830    for (actual, expected) in raw.subnets.iter().zip(&expected) {
831        if actual.subnet_specialization != expected.subnet_specialization
832            || actual.subnet_specialization_source != expected.subnet_specialization_source
833            || actual.geographic_scope != expected.geographic_scope
834            || actual.geographic_scope_source != expected.geographic_scope_source
835            || actual.subnet_label != expected.subnet_label
836            || actual.subnet_label_source != expected.subnet_label_source
837        {
838            return Err(CatalogError::ClassificationMismatch {
839                subnet_principal: actual.subnet_principal.clone(),
840                field: "curated_or_computed_annotations",
841                reason: "annotations do not match the recorded classification policy".to_string(),
842            });
843        }
844    }
845    Ok(())
846}
847
848#[cfg(feature = "subnet-catalog-host")]
849fn validate_catalog_digest(raw: &RawSubnetCatalog) -> Result<[u8; 32], CatalogError> {
850    if raw.catalog_digest.len() != 64 || !is_lowercase_hex(&raw.catalog_digest) {
851        return Err(CatalogError::InvalidCatalogDigest {
852            value: raw.catalog_digest.clone(),
853        });
854    }
855    let expected = canonical_catalog_digest(raw)?;
856    if raw.catalog_digest != hex_bytes(&expected) {
857        return Err(CatalogError::CatalogDigestMismatch {
858            expected: hex_bytes(&expected),
859            actual: raw.catalog_digest.clone(),
860        });
861    }
862    Ok(expected)
863}
864
865#[cfg(feature = "subnet-catalog-host")]
866fn canonical_catalog_digest(raw: &RawSubnetCatalog) -> Result<[u8; 32], CatalogError> {
867    #[derive(serde::Serialize)]
868    struct CatalogDigestPayload<'a> {
869        catalog_schema_version: u32,
870        provenance: &'a super::SubnetCatalogProvenance,
871        catalog_digest: &'a str,
872        subnets: &'a [SubnetInfo],
873        routing_ranges: &'a [RoutingRange],
874    }
875
876    let payload = CatalogDigestPayload {
877        catalog_schema_version: raw.catalog_schema_version,
878        provenance: &raw.provenance,
879        catalog_digest: "",
880        subnets: &raw.subnets,
881        routing_ranges: &raw.routing_ranges,
882    };
883    Ok(canonical_json_sha256(&payload)?)
884}
885
886#[cfg(feature = "subnet-catalog-host")]
887pub(in crate::subnet_catalog) fn catalog_agreement_digest(
888    raw: &RawSubnetCatalog,
889) -> Result<[u8; 32], CatalogError> {
890    #[derive(serde::Serialize)]
891    struct AgreementPayload<'a> {
892        catalog_schema_version: u32,
893        network: &'a str,
894        registry_canister_id: &'a str,
895        registry_version: u64,
896        routing_source: super::SubnetCatalogRoutingSource,
897        registry_records: Vec<AgreementRegistryRecord<'a>>,
898        subnets: &'a [SubnetInfo],
899        routing_ranges: &'a [RoutingRange],
900    }
901
902    #[derive(serde::Serialize)]
903    struct AgreementRegistryRecord<'a> {
904        record: &'a super::SubnetCatalogRegistryRecordSubject,
905        requested_registry_version: u64,
906        returned_registry_version: u64,
907        timestamp_nanoseconds: u64,
908        value_encoding: super::SubnetCatalogRegistryValueEncoding,
909    }
910
911    let registry_records = raw
912        .provenance
913        .registry_records
914        .iter()
915        .map(|evidence| AgreementRegistryRecord {
916            record: &evidence.record,
917            requested_registry_version: evidence.requested_registry_version,
918            returned_registry_version: evidence.returned_registry_version,
919            timestamp_nanoseconds: evidence.timestamp_nanoseconds,
920            value_encoding: evidence.value_encoding,
921        })
922        .collect();
923
924    let payload = AgreementPayload {
925        catalog_schema_version: raw.catalog_schema_version,
926        network: &raw.provenance.network,
927        registry_canister_id: &raw.provenance.registry_canister_id,
928        registry_version: raw.provenance.registry_version,
929        routing_source: raw.provenance.routing_source,
930        registry_records,
931        subnets: &raw.subnets,
932        routing_ranges: &raw.routing_ranges,
933    };
934    Ok(canonical_json_sha256(&payload)?)
935}
936
937#[cfg(feature = "subnet-catalog-host")]
938fn validate_agreement_digest(raw: &RawSubnetCatalog) -> Result<(), CatalogError> {
939    let actual = raw
940        .provenance
941        .agreement_digest
942        .as_deref()
943        .unwrap_or_default();
944    if actual.len() != 64 || !is_lowercase_hex(actual) {
945        return Err(CatalogError::InvalidAgreementDigest {
946            value: actual.to_string(),
947        });
948    }
949    let expected = hex_bytes(&catalog_agreement_digest(raw)?);
950    if actual != expected {
951        return Err(CatalogError::AgreementDigestMismatch {
952            expected,
953            actual: actual.to_string(),
954        });
955    }
956    Ok(())
957}
958
959fn compare_routing_keys(
960    left_start: &[u8],
961    left_end: &[u8],
962    left_subnet: &str,
963    right_start: &[u8],
964    right_end: &[u8],
965    right_subnet: &str,
966) -> Ordering {
967    left_start
968        .cmp(right_start)
969        .then_with(|| left_end.cmp(right_end))
970        .then_with(|| left_subnet.cmp(right_subnet))
971}