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