1#[cfg(feature = "subnet-catalog-host")]
8use super::{
9 CatalogAssurance, CatalogValidationContext, ValidatedSubnetCatalog,
10 policy::{RESOLVER_BACKEND, apply_mainnet_classification_policy, classification_policy_digest},
11};
12use super::{RawSubnetCatalog, RoutingRange, SubnetInfo};
13use crate::subnet_catalog::{
14 CATALOG_SCHEMA_VERSION, CatalogError, MAINNET_NETWORK, MAINNET_REGISTRY_CANISTER_ID,
15 parse_principal, principal_bytes, resolver::routing_range_sorts_after,
16};
17#[cfg(feature = "subnet-catalog-host")]
18use crate::{
19 hex::{hex_bytes, is_lowercase_hex},
20 http_endpoint::parse_http_endpoint,
21 subnet_catalog::{CLASSIFICATION_SCHEMA_VERSION, RESOLVER_SCHEMA_VERSION},
22};
23#[cfg(feature = "subnet-catalog-host")]
24use sha2::{Digest, Sha256};
25use std::{cmp::Ordering, collections::BTreeSet};
26
27impl RawSubnetCatalog {
28 #[cfg(feature = "subnet-catalog-host")]
30 pub fn new_mainnet_uncertified(
31 registry_version: u64,
32 source_endpoint: impl Into<String>,
33 fetched_at: impl Into<String>,
34 fetched_by: impl Into<String>,
35 collector_version: impl Into<String>,
36 subnets: Vec<SubnetInfo>,
37 routing_ranges: Vec<RoutingRange>,
38 ) -> Result<Self, CatalogError> {
39 let mut raw = Self {
40 catalog_schema_version: CATALOG_SCHEMA_VERSION,
41 provenance: super::SubnetCatalogProvenance {
42 network: MAINNET_NETWORK.to_string(),
43 registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
44 registry_version,
45 assurance: CatalogAssurance::UncertifiedQuery,
46 source_endpoints: vec![source_endpoint.into()],
47 fetched_at: fetched_at.into(),
48 certificate_time: None,
49 root_key_digest: None,
50 fetched_by: fetched_by.into(),
51 collector_version: collector_version.into(),
52 classification_schema_version: CLASSIFICATION_SCHEMA_VERSION,
53 classification_policy_digest: classification_policy_digest(),
54 resolver_schema_version: RESOLVER_SCHEMA_VERSION,
55 resolver_backend: RESOLVER_BACKEND.to_string(),
56 },
57 catalog_digest: String::new(),
58 subnets,
59 routing_ranges,
60 };
61 raw.canonicalize_and_seal()?;
62 Ok(raw)
63 }
64
65 #[cfg(feature = "subnet-catalog-host")]
67 pub fn canonicalize_and_seal(&mut self) -> Result<(), CatalogError> {
68 self.subnets
69 .sort_by(|left, right| left.subnet_principal.cmp(&right.subnet_principal));
70 let mut keyed_ranges = self
71 .routing_ranges
72 .drain(..)
73 .map(|range| {
74 Ok::<_, CatalogError>((
75 principal_bytes(&range.start_canister_id, "start_canister_id")?,
76 principal_bytes(&range.end_canister_id, "end_canister_id")?,
77 range.subnet_principal.clone(),
78 range,
79 ))
80 })
81 .collect::<Result<Vec<_>, _>>()?;
82 keyed_ranges.sort_by(|left, right| {
83 compare_routing_keys(&left.0, &left.1, &left.2, &right.0, &right.1, &right.2)
84 });
85 self.routing_ranges = keyed_ranges
86 .into_iter()
87 .map(|(_, _, _, range)| range)
88 .collect();
89 apply_mainnet_classification_policy(self);
90 self.provenance.classification_schema_version = CLASSIFICATION_SCHEMA_VERSION;
91 self.provenance.classification_policy_digest = classification_policy_digest();
92 self.provenance.resolver_schema_version = RESOLVER_SCHEMA_VERSION;
93 self.provenance.resolver_backend = RESOLVER_BACKEND.to_string();
94 self.catalog_digest = hex_bytes(&canonical_catalog_digest(self)?);
95 self.validate()
96 }
97
98 pub fn validate(&self) -> Result<(), CatalogError> {
100 if self.catalog_schema_version != CATALOG_SCHEMA_VERSION {
101 return Err(CatalogError::UnsupportedSchemaVersion {
102 found: self.catalog_schema_version,
103 supported: CATALOG_SCHEMA_VERSION,
104 });
105 }
106 if self.provenance.network != MAINNET_NETWORK {
107 return Err(CatalogError::NetworkMismatch {
108 expected: MAINNET_NETWORK.to_string(),
109 actual: self.provenance.network.clone(),
110 });
111 }
112 if self.provenance.registry_canister_id != MAINNET_REGISTRY_CANISTER_ID {
113 return Err(CatalogError::RegistryCanisterMismatch {
114 expected: MAINNET_REGISTRY_CANISTER_ID.to_string(),
115 actual: self.provenance.registry_canister_id.clone(),
116 });
117 }
118 if self.provenance.registry_version == 0 {
119 return Err(CatalogError::InvalidRegistryVersion);
120 }
121 if self.subnets.is_empty() {
122 return Err(CatalogError::EmptySubnets);
123 }
124 if self.routing_ranges.is_empty() {
125 return Err(CatalogError::EmptyRoutingRanges);
126 }
127 parse_principal(
128 &self.provenance.registry_canister_id,
129 "provenance.registry_canister_id",
130 )?;
131
132 let mut subnet_principals = BTreeSet::new();
133 let mut previous_subnet: Option<&str> = None;
134 for subnet in &self.subnets {
135 parse_principal(&subnet.subnet_principal, "subnet_principal")?;
136 if let Some(previous) = previous_subnet
137 && previous >= subnet.subnet_principal.as_str()
138 {
139 return Err(CatalogError::NonCanonicalSubnetOrder {
140 previous: previous.to_string(),
141 current: subnet.subnet_principal.clone(),
142 });
143 }
144 previous_subnet = Some(subnet.subnet_principal.as_str());
145 if !subnet_principals.insert(subnet.subnet_principal.clone()) {
146 return Err(CatalogError::DuplicateSubnet {
147 subnet_principal: subnet.subnet_principal.clone(),
148 });
149 }
150 validate_raw_subnet_classification(subnet)?;
151 }
152
153 let mut validated_ranges = Vec::with_capacity(self.routing_ranges.len());
154 for range in &self.routing_ranges {
155 if !subnet_principals.contains(&range.subnet_principal) {
156 return Err(CatalogError::UnknownRoutingSubnet {
157 subnet_principal: range.subnet_principal.clone(),
158 });
159 }
160 let start = principal_bytes(&range.start_canister_id, "start_canister_id")?;
161 let end = principal_bytes(&range.end_canister_id, "end_canister_id")?;
162 parse_principal(&range.subnet_principal, "routing_range.subnet_principal")?;
163 if routing_range_sorts_after(&start, &end) {
164 return Err(CatalogError::InvalidRoutingRange {
165 subnet_principal: range.subnet_principal.clone(),
166 start_canister_id: range.start_canister_id.clone(),
167 end_canister_id: range.end_canister_id.clone(),
168 });
169 }
170 validated_ranges.push((range, start, end));
171 }
172 for pair in validated_ranges.windows(2) {
173 let (first, first_start, first_end) = &pair[0];
174 let (second, second_start, second_end) = &pair[1];
175 if compare_routing_keys(
176 first_start,
177 first_end,
178 &first.subnet_principal,
179 second_start,
180 second_end,
181 &second.subnet_principal,
182 ) != Ordering::Less
183 {
184 return Err(CatalogError::NonCanonicalRoutingOrder {
185 previous: Box::new((*first).clone()),
186 current: Box::new((*second).clone()),
187 });
188 }
189 if second_start <= first_end {
190 return Err(CatalogError::OverlappingRoutingRanges {
191 first: Box::new((*first).clone()),
192 second: Box::new((*second).clone()),
193 });
194 }
195 }
196
197 Ok(())
198 }
199
200 #[must_use]
202 pub fn subnet_by_principal(&self, subnet_principal: &str) -> Option<&SubnetInfo> {
203 self.subnets
204 .iter()
205 .find(|subnet| subnet.subnet_principal == subnet_principal)
206 }
207
208 #[must_use]
210 pub fn routing_ranges_for_subnet(&self, subnet_principal: &str) -> Vec<&RoutingRange> {
211 self.routing_ranges
212 .iter()
213 .filter(|range| range.subnet_principal == subnet_principal)
214 .collect()
215 }
216}
217
218#[cfg(feature = "subnet-catalog-host")]
219impl ValidatedSubnetCatalog {
220 pub fn try_from_raw(
222 raw: RawSubnetCatalog,
223 context: &CatalogValidationContext,
224 ) -> Result<Self, CatalogError> {
225 raw.validate()?;
226 validate_expected_identity(&raw, context)?;
227 validate_provenance(&raw, context)?;
228 validate_classification_policy(&raw)?;
229 let catalog_digest = validate_catalog_digest(&raw)?;
230 Ok(Self::from_validated_parts(raw, catalog_digest))
231 }
232}
233
234fn validate_raw_subnet_classification(subnet: &SubnetInfo) -> Result<(), CatalogError> {
235 let expected_kind = super::SubnetKind::from_registry_subnet_type(subnet.registry_subnet_type);
236 if subnet.subnet_kind != expected_kind {
237 return Err(CatalogError::SubnetKindMismatch {
238 subnet_principal: subnet.subnet_principal.clone(),
239 registry_subnet_type: subnet.registry_subnet_type,
240 expected: expected_kind.as_str().to_string(),
241 actual: subnet.subnet_kind.as_str().to_string(),
242 });
243 }
244 if subnet.subnet_kind_source != super::ClassificationSource::Registry {
245 return Err(CatalogError::ClassificationMismatch {
246 subnet_principal: subnet.subnet_principal.clone(),
247 field: "subnet_kind_source",
248 reason: "raw Registry subnet kind must have registry source".to_string(),
249 });
250 }
251 let expected_charges = expected_kind.charges_apply_by_default();
252 if subnet.charges_apply_by_default != expected_charges {
253 return Err(CatalogError::ChargingPolicyMismatch {
254 subnet_principal: subnet.subnet_principal.clone(),
255 expected: expected_charges,
256 actual: subnet.charges_apply_by_default,
257 });
258 }
259 Ok(())
260}
261
262#[cfg(feature = "subnet-catalog-host")]
263fn validate_expected_identity(
264 raw: &RawSubnetCatalog,
265 context: &CatalogValidationContext,
266) -> Result<(), CatalogError> {
267 if raw.provenance.network != context.expected_network {
268 return Err(CatalogError::NetworkMismatch {
269 expected: context.expected_network.clone(),
270 actual: raw.provenance.network.clone(),
271 });
272 }
273 if raw.provenance.registry_canister_id != context.expected_registry_canister_id {
274 return Err(CatalogError::RegistryCanisterMismatch {
275 expected: context.expected_registry_canister_id.clone(),
276 actual: raw.provenance.registry_canister_id.clone(),
277 });
278 }
279 Ok(())
280}
281
282#[cfg(feature = "subnet-catalog-host")]
283fn validate_provenance(
284 raw: &RawSubnetCatalog,
285 context: &CatalogValidationContext,
286) -> Result<(), CatalogError> {
287 let fetched_at_unix_secs = crate::subnet_catalog::parse_utc_timestamp_secs(
288 &raw.provenance.fetched_at,
289 )
290 .ok_or_else(|| CatalogError::InvalidTimestamp {
291 field: "provenance.fetched_at",
292 value: raw.provenance.fetched_at.clone(),
293 })?;
294 if crate::subnet_catalog::format_utc_timestamp_secs(fetched_at_unix_secs)
295 != raw.provenance.fetched_at
296 {
297 return Err(CatalogError::InvalidTimestamp {
298 field: "provenance.fetched_at",
299 value: raw.provenance.fetched_at.clone(),
300 });
301 }
302 let latest_allowed = context
303 .now_unix_secs
304 .saturating_add(context.max_future_skew_seconds);
305 if fetched_at_unix_secs > latest_allowed {
306 return Err(CatalogError::FutureTimestamp {
307 field: "provenance.fetched_at",
308 value: raw.provenance.fetched_at.clone(),
309 latest_allowed_unix_secs: latest_allowed,
310 });
311 }
312 if raw.provenance.fetched_by.trim().is_empty() {
313 return Err(CatalogError::InvalidProvenance {
314 field: "provenance.fetched_by",
315 reason: "collector identity must not be empty".to_string(),
316 });
317 }
318 if raw.provenance.collector_version.trim().is_empty() {
319 return Err(CatalogError::InvalidProvenance {
320 field: "provenance.collector_version",
321 reason: "collector version must not be empty".to_string(),
322 });
323 }
324 if raw.provenance.source_endpoints.is_empty() {
325 return Err(CatalogError::InvalidProvenance {
326 field: "provenance.source_endpoints",
327 reason: "at least one source endpoint is required".to_string(),
328 });
329 }
330 for endpoint in &raw.provenance.source_endpoints {
331 parse_http_endpoint(endpoint).map_err(|reason| CatalogError::InvalidSourceEndpoint {
332 endpoint: endpoint.clone(),
333 reason,
334 })?;
335 }
336 match raw.provenance.assurance {
337 CatalogAssurance::UncertifiedQuery => {
338 if raw.provenance.source_endpoints.len() != 1 {
339 return Err(CatalogError::InvalidProvenance {
340 field: "provenance.source_endpoints",
341 reason: "uncertified query assurance requires exactly one source endpoint"
342 .to_string(),
343 });
344 }
345 if raw.provenance.certificate_time.is_some() || raw.provenance.root_key_digest.is_some()
346 {
347 return Err(CatalogError::InvalidProvenance {
348 field: "provenance.assurance",
349 reason: "uncertified query must not carry certificate evidence".to_string(),
350 });
351 }
352 }
353 assurance => {
354 return Err(CatalogError::UnsupportedAssurance {
355 assurance: assurance.as_str().to_string(),
356 });
357 }
358 }
359 if raw.provenance.classification_schema_version != CLASSIFICATION_SCHEMA_VERSION {
360 return Err(CatalogError::ClassificationPolicyVersionMismatch {
361 found: raw.provenance.classification_schema_version,
362 supported: CLASSIFICATION_SCHEMA_VERSION,
363 });
364 }
365 let expected_policy_digest = classification_policy_digest();
366 if raw.provenance.classification_policy_digest != expected_policy_digest {
367 return Err(CatalogError::ClassificationPolicyDigestMismatch {
368 expected: expected_policy_digest,
369 actual: raw.provenance.classification_policy_digest.clone(),
370 });
371 }
372 if raw.provenance.resolver_schema_version != RESOLVER_SCHEMA_VERSION
373 || raw.provenance.resolver_backend != RESOLVER_BACKEND
374 {
375 return Err(CatalogError::ResolverPolicyMismatch {
376 expected_version: RESOLVER_SCHEMA_VERSION,
377 actual_version: raw.provenance.resolver_schema_version,
378 expected_backend: RESOLVER_BACKEND.to_string(),
379 actual_backend: raw.provenance.resolver_backend.clone(),
380 });
381 }
382 Ok(())
383}
384
385#[cfg(feature = "subnet-catalog-host")]
386fn validate_classification_policy(raw: &RawSubnetCatalog) -> Result<(), CatalogError> {
387 let mut expected = raw.clone();
388 apply_mainnet_classification_policy(&mut expected);
389 for (actual, expected) in raw.subnets.iter().zip(&expected.subnets) {
390 if actual.subnet_specialization != expected.subnet_specialization
391 || actual.subnet_specialization_source != expected.subnet_specialization_source
392 || actual.geographic_scope != expected.geographic_scope
393 || actual.geographic_scope_source != expected.geographic_scope_source
394 || actual.subnet_label != expected.subnet_label
395 || actual.subnet_label_source != expected.subnet_label_source
396 {
397 return Err(CatalogError::ClassificationMismatch {
398 subnet_principal: actual.subnet_principal.clone(),
399 field: "curated_or_computed_annotations",
400 reason: "annotations do not match the recorded classification policy".to_string(),
401 });
402 }
403 }
404 Ok(())
405}
406
407#[cfg(feature = "subnet-catalog-host")]
408fn validate_catalog_digest(raw: &RawSubnetCatalog) -> Result<[u8; 32], CatalogError> {
409 if raw.catalog_digest.len() != 64 || !is_lowercase_hex(&raw.catalog_digest) {
410 return Err(CatalogError::InvalidCatalogDigest {
411 value: raw.catalog_digest.clone(),
412 });
413 }
414 let expected = canonical_catalog_digest(raw)?;
415 if raw.catalog_digest != hex_bytes(&expected) {
416 return Err(CatalogError::CatalogDigestMismatch {
417 expected: hex_bytes(&expected),
418 actual: raw.catalog_digest.clone(),
419 });
420 }
421 Ok(expected)
422}
423
424#[cfg(feature = "subnet-catalog-host")]
425fn canonical_catalog_digest(raw: &RawSubnetCatalog) -> Result<[u8; 32], CatalogError> {
426 let mut payload = raw.clone();
427 payload.catalog_digest.clear();
428 let serialized = serde_json::to_vec(&payload)?;
429 Ok(Sha256::digest(serialized).into())
430}
431
432fn compare_routing_keys(
433 left_start: &[u8],
434 left_end: &[u8],
435 left_subnet: &str,
436 right_start: &[u8],
437 right_end: &[u8],
438 right_subnet: &str,
439) -> Ordering {
440 left_start
441 .cmp(right_start)
442 .then_with(|| left_end.cmp(right_end))
443 .then_with(|| left_subnet.cmp(right_subnet))
444}