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
}