1mod graph;
8#[cfg(test)]
9mod tests;
10
11use self::graph::{CargoGraphEdge, CargoGraphEvidence, TREE_FORMAT, correlate_package_tree};
12use crate::cargo_metadata::{
13 CargoMetadata, CargoMetadataDependency, CargoMetadataNode, CargoMetadataNodeDependency,
14 CargoMetadataPackage, cargo_metadata, cargo_metadata_catalog_for_manifest,
15 cargo_metadata_for_manifest, cargo_tree_for_package,
16};
17use canic_core::{
18 bootstrap::parse_config_model,
19 ids::CanisterRole,
20 role_contract::{
21 BuiltInRoleKind, CanicFeatureKey, RoleContractFinding,
22 catalog::{
23 default_features, feature_definitions, implied_features, is_non_role_public_feature,
24 validate_catalog,
25 },
26 },
27};
28use std::{
29 collections::{BTreeMap, BTreeSet, VecDeque},
30 fs,
31 path::{Path, PathBuf},
32};
33use syn::visit::Visit;
34
35const WASM_TARGET: &str = "wasm32-unknown-unknown";
36const CANIC_PACKAGE: &str = "canic";
37const CANIC_CORE_PACKAGE: &str = "canic-core";
38struct ProtectedCanicPackage {
39 name: &'static str,
40 reason: &'static str,
41}
42
43const PROTECTED_CANIC_PACKAGES: &[ProtectedCanicPackage] = &[
44 ProtectedCanicPackage {
45 name: "canic",
46 reason: "public runtime facade and role feature authority",
47 },
48 ProtectedCanicPackage {
49 name: "canic-core",
50 reason: "runtime, state, lifecycle, authentication, and feature implementation",
51 },
52 ProtectedCanicPackage {
53 name: "canic-control-plane",
54 reason: "Coordinator, root, and Wasm-store runtime implementation",
55 },
56 ProtectedCanicPackage {
57 name: "canic-macros",
58 reason: "compile-time framework coupling",
59 },
60];
61
62struct ValidatedRoleDeclaration<'a> {
63 package: &'a CargoMetadataPackage,
64 direct_dependency: &'a CargoMetadataDependency,
65 dependency_key: String,
66}
67
68#[derive(Clone, Copy)]
69enum PackageBuildContract {
70 CompiledConfig,
71 RuntimeOnly,
72}
73
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub enum PackageValidationMode {
81 Build,
83 LockedBuild,
85 Passive,
87}
88
89impl PackageValidationMode {
90 const fn locked(self) -> bool {
91 !matches!(self, Self::Build)
92 }
93
94 const fn offline(self) -> bool {
95 matches!(self, Self::Passive)
96 }
97}
98
99#[derive(Clone, Debug, Eq, PartialEq)]
105pub struct RoleCargoGraphEvidence {
106 pub app: String,
107 pub role: CanisterRole,
108 pub role_package_name: String,
109 pub role_package_version: String,
110 pub role_manifest_path: PathBuf,
111 pub cargo_workspace_root: PathBuf,
112 pub canic_version: String,
113 pub canic_manifest_path: PathBuf,
114 pub default_features_enabled: bool,
115 pub direct_features: BTreeSet<CanicFeatureKey>,
116}
117
118#[derive(Clone, Debug, Eq, PartialEq)]
124pub enum RolePackageValidation {
125 Supported(RoleCargoGraphEvidence),
126 Unsupported(RoleContractFinding),
127}
128
129pub fn declared_role_manifest_path(
130 config_path: &Path,
131 config: &canic_core::bootstrap::compiled::ConfigModel,
132 role: &CanisterRole,
133) -> Result<PathBuf, RoleContractFinding> {
134 let declaration = config
135 .roles
136 .get(role)
137 .ok_or_else(|| RoleContractFinding::RoleUnknown { role: role.clone() })?;
138 let manifest_path = package_manifest_path(config_path, &declaration.package);
139 if !manifest_path.is_file() {
140 return Err(RoleContractFinding::PackageMissing { role: role.clone() });
141 }
142 Ok(manifest_path)
143}
144
145#[must_use]
146pub fn validate_declared_role_package(
147 config_path: &Path,
148 config: &canic_core::bootstrap::compiled::ConfigModel,
149 role: &CanisterRole,
150 mode: PackageValidationMode,
151) -> RolePackageValidation {
152 let mut cache = PackageValidationCache::default();
153 validate_declared_role_package_with_cache(config_path, config, role, mode, &mut cache)
154}
155
156pub fn validate_declared_role_packages(
157 config_path: &Path,
158 config: &canic_core::bootstrap::compiled::ConfigModel,
159 roles: &[CanisterRole],
160 mode: PackageValidationMode,
161) -> Vec<RolePackageValidation> {
162 let mut cache = PackageValidationCache::default();
163 roles
164 .iter()
165 .map(|role| {
166 validate_declared_role_package_with_cache(config_path, config, role, mode, &mut cache)
167 })
168 .collect()
169}
170
171fn validate_declared_role_package_with_cache(
172 config_path: &Path,
173 config: &canic_core::bootstrap::compiled::ConfigModel,
174 role: &CanisterRole,
175 mode: PackageValidationMode,
176 cache: &mut PackageValidationCache,
177) -> RolePackageValidation {
178 let Some(declaration) = config.roles.get(role) else {
179 return RolePackageValidation::Unsupported(RoleContractFinding::RoleUnknown {
180 role: role.clone(),
181 });
182 };
183 let app = config.app_id().as_str();
184 let manifest_path = package_manifest_path(config_path, &declaration.package);
185 if !manifest_path.is_file() {
186 return RolePackageValidation::Unsupported(RoleContractFinding::PackageMissing {
187 role: role.clone(),
188 });
189 }
190
191 validate_package_manifest_with_cache(&manifest_path, app, role, mode, None, cache)
192}
193
194#[must_use]
195pub fn validate_built_in_fleet_coordinator_package(
196 manifest_path: &Path,
197 mode: PackageValidationMode,
198) -> RolePackageValidation {
199 if !manifest_path.is_file() {
200 return RolePackageValidation::Unsupported(
201 RoleContractFinding::BuiltInPackageUnavailable {
202 role: BuiltInRoleKind::FleetCoordinator,
203 },
204 );
205 }
206
207 validate_package_manifest(
208 manifest_path,
209 "fleet_coordinator",
210 &CanisterRole::FLEET_COORDINATOR,
211 mode,
212 Some(BuiltInRoleKind::FleetCoordinator),
213 )
214}
215
216#[must_use]
217pub fn validate_built_in_wasm_store_package(
218 manifest_path: &Path,
219 mode: PackageValidationMode,
220) -> RolePackageValidation {
221 if !manifest_path.is_file() {
222 return RolePackageValidation::Unsupported(
223 RoleContractFinding::BuiltInPackageUnavailable {
224 role: BuiltInRoleKind::WasmStore,
225 },
226 );
227 }
228
229 validate_package_manifest(
230 manifest_path,
231 "wasm_store",
232 &CanisterRole::WASM_STORE,
233 mode,
234 Some(BuiltInRoleKind::WasmStore),
235 )
236}
237
238#[doc(hidden)]
243pub fn validate_internal_test_wasm_packages(
244 workspace_root: &Path,
245 package_names: &[&str],
246) -> Result<(), RoleContractFinding> {
247 let metadata = cargo_metadata(workspace_root, false)
248 .map_err(|_| unsupported_finding("unable to inspect internal test package metadata"))?;
249
250 for package_name in package_names {
251 let matches = metadata
252 .packages
253 .iter()
254 .filter(|package| package.name == *package_name)
255 .collect::<Vec<_>>();
256 let [package] = matches.as_slice() else {
257 return Err(unsupported_finding(format!(
258 "internal test package `{package_name}` did not resolve exactly once"
259 )));
260 };
261 let Some((app, role)) = package_canic_identity(package)? else {
262 continue;
263 };
264 let config_path = package_role_config_path(package, &app, &role).ok_or_else(|| {
265 unsupported_finding(format!(
266 "internal test package `{package_name}` has no matching ancestor canic.toml"
267 ))
268 })?;
269 let config_source = fs::read_to_string(&config_path)
270 .map_err(|_| unsupported_finding("unable to read role configuration"))?;
271 let config = parse_config_model(&config_source)
272 .map_err(|_| unsupported_finding("invalid role configuration"))?;
273 let evidence = match validate_declared_role_package(
274 &config_path,
275 &config,
276 &role,
277 PackageValidationMode::LockedBuild,
278 ) {
279 RolePackageValidation::Supported(evidence) => evidence,
280 RolePackageValidation::Unsupported(finding) => return Err(finding),
281 };
282 if evidence.role_package_name != *package_name
283 || normalized_manifest_path(&evidence.role_manifest_path)
284 != normalized_manifest_path(&package.manifest_path)
285 {
286 return Err(unsupported_finding(format!(
287 "internal test package `{package_name}` does not match the package selected by its role configuration"
288 )));
289 }
290 match super::resolve_declared_role_package_contract(&config, &evidence) {
291 canic_core::role_contract::RoleContractResolution::Resolved { .. } => {}
292 canic_core::role_contract::RoleContractResolution::Rejected { errors } => {
293 return Err(errors
294 .into_iter()
295 .next()
296 .expect("rejected role contract has a blocking finding"));
297 }
298 }
299 }
300
301 Ok(())
302}
303
304fn package_canic_identity(
305 package: &CargoMetadataPackage,
306) -> Result<Option<(String, CanisterRole)>, RoleContractFinding> {
307 let canic = package
308 .metadata
309 .as_ref()
310 .and_then(|metadata| metadata.get("canic"));
311 let app = canic
312 .and_then(|metadata| metadata.get("app"))
313 .and_then(serde_json::Value::as_str);
314 let role = canic
315 .and_then(|metadata| metadata.get("role"))
316 .and_then(serde_json::Value::as_str);
317 match (canic, app, role) {
318 (None, _, _) => Ok(None),
319 (Some(_), Some(app), Some(role)) => Ok(Some((
320 app.to_string(),
321 CanisterRole::owned(role.to_string()),
322 ))),
323 (Some(_), _, _) => Err(unsupported_finding(format!(
324 "internal test package `{}` has incomplete Canic package metadata",
325 package.name
326 ))),
327 }
328}
329
330fn package_role_config_path(
331 package: &CargoMetadataPackage,
332 expected_app: &str,
333 expected_role: &CanisterRole,
334) -> Option<PathBuf> {
335 let manifest_dir = package.manifest_path.parent()?;
336 for ancestor in manifest_dir.ancestors() {
337 let candidate = ancestor.join("canic.toml");
338 let Ok(source) = fs::read_to_string(&candidate) else {
339 continue;
340 };
341 let Ok(config) = parse_config_model(&source) else {
342 continue;
343 };
344 if config.app_id().as_str() != expected_app {
345 continue;
346 }
347 let Some(declaration) = config.roles.get(expected_role) else {
348 continue;
349 };
350 if normalized_manifest_path(&package_manifest_path(&candidate, &declaration.package))
351 == normalized_manifest_path(&package.manifest_path)
352 {
353 return Some(candidate);
354 }
355 }
356 None
357}
358
359fn normalized_manifest_path(path: &Path) -> PathBuf {
360 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
361}
362
363fn validate_package_manifest(
364 manifest_path: &Path,
365 expected_app: &str,
366 expected_role: &CanisterRole,
367 mode: PackageValidationMode,
368 built_in: Option<BuiltInRoleKind>,
369) -> RolePackageValidation {
370 let mut cache = PackageValidationCache::default();
371 validate_package_manifest_with_cache(
372 manifest_path,
373 expected_app,
374 expected_role,
375 mode,
376 built_in,
377 &mut cache,
378 )
379}
380
381fn validate_package_manifest_with_cache(
382 manifest_path: &Path,
383 expected_app: &str,
384 expected_role: &CanisterRole,
385 mode: PackageValidationMode,
386 built_in: Option<BuiltInRoleKind>,
387 cache: &mut PackageValidationCache,
388) -> RolePackageValidation {
389 let (metadata, catalog) = match cache.evidence_for_manifest(manifest_path, mode) {
390 Ok(evidence) => evidence,
391 Err(error) => return RolePackageValidation::Unsupported(error.into_finding()),
392 };
393
394 let selected = match exact_manifest_package(metadata, manifest_path, expected_role) {
395 Ok(selected) => selected,
396 Err(finding) => return RolePackageValidation::Unsupported(finding),
397 };
398 let build_contract = if matches!(built_in, Some(BuiltInRoleKind::FleetCoordinator)) {
399 PackageBuildContract::RuntimeOnly
400 } else {
401 PackageBuildContract::CompiledConfig
402 };
403 let declaration = match validate_role_declaration(
404 metadata,
405 selected,
406 expected_app,
407 expected_role,
408 build_contract,
409 ) {
410 Ok(declaration) => declaration,
411 Err(finding) => return RolePackageValidation::Unsupported(finding),
412 };
413 if let Err(finding) = validate_catalog() {
414 return RolePackageValidation::Unsupported(finding);
415 }
416 let tree = match cargo_tree_for_package(
417 manifest_path,
418 &declaration.package.id,
419 WASM_TARGET,
420 mode.locked(),
421 mode.offline(),
422 TREE_FORMAT,
423 ) {
424 Ok(tree) => tree,
425 Err(source) => {
426 return RolePackageValidation::Unsupported(
427 CargoEvidenceFailure::new(
428 CargoEvidencePhase::PackageSelectedTree,
429 manifest_path,
430 source.as_ref(),
431 )
432 .into_finding(),
433 );
434 }
435 };
436 let graph = match correlate_package_tree(catalog, metadata, declaration.package, &tree) {
437 Ok(graph) => graph,
438 Err(reason) => return unsupported_shape(reason),
439 };
440
441 match validate_resolved_package(metadata, &graph, &declaration, expected_app, expected_role) {
442 Ok(evidence) => RolePackageValidation::Supported(evidence),
443 Err(finding) => RolePackageValidation::Unsupported(finding),
444 }
445}
446
447#[derive(Default)]
448struct PackageValidationCache {
449 workspaces: Vec<CachedCargoWorkspace>,
450}
451
452struct CachedCargoWorkspace {
453 mode: PackageValidationMode,
454 metadata: CargoMetadata,
455 catalog: CargoMetadata,
456}
457
458#[derive(Clone, Copy, Debug, Eq, PartialEq)]
459enum CargoEvidencePhase {
460 CompleteCatalog,
461 PackageSelectedTree,
462 WasmFilteredMetadata,
463}
464
465impl CargoEvidencePhase {
466 const fn as_str(self) -> &'static str {
467 match self {
468 Self::CompleteCatalog => "complete_catalog",
469 Self::PackageSelectedTree => "package_selected_tree",
470 Self::WasmFilteredMetadata => "wasm_filtered_metadata",
471 }
472 }
473}
474
475#[derive(Debug)]
476struct CargoEvidenceFailure {
477 phase: CargoEvidencePhase,
478 cause: String,
479}
480
481impl CargoEvidenceFailure {
482 fn new(
483 phase: CargoEvidencePhase,
484 manifest_path: &Path,
485 source: &(dyn std::error::Error + 'static),
486 ) -> Self {
487 Self {
488 phase,
489 cause: bounded_cargo_cause(manifest_path, &source.to_string()),
490 }
491 }
492
493 fn into_finding(self) -> RoleContractFinding {
494 RoleContractFinding::CargoEvidenceUnavailable {
495 phase: self.phase.as_str().to_string(),
496 cause: self.cause,
497 }
498 }
499}
500
501impl PackageValidationCache {
502 fn evidence_for_manifest(
503 &mut self,
504 manifest_path: &Path,
505 mode: PackageValidationMode,
506 ) -> Result<(&CargoMetadata, &CargoMetadata), CargoEvidenceFailure> {
507 if let Some(index) = self.workspace_index(manifest_path, mode) {
508 let workspace = &self.workspaces[index];
509 return Ok((&workspace.metadata, &workspace.catalog));
510 }
511
512 let metadata =
513 cargo_metadata_for_manifest(manifest_path, WASM_TARGET, mode.locked(), mode.offline())
514 .map_err(|source| {
515 CargoEvidenceFailure::new(
516 CargoEvidencePhase::WasmFilteredMetadata,
517 manifest_path,
518 source.as_ref(),
519 )
520 })?;
521 let catalog =
522 cargo_metadata_catalog_for_manifest(manifest_path, mode.locked(), mode.offline())
523 .map_err(|source| {
524 CargoEvidenceFailure::new(
525 CargoEvidencePhase::CompleteCatalog,
526 manifest_path,
527 source.as_ref(),
528 )
529 })?;
530 self.workspaces.push(CachedCargoWorkspace {
531 mode,
532 metadata,
533 catalog,
534 });
535 let workspace = self
536 .workspaces
537 .last()
538 .expect("package validation cache just received one workspace");
539 Ok((&workspace.metadata, &workspace.catalog))
540 }
541
542 fn workspace_index(&self, manifest_path: &Path, mode: PackageValidationMode) -> Option<usize> {
543 let manifest_path = normalized_manifest_path(manifest_path);
544 self.workspaces.iter().position(|workspace| {
545 workspace.mode == mode
546 && workspace.metadata.packages.iter().any(|package| {
547 normalized_manifest_path(&package.manifest_path) == manifest_path
548 })
549 })
550 }
551}
552
553fn bounded_cargo_cause(manifest_path: &Path, source: &str) -> String {
554 const MAX_CAUSE_CHARS: usize = 768;
555 const MAX_CAUSE_LINES: usize = 4;
556
557 let manifest = manifest_path.display().to_string();
558 let workspace = manifest_path
559 .parent()
560 .map(|path| path.display().to_string())
561 .unwrap_or_default();
562 let canic_workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
563 .parent()
564 .and_then(Path::parent)
565 .map(|path| path.display().to_string())
566 .unwrap_or_default();
567 let scrubbed = source
568 .replace(&manifest, "<role-manifest>")
569 .replace(&workspace, "<role-workspace>")
570 .replace(&canic_workspace, "<canic-workspace>");
571 let mut selected = scrubbed
572 .lines()
573 .map(str::trim)
574 .filter(|line| !line.is_empty())
575 .filter(|line| {
576 let lower = line.to_ascii_lowercase();
577 lower.starts_with("error:")
578 || lower.starts_with("caused by:")
579 || lower.starts_with("failed to")
580 || lower.starts_with("could not")
581 || lower.starts_with("no matching package")
582 || lower.starts_with("perhaps")
583 })
584 .take(MAX_CAUSE_LINES)
585 .collect::<Vec<_>>()
586 .join(" ");
587 if selected.is_empty() {
588 selected = scrubbed
589 .lines()
590 .next()
591 .unwrap_or("Cargo command failed")
592 .trim()
593 .to_string();
594 }
595 if selected.chars().count() > MAX_CAUSE_CHARS {
596 selected = selected.chars().take(MAX_CAUSE_CHARS - 3).collect();
597 selected.push_str("...");
598 }
599 selected
600}
601
602fn validate_role_declaration<'a>(
603 metadata: &CargoMetadata,
604 package: &'a CargoMetadataPackage,
605 expected_app: &str,
606 expected_role: &CanisterRole,
607 build_contract: PackageBuildContract,
608) -> Result<ValidatedRoleDeclaration<'a>, RoleContractFinding> {
609 validate_package_metadata(package, expected_app, expected_role)?;
610
611 let direct_dependency = direct_canic_dependency(package, expected_role)?;
612 let dependency_key = direct_dependency
613 .rename
614 .as_deref()
615 .unwrap_or(CANIC_PACKAGE)
616 .to_string();
617 reject_package_feature_forwarding(package, &dependency_key)?;
618 validate_cargo_declarations(metadata, package, direct_dependency, build_contract)?;
619
620 Ok(ValidatedRoleDeclaration {
621 package,
622 direct_dependency,
623 dependency_key,
624 })
625}
626
627fn validate_resolved_package(
628 metadata: &CargoMetadata,
629 graph: &CargoGraphEvidence,
630 declaration: &ValidatedRoleDeclaration<'_>,
631 expected_app: &str,
632 expected_role: &CanisterRole,
633) -> Result<RoleCargoGraphEvidence, RoleContractFinding> {
634 let selected = declaration.package;
635 let direct_dependency = declaration.direct_dependency;
636 let dependency_key = &declaration.dependency_key;
637
638 let package_by_id = metadata
639 .packages
640 .iter()
641 .map(|package| (package.id.as_str(), package))
642 .collect::<BTreeMap<_, _>>();
643 let node_by_id = metadata
644 .resolve
645 .as_ref()
646 .ok_or_else(|| unsupported_finding("cargo metadata omitted the resolved graph"))?
647 .nodes
648 .iter()
649 .map(|node| (node.id.as_str(), node))
650 .collect::<BTreeMap<_, _>>();
651 let dependency_edge_name = cargo_dependency_edge_name(dependency_key);
652 let direct_edges = graph
653 .edges
654 .get(&selected.id)
655 .into_iter()
656 .flatten()
657 .filter(|dependency| dependency.alias == dependency_edge_name)
658 .collect::<Vec<_>>();
659 let [direct_edge] = direct_edges.as_slice() else {
660 return Err(unsupported_finding(
661 "the direct Canic dependency did not resolve to exactly one wasm runtime edge",
662 ));
663 };
664 let canic_package = package_by_id
665 .get(direct_edge.package_id.as_str())
666 .copied()
667 .ok_or_else(|| unsupported_finding("resolved Canic package metadata is missing"))?;
668 if canic_package.name != CANIC_PACKAGE {
669 return Err(unsupported_finding(
670 "the direct runtime dependency does not resolve to package `canic`",
671 ));
672 }
673
674 validate_runtime_graph(graph, direct_edge)?;
675 if canic_package.version != env!("CARGO_PKG_VERSION") {
676 return Err(RoleContractFinding::CanicVersionMismatch {
677 expected: env!("CARGO_PKG_VERSION").to_string(),
678 actual: canic_package.version.clone(),
679 });
680 }
681 validate_cargo_catalog_parity(canic_package, &package_by_id, &node_by_id)?;
682
683 let mut default_features_enabled = direct_dependency.uses_default_features;
684 let mut direct_features = BTreeSet::new();
685 for feature_name in &direct_dependency.features {
686 if feature_name == "default" {
687 default_features_enabled = true;
688 continue;
689 }
690 let Some(feature) = CanicFeatureKey::from_cargo_name(feature_name) else {
691 return Err(RoleContractFinding::CargoCatalogDrift {
692 reason: format!(
693 "direct Canic dependency enables unclassified public feature `{feature_name}`"
694 ),
695 });
696 };
697 direct_features.insert(feature);
698 }
699 validate_selected_canic_features(graph, direct_edge, canic_package, &direct_features)?;
700
701 Ok(RoleCargoGraphEvidence {
702 app: expected_app.to_string(),
703 role: expected_role.clone(),
704 role_package_name: selected.name.clone(),
705 role_package_version: selected.version.clone(),
706 role_manifest_path: selected.manifest_path.clone(),
707 cargo_workspace_root: graph.workspace_root.clone(),
708 canic_version: canic_package.version.clone(),
709 canic_manifest_path: canic_package.manifest_path.clone(),
710 default_features_enabled,
711 direct_features,
712 })
713}
714
715fn validate_selected_canic_features(
716 graph: &CargoGraphEvidence,
717 direct_edge: &CargoGraphEdge,
718 canic_package: &CargoMetadataPackage,
719 direct_features: &BTreeSet<CanicFeatureKey>,
720) -> Result<(), RoleContractFinding> {
721 let canic = graph
722 .packages
723 .get(&direct_edge.package_id)
724 .ok_or_else(|| unsupported_finding("selected Canic graph evidence is missing"))?;
725 let actual = canic
726 .enabled_features
727 .iter()
728 .map(|feature| {
729 CanicFeatureKey::from_cargo_name(feature).ok_or_else(|| {
730 unsupported_finding(format!(
731 "package-selected Canic graph enables unclassified feature `{feature}`"
732 ))
733 })
734 })
735 .collect::<Result<BTreeSet<_>, _>>()?;
736 let expected = selected_canic_cargo_feature_closure(canic_package, direct_features);
737 if actual != expected {
738 return Err(unsupported_finding(
739 "package-selected Canic features do not match the canonical role declaration and public Cargo implication closure",
740 ));
741 }
742 Ok(())
743}
744
745fn selected_canic_cargo_feature_closure(
746 canic_package: &CargoMetadataPackage,
747 direct_features: &BTreeSet<CanicFeatureKey>,
748) -> BTreeSet<CanicFeatureKey> {
749 let mut selected = direct_features.clone();
750 let mut frontier = direct_features.iter().copied().collect::<Vec<_>>();
751 while let Some(feature) = frontier.pop() {
752 for member in canic_package
753 .features
754 .get(feature.cargo_name())
755 .into_iter()
756 .flatten()
757 {
758 let Some(implied) = CanicFeatureKey::from_cargo_name(member) else {
759 continue;
760 };
761 if selected.insert(implied) {
762 frontier.push(implied);
763 }
764 }
765 }
766 selected
767}
768
769fn exact_manifest_package<'a>(
770 metadata: &'a CargoMetadata,
771 manifest_path: &Path,
772 role: &CanisterRole,
773) -> Result<&'a CargoMetadataPackage, RoleContractFinding> {
774 let expected = manifest_path
775 .canonicalize()
776 .unwrap_or_else(|_| manifest_path.to_path_buf());
777 let matches = metadata
778 .packages
779 .iter()
780 .filter(|package| {
781 package
782 .manifest_path
783 .canonicalize()
784 .unwrap_or_else(|_| package.manifest_path.clone())
785 == expected
786 })
787 .collect::<Vec<_>>();
788
789 match matches.as_slice() {
790 [package] => Ok(*package),
791 [] => Err(RoleContractFinding::PackageMissing { role: role.clone() }),
792 _ => Err(RoleContractFinding::PackageAmbiguous { role: role.clone() }),
793 }
794}
795
796fn validate_package_metadata(
797 package: &CargoMetadataPackage,
798 expected_app: &str,
799 expected_role: &CanisterRole,
800) -> Result<(), RoleContractFinding> {
801 let canic = package
802 .metadata
803 .as_ref()
804 .and_then(|metadata| metadata.get("canic"));
805 let actual_app = canic
806 .and_then(|metadata| metadata.get("app"))
807 .and_then(serde_json::Value::as_str)
808 .map(ToString::to_string);
809 let actual_role = canic
810 .and_then(|metadata| metadata.get("role"))
811 .and_then(serde_json::Value::as_str)
812 .map(ToString::to_string);
813
814 if actual_app.as_deref() == Some(expected_app)
815 && actual_role.as_deref() == Some(expected_role.as_str())
816 {
817 return Ok(());
818 }
819
820 Err(RoleContractFinding::PackageMetadataMismatch {
821 expected_app: expected_app.to_string(),
822 expected_role: expected_role.clone(),
823 actual_app,
824 actual_role,
825 })
826}
827
828fn direct_canic_dependency<'a>(
829 package: &'a CargoMetadataPackage,
830 role: &CanisterRole,
831) -> Result<&'a CargoMetadataDependency, RoleContractFinding> {
832 let dependencies = package
833 .dependencies
834 .iter()
835 .filter(|dependency| dependency.name == CANIC_PACKAGE && dependency.kind.is_none())
836 .collect::<Vec<_>>();
837 let [dependency] = dependencies.as_slice() else {
838 return if dependencies.is_empty() {
839 Err(RoleContractFinding::RuntimeCanicDependencyMissing { role: role.clone() })
840 } else {
841 Err(unsupported_finding(
842 "the role package declares more than one normal Canic dependency",
843 ))
844 };
845 };
846 if dependency.optional {
847 return Err(unsupported_finding(
848 "the direct normal Canic dependency must not be optional",
849 ));
850 }
851 if dependency.target.is_some() {
852 return Err(unsupported_finding(
853 "the direct normal Canic dependency must be unconditional",
854 ));
855 }
856 if dependency.rename.is_some() {
857 return Err(unsupported_finding(
858 "the direct normal Canic dependency key must be exactly `canic`",
859 ));
860 }
861 Ok(*dependency)
862}
863
864fn reject_package_feature_forwarding(
865 package: &CargoMetadataPackage,
866 dependency_key: &str,
867) -> Result<(), RoleContractFinding> {
868 let strong_prefix = format!("{dependency_key}/");
869 let weak_prefix = format!("{dependency_key}?/");
870 let optional_dependency = format!("dep:{dependency_key}");
871 if package.features.values().flatten().any(|member| {
872 member.starts_with(&strong_prefix)
873 || member.starts_with(&weak_prefix)
874 || member == &optional_dependency
875 }) {
876 return Err(unsupported_finding(
877 "package features must not forward features into the Canic dependency",
878 ));
879 }
880 Ok(())
881}
882
883fn validate_cargo_declarations(
884 metadata: &CargoMetadata,
885 package: &CargoMetadataPackage,
886 normal_dependency: &CargoMetadataDependency,
887 build_contract: PackageBuildContract,
888) -> Result<(), RoleContractFinding> {
889 let workspace_manifest = metadata.workspace_root.join("Cargo.toml");
890 let workspace_document = read_cargo_document(&workspace_manifest)?;
891 let resolver = workspace_document
892 .get("workspace")
893 .and_then(|workspace| workspace.get("resolver"))
894 .or_else(|| {
895 workspace_document
896 .get("package")
897 .and_then(|package| package.get("resolver"))
898 })
899 .and_then(toml::Value::as_str);
900 if resolver != Some("2") {
901 return Err(unsupported_finding(
902 "the top-level Cargo workspace or package must declare resolver = \"2\"",
903 ));
904 }
905 validate_workspace_canic_declaration(&workspace_document)?;
906
907 let role_document = read_cargo_document(&package.manifest_path)?;
908 let role_dependency = cargo_dependency_value(&role_document, "dependencies", CANIC_PACKAGE)
909 .ok_or_else(|| {
910 unsupported_finding(
911 "the role manifest must declare the normal Canic dependency under key `canic`",
912 )
913 })?;
914 let role_dependency = dependency_table(role_dependency, "normal Canic dependency")?;
915 let role_features = explicit_feature_array(role_dependency, "normal Canic dependency")?;
916 if role_features.iter().collect::<BTreeSet<_>>().len() != role_features.len() {
917 return Err(unsupported_finding(
918 "the normal Canic dependency feature list contains duplicates",
919 ));
920 }
921 validate_dependency_source(
922 role_dependency,
923 &workspace_document,
924 normal_dependency,
925 "normal Canic dependency",
926 )?;
927 let metadata_features = normal_dependency
928 .features
929 .iter()
930 .map(String::as_str)
931 .collect::<BTreeSet<_>>();
932 if role_features.iter().copied().collect::<BTreeSet<_>>() != metadata_features {
933 return Err(unsupported_finding(
934 "the role manifest Canic features do not match Cargo dependency evidence",
935 ));
936 }
937 if let Some(dependency) = package.dependencies.iter().find(|dependency| {
938 dependency.kind.as_deref() == Some("build")
939 && dependency.name != CANIC_PACKAGE
940 && protected_canic_package(&dependency.name).is_some()
941 }) {
942 return Err(unsupported_finding(format!(
943 "the role build graph must not depend directly on protected package `{}`",
944 dependency.name
945 )));
946 }
947 if matches!(build_contract, PackageBuildContract::RuntimeOnly) {
948 return validate_runtime_only_build_contract(package);
949 }
950
951 validate_compiled_config_build_contract(package, &role_document, &workspace_document)
952}
953
954fn validate_compiled_config_build_contract(
955 package: &CargoMetadataPackage,
956 role_document: &toml::Value,
957 workspace_document: &toml::Value,
958) -> Result<(), RoleContractFinding> {
959 let build_dependencies = package
960 .dependencies
961 .iter()
962 .filter(|dependency| dependency.name == CANIC_PACKAGE)
963 .filter(|dependency| dependency.kind.as_deref() == Some("build"))
964 .collect::<Vec<_>>();
965 let [build_dependency] = build_dependencies.as_slice() else {
966 return Err(unsupported_finding(
967 "the role package must declare exactly one build dependency on `canic`",
968 ));
969 };
970 if build_dependency.rename.is_some()
971 || build_dependency.optional
972 || build_dependency.target.is_some()
973 || !build_dependency.features.is_empty()
974 {
975 return Err(unsupported_finding(
976 "the Canic build dependency must be canonical, unconditional, non-optional, and feature-empty",
977 ));
978 }
979 let build_value = cargo_dependency_value(role_document, "build-dependencies", CANIC_PACKAGE)
980 .ok_or_else(|| unsupported_finding("the role manifest omits its Canic build dependency"))?;
981 let build_table = dependency_table(build_value, "Canic build dependency")?;
982 if let Some(features) = build_table.get("features") {
983 let features = features.as_array().ok_or_else(|| {
984 unsupported_finding("the Canic build dependency features must be an array")
985 })?;
986 if !features.is_empty() {
987 return Err(unsupported_finding(
988 "the Canic build dependency must not select runtime features",
989 ));
990 }
991 }
992 validate_dependency_source(
993 build_table,
994 workspace_document,
995 build_dependency,
996 "Canic build dependency",
997 )?;
998 validate_build_script_purpose(package)?;
999
1000 Ok(())
1001}
1002
1003fn validate_runtime_only_build_contract(
1004 package: &CargoMetadataPackage,
1005) -> Result<(), RoleContractFinding> {
1006 if package
1007 .dependencies
1008 .iter()
1009 .any(|dependency| dependency.kind.as_deref() == Some("build"))
1010 {
1011 return Err(unsupported_finding(
1012 "the built-in Fleet Coordinator package must not declare build dependencies",
1013 ));
1014 }
1015 if package
1016 .targets
1017 .iter()
1018 .any(|target| target.kind.iter().any(|kind| kind == "custom-build"))
1019 {
1020 return Err(unsupported_finding(
1021 "the built-in Fleet Coordinator package must not declare a build script",
1022 ));
1023 }
1024 Ok(())
1025}
1026
1027fn read_cargo_document(path: &Path) -> Result<toml::Value, RoleContractFinding> {
1028 let source = fs::read_to_string(path)
1029 .map_err(|_| unsupported_finding("unable to read selected Cargo manifest"))?;
1030 toml::from_str(&source)
1031 .map_err(|_| unsupported_finding("unable to parse selected Cargo manifest"))
1032}
1033
1034fn cargo_dependency_value<'a>(
1035 document: &'a toml::Value,
1036 section: &str,
1037 dependency: &str,
1038) -> Option<&'a toml::Value> {
1039 document.get(section)?.get(dependency)
1040}
1041
1042fn dependency_table<'a>(
1043 value: &'a toml::Value,
1044 label: &str,
1045) -> Result<&'a toml::map::Map<String, toml::Value>, RoleContractFinding> {
1046 value.as_table().ok_or_else(|| {
1047 unsupported_finding(format!("the {label} must use an explicit dependency table"))
1048 })
1049}
1050
1051fn explicit_feature_array<'a>(
1052 dependency: &'a toml::map::Map<String, toml::Value>,
1053 label: &str,
1054) -> Result<Vec<&'a str>, RoleContractFinding> {
1055 dependency
1056 .get("features")
1057 .and_then(toml::Value::as_array)
1058 .ok_or_else(|| {
1059 unsupported_finding(format!(
1060 "the {label} must declare an explicit features array"
1061 ))
1062 })?
1063 .iter()
1064 .map(|feature| {
1065 feature.as_str().ok_or_else(|| {
1066 unsupported_finding(format!(
1067 "the {label} features array must contain only strings"
1068 ))
1069 })
1070 })
1071 .collect()
1072}
1073
1074fn validate_dependency_source(
1075 dependency: &toml::map::Map<String, toml::Value>,
1076 workspace_document: &toml::Value,
1077 metadata_dependency: &CargoMetadataDependency,
1078 label: &str,
1079) -> Result<(), RoleContractFinding> {
1080 let effective = if dependency.get("workspace").and_then(toml::Value::as_bool) == Some(true) {
1081 workspace_canic_dependency(workspace_document)?.ok_or_else(|| {
1082 unsupported_finding(
1083 "the workspace-inherited Canic dependency has no workspace declaration",
1084 )
1085 })?
1086 } else {
1087 dependency
1088 };
1089
1090 if effective
1091 .get("default-features")
1092 .and_then(toml::Value::as_bool)
1093 != Some(false)
1094 || metadata_dependency.uses_default_features
1095 {
1096 return Err(unsupported_finding(format!(
1097 "the {label} must disable Canic default features"
1098 )));
1099 }
1100 Ok(())
1101}
1102
1103fn validate_workspace_canic_declaration(
1104 workspace_document: &toml::Value,
1105) -> Result<(), RoleContractFinding> {
1106 let Some(dependency) = workspace_canic_dependency(workspace_document)? else {
1107 return Ok(());
1108 };
1109 if let Some(features) = dependency.get("features") {
1110 let features = features
1111 .as_array()
1112 .ok_or_else(|| unsupported_finding("the workspace Canic features must be an array"))?;
1113 if !features.is_empty() {
1114 return Err(unsupported_finding(
1115 "the workspace Canic dependency must not select features",
1116 ));
1117 }
1118 }
1119 if dependency
1120 .get("default-features")
1121 .and_then(toml::Value::as_bool)
1122 != Some(false)
1123 {
1124 return Err(unsupported_finding(
1125 "the workspace Canic dependency must disable Canic default features",
1126 ));
1127 }
1128 Ok(())
1129}
1130
1131fn workspace_canic_dependency(
1132 workspace_document: &toml::Value,
1133) -> Result<Option<&toml::map::Map<String, toml::Value>>, RoleContractFinding> {
1134 let Some(value) = workspace_document
1135 .get("workspace")
1136 .and_then(|workspace| workspace.get("dependencies"))
1137 .and_then(|dependencies| dependencies.get(CANIC_PACKAGE))
1138 else {
1139 return Ok(None);
1140 };
1141 value.as_table().map(Some).ok_or_else(|| {
1142 unsupported_finding("the workspace Canic dependency must use an explicit dependency table")
1143 })
1144}
1145
1146fn validate_build_script_purpose(
1147 package: &CargoMetadataPackage,
1148) -> Result<(), RoleContractFinding> {
1149 let build_targets = package
1150 .targets
1151 .iter()
1152 .filter(|target| target.kind.iter().any(|kind| kind == "custom-build"))
1153 .collect::<Vec<_>>();
1154 let [build_target] = build_targets.as_slice() else {
1155 return Err(unsupported_finding(
1156 "the Canic build dependency requires exactly one package build script",
1157 ));
1158 };
1159 let source = fs::read_to_string(&build_target.src_path)
1160 .map_err(|_| unsupported_finding("unable to read Canic role build script"))?;
1161 let syntax = syn::parse_file(&source)
1162 .map_err(|_| unsupported_finding("unable to parse Canic role build script"))?;
1163 let mut visitor = BuildMacroVisitor::default();
1164 visitor.visit_file(&syntax);
1165 if visitor.invocations != 1 {
1166 return Err(unsupported_finding(
1167 "the Canic build dependency requires exactly one `canic::build!` invocation",
1168 ));
1169 }
1170 Ok(())
1171}
1172
1173#[derive(Default)]
1174struct BuildMacroVisitor {
1175 invocations: usize,
1176}
1177
1178impl<'ast> Visit<'ast> for BuildMacroVisitor {
1179 fn visit_macro(&mut self, invocation: &'ast syn::Macro) {
1180 let mut segments = invocation.path.segments.iter();
1181 if invocation.path.leading_colon.is_none()
1182 && segments
1183 .next()
1184 .is_some_and(|segment| segment.ident == "canic")
1185 && segments
1186 .next()
1187 .is_some_and(|segment| segment.ident == "build")
1188 && segments.next().is_none()
1189 {
1190 self.invocations += 1;
1191 }
1192 syn::visit::visit_macro(self, invocation);
1193 }
1194}
1195
1196fn validate_runtime_graph(
1197 graph: &CargoGraphEvidence,
1198 direct_edge: &CargoGraphEdge,
1199) -> Result<(), RoleContractFinding> {
1200 let mut canic_package_ids = graph
1201 .packages
1202 .iter()
1203 .filter(|(_, package)| package.name == CANIC_PACKAGE)
1204 .map(|(package_id, _)| package_id.clone())
1205 .collect::<BTreeSet<_>>();
1206 canic_package_ids.insert(direct_edge.package_id.clone());
1207 if canic_package_ids.len() != 1 {
1208 return Err(RoleContractFinding::MultipleCanicPackages {
1209 packages: canic_package_ids
1210 .iter()
1211 .filter_map(|package_id| graph.packages.get(package_id))
1212 .map(|package| normalized_package_description(graph, package))
1213 .collect(),
1214 });
1215 }
1216
1217 let mut protected_paths = Vec::new();
1218 for dependency in graph
1219 .edges
1220 .get(&graph.selected_package_id)
1221 .into_iter()
1222 .flatten()
1223 {
1224 if dependency == direct_edge {
1225 continue;
1226 }
1227 if dependency.package_id == direct_edge.package_id {
1228 return Err(unsupported_finding(
1229 "the role package has more than one normal runtime path to Canic",
1230 ));
1231 }
1232 if let Some(path) = shortest_protected_path(graph, dependency) {
1233 protected_paths.push(path);
1234 }
1235 }
1236
1237 protected_paths.sort_by(|left, right| {
1238 left.edges.len().cmp(&right.edges.len()).then_with(|| {
1239 left.edges
1240 .iter()
1241 .map(|edge| graph_edge_sort_key(graph, edge))
1242 .cmp(
1243 right
1244 .edges
1245 .iter()
1246 .map(|edge| graph_edge_sort_key(graph, edge)),
1247 )
1248 })
1249 });
1250 if let Some(path) = protected_paths.first() {
1251 return Err(unsupported_finding(render_protected_path(graph, path)));
1252 }
1253
1254 Ok(())
1255}
1256
1257#[derive(Clone, Debug, Eq, PartialEq)]
1258struct DependencyPathEvidence {
1259 edges: Vec<CargoGraphEdge>,
1260 target_reason: &'static str,
1261}
1262
1263fn shortest_protected_path(
1264 graph: &CargoGraphEvidence,
1265 first_edge: &CargoGraphEdge,
1266) -> Option<DependencyPathEvidence> {
1267 let mut queue = VecDeque::from([(first_edge.clone(), vec![first_edge.clone()])]);
1268 let mut visited = BTreeSet::new();
1269
1270 while let Some((edge, path)) = queue.pop_front() {
1271 if !visited.insert(edge.package_id.clone()) {
1272 continue;
1273 }
1274 let package = graph.packages.get(&edge.package_id)?;
1275 if let Some(protected) = protected_canic_package(&package.name) {
1276 return Some(DependencyPathEvidence {
1277 edges: path,
1278 target_reason: protected.reason,
1279 });
1280 }
1281
1282 let mut children = graph
1283 .edges
1284 .get(&edge.package_id)
1285 .cloned()
1286 .unwrap_or_default();
1287 children.sort_by(|left, right| {
1288 graph_edge_sort_key(graph, left).cmp(&graph_edge_sort_key(graph, right))
1289 });
1290 for child in children {
1291 if visited.contains(&child.package_id) {
1292 continue;
1293 }
1294 let mut child_path = path.clone();
1295 child_path.push(child.clone());
1296 queue.push_back((child, child_path));
1297 }
1298 }
1299
1300 None
1301}
1302
1303fn graph_edge_sort_key(
1304 graph: &CargoGraphEvidence,
1305 edge: &CargoGraphEdge,
1306) -> (String, String, String, String, String) {
1307 let Some(package) = graph.packages.get(&edge.package_id) else {
1308 return (
1309 edge.alias.clone(),
1310 String::new(),
1311 String::new(),
1312 String::new(),
1313 String::new(),
1314 );
1315 };
1316 (
1317 edge.alias.clone(),
1318 package.name.clone(),
1319 package.version.clone(),
1320 normalized_source_kind(graph, package).to_string(),
1321 normalized_workspace_path(graph, package),
1322 )
1323}
1324
1325fn normalized_source_kind(
1326 graph: &CargoGraphEvidence,
1327 package: &graph::CargoGraphPackage,
1328) -> &'static str {
1329 match package.source.as_deref() {
1330 None if normalized_workspace_path(graph, package).is_empty() => "external_path",
1331 None => "workspace_path",
1332 Some(source) if source.starts_with("registry+") => "registry",
1333 Some(source) if source.starts_with("git+") => "git",
1334 Some(_) => "other",
1335 }
1336}
1337
1338fn normalized_workspace_path(
1339 graph: &CargoGraphEvidence,
1340 package: &graph::CargoGraphPackage,
1341) -> String {
1342 let workspace_root = graph
1343 .workspace_root
1344 .canonicalize()
1345 .unwrap_or_else(|_| graph.workspace_root.clone());
1346 let manifest_path = package
1347 .manifest_path
1348 .canonicalize()
1349 .unwrap_or_else(|_| package.manifest_path.clone());
1350 let Some(package_directory) = manifest_path.parent() else {
1351 return String::new();
1352 };
1353 let Ok(relative) = package_directory.strip_prefix(workspace_root) else {
1354 return String::new();
1355 };
1356 relative
1357 .components()
1358 .filter_map(|component| component.as_os_str().to_str())
1359 .collect::<Vec<_>>()
1360 .join("/")
1361}
1362
1363fn normalized_package_description(
1364 graph: &CargoGraphEvidence,
1365 package: &graph::CargoGraphPackage,
1366) -> String {
1367 let source_kind = normalized_source_kind(graph, package);
1368 let path = normalized_workspace_path(graph, package);
1369 if path.is_empty() {
1370 format!("{} {} ({source_kind})", package.name, package.version)
1371 } else {
1372 format!(
1373 "{} {} ({source_kind}:{path})",
1374 package.name, package.version
1375 )
1376 }
1377}
1378
1379fn protected_canic_package(name: &str) -> Option<&'static ProtectedCanicPackage> {
1380 PROTECTED_CANIC_PACKAGES
1381 .iter()
1382 .find(|package| package.name == name)
1383}
1384
1385fn render_protected_path(graph: &CargoGraphEvidence, path: &DependencyPathEvidence) -> String {
1386 let role = graph
1387 .packages
1388 .get(&graph.selected_package_id)
1389 .map_or("role package", |package| package.name.as_str());
1390 let mut rendered = vec![role.to_string()];
1391 for edge in &path.edges {
1392 let Some(package) = graph.packages.get(&edge.package_id) else {
1393 continue;
1394 };
1395 if edge.alias == cargo_dependency_edge_name(&package.name) {
1396 rendered.push(package.name.clone());
1397 } else {
1398 rendered.push(format!(
1399 "{} ({} {})",
1400 edge.alias, package.name, package.version
1401 ));
1402 }
1403 }
1404 let target = path
1405 .edges
1406 .last()
1407 .and_then(|edge| graph.packages.get(&edge.package_id))
1408 .map_or("protected Canic package", |package| package.name.as_str());
1409 format!(
1410 "role package `{role}` reaches protected package `{target}` outside its direct Canic subtree: {}; protected ownership: {}",
1411 rendered.join(" -> "),
1412 path.target_reason
1413 )
1414}
1415
1416fn normal_dependencies(
1417 node: &CargoMetadataNode,
1418) -> impl Iterator<Item = &CargoMetadataNodeDependency> {
1419 node.deps
1420 .iter()
1421 .filter(|dependency| dependency.dep_kinds.iter().any(|kind| kind.kind.is_none()))
1422}
1423
1424fn validate_cargo_catalog_parity(
1425 canic_package: &CargoMetadataPackage,
1426 package_by_id: &BTreeMap<&str, &CargoMetadataPackage>,
1427 node_by_id: &BTreeMap<&str, &CargoMetadataNode>,
1428) -> Result<(), RoleContractFinding> {
1429 let cargo_public_features = canic_package
1430 .features
1431 .keys()
1432 .filter(|name| name.as_str() != "default" && !is_non_role_public_feature(name.as_str()))
1433 .cloned()
1434 .collect::<BTreeSet<_>>();
1435 let catalog_public_features = feature_definitions()
1436 .iter()
1437 .map(|definition| definition.cargo_name.to_string())
1438 .collect::<BTreeSet<_>>();
1439 if cargo_public_features != catalog_public_features {
1440 return Err(RoleContractFinding::CargoCatalogDrift {
1441 reason: "resolved Canic public features differ from the role-contract catalog"
1442 .to_string(),
1443 });
1444 }
1445
1446 let cargo_defaults = canic_package
1447 .features
1448 .get("default")
1449 .cloned()
1450 .unwrap_or_default()
1451 .into_iter()
1452 .collect::<BTreeSet<_>>();
1453 let catalog_defaults = default_features()
1454 .iter()
1455 .map(|feature| feature.cargo_name().to_string())
1456 .collect::<BTreeSet<_>>();
1457 if cargo_defaults != catalog_defaults {
1458 return Err(RoleContractFinding::CargoCatalogDrift {
1459 reason: "resolved Canic default features differ from the role-contract catalog"
1460 .to_string(),
1461 });
1462 }
1463
1464 let canic_node = node_by_id
1465 .get(canic_package.id.as_str())
1466 .copied()
1467 .ok_or_else(|| cargo_catalog_drift("resolved Canic graph node is missing"))?;
1468 let core_dependency = canic_package
1469 .dependencies
1470 .iter()
1471 .find(|dependency| {
1472 dependency.name == CANIC_CORE_PACKAGE
1473 && dependency.kind.is_none()
1474 && dependency.target.is_none()
1475 })
1476 .ok_or_else(|| cargo_catalog_drift("resolved Canic core dependency is missing"))?;
1477 let core_key = core_dependency
1478 .rename
1479 .as_deref()
1480 .unwrap_or(CANIC_CORE_PACKAGE);
1481 let core_edge_name = cargo_dependency_edge_name(core_key);
1482 let core_edge = normal_dependencies(canic_node)
1483 .find(|dependency| dependency.name == core_edge_name)
1484 .ok_or_else(|| cargo_catalog_drift("resolved Canic core graph edge is missing"))?;
1485 let core_package = package_by_id
1486 .get(core_edge.pkg.as_str())
1487 .copied()
1488 .ok_or_else(|| cargo_catalog_drift("resolved Canic core package is missing"))?;
1489
1490 let cargo_implications = cargo_public_implications(
1491 &canic_package.features,
1492 &core_package.features,
1493 core_key,
1494 &cargo_public_features,
1495 );
1496 let catalog_implications = CanicFeatureKey::ALL
1497 .iter()
1498 .flat_map(|feature| {
1499 implied_features(*feature).map(|implied| {
1500 (
1501 feature.cargo_name().to_string(),
1502 implied.cargo_name().to_string(),
1503 )
1504 })
1505 })
1506 .collect::<BTreeSet<_>>();
1507 if cargo_implications != catalog_implications {
1508 return Err(RoleContractFinding::CargoCatalogDrift {
1509 reason:
1510 "resolved Canic public feature implications differ from the role-contract catalog"
1511 .to_string(),
1512 });
1513 }
1514
1515 Ok(())
1516}
1517
1518fn cargo_public_implications(
1519 canic_features: &BTreeMap<String, Vec<String>>,
1520 core_features: &BTreeMap<String, Vec<String>>,
1521 core_dependency_key: &str,
1522 public_features: &BTreeSet<String>,
1523) -> BTreeSet<(String, String)> {
1524 let mut implications = BTreeSet::new();
1525 let core_prefix = format!("{core_dependency_key}/");
1526
1527 for feature in public_features {
1528 for member in canic_features.get(feature).into_iter().flatten() {
1529 if public_features.contains(member) {
1530 implications.insert((feature.clone(), member.clone()));
1531 continue;
1532 }
1533 let Some(core_feature) = member.strip_prefix(&core_prefix) else {
1534 continue;
1535 };
1536 for core_member in core_features.get(core_feature).into_iter().flatten() {
1537 if public_features.contains(core_member) {
1538 implications.insert((feature.clone(), core_member.clone()));
1539 }
1540 }
1541 }
1542 }
1543
1544 implications
1545}
1546
1547fn package_manifest_path(config_path: &Path, package: &str) -> PathBuf {
1548 let package_path = PathBuf::from(package);
1549 let path = if package_path.is_absolute() {
1550 package_path
1551 } else {
1552 config_path
1553 .parent()
1554 .map_or_else(|| PathBuf::from(package), |parent| parent.join(package))
1555 };
1556 if path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml") {
1557 path
1558 } else {
1559 path.join("Cargo.toml")
1560 }
1561}
1562
1563fn cargo_dependency_edge_name(dependency_key: &str) -> String {
1564 dependency_key.replace('-', "_")
1565}
1566
1567const fn unsupported_shape(reason: String) -> RolePackageValidation {
1568 RolePackageValidation::Unsupported(RoleContractFinding::DependencyShapeUnsupported { reason })
1569}
1570
1571fn unsupported_finding(reason: impl Into<String>) -> RoleContractFinding {
1572 RoleContractFinding::DependencyShapeUnsupported {
1573 reason: reason.into(),
1574 }
1575}
1576
1577fn cargo_catalog_drift(reason: impl Into<String>) -> RoleContractFinding {
1578 RoleContractFinding::CargoCatalogDrift {
1579 reason: reason.into(),
1580 }
1581}