canic-core 0.84.0

Canic — a canister orchestration and management toolkit for the Internet Computer
Documentation
use super::{
    AllocationDefinition, AllocationLifecycle, AllocationOwner, BuiltInRoleKind,
    CanicFeatureEffect, CanicFeatureKey, MemoryId, RoleCapabilityKey, RoleContractFinding,
    RoleContractInput, RoleContractResolution, RoleContractSource, SelectionProvenance,
    StateAllocationKey, allocation,
    catalog::{self, default_features, implied_features},
    derive_role_capabilities, resolve_effective_features, resolve_role_contract,
};
use crate::{
    config::schema::{CanisterAuthConfig, CanisterKind, ShardingConfig},
    ids::CanisterRole,
    test::config::ConfigTestBuilder,
};
use std::{
    collections::{BTreeMap, BTreeSet},
    path::Path,
};

#[test]
fn catalog_matches_canic_cargo_features() {
    let canic_manifest = read_manifest("../canic/Cargo.toml");
    let core_manifest = read_manifest("Cargo.toml");
    let canic_features = feature_table(&canic_manifest);
    let core_features = feature_table(&core_manifest);

    let cargo_public_features = canic_features
        .keys()
        .filter(|name| name.as_str() != "default")
        .cloned()
        .collect::<BTreeSet<_>>();
    let catalog_public_features = catalog::feature_definitions()
        .iter()
        .map(|definition| definition.cargo_name.to_string())
        .collect::<BTreeSet<_>>();
    assert_eq!(catalog_public_features, cargo_public_features);

    let cargo_defaults = feature_members(canic_features, "default")
        .into_iter()
        .collect::<BTreeSet<_>>();
    let catalog_defaults = default_features()
        .iter()
        .map(|feature| feature.cargo_name().to_string())
        .collect::<BTreeSet<_>>();
    assert_eq!(catalog_defaults, cargo_defaults);

    let cargo_implications =
        cargo_public_implications(canic_features, core_features, &cargo_public_features);
    let catalog_implications = CanicFeatureKey::ALL
        .iter()
        .flat_map(|feature| {
            implied_features(*feature).map(|implied| {
                (
                    feature.cargo_name().to_string(),
                    implied.cargo_name().to_string(),
                )
            })
        })
        .collect::<BTreeSet<_>>();
    assert_eq!(catalog_implications, cargo_implications);
}

#[test]
fn catalog_is_valid_and_classifies_every_public_feature() {
    catalog::validate_catalog().expect("canonical role-contract catalog should be valid");

    for feature in CanicFeatureKey::ALL {
        assert!(matches!(
            feature.effect(),
            CanicFeatureEffect::NoState | CanicFeatureEffect::StateBearing
        ));
    }
}

#[test]
fn canonical_allocations_preserve_existing_ids() {
    allocation::validate_canonical_allocations()
        .expect("canonical allocation definitions should be valid");

    let actual = allocation::allocation_definitions()
        .iter()
        .map(|definition| {
            (
                definition.key,
                definition
                    .memory_ids
                    .iter()
                    .map(|memory_id| memory_id.get())
                    .collect::<Vec<_>>(),
            )
        })
        .collect::<BTreeMap<_, _>>();
    let expected = BTreeMap::from([
        (
            StateAllocationKey::CoreRootTopology,
            vec![11, 12, 13, 14, 15],
        ),
        (StateAllocationKey::CoreRootEnvironment, vec![16, 17, 18]),
        (StateAllocationKey::CoreRootAuth, vec![19, 21]),
        (StateAllocationKey::RetiredRootReplay, vec![20]),
        (
            StateAllocationKey::CoreRootObservability,
            vec![29, 30, 31, 32, 33, 34],
        ),
        (StateAllocationKey::CoreRootIntent, vec![39, 40, 41, 42]),
        (StateAllocationKey::CoreRootCapacity, vec![49, 52, 55]),
        (StateAllocationKey::ShardingRegistry, vec![53]),
        (StateAllocationKey::ShardingAssignments, vec![54]),
        (StateAllocationKey::ShardingActiveSet, vec![56]),
        (StateAllocationKey::StoredBlobs, vec![62]),
        (StateAllocationKey::BlobDeletionPending, vec![63]),
        (StateAllocationKey::StorageGatewayPrincipals, vec![64]),
        (StateAllocationKey::BlobStorageBilling, vec![65]),
        (StateAllocationKey::TemplateManifests, vec![80]),
        (StateAllocationKey::TemplateChunkSets, vec![81]),
        (StateAllocationKey::TemplateChunkRefs, vec![82]),
        (StateAllocationKey::TemplateChunkPayloads, vec![83]),
        (StateAllocationKey::ControlPlaneSubnetState, vec![84]),
        (StateAllocationKey::WasmStoreGcState, vec![85]),
    ]);
    assert_eq!(actual, expected);
}

#[test]
fn distinct_allocation_keys_cannot_share_a_memory_id() {
    const FIRST_IDS: &[MemoryId] = &[MemoryId::new(70)];
    const SECOND_IDS: &[MemoryId] = &[MemoryId::new(70)];
    let definitions = [
        AllocationDefinition {
            key: StateAllocationKey::StoredBlobs,
            owner: AllocationOwner::CanicCore,
            lifecycle: AllocationLifecycle::Active,
            memory_ids: FIRST_IDS,
        },
        AllocationDefinition {
            key: StateAllocationKey::BlobDeletionPending,
            owner: AllocationOwner::CanicCore,
            lifecycle: AllocationLifecycle::Active,
            memory_ids: SECOND_IDS,
        },
    ];

    assert_eq!(
        allocation::validate_allocation_definitions(&definitions),
        Err(RoleContractFinding::MemoryIdCollision {
            memory_id: MemoryId::new(70),
            first: StateAllocationKey::StoredBlobs,
            second: StateAllocationKey::BlobDeletionPending,
        })
    );
}

#[test]
fn capability_derivation_is_centralized_for_auth_and_sharding() {
    let mut app = ConfigTestBuilder::canister_config(CanisterKind::Service);
    app.auth = CanisterAuthConfig {
        delegated_token_issuer: false,
        delegated_token_verifier: true,
        role_attestation_cache: true,
    };
    app.sharding = Some(ShardingConfig::default());
    let config = ConfigTestBuilder::new()
        .with_prime_canister("app", app)
        .build();
    let role = CanisterRole::owned("app".to_string());

    let first = derive_role_capabilities(&config, &role).expect("known role should resolve");
    let second = derive_role_capabilities(&config, &role).expect("known role should resolve");
    assert_eq!(first, second);
    assert_eq!(
        first,
        BTreeSet::from([
            RoleCapabilityKey::DelegatedTokenVerifier,
            RoleCapabilityKey::RoleAttestationVerifier,
            RoleCapabilityKey::Sharding,
        ])
    );
}

#[test]
fn feature_implication_closure_is_idempotent() {
    let direct = BTreeSet::from([
        CanicFeatureKey::AuthDelegatedTokenVerify,
        CanicFeatureKey::BlobStorageBilling,
    ]);
    let first = resolve_effective_features(direct, true);
    let second = resolve_effective_features(first.clone(), false);

    assert_eq!(first, second);
    assert!(first.contains(&CanicFeatureKey::AuthChainKeyEcdsa));
    assert!(first.contains(&CanicFeatureKey::AuthIssuerCanisterSigVerify));
    assert!(first.contains(&CanicFeatureKey::BlobStorage));
    assert!(first.contains(&CanicFeatureKey::Metrics));
}

#[test]
fn missing_required_feature_rejects_without_a_contract() {
    let config = ConfigTestBuilder::new()
        .with_prime_canister_kind(CanisterRole::ROOT, CanisterKind::Root)
        .build();
    let resolution = resolve_role_contract(RoleContractInput {
        source: RoleContractSource::Declared {
            config: &config,
            role: &CanisterRole::ROOT,
        },
        declared_features: BTreeSet::new(),
        default_features_enabled: true,
    });

    assert_eq!(
        resolution,
        RoleContractResolution::Rejected {
            errors: vec![RoleContractFinding::RequiredFeatureMissing {
                capability: RoleCapabilityKey::RootControlPlane,
                feature: CanicFeatureKey::ControlPlane,
            }],
        }
    );
}

#[test]
fn unknown_role_rejects_without_a_contract() {
    let config = ConfigTestBuilder::new().build();
    let role = CanisterRole::owned("missing".to_string());

    assert_eq!(
        resolve_role_contract(RoleContractInput {
            source: RoleContractSource::Declared {
                config: &config,
                role: &role,
            },
            declared_features: CanicFeatureKey::ALL.iter().copied().collect(),
            default_features_enabled: true,
        }),
        RoleContractResolution::Rejected {
            errors: vec![RoleContractFinding::RoleUnknown { role }],
        }
    );
}

#[test]
fn surplus_state_feature_allocates_normally() {
    let config = ConfigTestBuilder::new()
        .with_prime_canister_kind("app", CanisterKind::Service)
        .build();
    let role = CanisterRole::owned("app".to_string());
    let resolution = resolve_role_contract(RoleContractInput {
        source: RoleContractSource::Declared {
            config: &config,
            role: &role,
        },
        declared_features: BTreeSet::from([CanicFeatureKey::BlobStorageBilling]),
        default_features_enabled: true,
    });
    let RoleContractResolution::Resolved { contract } = resolution else {
        panic!("surplus state-bearing features should resolve normally");
    };

    assert_eq!(allocation_ids(&contract.allocations), vec![62, 63, 64, 65]);
}

#[test]
fn repeated_selection_merges_allocation_provenance() {
    let config = ConfigTestBuilder::new()
        .with_prime_canister_kind(CanisterRole::ROOT, CanisterKind::Root)
        .build();
    let resolution = resolve_role_contract(RoleContractInput {
        source: RoleContractSource::Declared {
            config: &config,
            role: &CanisterRole::ROOT,
        },
        declared_features: BTreeSet::from([CanicFeatureKey::ControlPlane]),
        default_features_enabled: true,
    });
    let RoleContractResolution::Resolved { contract } = resolution else {
        panic!("root contract should resolve");
    };
    let template_manifests = contract
        .allocations
        .iter()
        .find(|allocation| allocation.key == StateAllocationKey::TemplateManifests)
        .expect("root should own template manifests");

    assert_eq!(
        template_manifests.selected_by,
        BTreeSet::from([
            SelectionProvenance::Capability(RoleCapabilityKey::RootControlPlane),
            SelectionProvenance::EffectiveFeature(CanicFeatureKey::ControlPlane),
        ])
    );
    assert_eq!(
        allocation_ids(&contract.allocations),
        vec![
            11, 12, 13, 14, 15, 16, 17, 18, 19, 21, 29, 30, 31, 32, 33, 34, 39, 40, 41, 42, 49, 52,
            55, 80, 81, 82, 83, 84,
        ]
    );
}

#[test]
fn built_in_wasm_store_keeps_template_and_gc_ids() {
    let resolution = resolve_role_contract(RoleContractInput {
        source: RoleContractSource::BuiltIn(BuiltInRoleKind::WasmStore),
        declared_features: BTreeSet::from([CanicFeatureKey::WasmStoreCanister]),
        default_features_enabled: false,
    });
    let RoleContractResolution::Resolved { contract } = resolution else {
        panic!("built-in wasm_store contract should resolve");
    };

    assert_eq!(
        allocation_ids(&contract.allocations),
        vec![80, 81, 82, 83, 85]
    );
    assert_eq!(
        contract.required_features,
        BTreeSet::from([CanicFeatureKey::WasmStoreCanister])
    );
}

fn allocation_ids(allocations: &[super::ResolvedStateAllocation]) -> Vec<u8> {
    let mut ids = allocations
        .iter()
        .flat_map(|allocation| allocation.memory_ids.iter())
        .map(|memory_id| memory_id.get())
        .collect::<Vec<_>>();
    ids.sort_unstable();
    ids
}

fn read_manifest(relative_path: &str) -> toml::Value {
    let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative_path);
    let source = std::fs::read_to_string(&path)
        .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
    toml::from_str(&source)
        .unwrap_or_else(|error| panic!("failed to parse {}: {error}", path.display()))
}

fn feature_table(manifest: &toml::Value) -> &toml::map::Map<String, toml::Value> {
    manifest
        .get("features")
        .and_then(toml::Value::as_table)
        .expect("manifest should have a feature table")
}

fn feature_members(features: &toml::map::Map<String, toml::Value>, feature: &str) -> Vec<String> {
    features
        .get(feature)
        .and_then(toml::Value::as_array)
        .unwrap_or_else(|| panic!("feature {feature} should be an array"))
        .iter()
        .map(|member| {
            member
                .as_str()
                .unwrap_or_else(|| panic!("feature {feature} should contain strings"))
                .to_string()
        })
        .collect()
}

fn cargo_public_implications(
    canic_features: &toml::map::Map<String, toml::Value>,
    core_features: &toml::map::Map<String, toml::Value>,
    public_features: &BTreeSet<String>,
) -> BTreeSet<(String, String)> {
    let mut implications = BTreeSet::new();

    for feature in public_features {
        for member in feature_members(canic_features, feature) {
            if public_features.contains(&member) {
                implications.insert((feature.clone(), member));
                continue;
            }

            let Some(core_feature) = member.strip_prefix("canic-core/") else {
                continue;
            };
            for core_member in feature_members(core_features, core_feature) {
                if public_features.contains(&core_member) {
                    implications.insert((feature.clone(), core_member));
                }
            }
        }
    }

    implications
}