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