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 = "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 #[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 #[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 #[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 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 #[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 #[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 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}