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