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