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