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