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