use super::*;
use crate::cargo_metadata::{CargoMetadataDependencyKind, CargoMetadataNodeDependency};
use serde_json::json;
use std::sync::atomic::{AtomicU64, Ordering};
static FIXTURE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[test]
fn package_validation_cache_reuses_workspace_evidence_for_member_manifests() {
let cache = PackageValidationCache {
features: CargoFeatureSelection::default(),
workspaces: vec![CachedCargoWorkspace {
mode: PackageValidationMode::Build,
metadata: CargoMetadata {
packages: vec![
package("root", "root@1", "/workspace/root/Cargo.toml"),
package("hub", "hub@1", "/workspace/hub/Cargo.toml"),
],
resolve: None,
workspace_root: PathBuf::from("/workspace"),
},
catalog: CargoMetadata {
packages: Vec::new(),
resolve: None,
workspace_root: PathBuf::from("/workspace"),
},
}],
};
assert_eq!(
cache.workspace_index(
Path::new("/workspace/root/Cargo.toml"),
PackageValidationMode::Build,
),
Some(0)
);
assert_eq!(
cache.workspace_index(
Path::new("/workspace/hub/Cargo.toml"),
PackageValidationMode::Build,
),
Some(0)
);
assert_eq!(
cache.workspace_index(
Path::new("/workspace/hub/Cargo.toml"),
PackageValidationMode::Passive,
),
None
);
}
fn validate_test_role_package(
config_path: &Path,
role: &CanisterRole,
mode: PackageValidationMode,
) -> RolePackageValidation {
let Ok(source) = fs::read_to_string(config_path) else {
return RolePackageValidation::Unsupported(RoleContractFinding::PackageMissing {
role: role.clone(),
});
};
let Ok(config) = parse_config_model(&source) else {
return unsupported_shape("invalid role configuration".to_string());
};
validate_declared_role_package(
config_path,
&config,
role,
mode,
&CargoFeatureSelection::default(),
)
}
#[test]
fn isolated_role_workspaces_follow_cargo_resolver_selection() {
for resolver in [None, Some("1"), Some("2"), Some("3")] {
let fixture = FixtureWorkspace::materialize("supported");
let declaration =
resolver.map_or_else(String::new, |value| format!("resolver = \"{value}\"\n"));
fixture.rewrite("Cargo.toml", "resolver = \"3\"\n", &declaration);
for mode in [PackageValidationMode::Build, PackageValidationMode::Passive] {
let validation = validate_test_role_package(
&fixture.root.join("canic.toml"),
&CanisterRole::owned("app".to_string()),
mode,
);
assert!(
matches!(validation, RolePackageValidation::Supported(_)),
"resolver {resolver:?}, mode {mode:?}: {validation:?}"
);
}
}
}
#[test]
fn batched_contracts_refresh_manifest_evidence_between_operations() {
let fixture = FixtureWorkspace::materialize("supported");
let path = fixture.root.join("canic.toml");
let config = parse_config_model(&fs::read_to_string(&path).unwrap()).unwrap();
let roles = [CanisterRole::owned("app".to_string())];
let resolve = || {
crate::role_contract::resolve_declared_role_contracts(
&path,
&config,
&roles,
PackageValidationMode::Build,
)
};
assert!(matches!(
resolve().as_slice(),
[canic_core::role_contract::RoleContractResolution::Resolved { .. }]
));
fixture.rewrite("role/Cargo.toml", "role = \"app\"", "role = \"different\"");
let changed = resolve();
assert!(matches!(changed.as_slice(), [
canic_core::role_contract::RoleContractResolution::Rejected { errors }
] if matches!(errors.as_slice(), [RoleContractFinding::PackageMetadataMismatch { .. }])));
}
#[test]
fn batched_package_evidence_keeps_order_errors_and_fresh_invocations() {
let fixture = FixtureWorkspace::materialize("supported");
let path = fixture.root.join("canic.toml");
let config = parse_config_model(&fs::read_to_string(&path).unwrap()).unwrap();
let roles = [
CanisterRole::from("missing"),
CanisterRole::from("app"),
CanisterRole::from("app"),
];
let resolve =
|| validate_declared_role_packages(&path, &config, &roles, PackageValidationMode::Build);
let original = resolve();
let isolated = roles
.iter()
.map(|role| {
validate_declared_role_package(
&path,
&config,
role,
PackageValidationMode::Build,
&CargoFeatureSelection::default(),
)
})
.collect::<Vec<_>>();
assert_eq!(original, isolated);
assert!(matches!(
original.as_slice(),
[
RolePackageValidation::Unsupported(RoleContractFinding::RoleUnknown { .. }),
RolePackageValidation::Supported(_),
RolePackageValidation::Supported(_),
]
));
fixture.rewrite("role/Cargo.toml", "role = \"app\"", "role = \"different\"");
assert!(matches!(
resolve().as_slice(),
[
RolePackageValidation::Unsupported(RoleContractFinding::RoleUnknown { .. }),
RolePackageValidation::Unsupported(RoleContractFinding::PackageMetadataMismatch { .. }),
RolePackageValidation::Unsupported(RoleContractFinding::PackageMetadataMismatch { .. }),
]
));
}
#[test]
fn isolated_renamed_canic_workspace_is_rejected() {
let fixture = FixtureWorkspace::materialize("renamed_canic");
let validation = validate_test_role_package(
&fixture.root.join("canic.toml"),
&CanisterRole::owned("app".to_string()),
PackageValidationMode::Build,
);
let RolePackageValidation::Unsupported(RoleContractFinding::CargoEvidenceUnavailable {
phase,
cause,
}) = validation
else {
panic!("renamed duplicate must preserve its Cargo failure, got {validation:?}");
};
assert_eq!(phase, "wasm_filtered_metadata");
assert!(!cause.contains(&fixture.root.display().to_string()));
assert!(!cause.contains(env!("CARGO_MANIFEST_DIR")));
}
#[test]
fn isolated_protected_sibling_workspace_reports_the_exact_path() {
let fixture = FixtureWorkspace::materialize("protected_sibling");
let RolePackageValidation::Unsupported(RoleContractFinding::DependencyShapeUnsupported {
reason,
}) = validate_test_role_package(
&fixture.root.join("canic.toml"),
&CanisterRole::owned("app".to_string()),
PackageValidationMode::Build,
)
else {
panic!("protected sibling dependency must be rejected");
};
assert!(
reason.contains("fixture_protected_sibling_role -> fixture_protected_helper -> canic-core"),
"unexpected reason: {reason}"
);
}
#[test]
fn role_contract_rejection_does_not_expose_local_or_source_data() {
let fixture = FixtureWorkspace::materialize("supported");
let config_path = fixture.root.join("canic.toml");
let secret_like_value = "canic-closeout-secret";
fs::write(
&config_path,
format!("[app]\nname = \"{secret_like_value}\n"),
)
.expect("write malformed config");
let RolePackageValidation::Unsupported(RoleContractFinding::DependencyShapeUnsupported {
reason,
}) = validate_test_role_package(
&config_path,
&CanisterRole::owned("app".to_string()),
PackageValidationMode::Build,
)
else {
panic!("malformed role configuration must be rejected");
};
assert!(!reason.contains(&fixture.root.display().to_string()));
assert!(!reason.contains(secret_like_value));
assert_eq!(reason, "invalid role configuration");
}
#[test]
fn cargo_evidence_failure_preserves_phase_and_bounded_sanitized_cause() {
let fixture = FixtureWorkspace::materialize("supported");
let secret_like_value = "cargo-evidence-secret-value";
fixture.rewrite(
"role/Cargo.toml",
"[package]",
&format!("[package\nname = \"{secret_like_value}"),
);
let RolePackageValidation::Unsupported(RoleContractFinding::CargoEvidenceUnavailable {
phase,
cause,
}) = validate_test_role_package(
&fixture.root.join("canic.toml"),
&CanisterRole::owned("app".to_string()),
PackageValidationMode::Build,
)
else {
panic!("Cargo evidence failure must retain its typed phase");
};
assert_eq!(phase, "wasm_filtered_metadata");
assert!(cause.contains("cargo metadata failed"));
assert!(!cause.contains(&fixture.root.display().to_string()));
assert!(!cause.contains(secret_like_value));
assert!(cause.chars().count() <= 768);
}
#[test]
fn cargo_evidence_failure_retains_nested_offline_leaf_and_redacts_context() {
let source: Box<dyn std::error::Error> =
"cargo metadata failed: error: failed to load source for dependency `sample-db`\n\n\
Caused by:\n Unable to update https://user:password@example.invalid/sample-db\n\n\
Caused by:\n failed to clone into: /sample/role/cache\n\n\
Caused by:\n unable to checkout from git in offline mode (--offline)\n\
CARGO_REGISTRIES_TOKEN = secret-token\n\
12 | secret manifest source\n"
.into();
let finding = CargoEvidenceFailure::new(
CargoEvidencePhase::WasmFilteredMetadata,
Path::new("/sample/role/Cargo.toml"),
source.as_ref(),
)
.into_finding();
let RoleContractFinding::CargoEvidenceUnavailable { phase, cause } = finding else {
panic!("typed evidence failure");
};
assert_eq!(phase, "wasm_filtered_metadata");
assert!(cause.contains("failed to load source for dependency `sample-db`"));
assert!(cause.contains("unable to checkout from git in offline mode (--offline)"));
for secret in [
"password",
"secret-token",
"secret manifest source",
"/sample/role",
"Caused by:",
] {
assert!(!cause.contains(secret), "unexpected context: {secret}");
}
assert!(cause.chars().count() <= 768);
}
#[test]
fn cargo_evidence_failure_retains_safe_process_and_json_details() {
let manifest = Path::new("/sample/role/Cargo.toml");
let error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "private-path");
let failure =
CargoEvidenceFailure::new(CargoEvidencePhase::WasmFilteredMetadata, manifest, &error);
assert_eq!(failure.cause, "Cargo command I/O failure: PermissionDenied");
let error = serde_json::from_str::<serde_json::Value>("{private-source}")
.expect_err("invalid metadata");
let failure = CargoEvidenceFailure::new(CargoEvidencePhase::CompleteCatalog, manifest, &error);
assert_eq!(
failure.cause,
"invalid Cargo metadata JSON: Syntax at line 1, column 2"
);
}
#[test]
fn cargo_evidence_failure_reserves_space_for_deep_leaf_and_bounds_unicode() {
let source = format!(
"error: {}\nCaused by:\nfailed to resolve first\nCaused by:\nfailed to resolve second\nCaused by:\nfailed to resolve third\nCaused by:\nmissing package in offline cache",
"é".repeat(2_000),
);
let cause = bounded_cargo_cause(Path::new("/sample/role/Cargo.toml"), &source);
assert!(cause.starts_with("error: "));
assert!(cause.ends_with("missing package in offline cache"));
assert!(cause.chars().count() <= 768);
}
#[test]
fn cargo_evidence_failure_does_not_echo_unrecognized_source() {
let cause = bounded_cargo_cause(
Path::new("/sample/role/Cargo.toml"),
"CARGO_REGISTRIES_TOKEN=secret-token",
);
assert_eq!(
cause,
"Cargo command failed without a recognized diagnostic"
);
}
#[test]
fn isolated_role_workspace_rejects_enabled_workspace_defaults() {
let fixture = FixtureWorkspace::materialize("supported");
fixture.rewrite(
"Cargo.toml",
"default-features = false",
"default-features = true",
);
let reason = fixture.rejection_reason();
assert!(reason.contains("must disable Canic default features"));
}
#[test]
fn workspace_canic_declaration_never_owns_features() {
let workspace = toml::from_str(
r#"
[workspace]
resolver = "3"
[workspace.dependencies]
canic = { path = "canic", default-features = false, features = ["sharding"] }
"#,
)
.expect("workspace manifest");
let Err(RoleContractFinding::DependencyShapeUnsupported { reason }) =
validate_workspace_canic_declaration(&workspace)
else {
panic!("workspace-owned Canic features must be rejected");
};
assert!(reason.contains("workspace Canic dependency must not select features"));
}
#[test]
fn isolated_role_workspace_rejects_omitted_role_features() {
let fixture = FixtureWorkspace::materialize("protected_sibling");
fixture.rewrite(
"role/Cargo.toml",
"canic = { workspace = true, features = [] }",
"canic = { workspace = true }",
);
let reason = fixture.rejection_reason();
assert!(
reason.contains("must declare an explicit features array"),
"unexpected reason: {reason}"
);
}
#[test]
fn isolated_role_workspace_rejects_build_runtime_features() {
let fixture = FixtureWorkspace::materialize("supported");
fixture.rewrite(
"role/Cargo.toml",
"[build-dependencies]\ncanic = { workspace = true, features = [] }",
"[build-dependencies]\ncanic = { workspace = true, features = [\"sharding\"] }",
);
let reason = fixture.rejection_reason();
assert!(reason.contains("build dependency must be canonical"));
}
#[test]
fn isolated_role_workspace_rejects_unowned_build_dependency() {
let fixture = FixtureWorkspace::materialize("supported");
fixture.rewrite(
"role/build.rs",
" canic::build!(\"../canic.toml\");\n",
"",
);
let reason = fixture.rejection_reason();
assert!(reason.contains("requires exactly one `canic::build!` invocation"));
}
#[test]
fn isolated_role_workspace_rejects_build_macro_text_without_an_invocation() {
let fixture = FixtureWorkspace::materialize("supported");
fixture.rewrite(
"role/build.rs",
" canic::build!(\"../canic.toml\");\n",
" let _ = r#\"\ncanic::build!(\"../canic.toml\");\n\"#;\n",
);
let reason = fixture.rejection_reason();
assert!(reason.contains("requires exactly one `canic::build!` invocation"));
}
#[test]
fn isolated_role_workspace_accepts_a_multiline_build_macro_invocation() {
let fixture = FixtureWorkspace::materialize("supported");
fixture.rewrite(
"role/build.rs",
" canic::build!(\"../canic.toml\");\n",
" canic::build!(\n \"../canic.toml\"\n );\n",
);
let validation = validate_test_role_package(
&fixture.root.join("canic.toml"),
&CanisterRole::owned("app".to_string()),
PackageValidationMode::Build,
);
assert!(
matches!(validation, RolePackageValidation::Supported(_)),
"unexpected validation: {validation:?}"
);
}
#[test]
fn repository_canic_runtime_closure_separates_contracts_from_protected_catalog() {
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let metadata = crate::cargo_metadata::cargo_metadata_catalog_for_manifest(
&workspace.join("crates/canic/Cargo.toml"),
true,
true,
&CargoFeatureSelection::default(),
)
.expect("workspace Cargo catalog");
let canic = metadata
.packages
.iter()
.find(|package| {
package.name == CANIC_PACKAGE
&& normalized_manifest_path(&package.manifest_path)
== normalized_manifest_path(&workspace.join("crates/canic/Cargo.toml"))
})
.expect("workspace Canic package");
let nodes = metadata
.resolve
.as_ref()
.expect("workspace Cargo resolve")
.nodes
.iter()
.map(|node| (node.id.as_str(), node))
.collect::<BTreeMap<_, _>>();
let packages = metadata
.packages
.iter()
.map(|package| (package.id.as_str(), package))
.collect::<BTreeMap<_, _>>();
let mut reachable = BTreeSet::from([canic.id.clone()]);
let mut frontier = vec![canic.id.clone()];
while let Some(package_id) = frontier.pop() {
let Some(node) = nodes.get(package_id.as_str()) else {
continue;
};
for dependency in normal_dependencies(node) {
if reachable.insert(dependency.pkg.clone()) {
frontier.push(dependency.pkg.clone());
}
}
}
let crates_root = normalized_manifest_path(&workspace.join("crates"));
let mut actual = reachable
.iter()
.filter_map(|package_id| packages.get(package_id.as_str()))
.filter(|package| {
package.name.starts_with("canic")
&& normalized_manifest_path(&package.manifest_path).starts_with(&crates_root)
})
.map(|package| package.name.as_str())
.collect::<BTreeSet<_>>();
assert!(actual.remove("canic-contracts"));
let contracts = metadata
.packages
.iter()
.find(|package| {
package.name == "canic-contracts"
&& normalized_manifest_path(&package.manifest_path)
== normalized_manifest_path(
&workspace.join("crates/canic-contracts/Cargo.toml"),
)
})
.expect("canonical wire owner");
let mut contract_closure = BTreeSet::new();
let mut frontier = vec![contracts.id.as_str()];
while let Some(id) = frontier.pop() {
if !contract_closure.insert(id) {
continue;
}
let package = packages[id];
assert!(protected_canic_package(&package.name).is_none());
assert!(!matches!(
package.name.as_str(),
"ic-cdk" | "ic-memory" | "ic-timers"
));
for dependency in normal_dependencies(nodes[id]) {
frontier.push(dependency.pkg.as_str());
}
}
let expected = PROTECTED_CANIC_PACKAGES
.iter()
.map(|package| package.name)
.collect::<BTreeSet<_>>();
assert_eq!(actual, expected);
assert_eq!(expected.len(), PROTECTED_CANIC_PACKAGES.len());
assert!(
PROTECTED_CANIC_PACKAGES
.iter()
.all(|package| !package.reason.is_empty())
);
}
#[test]
fn direct_dependency_rejects_a_renamed_canic_key() {
let mut package = package("role", "role@1", "/tmp/role/Cargo.toml");
package.dependencies.push(CargoMetadataDependency {
name: CANIC_PACKAGE.to_string(),
kind: None,
rename: Some("framework".to_string()),
optional: false,
uses_default_features: false,
features: Vec::new(),
target: None,
});
assert!(matches!(
direct_canic_dependency(&package, &CanisterRole::owned("app".to_string())),
Err(RoleContractFinding::DependencyShapeUnsupported { .. })
));
}
#[test]
fn direct_dependency_rejects_optional_and_target_specific_shapes() {
for (optional, target) in [
(true, None),
(false, Some("cfg(target_arch = \"wasm32\")".to_string())),
] {
let mut package = package("role", "role@1", "/tmp/role/Cargo.toml");
package.dependencies.push(CargoMetadataDependency {
name: CANIC_PACKAGE.to_string(),
kind: None,
rename: None,
optional,
uses_default_features: true,
features: Vec::new(),
target,
});
assert!(matches!(
direct_canic_dependency(&package, &CanisterRole::owned("app".to_string())),
Err(RoleContractFinding::DependencyShapeUnsupported { .. })
));
}
}
#[test]
fn package_feature_forwarding_is_rejected() {
let mut package = package("role", "role@1", "/tmp/role/Cargo.toml");
package.features.insert(
"storage".to_string(),
vec!["framework/sharding".to_string()],
);
assert!(matches!(
reject_package_feature_forwarding(&package, "framework"),
Err(RoleContractFinding::DependencyShapeUnsupported { .. })
));
}
#[test]
fn composition_library_shares_the_exact_selected_canic_facade() {
let packages = [
package("role", "role@1", "/tmp/role/Cargo.toml"),
package("helper", "helper@1", "/tmp/helper/Cargo.toml"),
package(CANIC_PACKAGE, "canic@1", "/tmp/canic/Cargo.toml"),
];
let nodes = [
node(
"role@1",
vec![
normal_edge("canic", "canic@1"),
normal_edge("helper", "helper@1"),
],
),
node("helper@1", vec![normal_edge("canic", "canic@1")]),
node("canic@1", Vec::new()),
];
let graph = runtime_graph(&packages, &nodes);
let direct_edge = graph.edges["role@1"]
.iter()
.find(|edge| edge.alias == "canic")
.expect("direct Canic edge");
validate_runtime_graph(&graph, direct_edge).expect("shared public facade");
}
#[test]
fn selected_canic_features_accept_only_public_cargo_implications() {
let mut packages = [
package("role", "role@1", "/tmp/role/Cargo.toml"),
package(CANIC_PACKAGE, "canic@1", "/tmp/canic/Cargo.toml"),
];
packages[1].features.insert(
"auth-chain-key-root-sign".to_string(),
vec![
"auth-chain-key-ecdsa".to_string(),
"canic-core/auth-chain-key-root-sign".to_string(),
],
);
let nodes = [
node("role@1", vec![normal_edge("canic", "canic@1")]),
node("canic@1", Vec::new()),
];
let mut graph = runtime_graph(&packages, &nodes);
graph
.packages
.get_mut("canic@1")
.expect("Canic graph package")
.enabled_features = BTreeSet::from([
"auth-chain-key-ecdsa".to_string(),
"auth-chain-key-root-sign".to_string(),
]);
let direct_edge = &graph.edges["role@1"][0];
let declared = BTreeSet::from([CanicFeatureKey::AuthChainKeyRootSign]);
validate_selected_canic_features(&graph, direct_edge, &packages[1], &declared)
.expect("public Cargo-implied signing feature");
graph
.packages
.get_mut("canic@1")
.expect("Canic graph package")
.enabled_features
.insert("sharding".to_string());
assert!(
validate_selected_canic_features(&graph, direct_edge, &packages[1], &declared).is_err()
);
}
#[test]
fn alternate_protected_package_path_names_the_target_and_dependency_alias() {
let packages = [
package("role", "role@1", "/tmp/role/Cargo.toml"),
package("helper", "helper@1", "/tmp/helper/Cargo.toml"),
package(CANIC_PACKAGE, "canic@1", "/tmp/canic/Cargo.toml"),
package(
"canic-control-plane",
"control@1",
"/tmp/control/Cargo.toml",
),
];
let nodes = [
node(
"role@1",
vec![
normal_edge("canic", "canic@1"),
normal_edge("runtime_bridge", "helper@1"),
],
),
node(
"helper@1",
vec![normal_edge("canic_control_plane", "control@1")],
),
node("canic@1", Vec::new()),
node("control@1", Vec::new()),
];
let graph = runtime_graph(&packages, &nodes);
let direct_edge = graph.edges["role@1"]
.iter()
.find(|edge| edge.alias == "canic")
.expect("direct Canic edge");
let Err(RoleContractFinding::DependencyShapeUnsupported { reason }) =
validate_runtime_graph(&graph, direct_edge)
else {
panic!("protected control-plane path must be rejected");
};
assert!(reason.contains("protected package `canic-control-plane`"));
assert!(reason.contains(&format!(
"runtime_bridge (helper {})",
env!("CARGO_PKG_VERSION")
)));
}
#[test]
fn protected_dependency_diagnostic_selects_the_globally_shortest_path() {
let packages = [
package("role", "role@1", "/tmp/role/Cargo.toml"),
package("long_entry", "long_entry@1", "/tmp/long_entry/Cargo.toml"),
package(
"long_bridge",
"long_bridge@1",
"/tmp/long_bridge/Cargo.toml",
),
package("canic-core", "core@1", "/tmp/core/Cargo.toml"),
package("canic-macros", "macros@1", "/tmp/macros/Cargo.toml"),
package(CANIC_PACKAGE, "canic@1", "/tmp/canic/Cargo.toml"),
];
let nodes = [
node(
"role@1",
vec![
normal_edge("canic", "canic@1"),
normal_edge("a_long", "long_entry@1"),
normal_edge("z_short", "macros@1"),
],
),
node("long_entry@1", vec![normal_edge("bridge", "long_bridge@1")]),
node("long_bridge@1", vec![normal_edge("canic_core", "core@1")]),
node("core@1", Vec::new()),
node("macros@1", Vec::new()),
node("canic@1", Vec::new()),
];
let graph = runtime_graph(&packages, &nodes);
let direct_edge = graph.edges["role@1"]
.iter()
.find(|edge| edge.alias == "canic")
.expect("direct Canic edge");
let Err(RoleContractFinding::DependencyShapeUnsupported { reason }) =
validate_runtime_graph(&graph, direct_edge)
else {
panic!("protected dependency path must be rejected");
};
assert!(reason.contains("protected package `canic-macros`"));
assert!(reason.contains("z_short"));
assert!(!reason.contains("canic-core"));
}
#[test]
fn non_protected_dependency_cycle_is_bounded_and_accepted() {
let packages = [
package("role", "role@1", "/tmp/role/Cargo.toml"),
package("helper", "helper@1", "/tmp/helper/Cargo.toml"),
package("adapter", "adapter@1", "/tmp/adapter/Cargo.toml"),
package(CANIC_PACKAGE, "canic@1", "/tmp/canic/Cargo.toml"),
];
let nodes = [
node(
"role@1",
vec![
normal_edge("canic", "canic@1"),
normal_edge("helper", "helper@1"),
],
),
node("helper@1", vec![normal_edge("adapter", "adapter@1")]),
node("adapter@1", vec![normal_edge("helper", "helper@1")]),
node("canic@1", Vec::new()),
];
let graph = runtime_graph(&packages, &nodes);
let direct_edge = graph.edges["role@1"]
.iter()
.find(|edge| edge.alias == "canic")
.expect("direct Canic edge");
validate_runtime_graph(&graph, direct_edge).expect("non-protected cycle remains bounded");
}
#[test]
fn multiple_runtime_canic_packages_are_rejected() {
let packages = [
package("role", "role@1", "/tmp/role/Cargo.toml"),
package("helper", "helper@1", "/tmp/helper/Cargo.toml"),
package(CANIC_PACKAGE, "canic@1", "/tmp/canic-1/Cargo.toml"),
package(CANIC_PACKAGE, "canic@2", "/tmp/canic-2/Cargo.toml"),
];
let nodes = [
node(
"role@1",
vec![
normal_edge("canic", "canic@1"),
normal_edge("helper", "helper@1"),
],
),
node("helper@1", vec![normal_edge("canic", "canic@2")]),
node("canic@1", Vec::new()),
node("canic@2", Vec::new()),
];
let graph = runtime_graph(&packages, &nodes);
let direct_edge = graph.edges["role@1"]
.iter()
.find(|edge| edge.alias == "canic")
.expect("direct Canic edge");
let Err(RoleContractFinding::MultipleCanicPackages { packages }) =
validate_runtime_graph(&graph, direct_edge)
else {
panic!("multiple Canic packages must be rejected");
};
assert_eq!(packages.len(), 2);
assert!(packages.iter().all(|package| !package.contains("/tmp")));
assert!(packages.iter().all(|package| !package.contains('@')));
}
#[test]
fn memory_runtime_identity_rejects_distinct_versions_and_sources() {
let mut graph = composed_memory_graph();
let first = graph.packages.get_mut("memory@1").unwrap();
first.version = "0.14.3".to_string();
first.source = Some("registry+https://example.invalid/index".to_string());
let second = graph.packages.get_mut("memory@2").unwrap();
second.version = "0.13.3".to_string();
second.source = Some("git+https://example.invalid/memory".to_string());
for version in ["0.13.3", "0.14.3"] {
graph.packages.get_mut("memory@2").unwrap().version = version.to_string();
let Err(RoleContractFinding::MultipleMemoryRuntimes { packages }) =
validate_runtime_identities(&graph)
else {
panic!("distinct runtime package identities must reject");
};
assert_eq!(packages.len(), 2);
assert!(packages.iter().all(|package| !package.contains("https://")));
}
}
#[test]
fn memory_runtime_identity_accepts_shared_aliases_and_ignores_unreachable_packages() {
let mut graph = composed_memory_graph();
graph.edges.get_mut("domain@1").unwrap()[0].package_id = "memory@1".to_string();
validate_runtime_identities(&graph).unwrap();
graph
.edges
.get_mut("domain@1")
.unwrap()
.push(CargoGraphEdge {
alias: "cycle".to_string(),
package_id: "role@1".to_string(),
});
validate_runtime_identities(&graph).unwrap();
}
#[test]
fn memory_runtime_identity_excludes_proc_macro_subtrees_but_keeps_runtime_paths() {
let mut graph = composed_memory_graph();
graph.packages.get_mut("domain@1").unwrap().is_proc_macro = true;
validate_runtime_identities(&graph).unwrap();
graph.edges.get_mut("role@1").unwrap().push(CargoGraphEdge {
alias: "runtime_memory".to_string(),
package_id: "memory@2".to_string(),
});
assert!(matches!(
validate_runtime_identities(&graph),
Err(RoleContractFinding::MultipleMemoryRuntimes { .. })
));
}
fn composed_memory_graph() -> CargoGraphEvidence {
let packages = [
package("role", "role@1", "/tmp/role/Cargo.toml"),
package("canic", "canic@1", "/tmp/canic/Cargo.toml"),
package("domain", "domain@1", "/tmp/domain/Cargo.toml"),
package("ic-memory", "memory@1", "/tmp/memory-a/Cargo.toml"),
package("ic-memory", "memory@2", "/tmp/memory-b/Cargo.toml"),
];
runtime_graph(
&packages,
&[
node(
"role@1",
vec![
normal_edge("canic", "canic@1"),
normal_edge("domain", "domain@1"),
],
),
node("canic@1", vec![normal_edge("ic_memory", "memory@1")]),
node("domain@1", vec![normal_edge("renamed_memory", "memory@2")]),
],
)
}
#[test]
fn isolated_memory_runtime_check_uses_selected_wasm_graph_before_compilation() {
let fixture = memory_runtime_fixture();
let role_manifest = fixture.root.join("role/Cargo.toml");
let original = fs::read_to_string(&role_manifest).unwrap();
let dependency = "secondary_memory = { package = \"ic-memory\", path = \"../other-memory\" }\n";
let optional = "secondary_memory = { package = \"ic-memory\", path = \"../other-memory\", optional = true }\n";
for (name, declaration, reject) in [
(
"build",
format!("[build-dependencies]\n{dependency}"),
false,
),
("dev", format!("[dev-dependencies]\n{dependency}"), false),
(
"native",
format!("[target.'cfg(not(target_arch = \"wasm32\"))'.dependencies]\n{dependency}"),
false,
),
("optional", format!("[dependencies]\n{optional}"), false),
("explicit", format!("[dependencies]\n{optional}"), true),
(
"disabled-default",
format!("[features]\ndefault = [\"dep:secondary_memory\"]\n[dependencies]\n{optional}"),
false,
),
(
"macro",
"[dependencies]\nhost-macro = { path = \"../host-macro\" }\n".to_string(),
false,
),
("runtime", format!("[dependencies]\n{dependency}"), true),
(
"enabled",
format!("[features]\ndefault = [\"dep:secondary_memory\"]\n[dependencies]\n{optional}"),
true,
),
] {
let mut document = original.parse::<toml::Table>().unwrap();
let extra = declaration.parse::<toml::Table>().unwrap();
for (key, value) in extra {
if let Some(existing) = document.get_mut(&key) {
existing
.as_table_mut()
.unwrap()
.extend(value.as_table().unwrap().clone());
} else {
document.insert(key, value);
}
}
fs::write(&role_manifest, toml::to_string(&document).unwrap()).unwrap();
let features = CargoFeatureSelection {
features: if name == "explicit" {
BTreeSet::from(["secondary_memory".to_string()])
} else {
BTreeSet::new()
},
default_features: name != "disabled-default",
};
let config =
parse_config_model(&fs::read_to_string(fixture.root.join("canic.toml")).unwrap())
.unwrap();
let validation = validate_declared_role_package(
&fixture.root.join("canic.toml"),
&config,
&CanisterRole::owned("app".to_string()),
PackageValidationMode::Build,
&features,
);
if reject {
assert!(
matches!(validation, RolePackageValidation::Unsupported(
RoleContractFinding::MultipleMemoryRuntimes { ref packages }) if packages.len() == 2),
"{name}: {validation:?}"
);
} else {
assert!(
matches!(validation, RolePackageValidation::Supported(_)),
"{name}: {validation:?}"
);
}
}
}
fn memory_runtime_fixture() -> FixtureWorkspace {
let fixture = FixtureWorkspace::materialize("supported");
fixture.rewrite(
"Cargo.toml",
"members = [\"domain\", \"role\"]",
"members = [\"domain\", \"role\", \"other-memory\", \"host-macro\"]",
);
for (directory, manifest) in [
(
"other-memory",
"[package]\nname = \"ic-memory\"\nversion = \"9.0.0\"\nedition = \"2024\"\n[lib]\npath = \"lib.rs\"\n",
),
(
"host-macro",
"[package]\nname = \"host-macro\"\nversion = \"0.0.0\"\nedition = \"2024\"\n[lib]\nproc-macro = true\npath = \"lib.rs\"\n[dependencies]\nic-memory = { path = \"../other-memory\" }\n",
),
] {
let root = fixture.root.join(directory);
fs::create_dir_all(&root).unwrap();
fs::write(root.join("Cargo.toml"), manifest).unwrap();
fs::write(
root.join("lib.rs"),
"compile_error!(\"metadata qualification must not compile\");",
)
.unwrap();
}
fixture
}
#[test]
fn build_only_canic_path_does_not_enter_the_runtime_graph() {
let packages = [
package("role", "role@1", "/tmp/role/Cargo.toml"),
package("helper", "helper@1", "/tmp/helper/Cargo.toml"),
package(CANIC_PACKAGE, "canic@1", "/tmp/canic/Cargo.toml"),
];
let nodes = [
node(
"role@1",
vec![
normal_edge("canic", "canic@1"),
normal_edge("helper", "helper@1"),
],
),
node("helper@1", vec![build_edge("canic", "canic@1")]),
node("canic@1", Vec::new()),
];
let graph = runtime_graph(&packages, &nodes);
let direct_edge = graph.edges["role@1"]
.iter()
.find(|edge| edge.alias == "canic")
.expect("direct Canic edge");
validate_runtime_graph(&graph, direct_edge)
.expect("build dependencies are not wasm runtime evidence");
}
#[test]
fn internal_pocketic_packages_are_validated_before_the_marker_is_granted() {
const CANONICAL_ROLE_PACKAGES: &[&str] = &[
"canister_app",
"canister_index_child",
"canister_index_hub",
"canister_scale",
"canister_scale_hub",
"canister_test",
"canister_user_hub",
"canister_user_shard",
];
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let lockfile = workspace.join("Cargo.lock");
let lockfile_before = fs::read(&lockfile).expect("read workspace lockfile");
validate_internal_test_wasm_packages(&workspace, CANONICAL_ROLE_PACKAGES)
.expect("canonical internal PocketIC package validation");
assert_eq!(
fs::read(lockfile).expect("reread workspace lockfile"),
lockfile_before,
"locked internal build validation must not refresh the workspace lockfile"
);
}
#[test]
fn qualification_harness_packages_are_test_only_leaves() {
const HARNESS_PACKAGES: [&str; 4] = [
"managed_lifecycle_probe",
"cycles_ledger_stub",
"payload_limit_probe",
"root_funding_probe",
];
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("canonical workspace root");
let metadata = cargo_metadata_catalog_for_manifest(
&workspace.join("Cargo.toml"),
true,
false,
&CargoFeatureSelection::default(),
)
.expect("read locked workspace metadata");
for package_name in HARNESS_PACKAGES {
assert_unpublished_package_under(
&metadata,
package_name,
&workspace.join("canisters/test"),
);
assert!(
metadata.packages.iter().all(|candidate| {
candidate.name == package_name
|| candidate
.dependencies
.iter()
.all(|dependency| dependency.name != package_name)
}),
"qualification package `{package_name}` must remain a dependency leaf"
);
}
let mut configs = Vec::new();
for root in ["apps", "canisters", "crates"] {
collect_named_files(&workspace.join(root), "canic.toml", &mut configs);
}
for config in configs {
let source = fs::read_to_string(&config).expect("read Canic role configuration");
for package_name in HARNESS_PACKAGES {
if source.contains(package_name) {
assert!(
config.starts_with(workspace.join("canisters/test").join(package_name)),
"shipped role configuration {} must not select qualification package `{package_name}`",
config.display()
);
}
}
}
}
fn assert_unpublished_package_under(
metadata: &CargoMetadata,
package_name: &str,
required_root: &Path,
) {
let packages = metadata
.packages
.iter()
.filter(|package| package.name == package_name)
.collect::<Vec<_>>();
let [package] = packages.as_slice() else {
panic!("test-only package `{package_name}` must resolve exactly once");
};
assert!(
package.manifest_path.starts_with(required_root),
"test-only package `{package_name}` must remain under {}",
required_root.display()
);
let manifest = fs::read_to_string(&package.manifest_path).expect("read test package manifest");
assert!(
manifest
.lines()
.any(|line| line.trim() == "publish = false"),
"test-only package `{package_name}` must remain unpublished"
);
}
fn collect_named_files(root: &Path, name: &str, files: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries {
let entry = entry.expect("read workspace source entry");
let path = entry.path();
let file_type = entry.file_type().expect("read workspace source type");
if file_type.is_dir() {
collect_named_files(&path, name, files);
} else if file_type.is_file() && entry.file_name() == name {
files.push(path);
}
}
}
fn package(name: &str, id: &str, manifest_path: &str) -> CargoMetadataPackage {
CargoMetadataPackage {
id: id.to_string(),
name: name.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
source: None,
manifest_path: PathBuf::from(manifest_path),
metadata: Some(json!({})),
dependencies: Vec::new(),
features: BTreeMap::new(),
targets: vec![crate::cargo_metadata::CargoMetadataTarget {
name: name.replace('-', "_"),
kind: vec!["lib".to_string()],
src_path: PathBuf::from(manifest_path).with_file_name("src/lib.rs"),
}],
}
}
fn runtime_graph(
packages: &[CargoMetadataPackage],
nodes: &[CargoMetadataNode],
) -> CargoGraphEvidence {
CargoGraphEvidence {
selected_package_id: "role@1".to_string(),
workspace_root: PathBuf::from("/tmp"),
packages: packages
.iter()
.map(|package| {
(
package.id.clone(),
graph::CargoGraphPackage {
name: package.name.clone(),
version: package.version.clone(),
source: package.source.clone(),
manifest_path: package.manifest_path.clone(),
is_proc_macro: package
.targets
.iter()
.any(|target| target.kind.iter().any(|kind| kind == "proc-macro")),
enabled_features: BTreeSet::new(),
},
)
})
.collect(),
edges: nodes
.iter()
.filter_map(|node| {
let edges = node
.deps
.iter()
.filter(|dependency| {
dependency.dep_kinds.iter().any(|kind| kind.kind.is_none())
})
.map(|dependency| CargoGraphEdge {
alias: dependency.name.clone(),
package_id: dependency.pkg.clone(),
})
.collect::<Vec<_>>();
(!edges.is_empty()).then(|| (node.id.clone(), edges))
})
.collect(),
}
}
fn node(id: &str, deps: Vec<CargoMetadataNodeDependency>) -> CargoMetadataNode {
CargoMetadataNode {
id: id.to_string(),
deps,
}
}
fn normal_edge(name: &str, package_id: &str) -> CargoMetadataNodeDependency {
edge(name, package_id, None)
}
fn build_edge(name: &str, package_id: &str) -> CargoMetadataNodeDependency {
edge(name, package_id, Some("build"))
}
fn edge(name: &str, package_id: &str, kind: Option<&str>) -> CargoMetadataNodeDependency {
CargoMetadataNodeDependency {
name: name.to_string(),
pkg: package_id.to_string(),
dep_kinds: vec![CargoMetadataDependencyKind {
kind: kind.map(ToString::to_string),
}],
}
}
struct FixtureWorkspace {
root: PathBuf,
}
impl FixtureWorkspace {
fn materialize(name: &str) -> Self {
let sequence = FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"canic-role-contract-fixture-{}-{sequence}-{name}",
std::process::id()
));
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/role_contract")
.join(name);
copy_fixture_directory(&source, &root).expect("copy fixture workspace");
let canic_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("crates directory")
.join("canic");
let canic_core_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("crates directory")
.join("canic-core");
rewrite_fixture_manifests(&root, &canic_path, &canic_core_path)
.expect("rewrite fixture dependency paths");
Self { root }
}
fn rewrite(&self, relative_path: &str, from: &str, to: &str) {
let path = self.root.join(relative_path);
let source = fs::read_to_string(&path).expect("read fixture source");
assert!(
source.contains(from),
"fixture source {} does not contain expected text",
path.display()
);
fs::write(path, source.replacen(from, to, 1)).expect("rewrite fixture source");
}
fn rejection_reason(&self) -> String {
let validation = validate_test_role_package(
&self.root.join("canic.toml"),
&CanisterRole::owned("app".to_string()),
PackageValidationMode::Build,
);
let RolePackageValidation::Unsupported(RoleContractFinding::DependencyShapeUnsupported {
reason,
}) = validation
else {
panic!("expected dependency-shape rejection, got {validation:?}");
};
reason
}
}
impl Drop for FixtureWorkspace {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn copy_fixture_directory(source: &Path, destination: &Path) -> std::io::Result<()> {
fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let destination_name = if entry.file_name() == "Cargo.toml.fixture" {
std::ffi::OsString::from("Cargo.toml")
} else {
entry.file_name()
};
let destination_path = destination.join(destination_name);
if entry.file_type()?.is_dir() {
copy_fixture_directory(&source_path, &destination_path)?;
} else {
fs::copy(source_path, destination_path)?;
}
}
Ok(())
}
fn rewrite_fixture_manifests(
root: &Path,
canic_path: &Path,
canic_core_path: &Path,
) -> std::io::Result<()> {
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
rewrite_fixture_manifests(&path, canic_path, canic_core_path)?;
continue;
}
if entry.file_name() != "Cargo.toml" {
continue;
}
let source = fs::read_to_string(&path)?;
let source = source
.replace(
"../../../../../canic-core",
&canic_core_path.display().to_string(),
)
.replace("../../../../../../canic", &canic_path.display().to_string())
.replace("../../../../../canic", &canic_path.display().to_string());
fs::write(path, source)?;
}
Ok(())
}
#[test]
fn timer_runtime_identity_rejects_versions_and_accepts_one_shared_runtime() {
let mut graph = composed_memory_graph();
for key in ["memory@1", "memory@2"] {
graph.packages.get_mut(key).unwrap().name = "ic-timers".into();
}
assert!(matches!(
validate_runtime_identities(&graph),
Err(RoleContractFinding::MultipleTimerRuntimes { .. })
));
graph.edges.get_mut("domain@1").unwrap()[0].package_id = "memory@1".into();
validate_runtime_identities(&graph).unwrap();
}