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