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::{
9    CatalogAssurance, CatalogValidationContext, ValidatedSubnetCatalog,
10    policy::{RESOLVER_BACKEND, apply_mainnet_classification_policy, classification_policy_digest},
11};
12use super::{RawSubnetCatalog, RoutingRange, SubnetInfo};
13use crate::subnet_catalog::{
14    CATALOG_SCHEMA_VERSION, CatalogError, MAINNET_NETWORK, MAINNET_REGISTRY_CANISTER_ID,
15    parse_principal, principal_bytes, resolver::routing_range_sorts_after,
16};
17#[cfg(feature = "subnet-catalog-host")]
18use crate::{
19    hex::{hex_bytes, is_lowercase_hex},
20    http_endpoint::parse_http_endpoint,
21    subnet_catalog::{CLASSIFICATION_SCHEMA_VERSION, RESOLVER_SCHEMA_VERSION},
22};
23#[cfg(feature = "subnet-catalog-host")]
24use sha2::{Digest, Sha256};
25use std::{cmp::Ordering, collections::BTreeSet};
26
27impl RawSubnetCatalog {
28    /// Build, canonicalize, classify, and seal one uncertified mainnet source snapshot.
29    #[cfg(feature = "subnet-catalog-host")]
30    pub fn new_mainnet_uncertified(
31        registry_version: u64,
32        source_endpoint: impl Into<String>,
33        fetched_at: impl Into<String>,
34        fetched_by: impl Into<String>,
35        collector_version: impl Into<String>,
36        subnets: Vec<SubnetInfo>,
37        routing_ranges: Vec<RoutingRange>,
38    ) -> Result<Self, CatalogError> {
39        let mut raw = Self {
40            catalog_schema_version: CATALOG_SCHEMA_VERSION,
41            provenance: super::SubnetCatalogProvenance {
42                network: MAINNET_NETWORK.to_string(),
43                registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
44                registry_version,
45                assurance: CatalogAssurance::UncertifiedQuery,
46                source_endpoints: vec![source_endpoint.into()],
47                fetched_at: fetched_at.into(),
48                certificate_time: None,
49                root_key_digest: None,
50                fetched_by: fetched_by.into(),
51                collector_version: collector_version.into(),
52                classification_schema_version: CLASSIFICATION_SCHEMA_VERSION,
53                classification_policy_digest: classification_policy_digest(),
54                resolver_schema_version: RESOLVER_SCHEMA_VERSION,
55                resolver_backend: RESOLVER_BACKEND.to_string(),
56            },
57            catalog_digest: String::new(),
58            subnets,
59            routing_ranges,
60        };
61        raw.canonicalize_and_seal()?;
62        Ok(raw)
63    }
64
65    /// Canonicalize source rows, apply the current policy, and replace the catalog digest.
66    #[cfg(feature = "subnet-catalog-host")]
67    pub fn canonicalize_and_seal(&mut self) -> Result<(), CatalogError> {
68        self.subnets
69            .sort_by(|left, right| left.subnet_principal.cmp(&right.subnet_principal));
70        let mut keyed_ranges = self
71            .routing_ranges
72            .drain(..)
73            .map(|range| {
74                Ok::<_, CatalogError>((
75                    principal_bytes(&range.start_canister_id, "start_canister_id")?,
76                    principal_bytes(&range.end_canister_id, "end_canister_id")?,
77                    range.subnet_principal.clone(),
78                    range,
79                ))
80            })
81            .collect::<Result<Vec<_>, _>>()?;
82        keyed_ranges.sort_by(|left, right| {
83            compare_routing_keys(&left.0, &left.1, &left.2, &right.0, &right.1, &right.2)
84        });
85        self.routing_ranges = keyed_ranges
86            .into_iter()
87            .map(|(_, _, _, range)| range)
88            .collect();
89        apply_mainnet_classification_policy(self);
90        self.provenance.classification_schema_version = CLASSIFICATION_SCHEMA_VERSION;
91        self.provenance.classification_policy_digest = classification_policy_digest();
92        self.provenance.resolver_schema_version = RESOLVER_SCHEMA_VERSION;
93        self.provenance.resolver_backend = RESOLVER_BACKEND.to_string();
94        self.catalog_digest = hex_bytes(&canonical_catalog_digest(self)?);
95        self.validate()
96    }
97
98    /// Validate schema, fixed mainnet identity, raw classifications, and routing structure.
99    pub fn validate(&self) -> Result<(), CatalogError> {
100        if self.catalog_schema_version != CATALOG_SCHEMA_VERSION {
101            return Err(CatalogError::UnsupportedSchemaVersion {
102                found: self.catalog_schema_version,
103                supported: CATALOG_SCHEMA_VERSION,
104            });
105        }
106        if self.provenance.network != MAINNET_NETWORK {
107            return Err(CatalogError::NetworkMismatch {
108                expected: MAINNET_NETWORK.to_string(),
109                actual: self.provenance.network.clone(),
110            });
111        }
112        if self.provenance.registry_canister_id != MAINNET_REGISTRY_CANISTER_ID {
113            return Err(CatalogError::RegistryCanisterMismatch {
114                expected: MAINNET_REGISTRY_CANISTER_ID.to_string(),
115                actual: self.provenance.registry_canister_id.clone(),
116            });
117        }
118        if self.provenance.registry_version == 0 {
119            return Err(CatalogError::InvalidRegistryVersion);
120        }
121        if self.subnets.is_empty() {
122            return Err(CatalogError::EmptySubnets);
123        }
124        if self.routing_ranges.is_empty() {
125            return Err(CatalogError::EmptyRoutingRanges);
126        }
127        parse_principal(
128            &self.provenance.registry_canister_id,
129            "provenance.registry_canister_id",
130        )?;
131
132        let mut subnet_principals = BTreeSet::new();
133        let mut previous_subnet: Option<&str> = None;
134        for subnet in &self.subnets {
135            parse_principal(&subnet.subnet_principal, "subnet_principal")?;
136            if let Some(previous) = previous_subnet
137                && previous >= subnet.subnet_principal.as_str()
138            {
139                return Err(CatalogError::NonCanonicalSubnetOrder {
140                    previous: previous.to_string(),
141                    current: subnet.subnet_principal.clone(),
142                });
143            }
144            previous_subnet = Some(subnet.subnet_principal.as_str());
145            if !subnet_principals.insert(subnet.subnet_principal.clone()) {
146                return Err(CatalogError::DuplicateSubnet {
147                    subnet_principal: subnet.subnet_principal.clone(),
148                });
149            }
150            validate_raw_subnet_classification(subnet)?;
151        }
152
153        let mut validated_ranges = Vec::with_capacity(self.routing_ranges.len());
154        for range in &self.routing_ranges {
155            if !subnet_principals.contains(&range.subnet_principal) {
156                return Err(CatalogError::UnknownRoutingSubnet {
157                    subnet_principal: range.subnet_principal.clone(),
158                });
159            }
160            let start = principal_bytes(&range.start_canister_id, "start_canister_id")?;
161            let end = principal_bytes(&range.end_canister_id, "end_canister_id")?;
162            parse_principal(&range.subnet_principal, "routing_range.subnet_principal")?;
163            if routing_range_sorts_after(&start, &end) {
164                return Err(CatalogError::InvalidRoutingRange {
165                    subnet_principal: range.subnet_principal.clone(),
166                    start_canister_id: range.start_canister_id.clone(),
167                    end_canister_id: range.end_canister_id.clone(),
168                });
169            }
170            validated_ranges.push((range, start, end));
171        }
172        for pair in validated_ranges.windows(2) {
173            let (first, first_start, first_end) = &pair[0];
174            let (second, second_start, second_end) = &pair[1];
175            if compare_routing_keys(
176                first_start,
177                first_end,
178                &first.subnet_principal,
179                second_start,
180                second_end,
181                &second.subnet_principal,
182            ) != Ordering::Less
183            {
184                return Err(CatalogError::NonCanonicalRoutingOrder {
185                    previous: Box::new((*first).clone()),
186                    current: Box::new((*second).clone()),
187                });
188            }
189            if second_start <= first_end {
190                return Err(CatalogError::OverlappingRoutingRanges {
191                    first: Box::new((*first).clone()),
192                    second: Box::new((*second).clone()),
193                });
194            }
195        }
196
197        Ok(())
198    }
199
200    /// Find one raw Subnet row by canonical principal text.
201    #[must_use]
202    pub fn subnet_by_principal(&self, subnet_principal: &str) -> Option<&SubnetInfo> {
203        self.subnets
204            .iter()
205            .find(|subnet| subnet.subnet_principal == subnet_principal)
206    }
207
208    /// Return raw routing ranges assigned to one Subnet.
209    #[must_use]
210    pub fn routing_ranges_for_subnet(&self, subnet_principal: &str) -> Vec<&RoutingRange> {
211        self.routing_ranges
212            .iter()
213            .filter(|range| range.subnet_principal == subnet_principal)
214            .collect()
215    }
216}
217
218#[cfg(feature = "subnet-catalog-host")]
219impl ValidatedSubnetCatalog {
220    /// Validate raw authority evidence against caller-owned identity and time policy.
221    pub fn try_from_raw(
222        raw: RawSubnetCatalog,
223        context: &CatalogValidationContext,
224    ) -> Result<Self, CatalogError> {
225        raw.validate()?;
226        validate_expected_identity(&raw, context)?;
227        validate_provenance(&raw, context)?;
228        validate_classification_policy(&raw)?;
229        let catalog_digest = validate_catalog_digest(&raw)?;
230        Ok(Self::from_validated_parts(raw, catalog_digest))
231    }
232}
233
234fn validate_raw_subnet_classification(subnet: &SubnetInfo) -> Result<(), CatalogError> {
235    let expected_kind = super::SubnetKind::from_registry_subnet_type(subnet.registry_subnet_type);
236    if subnet.subnet_kind != expected_kind {
237        return Err(CatalogError::SubnetKindMismatch {
238            subnet_principal: subnet.subnet_principal.clone(),
239            registry_subnet_type: subnet.registry_subnet_type,
240            expected: expected_kind.as_str().to_string(),
241            actual: subnet.subnet_kind.as_str().to_string(),
242        });
243    }
244    if subnet.subnet_kind_source != super::ClassificationSource::Registry {
245        return Err(CatalogError::ClassificationMismatch {
246            subnet_principal: subnet.subnet_principal.clone(),
247            field: "subnet_kind_source",
248            reason: "raw Registry subnet kind must have registry source".to_string(),
249        });
250    }
251    let expected_charges = expected_kind.charges_apply_by_default();
252    if subnet.charges_apply_by_default != expected_charges {
253        return Err(CatalogError::ChargingPolicyMismatch {
254            subnet_principal: subnet.subnet_principal.clone(),
255            expected: expected_charges,
256            actual: subnet.charges_apply_by_default,
257        });
258    }
259    Ok(())
260}
261
262#[cfg(feature = "subnet-catalog-host")]
263fn validate_expected_identity(
264    raw: &RawSubnetCatalog,
265    context: &CatalogValidationContext,
266) -> Result<(), CatalogError> {
267    if raw.provenance.network != context.expected_network {
268        return Err(CatalogError::NetworkMismatch {
269            expected: context.expected_network.clone(),
270            actual: raw.provenance.network.clone(),
271        });
272    }
273    if raw.provenance.registry_canister_id != context.expected_registry_canister_id {
274        return Err(CatalogError::RegistryCanisterMismatch {
275            expected: context.expected_registry_canister_id.clone(),
276            actual: raw.provenance.registry_canister_id.clone(),
277        });
278    }
279    Ok(())
280}
281
282#[cfg(feature = "subnet-catalog-host")]
283fn validate_provenance(
284    raw: &RawSubnetCatalog,
285    context: &CatalogValidationContext,
286) -> Result<(), CatalogError> {
287    let fetched_at_unix_secs = crate::subnet_catalog::parse_utc_timestamp_secs(
288        &raw.provenance.fetched_at,
289    )
290    .ok_or_else(|| CatalogError::InvalidTimestamp {
291        field: "provenance.fetched_at",
292        value: raw.provenance.fetched_at.clone(),
293    })?;
294    if crate::subnet_catalog::format_utc_timestamp_secs(fetched_at_unix_secs)
295        != raw.provenance.fetched_at
296    {
297        return Err(CatalogError::InvalidTimestamp {
298            field: "provenance.fetched_at",
299            value: raw.provenance.fetched_at.clone(),
300        });
301    }
302    let latest_allowed = context
303        .now_unix_secs
304        .saturating_add(context.max_future_skew_seconds);
305    if fetched_at_unix_secs > latest_allowed {
306        return Err(CatalogError::FutureTimestamp {
307            field: "provenance.fetched_at",
308            value: raw.provenance.fetched_at.clone(),
309            latest_allowed_unix_secs: latest_allowed,
310        });
311    }
312    if raw.provenance.fetched_by.trim().is_empty() {
313        return Err(CatalogError::InvalidProvenance {
314            field: "provenance.fetched_by",
315            reason: "collector identity must not be empty".to_string(),
316        });
317    }
318    if raw.provenance.collector_version.trim().is_empty() {
319        return Err(CatalogError::InvalidProvenance {
320            field: "provenance.collector_version",
321            reason: "collector version must not be empty".to_string(),
322        });
323    }
324    if raw.provenance.source_endpoints.is_empty() {
325        return Err(CatalogError::InvalidProvenance {
326            field: "provenance.source_endpoints",
327            reason: "at least one source endpoint is required".to_string(),
328        });
329    }
330    for endpoint in &raw.provenance.source_endpoints {
331        parse_http_endpoint(endpoint).map_err(|reason| CatalogError::InvalidSourceEndpoint {
332            endpoint: endpoint.clone(),
333            reason,
334        })?;
335    }
336    match raw.provenance.assurance {
337        CatalogAssurance::UncertifiedQuery => {
338            if raw.provenance.source_endpoints.len() != 1 {
339                return Err(CatalogError::InvalidProvenance {
340                    field: "provenance.source_endpoints",
341                    reason: "uncertified query assurance requires exactly one source endpoint"
342                        .to_string(),
343                });
344            }
345            if raw.provenance.certificate_time.is_some() || raw.provenance.root_key_digest.is_some()
346            {
347                return Err(CatalogError::InvalidProvenance {
348                    field: "provenance.assurance",
349                    reason: "uncertified query must not carry certificate evidence".to_string(),
350                });
351            }
352        }
353        assurance => {
354            return Err(CatalogError::UnsupportedAssurance {
355                assurance: assurance.as_str().to_string(),
356            });
357        }
358    }
359    if raw.provenance.classification_schema_version != CLASSIFICATION_SCHEMA_VERSION {
360        return Err(CatalogError::ClassificationPolicyVersionMismatch {
361            found: raw.provenance.classification_schema_version,
362            supported: CLASSIFICATION_SCHEMA_VERSION,
363        });
364    }
365    let expected_policy_digest = classification_policy_digest();
366    if raw.provenance.classification_policy_digest != expected_policy_digest {
367        return Err(CatalogError::ClassificationPolicyDigestMismatch {
368            expected: expected_policy_digest,
369            actual: raw.provenance.classification_policy_digest.clone(),
370        });
371    }
372    if raw.provenance.resolver_schema_version != RESOLVER_SCHEMA_VERSION
373        || raw.provenance.resolver_backend != RESOLVER_BACKEND
374    {
375        return Err(CatalogError::ResolverPolicyMismatch {
376            expected_version: RESOLVER_SCHEMA_VERSION,
377            actual_version: raw.provenance.resolver_schema_version,
378            expected_backend: RESOLVER_BACKEND.to_string(),
379            actual_backend: raw.provenance.resolver_backend.clone(),
380        });
381    }
382    Ok(())
383}
384
385#[cfg(feature = "subnet-catalog-host")]
386fn validate_classification_policy(raw: &RawSubnetCatalog) -> Result<(), CatalogError> {
387    let mut expected = raw.clone();
388    apply_mainnet_classification_policy(&mut expected);
389    for (actual, expected) in raw.subnets.iter().zip(&expected.subnets) {
390        if actual.subnet_specialization != expected.subnet_specialization
391            || actual.subnet_specialization_source != expected.subnet_specialization_source
392            || actual.geographic_scope != expected.geographic_scope
393            || actual.geographic_scope_source != expected.geographic_scope_source
394            || actual.subnet_label != expected.subnet_label
395            || actual.subnet_label_source != expected.subnet_label_source
396        {
397            return Err(CatalogError::ClassificationMismatch {
398                subnet_principal: actual.subnet_principal.clone(),
399                field: "curated_or_computed_annotations",
400                reason: "annotations do not match the recorded classification policy".to_string(),
401            });
402        }
403    }
404    Ok(())
405}
406
407#[cfg(feature = "subnet-catalog-host")]
408fn validate_catalog_digest(raw: &RawSubnetCatalog) -> Result<[u8; 32], CatalogError> {
409    if raw.catalog_digest.len() != 64 || !is_lowercase_hex(&raw.catalog_digest) {
410        return Err(CatalogError::InvalidCatalogDigest {
411            value: raw.catalog_digest.clone(),
412        });
413    }
414    let expected = canonical_catalog_digest(raw)?;
415    if raw.catalog_digest != hex_bytes(&expected) {
416        return Err(CatalogError::CatalogDigestMismatch {
417            expected: hex_bytes(&expected),
418            actual: raw.catalog_digest.clone(),
419        });
420    }
421    Ok(expected)
422}
423
424#[cfg(feature = "subnet-catalog-host")]
425fn canonical_catalog_digest(raw: &RawSubnetCatalog) -> Result<[u8; 32], CatalogError> {
426    let mut payload = raw.clone();
427    payload.catalog_digest.clear();
428    let serialized = serde_json::to_vec(&payload)?;
429    Ok(Sha256::digest(serialized).into())
430}
431
432fn compare_routing_keys(
433    left_start: &[u8],
434    left_end: &[u8],
435    left_subnet: &str,
436    right_start: &[u8],
437    right_end: &[u8],
438    right_subnet: &str,
439) -> Ordering {
440    left_start
441        .cmp(right_start)
442        .then_with(|| left_end.cmp(right_end))
443        .then_with(|| left_subnet.cmp(right_subnet))
444}