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