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