Skip to main content

canic_host/role_contract/package/
mod.rs

1//! Module: role_contract::package
2//!
3//! Responsibility: validate the single supported wasm runtime Cargo package shape.
4//! Does not own: arbitrary Cargo feature resolution or role allocation policy.
5//! Boundary: rich metadata stays private; consumers receive compact evidence.
6
7mod 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///
72/// PackageValidationMode
73///
74/// Cargo resolution fidelity used by the host-owned role evidence producer.
75///
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77pub enum PackageValidationMode {
78    /// Development evidence that may resolve and update dependencies.
79    Build,
80    /// Build evidence that must use the existing lockfile but may fetch dependencies.
81    LockedBuild,
82    /// Read-only evidence that must use the existing lockfile and local cache.
83    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///
97/// RoleCargoGraphEvidence
98///
99/// Supported role package evidence consumed by build, Medic, and state resolution.
100///
101#[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///
114/// RolePackageValidation
115///
116/// Fail-closed result of collecting and checking one role's Cargo graph.
117///
118#[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/// Validate every Canic role package before an internal PocketIC Cargo build.
208///
209/// Packages without Canic role metadata are test stubs and are ignored. The
210/// private build marker may be granted only after this function succeeds.
211#[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}