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