canic-subnet-catalog 0.66.10

Pure cached IC subnet catalog model and resolver for Canic host tools
Documentation
use crate::{
    CATALOG_SCHEMA_VERSION, CatalogError, ClassificationSource, GeographicScope, MAINNET_NETWORK,
    MAINNET_REGISTRY_CANISTER_ID, ResolveAs, ResolvedSubnetSubject, RoutingRange, SubnetCatalog,
    SubnetInfo, SubnetKind, SubnetSpecialization, parse_catalog_json, principal_bytes,
};

const SUBNET_A: &str = "rwlgt-iiaaa-aaaaa-aaaaa-cai";
const SUBNET_B: &str = "aaaaa-aa";

#[test]
fn catalog_schema_round_trips_through_json() {
    let catalog = fixture_catalog();
    let json = serde_json::to_string_pretty(&catalog).expect("serialize catalog");
    let decoded = parse_catalog_json(&json).expect("parse catalog");

    assert_eq!(decoded, catalog);
}

#[test]
fn unknown_future_schema_version_is_rejected() {
    let mut catalog = fixture_catalog();
    catalog.catalog_schema_version = CATALOG_SCHEMA_VERSION + 1;
    let json = serde_json::to_string(&catalog).expect("serialize catalog");

    let err = parse_catalog_json(&json).expect_err("future schema must fail");

    std::assert_matches!(
        err,
        CatalogError::UnsupportedSchemaVersion {
            found,
            supported: CATALOG_SCHEMA_VERSION
        } if found == CATALOG_SCHEMA_VERSION + 1
    );
}

#[test]
fn empty_subnets_and_empty_ranges_are_rejected() {
    let mut empty_subnets = fixture_catalog();
    empty_subnets.subnets.clear();
    std::assert_matches!(empty_subnets.validate(), Err(CatalogError::EmptySubnets));

    let mut empty_ranges = fixture_catalog();
    empty_ranges.routing_ranges.clear();
    std::assert_matches!(
        empty_ranges.validate(),
        Err(CatalogError::EmptyRoutingRanges)
    );
}

#[test]
fn resolver_maps_canister_by_inclusive_principal_byte_range() {
    let ids = sorted_principals([
        "ryjl3-tyaaa-aaaaa-aaaba-cai",
        "rrkah-fqaaa-aaaaa-aaaaq-cai",
        "r7inp-6aaaa-aaaaa-aaabq-cai",
        "t63gs-up777-77776-aaaba-cai",
        "uxrrr-q7777-77774-qaaaq-cai",
    ]);
    let mut catalog = fixture_catalog();
    catalog.routing_ranges = vec![RoutingRange {
        start_canister_id: ids[1].clone(),
        end_canister_id: ids[3].clone(),
        subnet_principal: SUBNET_A.to_string(),
    }];
    catalog.validate().expect("valid range");

    let start = catalog
        .resolve_principal(&ids[1], None)
        .expect("start boundary resolves");
    let inside = catalog
        .resolve_principal(&ids[2], None)
        .expect("inside resolves");
    let end = catalog
        .resolve_principal(&ids[3], None)
        .expect("end boundary resolves");

    assert_eq!(start.subnet.subnet_principal, SUBNET_A);
    assert_eq!(inside.subnet.subnet_principal, SUBNET_A);
    assert_eq!(end.subnet.subnet_principal, SUBNET_A);
    std::assert_matches!(
        catalog.resolve_principal(&ids[0], None),
        Err(CatalogError::RouteNotFound { .. })
    );
    std::assert_matches!(
        catalog.resolve_principal(&ids[4], None),
        Err(CatalogError::RouteNotFound { .. })
    );
}

#[test]
fn known_subnet_principal_wins_over_canister_range_interpretation() {
    let mut catalog = fixture_catalog();
    catalog.routing_ranges = vec![RoutingRange {
        start_canister_id: SUBNET_A.to_string(),
        end_canister_id: SUBNET_A.to_string(),
        subnet_principal: SUBNET_B.to_string(),
    }];
    catalog.validate().expect("valid synthetic overlap");

    let resolved = catalog
        .resolve_principal(SUBNET_A, None)
        .expect("known subnet wins");

    assert_eq!(resolved.resolved_as, ResolvedSubnetSubject::Subnet);
    assert_eq!(resolved.subnet.subnet_principal, SUBNET_A);
}

#[test]
fn forced_interpretations_are_explicit() {
    let catalog = fixture_catalog();

    let subnet = catalog
        .resolve_principal(SUBNET_A, Some(ResolveAs::Subnet))
        .expect("forced subnet");
    assert_eq!(subnet.resolved_as, ResolvedSubnetSubject::Subnet);

    let err = catalog
        .resolve_principal(SUBNET_A, Some(ResolveAs::Canister))
        .expect_err("subnet principal is not covered as a canister");
    std::assert_matches!(err, CatalogError::RouteNotFound { .. });
}

#[test]
fn resolver_accepts_unique_subnet_principal_prefix() {
    let catalog = fixture_catalog();

    let resolved = catalog
        .resolve_principal_or_prefix("rwl", None)
        .expect("subnet prefix resolves");

    assert_eq!(resolved.resolved_as, ResolvedSubnetSubject::Subnet);
    assert_eq!(resolved.resolved_from, "subnet_principal_prefix");
    assert_eq!(resolved.input_principal, "rwl");
    assert_eq!(resolved.subnet.subnet_principal, SUBNET_A);
}

#[test]
fn resolver_rejects_canister_boundary_prefix() {
    let catalog = fixture_catalog();

    let err = catalog
        .resolve_principal_or_prefix("ryj", None)
        .expect_err("partial canister principals are not accepted");

    std::assert_matches!(
        err,
        CatalogError::PrincipalPrefixNotFound { prefix } if prefix == "ryj"
    );
}

#[test]
fn resolver_rejects_ambiguous_principal_prefix() {
    let mut catalog = fixture_catalog();
    catalog.subnets.push(SubnetInfo {
        subnet_principal: "r7inp-6aaaa-aaaaa-aaabq-cai".to_string(),
        subnet_kind: SubnetKind::Application,
        subnet_kind_source: ClassificationSource::Registry,
        subnet_specialization: SubnetSpecialization::None,
        subnet_specialization_source: ClassificationSource::Computed,
        geographic_scope: GeographicScope::Global,
        geographic_scope_source: ClassificationSource::Computed,
        subnet_label: "application".to_string(),
        subnet_label_source: ClassificationSource::Computed,
        node_count: Some(13),
        charges_apply_by_default: true,
    });
    catalog.validate().expect("valid ambiguous fixture");

    let err = catalog
        .resolve_principal_or_prefix("r", None)
        .expect_err("ambiguous prefix fails");

    std::assert_matches!(
        err,
        CatalogError::AmbiguousPrincipalPrefix { prefix, matches }
            if prefix == "r" && matches.len() > 1
    );
}

#[test]
fn validation_rejects_unknown_routing_subnet_and_reversed_range() {
    let mut unknown = fixture_catalog();
    unknown.routing_ranges[0].subnet_principal = "uxrrr-q7777-77774-qaaaq-cai".to_string();
    std::assert_matches!(
        unknown.validate(),
        Err(CatalogError::UnknownRoutingSubnet { .. })
    );

    let ids = sorted_principals(["ryjl3-tyaaa-aaaaa-aaaba-cai", "rrkah-fqaaa-aaaaa-aaaaq-cai"]);
    let mut reversed = fixture_catalog();
    reversed.routing_ranges = vec![RoutingRange {
        start_canister_id: ids[1].clone(),
        end_canister_id: ids[0].clone(),
        subnet_principal: SUBNET_A.to_string(),
    }];
    std::assert_matches!(
        reversed.validate(),
        Err(CatalogError::InvalidRoutingRange { .. })
    );
}

fn fixture_catalog() -> SubnetCatalog {
    SubnetCatalog {
        catalog_schema_version: CATALOG_SCHEMA_VERSION,
        network: MAINNET_NETWORK.to_string(),
        registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
        registry_version: 123_456,
        fetched_at: "2026-06-04T00:00:00Z".to_string(),
        fetched_by: "fixture".to_string(),
        source_endpoint: "https://icp-api.io".to_string(),
        resolver_backend: "local-nns-subnet-catalog".to_string(),
        subnets: vec![
            SubnetInfo {
                subnet_principal: SUBNET_A.to_string(),
                subnet_kind: SubnetKind::Application,
                subnet_kind_source: ClassificationSource::Registry,
                subnet_specialization: SubnetSpecialization::Fiduciary,
                subnet_specialization_source: ClassificationSource::Curated,
                geographic_scope: GeographicScope::Global,
                geographic_scope_source: ClassificationSource::Curated,
                subnet_label: "fiduciary".to_string(),
                subnet_label_source: ClassificationSource::Curated,
                node_count: Some(34),
                charges_apply_by_default: true,
            },
            SubnetInfo {
                subnet_principal: SUBNET_B.to_string(),
                subnet_kind: SubnetKind::Application,
                subnet_kind_source: ClassificationSource::Registry,
                subnet_specialization: SubnetSpecialization::European,
                subnet_specialization_source: ClassificationSource::Curated,
                geographic_scope: GeographicScope::Europe,
                geographic_scope_source: ClassificationSource::Curated,
                subnet_label: "european".to_string(),
                subnet_label_source: ClassificationSource::Curated,
                node_count: Some(13),
                charges_apply_by_default: true,
            },
        ],
        routing_ranges: vec![RoutingRange {
            start_canister_id: "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(),
            end_canister_id: "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(),
            subnet_principal: SUBNET_A.to_string(),
        }],
    }
}

fn sorted_principals<const N: usize>(ids: [&str; N]) -> Vec<String> {
    let mut ids = ids.map(str::to_string).to_vec();
    ids.sort_by(|left, right| {
        principal_bytes(left, "test")
            .expect("valid left")
            .cmp(&principal_bytes(right, "test").expect("valid right"))
    });
    ids
}