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