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