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