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