Skip to main content

ic_query/subnet_catalog/model/
validation.rs

1//! Module: subnet_catalog::model::validation
2//!
3//! Responsibility: validate raw structural and host authority catalog evidence.
4//! Does not own: Registry transport, cache policy, or report rendering.
5//! Boundary: only this module constructs `ValidatedSubnetCatalog`.
6
7#[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    /// Build, canonicalize, classify, and seal one uncertified mainnet source snapshot.
34    #[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    /// Canonicalize source rows, apply the current policy, and replace the catalog digest.
69    #[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    /// Promote matching single-endpoint evidence into one sealed agreement snapshot.
102    #[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    /// Validate schema, fixed mainnet identity, raw classifications, and routing structure.
116    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    /// Find one raw Subnet row by canonical principal text.
218    #[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    /// Return raw routing ranges assigned to one Subnet.
226    #[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    /// Validate raw authority evidence against caller-owned identity and time policy.
238    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}