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