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::{
23            default_features, feature_definitions, implied_features, is_non_role_public_feature,
24            validate_catalog,
25        },
26    },
27};
28use std::{
29    collections::{BTreeMap, BTreeSet, VecDeque},
30    fs,
31    path::{Path, PathBuf},
32};
33use syn::visit::Visit;
34
35const WASM_TARGET: &str = "wasm32-unknown-unknown";
36const CANIC_PACKAGE: &str = "canic";
37const CANIC_CORE_PACKAGE: &str = "canic-core";
38struct ProtectedCanicPackage {
39    name: &'static str,
40    reason: &'static str,
41}
42
43const PROTECTED_CANIC_PACKAGES: &[ProtectedCanicPackage] = &[
44    ProtectedCanicPackage {
45        name: "canic",
46        reason: "public runtime facade and role feature authority",
47    },
48    ProtectedCanicPackage {
49        name: "canic-core",
50        reason: "runtime, state, lifecycle, authentication, and feature implementation",
51    },
52    ProtectedCanicPackage {
53        name: "canic-control-plane",
54        reason: "Coordinator, root, and Wasm-store runtime implementation",
55    },
56    ProtectedCanicPackage {
57        name: "canic-macros",
58        reason: "compile-time framework coupling",
59    },
60];
61
62struct ValidatedRoleDeclaration<'a> {
63    package: &'a CargoMetadataPackage,
64    direct_dependency: &'a CargoMetadataDependency,
65    dependency_key: String,
66}
67
68#[derive(Clone, Copy)]
69enum PackageBuildContract {
70    CompiledConfig,
71    RuntimeOnly,
72}
73
74///
75/// PackageValidationMode
76///
77/// Cargo resolution fidelity used by the host-owned role evidence producer.
78///
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub enum PackageValidationMode {
81    /// Development evidence that may resolve and update dependencies.
82    Build,
83    /// Build evidence that must use the existing lockfile but may fetch dependencies.
84    LockedBuild,
85    /// Read-only evidence that must use the existing lockfile and local cache.
86    Passive,
87}
88
89impl PackageValidationMode {
90    const fn locked(self) -> bool {
91        !matches!(self, Self::Build)
92    }
93
94    const fn offline(self) -> bool {
95        matches!(self, Self::Passive)
96    }
97}
98
99///
100/// RoleCargoGraphEvidence
101///
102/// Supported role package evidence consumed by build, Medic, and state resolution.
103///
104#[derive(Clone, Debug, Eq, PartialEq)]
105pub struct RoleCargoGraphEvidence {
106    pub app: String,
107    pub role: CanisterRole,
108    pub role_package_name: String,
109    pub role_package_version: String,
110    pub role_manifest_path: PathBuf,
111    pub cargo_workspace_root: PathBuf,
112    pub canic_version: String,
113    pub canic_manifest_path: PathBuf,
114    pub default_features_enabled: bool,
115    pub direct_features: BTreeSet<CanicFeatureKey>,
116}
117
118///
119/// RolePackageValidation
120///
121/// Fail-closed result of collecting and checking one role's Cargo graph.
122///
123#[derive(Clone, Debug, Eq, PartialEq)]
124pub enum RolePackageValidation {
125    Supported(RoleCargoGraphEvidence),
126    Unsupported(RoleContractFinding),
127}
128
129pub fn declared_role_manifest_path(
130    config_path: &Path,
131    config: &canic_core::bootstrap::compiled::ConfigModel,
132    role: &CanisterRole,
133) -> Result<PathBuf, RoleContractFinding> {
134    let declaration = config
135        .roles
136        .get(role)
137        .ok_or_else(|| RoleContractFinding::RoleUnknown { role: role.clone() })?;
138    let manifest_path = if role.is_root() {
139        crate::canonical_root::manifest_path(config_path)
140    } else {
141        let package = declaration
142            .package
143            .as_deref()
144            .ok_or_else(|| RoleContractFinding::PackageMissing { role: role.clone() })?;
145        package_manifest_path(config_path, package)
146    };
147    if !manifest_path.is_file() {
148        return Err(RoleContractFinding::PackageMissing { role: role.clone() });
149    }
150    Ok(manifest_path)
151}
152
153#[must_use]
154pub fn validate_declared_role_package(
155    config_path: &Path,
156    config: &canic_core::bootstrap::compiled::ConfigModel,
157    role: &CanisterRole,
158    mode: PackageValidationMode,
159) -> RolePackageValidation {
160    let mut cache = PackageValidationCache::default();
161    validate_declared_role_package_with_cache(config_path, config, role, mode, &mut cache)
162}
163
164pub fn validate_declared_role_packages(
165    config_path: &Path,
166    config: &canic_core::bootstrap::compiled::ConfigModel,
167    roles: &[CanisterRole],
168    mode: PackageValidationMode,
169) -> Vec<RolePackageValidation> {
170    let mut cache = PackageValidationCache::default();
171    roles
172        .iter()
173        .map(|role| {
174            validate_declared_role_package_with_cache(config_path, config, role, mode, &mut cache)
175        })
176        .collect()
177}
178
179fn validate_declared_role_package_with_cache(
180    config_path: &Path,
181    config: &canic_core::bootstrap::compiled::ConfigModel,
182    role: &CanisterRole,
183    mode: PackageValidationMode,
184    cache: &mut PackageValidationCache,
185) -> RolePackageValidation {
186    let Some(declaration) = config.roles.get(role) else {
187        return RolePackageValidation::Unsupported(RoleContractFinding::RoleUnknown {
188            role: role.clone(),
189        });
190    };
191    let app = config.app_id().as_str();
192    let manifest_path = if role.is_root() {
193        match crate::canonical_root::materialize(config_path, config, mode) {
194            Ok(path) => path,
195            Err(finding) => return RolePackageValidation::Unsupported(finding),
196        }
197    } else {
198        let Some(package) = declaration.package.as_deref() else {
199            return RolePackageValidation::Unsupported(RoleContractFinding::PackageMissing {
200                role: role.clone(),
201            });
202        };
203        package_manifest_path(config_path, package)
204    };
205    if !manifest_path.is_file() {
206        return RolePackageValidation::Unsupported(RoleContractFinding::PackageMissing {
207            role: role.clone(),
208        });
209    }
210
211    validate_package_manifest_with_cache(&manifest_path, app, role, mode, None, cache)
212}
213
214#[must_use]
215pub fn validate_built_in_fleet_coordinator_package(
216    manifest_path: &Path,
217    mode: PackageValidationMode,
218) -> RolePackageValidation {
219    if !manifest_path.is_file() {
220        return RolePackageValidation::Unsupported(
221            RoleContractFinding::BuiltInPackageUnavailable {
222                role: BuiltInRoleKind::FleetCoordinator,
223            },
224        );
225    }
226
227    validate_package_manifest(
228        manifest_path,
229        "fleet_coordinator",
230        &CanisterRole::FLEET_COORDINATOR,
231        mode,
232        Some(BuiltInRoleKind::FleetCoordinator),
233    )
234}
235
236#[must_use]
237pub fn validate_built_in_wasm_store_package(
238    manifest_path: &Path,
239    mode: PackageValidationMode,
240) -> RolePackageValidation {
241    if !manifest_path.is_file() {
242        return RolePackageValidation::Unsupported(
243            RoleContractFinding::BuiltInPackageUnavailable {
244                role: BuiltInRoleKind::WasmStore,
245            },
246        );
247    }
248
249    validate_package_manifest(
250        manifest_path,
251        "wasm_store",
252        &CanisterRole::WASM_STORE,
253        mode,
254        Some(BuiltInRoleKind::WasmStore),
255    )
256}
257
258/// Validate every Canic role package before an internal PocketIC Cargo build.
259///
260/// Packages without Canic role metadata are test stubs and are ignored. The
261/// private build marker may be granted only after this function succeeds.
262#[doc(hidden)]
263pub fn validate_internal_test_wasm_packages(
264    workspace_root: &Path,
265    package_names: &[&str],
266) -> Result<(), RoleContractFinding> {
267    let metadata = cargo_metadata(workspace_root, false)
268        .map_err(|_| unsupported_finding("unable to inspect internal test package metadata"))?;
269
270    for package_name in package_names {
271        let matches = metadata
272            .packages
273            .iter()
274            .filter(|package| package.name == *package_name)
275            .collect::<Vec<_>>();
276        let [package] = matches.as_slice() else {
277            return Err(unsupported_finding(format!(
278                "internal test package `{package_name}` did not resolve exactly once"
279            )));
280        };
281        let Some((app, role)) = package_canic_identity(package)? else {
282            continue;
283        };
284        let config_path = package_role_config_path(package, &app, &role).ok_or_else(|| {
285            unsupported_finding(format!(
286                "internal test package `{package_name}` has no matching ancestor canic.toml"
287            ))
288        })?;
289        let config_source = fs::read_to_string(&config_path)
290            .map_err(|_| unsupported_finding("unable to read role configuration"))?;
291        let config = parse_config_model(&config_source)
292            .map_err(|_| unsupported_finding("invalid role configuration"))?;
293        let validation = if role.is_root() {
294            let internal_source = package
295                .manifest_path
296                .starts_with(normalized_manifest_path(workspace_root).join("canisters"));
297            if !internal_source {
298                return Err(unsupported_finding(
299                    "Root fixtures must be internal canisters",
300                ));
301            }
302            validate_package_manifest(
303                &package.manifest_path,
304                &app,
305                &role,
306                PackageValidationMode::LockedBuild,
307                None,
308            )
309        } else {
310            validate_declared_role_package(
311                &config_path,
312                &config,
313                &role,
314                PackageValidationMode::LockedBuild,
315            )
316        };
317        let evidence = match validation {
318            RolePackageValidation::Supported(evidence) => evidence,
319            RolePackageValidation::Unsupported(finding) => return Err(finding),
320        };
321        if evidence.role_package_name != *package_name
322            || normalized_manifest_path(&evidence.role_manifest_path)
323                != normalized_manifest_path(&package.manifest_path)
324        {
325            return Err(unsupported_finding(format!(
326                "internal test package `{package_name}` does not match the package selected by its role configuration"
327            )));
328        }
329        match super::resolve_declared_role_package_contract(&config, &evidence) {
330            canic_core::role_contract::RoleContractResolution::Resolved { .. } => {}
331            canic_core::role_contract::RoleContractResolution::Rejected { errors } => {
332                return Err(errors
333                    .into_iter()
334                    .next()
335                    .expect("rejected role contract has a blocking finding"));
336            }
337        }
338    }
339
340    Ok(())
341}
342
343fn package_canic_identity(
344    package: &CargoMetadataPackage,
345) -> Result<Option<(String, CanisterRole)>, RoleContractFinding> {
346    let canic = package
347        .metadata
348        .as_ref()
349        .and_then(|metadata| metadata.get("canic"));
350    let app = canic
351        .and_then(|metadata| metadata.get("app"))
352        .and_then(serde_json::Value::as_str);
353    let role = canic
354        .and_then(|metadata| metadata.get("role"))
355        .and_then(serde_json::Value::as_str);
356    match (canic, app, role) {
357        (None, _, _) => Ok(None),
358        (Some(_), Some(app), Some(role)) => Ok(Some((
359            app.to_string(),
360            CanisterRole::owned(role.to_string()),
361        ))),
362        (Some(_), _, _) => Err(unsupported_finding(format!(
363            "internal test package `{}` has incomplete Canic package metadata",
364            package.name
365        ))),
366    }
367}
368
369fn package_role_config_path(
370    package: &CargoMetadataPackage,
371    expected_app: &str,
372    expected_role: &CanisterRole,
373) -> Option<PathBuf> {
374    let manifest_dir = package.manifest_path.parent()?;
375    for ancestor in manifest_dir.ancestors() {
376        let candidate = ancestor.join("canic.toml");
377        let Ok(source) = fs::read_to_string(&candidate) else {
378            continue;
379        };
380        let Ok(config) = parse_config_model(&source) else {
381            continue;
382        };
383        if config.app_id().as_str() != expected_app {
384            continue;
385        }
386        let Some(declaration) = config.roles.get(expected_role) else {
387            continue;
388        };
389        if expected_role.is_root() {
390            return Some(candidate);
391        }
392        if normalized_manifest_path(&package_manifest_path(
393            &candidate,
394            declaration.package.as_deref()?,
395        )) == normalized_manifest_path(&package.manifest_path)
396        {
397            return Some(candidate);
398        }
399    }
400    None
401}
402
403fn normalized_manifest_path(path: &Path) -> PathBuf {
404    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
405}
406
407fn validate_package_manifest(
408    manifest_path: &Path,
409    expected_app: &str,
410    expected_role: &CanisterRole,
411    mode: PackageValidationMode,
412    built_in: Option<BuiltInRoleKind>,
413) -> RolePackageValidation {
414    let mut cache = PackageValidationCache::default();
415    validate_package_manifest_with_cache(
416        manifest_path,
417        expected_app,
418        expected_role,
419        mode,
420        built_in,
421        &mut cache,
422    )
423}
424
425fn validate_package_manifest_with_cache(
426    manifest_path: &Path,
427    expected_app: &str,
428    expected_role: &CanisterRole,
429    mode: PackageValidationMode,
430    built_in: Option<BuiltInRoleKind>,
431    cache: &mut PackageValidationCache,
432) -> RolePackageValidation {
433    let (metadata, catalog) = match cache.evidence_for_manifest(manifest_path, mode) {
434        Ok(evidence) => evidence,
435        Err(error) => return RolePackageValidation::Unsupported(error.into_finding()),
436    };
437
438    let selected = match exact_manifest_package(metadata, manifest_path, expected_role) {
439        Ok(selected) => selected,
440        Err(finding) => return RolePackageValidation::Unsupported(finding),
441    };
442    let build_contract = if matches!(built_in, Some(BuiltInRoleKind::FleetCoordinator)) {
443        PackageBuildContract::RuntimeOnly
444    } else {
445        PackageBuildContract::CompiledConfig
446    };
447    let declaration = match validate_role_declaration(
448        metadata,
449        selected,
450        expected_app,
451        expected_role,
452        build_contract,
453    ) {
454        Ok(declaration) => declaration,
455        Err(finding) => return RolePackageValidation::Unsupported(finding),
456    };
457    if let Err(finding) = validate_catalog() {
458        return RolePackageValidation::Unsupported(finding);
459    }
460    let tree = match cargo_tree_for_package(
461        manifest_path,
462        &declaration.package.id,
463        WASM_TARGET,
464        mode.locked(),
465        mode.offline(),
466        TREE_FORMAT,
467    ) {
468        Ok(tree) => tree,
469        Err(source) => {
470            return RolePackageValidation::Unsupported(
471                CargoEvidenceFailure::new(
472                    CargoEvidencePhase::PackageSelectedTree,
473                    manifest_path,
474                    source.as_ref(),
475                )
476                .into_finding(),
477            );
478        }
479    };
480    let graph = match correlate_package_tree(catalog, metadata, declaration.package, &tree) {
481        Ok(graph) => graph,
482        Err(reason) => return unsupported_shape(reason),
483    };
484
485    match validate_resolved_package(metadata, &graph, &declaration, expected_app, expected_role) {
486        Ok(evidence) => RolePackageValidation::Supported(evidence),
487        Err(finding) => RolePackageValidation::Unsupported(finding),
488    }
489}
490
491#[derive(Default)]
492struct PackageValidationCache {
493    workspaces: Vec<CachedCargoWorkspace>,
494}
495
496struct CachedCargoWorkspace {
497    mode: PackageValidationMode,
498    metadata: CargoMetadata,
499    catalog: CargoMetadata,
500}
501
502#[derive(Clone, Copy, Debug, Eq, PartialEq)]
503enum CargoEvidencePhase {
504    CompleteCatalog,
505    PackageSelectedTree,
506    WasmFilteredMetadata,
507}
508
509impl CargoEvidencePhase {
510    const fn as_str(self) -> &'static str {
511        match self {
512            Self::CompleteCatalog => "complete_catalog",
513            Self::PackageSelectedTree => "package_selected_tree",
514            Self::WasmFilteredMetadata => "wasm_filtered_metadata",
515        }
516    }
517}
518
519#[derive(Debug)]
520struct CargoEvidenceFailure {
521    phase: CargoEvidencePhase,
522    cause: String,
523}
524
525impl CargoEvidenceFailure {
526    fn new(
527        phase: CargoEvidencePhase,
528        manifest_path: &Path,
529        source: &(dyn std::error::Error + 'static),
530    ) -> Self {
531        let cause = if let Some(error) = source.downcast_ref::<std::io::Error>() {
532            format!("Cargo command I/O failure: {:?}", error.kind())
533        } else if let Some(error) = source.downcast_ref::<serde_json::Error>() {
534            format!(
535                "invalid Cargo metadata JSON: {:?} at line {}, column {}",
536                error.classify(),
537                error.line(),
538                error.column()
539            )
540        } else {
541            bounded_cargo_cause(manifest_path, &source.to_string())
542        };
543        Self { phase, cause }
544    }
545
546    fn into_finding(self) -> RoleContractFinding {
547        RoleContractFinding::CargoEvidenceUnavailable {
548            phase: self.phase.as_str().to_string(),
549            cause: self.cause,
550        }
551    }
552}
553
554impl PackageValidationCache {
555    fn evidence_for_manifest(
556        &mut self,
557        manifest_path: &Path,
558        mode: PackageValidationMode,
559    ) -> Result<(&CargoMetadata, &CargoMetadata), CargoEvidenceFailure> {
560        if let Some(index) = self.workspace_index(manifest_path, mode) {
561            let workspace = &self.workspaces[index];
562            return Ok((&workspace.metadata, &workspace.catalog));
563        }
564
565        let metadata =
566            cargo_metadata_for_manifest(manifest_path, WASM_TARGET, mode.locked(), mode.offline())
567                .map_err(|source| {
568                    CargoEvidenceFailure::new(
569                        CargoEvidencePhase::WasmFilteredMetadata,
570                        manifest_path,
571                        source.as_ref(),
572                    )
573                })?;
574        let catalog =
575            cargo_metadata_catalog_for_manifest(manifest_path, mode.locked(), mode.offline())
576                .map_err(|source| {
577                    CargoEvidenceFailure::new(
578                        CargoEvidencePhase::CompleteCatalog,
579                        manifest_path,
580                        source.as_ref(),
581                    )
582                })?;
583        self.workspaces.push(CachedCargoWorkspace {
584            mode,
585            metadata,
586            catalog,
587        });
588        let workspace = self
589            .workspaces
590            .last()
591            .expect("package validation cache just received one workspace");
592        Ok((&workspace.metadata, &workspace.catalog))
593    }
594
595    fn workspace_index(&self, manifest_path: &Path, mode: PackageValidationMode) -> Option<usize> {
596        let manifest_path = normalized_manifest_path(manifest_path);
597        self.workspaces.iter().position(|workspace| {
598            workspace.mode == mode
599                && workspace.metadata.packages.iter().any(|package| {
600                    normalized_manifest_path(&package.manifest_path) == manifest_path
601                })
602        })
603    }
604}
605
606fn bounded_cargo_cause(manifest_path: &Path, source: &str) -> String {
607    const MAX_CAUSE_CHARS: usize = 768;
608    const MAX_CAUSE_LINES: usize = 4;
609
610    let manifest = manifest_path.display().to_string();
611    let workspace = manifest_path
612        .parent()
613        .map(|path| path.display().to_string())
614        .unwrap_or_default();
615    let canic_workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
616        .parent()
617        .and_then(Path::parent)
618        .map(|path| path.display().to_string())
619        .unwrap_or_default();
620    let scrubbed = source
621        .replace(&manifest, "<role-manifest>")
622        .replace(&workspace, "<role-workspace>")
623        .replace(&canic_workspace, "<canic-workspace>");
624    let mut causes = VecDeque::new();
625    let mut in_cause = false;
626    for line in scrubbed.lines().map(str::trim) {
627        let line = if let Some(cause) = line.strip_prefix("Caused by:") {
628            in_cause = true;
629            cause.trim()
630        } else {
631            line
632        };
633        if line.is_empty() {
634            continue;
635        }
636        // Cargo source excerpts are not diagnostic causes and may contain secrets.
637        if cargo_source_excerpt(line) {
638            in_cause = false;
639            continue;
640        }
641        if !cargo_diagnostic_line(line) && !in_cause {
642            continue;
643        }
644        // Keep the outer failure and the deepest causes, with room for each leaf.
645        let line_limit = MAX_CAUSE_CHARS / MAX_CAUSE_LINES - 1;
646        if causes.len() == MAX_CAUSE_LINES {
647            causes.remove(1);
648        }
649        causes.push_back(bounded_cargo_line(line, line_limit));
650    }
651    if causes.is_empty() {
652        return "Cargo command failed without a recognized diagnostic".to_string();
653    }
654    causes.into_iter().collect::<Vec<_>>().join(" ")
655}
656
657fn cargo_diagnostic_line(line: &str) -> bool {
658    let lower = line.to_ascii_lowercase();
659    [
660        "error:",
661        "cargo metadata failed:",
662        "cargo tree failed:",
663        "failed to",
664        "could not",
665        "no matching package",
666        "perhaps",
667    ]
668    .iter()
669    .any(|prefix| lower.starts_with(prefix))
670}
671
672fn cargo_source_excerpt(line: &str) -> bool {
673    line.starts_with(['|', '^'])
674        || line.starts_with("-->")
675        || line.starts_with(":::")
676        || line.split_once('|').is_some_and(|(prefix, _)| {
677            prefix
678                .trim()
679                .chars()
680                .all(|character| character.is_ascii_digit())
681        })
682}
683
684fn bounded_cargo_line(line: &str, limit: usize) -> String {
685    let assignment = line.split_once('=').is_some_and(|(key, _)| {
686        !key.trim().is_empty()
687            && key
688                .trim()
689                .chars()
690                .all(|character| character.is_ascii_alphanumeric() || character == '_')
691    });
692    if assignment {
693        return "<redacted assignment>".to_string();
694    }
695    let sanitized = line
696        .split_whitespace()
697        .map(|word| {
698            if word.contains("://") || word.contains('=') {
699                "<redacted>".to_string()
700            } else {
701                word.chars()
702                    .filter(|character| !character.is_control())
703                    .collect()
704            }
705        })
706        .collect::<Vec<_>>()
707        .join(" ");
708    let mut bounded = sanitized.chars().take(limit - 3).collect::<String>();
709    if sanitized.chars().count() > limit - 3 {
710        bounded.push_str("...");
711    }
712    bounded
713}
714
715fn validate_role_declaration<'a>(
716    metadata: &CargoMetadata,
717    package: &'a CargoMetadataPackage,
718    expected_app: &str,
719    expected_role: &CanisterRole,
720    build_contract: PackageBuildContract,
721) -> Result<ValidatedRoleDeclaration<'a>, RoleContractFinding> {
722    validate_package_metadata(package, expected_app, expected_role)?;
723
724    let direct_dependency = direct_canic_dependency(package, expected_role)?;
725    let dependency_key = direct_dependency
726        .rename
727        .as_deref()
728        .unwrap_or(CANIC_PACKAGE)
729        .to_string();
730    reject_package_feature_forwarding(package, &dependency_key)?;
731    validate_cargo_declarations(metadata, package, direct_dependency, build_contract)?;
732
733    Ok(ValidatedRoleDeclaration {
734        package,
735        direct_dependency,
736        dependency_key,
737    })
738}
739
740fn validate_resolved_package(
741    metadata: &CargoMetadata,
742    graph: &CargoGraphEvidence,
743    declaration: &ValidatedRoleDeclaration<'_>,
744    expected_app: &str,
745    expected_role: &CanisterRole,
746) -> Result<RoleCargoGraphEvidence, RoleContractFinding> {
747    let selected = declaration.package;
748    let direct_dependency = declaration.direct_dependency;
749    let dependency_key = &declaration.dependency_key;
750
751    let package_by_id = metadata
752        .packages
753        .iter()
754        .map(|package| (package.id.as_str(), package))
755        .collect::<BTreeMap<_, _>>();
756    let node_by_id = metadata
757        .resolve
758        .as_ref()
759        .ok_or_else(|| unsupported_finding("cargo metadata omitted the resolved graph"))?
760        .nodes
761        .iter()
762        .map(|node| (node.id.as_str(), node))
763        .collect::<BTreeMap<_, _>>();
764    let dependency_edge_name = cargo_dependency_edge_name(dependency_key);
765    let direct_edges = graph
766        .edges
767        .get(&selected.id)
768        .into_iter()
769        .flatten()
770        .filter(|dependency| dependency.alias == dependency_edge_name)
771        .collect::<Vec<_>>();
772    let [direct_edge] = direct_edges.as_slice() else {
773        return Err(unsupported_finding(
774            "the direct Canic dependency did not resolve to exactly one wasm runtime edge",
775        ));
776    };
777    let canic_package = package_by_id
778        .get(direct_edge.package_id.as_str())
779        .copied()
780        .ok_or_else(|| unsupported_finding("resolved Canic package metadata is missing"))?;
781    if canic_package.name != CANIC_PACKAGE {
782        return Err(unsupported_finding(
783            "the direct runtime dependency does not resolve to package `canic`",
784        ));
785    }
786
787    validate_runtime_graph(graph, direct_edge)?;
788    if canic_package.version != env!("CARGO_PKG_VERSION") {
789        return Err(RoleContractFinding::CanicVersionMismatch {
790            expected: env!("CARGO_PKG_VERSION").to_string(),
791            actual: canic_package.version.clone(),
792        });
793    }
794    validate_cargo_catalog_parity(canic_package, &package_by_id, &node_by_id)?;
795
796    let mut default_features_enabled = direct_dependency.uses_default_features;
797    let mut direct_features = BTreeSet::new();
798    for feature_name in &direct_dependency.features {
799        if feature_name == "default" {
800            default_features_enabled = true;
801            continue;
802        }
803        let Some(feature) = CanicFeatureKey::from_cargo_name(feature_name) else {
804            return Err(RoleContractFinding::CargoCatalogDrift {
805                reason: format!(
806                    "direct Canic dependency enables unclassified public feature `{feature_name}`"
807                ),
808            });
809        };
810        direct_features.insert(feature);
811    }
812    validate_selected_canic_features(graph, direct_edge, canic_package, &direct_features)?;
813
814    Ok(RoleCargoGraphEvidence {
815        app: expected_app.to_string(),
816        role: expected_role.clone(),
817        role_package_name: selected.name.clone(),
818        role_package_version: selected.version.clone(),
819        role_manifest_path: selected.manifest_path.clone(),
820        cargo_workspace_root: graph.workspace_root.clone(),
821        canic_version: canic_package.version.clone(),
822        canic_manifest_path: canic_package.manifest_path.clone(),
823        default_features_enabled,
824        direct_features,
825    })
826}
827
828fn validate_selected_canic_features(
829    graph: &CargoGraphEvidence,
830    direct_edge: &CargoGraphEdge,
831    canic_package: &CargoMetadataPackage,
832    direct_features: &BTreeSet<CanicFeatureKey>,
833) -> Result<(), RoleContractFinding> {
834    let canic = graph
835        .packages
836        .get(&direct_edge.package_id)
837        .ok_or_else(|| unsupported_finding("selected Canic graph evidence is missing"))?;
838    let actual = canic
839        .enabled_features
840        .iter()
841        .map(|feature| {
842            CanicFeatureKey::from_cargo_name(feature).ok_or_else(|| {
843                unsupported_finding(format!(
844                    "package-selected Canic graph enables unclassified feature `{feature}`"
845                ))
846            })
847        })
848        .collect::<Result<BTreeSet<_>, _>>()?;
849    let expected = selected_canic_cargo_feature_closure(canic_package, direct_features);
850    if actual != expected {
851        return Err(unsupported_finding(
852            "package-selected Canic features do not match the canonical role declaration and public Cargo implication closure",
853        ));
854    }
855    Ok(())
856}
857
858fn selected_canic_cargo_feature_closure(
859    canic_package: &CargoMetadataPackage,
860    direct_features: &BTreeSet<CanicFeatureKey>,
861) -> BTreeSet<CanicFeatureKey> {
862    let mut selected = direct_features.clone();
863    let mut frontier = direct_features.iter().copied().collect::<Vec<_>>();
864    while let Some(feature) = frontier.pop() {
865        for member in canic_package
866            .features
867            .get(feature.cargo_name())
868            .into_iter()
869            .flatten()
870        {
871            let Some(implied) = CanicFeatureKey::from_cargo_name(member) else {
872                continue;
873            };
874            if selected.insert(implied) {
875                frontier.push(implied);
876            }
877        }
878    }
879    selected
880}
881
882fn exact_manifest_package<'a>(
883    metadata: &'a CargoMetadata,
884    manifest_path: &Path,
885    role: &CanisterRole,
886) -> Result<&'a CargoMetadataPackage, RoleContractFinding> {
887    let expected = manifest_path
888        .canonicalize()
889        .unwrap_or_else(|_| manifest_path.to_path_buf());
890    let matches = metadata
891        .packages
892        .iter()
893        .filter(|package| {
894            package
895                .manifest_path
896                .canonicalize()
897                .unwrap_or_else(|_| package.manifest_path.clone())
898                == expected
899        })
900        .collect::<Vec<_>>();
901
902    match matches.as_slice() {
903        [package] => Ok(*package),
904        [] => Err(RoleContractFinding::PackageMissing { role: role.clone() }),
905        _ => Err(RoleContractFinding::PackageAmbiguous { role: role.clone() }),
906    }
907}
908
909fn validate_package_metadata(
910    package: &CargoMetadataPackage,
911    expected_app: &str,
912    expected_role: &CanisterRole,
913) -> Result<(), RoleContractFinding> {
914    let canic = package
915        .metadata
916        .as_ref()
917        .and_then(|metadata| metadata.get("canic"));
918    let actual_app = canic
919        .and_then(|metadata| metadata.get("app"))
920        .and_then(serde_json::Value::as_str)
921        .map(ToString::to_string);
922    let actual_role = canic
923        .and_then(|metadata| metadata.get("role"))
924        .and_then(serde_json::Value::as_str)
925        .map(ToString::to_string);
926
927    if actual_app.as_deref() == Some(expected_app)
928        && actual_role.as_deref() == Some(expected_role.as_str())
929    {
930        return Ok(());
931    }
932
933    Err(RoleContractFinding::PackageMetadataMismatch {
934        expected_app: expected_app.to_string(),
935        expected_role: expected_role.clone(),
936        actual_app,
937        actual_role,
938    })
939}
940
941fn direct_canic_dependency<'a>(
942    package: &'a CargoMetadataPackage,
943    role: &CanisterRole,
944) -> Result<&'a CargoMetadataDependency, RoleContractFinding> {
945    let dependencies = package
946        .dependencies
947        .iter()
948        .filter(|dependency| dependency.name == CANIC_PACKAGE && dependency.kind.is_none())
949        .collect::<Vec<_>>();
950    let [dependency] = dependencies.as_slice() else {
951        return if dependencies.is_empty() {
952            Err(RoleContractFinding::RuntimeCanicDependencyMissing { role: role.clone() })
953        } else {
954            Err(unsupported_finding(
955                "the role package declares more than one normal Canic dependency",
956            ))
957        };
958    };
959    if dependency.optional {
960        return Err(unsupported_finding(
961            "the direct normal Canic dependency must not be optional",
962        ));
963    }
964    if dependency.target.is_some() {
965        return Err(unsupported_finding(
966            "the direct normal Canic dependency must be unconditional",
967        ));
968    }
969    if dependency.rename.is_some() {
970        return Err(unsupported_finding(
971            "the direct normal Canic dependency key must be exactly `canic`",
972        ));
973    }
974    Ok(*dependency)
975}
976
977fn reject_package_feature_forwarding(
978    package: &CargoMetadataPackage,
979    dependency_key: &str,
980) -> Result<(), RoleContractFinding> {
981    let strong_prefix = format!("{dependency_key}/");
982    let weak_prefix = format!("{dependency_key}?/");
983    let optional_dependency = format!("dep:{dependency_key}");
984    if package.features.values().flatten().any(|member| {
985        member.starts_with(&strong_prefix)
986            || member.starts_with(&weak_prefix)
987            || member == &optional_dependency
988    }) {
989        return Err(unsupported_finding(
990            "package features must not forward features into the Canic dependency",
991        ));
992    }
993    Ok(())
994}
995
996fn validate_cargo_declarations(
997    metadata: &CargoMetadata,
998    package: &CargoMetadataPackage,
999    normal_dependency: &CargoMetadataDependency,
1000    build_contract: PackageBuildContract,
1001) -> Result<(), RoleContractFinding> {
1002    let workspace_manifest = metadata.workspace_root.join("Cargo.toml");
1003    let workspace_document = read_cargo_document(&workspace_manifest)?;
1004    let resolver = workspace_document
1005        .get("workspace")
1006        .and_then(|workspace| workspace.get("resolver"))
1007        .or_else(|| {
1008            workspace_document
1009                .get("package")
1010                .and_then(|package| package.get("resolver"))
1011        })
1012        .and_then(toml::Value::as_str);
1013    if resolver != Some("2") {
1014        return Err(unsupported_finding(
1015            "the top-level Cargo workspace or package must declare resolver = \"2\"",
1016        ));
1017    }
1018    validate_workspace_canic_declaration(&workspace_document)?;
1019
1020    let role_document = read_cargo_document(&package.manifest_path)?;
1021    let role_dependency = cargo_dependency_value(&role_document, "dependencies", CANIC_PACKAGE)
1022        .ok_or_else(|| {
1023            unsupported_finding(
1024                "the role manifest must declare the normal Canic dependency under key `canic`",
1025            )
1026        })?;
1027    let role_dependency = dependency_table(role_dependency, "normal Canic dependency")?;
1028    let role_features = explicit_feature_array(role_dependency, "normal Canic dependency")?;
1029    if role_features.iter().collect::<BTreeSet<_>>().len() != role_features.len() {
1030        return Err(unsupported_finding(
1031            "the normal Canic dependency feature list contains duplicates",
1032        ));
1033    }
1034    validate_dependency_source(
1035        role_dependency,
1036        &workspace_document,
1037        normal_dependency,
1038        "normal Canic dependency",
1039    )?;
1040    let metadata_features = normal_dependency
1041        .features
1042        .iter()
1043        .map(String::as_str)
1044        .collect::<BTreeSet<_>>();
1045    if role_features.iter().copied().collect::<BTreeSet<_>>() != metadata_features {
1046        return Err(unsupported_finding(
1047            "the role manifest Canic features do not match Cargo dependency evidence",
1048        ));
1049    }
1050    if let Some(dependency) = package.dependencies.iter().find(|dependency| {
1051        dependency.kind.as_deref() == Some("build")
1052            && dependency.name != CANIC_PACKAGE
1053            && protected_canic_package(&dependency.name).is_some()
1054    }) {
1055        return Err(unsupported_finding(format!(
1056            "the role build graph must not depend directly on protected package `{}`",
1057            dependency.name
1058        )));
1059    }
1060    if matches!(build_contract, PackageBuildContract::RuntimeOnly) {
1061        return validate_runtime_only_build_contract(package);
1062    }
1063
1064    validate_compiled_config_build_contract(package, &role_document, &workspace_document)
1065}
1066
1067fn validate_compiled_config_build_contract(
1068    package: &CargoMetadataPackage,
1069    role_document: &toml::Value,
1070    workspace_document: &toml::Value,
1071) -> Result<(), RoleContractFinding> {
1072    let build_dependencies = package
1073        .dependencies
1074        .iter()
1075        .filter(|dependency| dependency.name == CANIC_PACKAGE)
1076        .filter(|dependency| dependency.kind.as_deref() == Some("build"))
1077        .collect::<Vec<_>>();
1078    let [build_dependency] = build_dependencies.as_slice() else {
1079        return Err(unsupported_finding(
1080            "the role package must declare exactly one build dependency on `canic`",
1081        ));
1082    };
1083    if build_dependency.rename.is_some()
1084        || build_dependency.optional
1085        || build_dependency.target.is_some()
1086        || !build_dependency.features.is_empty()
1087    {
1088        return Err(unsupported_finding(
1089            "the Canic build dependency must be canonical, unconditional, non-optional, and feature-empty",
1090        ));
1091    }
1092    let build_value = cargo_dependency_value(role_document, "build-dependencies", CANIC_PACKAGE)
1093        .ok_or_else(|| unsupported_finding("the role manifest omits its Canic build dependency"))?;
1094    let build_table = dependency_table(build_value, "Canic build dependency")?;
1095    if let Some(features) = build_table.get("features") {
1096        let features = features.as_array().ok_or_else(|| {
1097            unsupported_finding("the Canic build dependency features must be an array")
1098        })?;
1099        if !features.is_empty() {
1100            return Err(unsupported_finding(
1101                "the Canic build dependency must not select runtime features",
1102            ));
1103        }
1104    }
1105    validate_dependency_source(
1106        build_table,
1107        workspace_document,
1108        build_dependency,
1109        "Canic build dependency",
1110    )?;
1111    validate_build_script_purpose(package)?;
1112
1113    Ok(())
1114}
1115
1116fn validate_runtime_only_build_contract(
1117    package: &CargoMetadataPackage,
1118) -> Result<(), RoleContractFinding> {
1119    if package
1120        .dependencies
1121        .iter()
1122        .any(|dependency| dependency.kind.as_deref() == Some("build"))
1123    {
1124        return Err(unsupported_finding(
1125            "the built-in Fleet Coordinator package must not declare build dependencies",
1126        ));
1127    }
1128    if package
1129        .targets
1130        .iter()
1131        .any(|target| target.kind.iter().any(|kind| kind == "custom-build"))
1132    {
1133        return Err(unsupported_finding(
1134            "the built-in Fleet Coordinator package must not declare a build script",
1135        ));
1136    }
1137    Ok(())
1138}
1139
1140fn read_cargo_document(path: &Path) -> Result<toml::Value, RoleContractFinding> {
1141    let source = fs::read_to_string(path)
1142        .map_err(|_| unsupported_finding("unable to read selected Cargo manifest"))?;
1143    toml::from_str(&source)
1144        .map_err(|_| unsupported_finding("unable to parse selected Cargo manifest"))
1145}
1146
1147fn cargo_dependency_value<'a>(
1148    document: &'a toml::Value,
1149    section: &str,
1150    dependency: &str,
1151) -> Option<&'a toml::Value> {
1152    document.get(section)?.get(dependency)
1153}
1154
1155fn dependency_table<'a>(
1156    value: &'a toml::Value,
1157    label: &str,
1158) -> Result<&'a toml::map::Map<String, toml::Value>, RoleContractFinding> {
1159    value.as_table().ok_or_else(|| {
1160        unsupported_finding(format!("the {label} must use an explicit dependency table"))
1161    })
1162}
1163
1164fn explicit_feature_array<'a>(
1165    dependency: &'a toml::map::Map<String, toml::Value>,
1166    label: &str,
1167) -> Result<Vec<&'a str>, RoleContractFinding> {
1168    dependency
1169        .get("features")
1170        .and_then(toml::Value::as_array)
1171        .ok_or_else(|| {
1172            unsupported_finding(format!(
1173                "the {label} must declare an explicit features array"
1174            ))
1175        })?
1176        .iter()
1177        .map(|feature| {
1178            feature.as_str().ok_or_else(|| {
1179                unsupported_finding(format!(
1180                    "the {label} features array must contain only strings"
1181                ))
1182            })
1183        })
1184        .collect()
1185}
1186
1187fn validate_dependency_source(
1188    dependency: &toml::map::Map<String, toml::Value>,
1189    workspace_document: &toml::Value,
1190    metadata_dependency: &CargoMetadataDependency,
1191    label: &str,
1192) -> Result<(), RoleContractFinding> {
1193    let effective = if dependency.get("workspace").and_then(toml::Value::as_bool) == Some(true) {
1194        workspace_canic_dependency(workspace_document)?.ok_or_else(|| {
1195            unsupported_finding(
1196                "the workspace-inherited Canic dependency has no workspace declaration",
1197            )
1198        })?
1199    } else {
1200        dependency
1201    };
1202
1203    if effective
1204        .get("default-features")
1205        .and_then(toml::Value::as_bool)
1206        != Some(false)
1207        || metadata_dependency.uses_default_features
1208    {
1209        return Err(unsupported_finding(format!(
1210            "the {label} must disable Canic default features"
1211        )));
1212    }
1213    Ok(())
1214}
1215
1216fn validate_workspace_canic_declaration(
1217    workspace_document: &toml::Value,
1218) -> Result<(), RoleContractFinding> {
1219    let Some(dependency) = workspace_canic_dependency(workspace_document)? else {
1220        return Ok(());
1221    };
1222    if let Some(features) = dependency.get("features") {
1223        let features = features
1224            .as_array()
1225            .ok_or_else(|| unsupported_finding("the workspace Canic features must be an array"))?;
1226        if !features.is_empty() {
1227            return Err(unsupported_finding(
1228                "the workspace Canic dependency must not select features",
1229            ));
1230        }
1231    }
1232    if dependency
1233        .get("default-features")
1234        .and_then(toml::Value::as_bool)
1235        != Some(false)
1236    {
1237        return Err(unsupported_finding(
1238            "the workspace Canic dependency must disable Canic default features",
1239        ));
1240    }
1241    Ok(())
1242}
1243
1244fn workspace_canic_dependency(
1245    workspace_document: &toml::Value,
1246) -> Result<Option<&toml::map::Map<String, toml::Value>>, RoleContractFinding> {
1247    let Some(value) = workspace_document
1248        .get("workspace")
1249        .and_then(|workspace| workspace.get("dependencies"))
1250        .and_then(|dependencies| dependencies.get(CANIC_PACKAGE))
1251    else {
1252        return Ok(None);
1253    };
1254    value.as_table().map(Some).ok_or_else(|| {
1255        unsupported_finding("the workspace Canic dependency must use an explicit dependency table")
1256    })
1257}
1258
1259fn validate_build_script_purpose(
1260    package: &CargoMetadataPackage,
1261) -> Result<(), RoleContractFinding> {
1262    let build_targets = package
1263        .targets
1264        .iter()
1265        .filter(|target| target.kind.iter().any(|kind| kind == "custom-build"))
1266        .collect::<Vec<_>>();
1267    let [build_target] = build_targets.as_slice() else {
1268        return Err(unsupported_finding(
1269            "the Canic build dependency requires exactly one package build script",
1270        ));
1271    };
1272    let source = fs::read_to_string(&build_target.src_path)
1273        .map_err(|_| unsupported_finding("unable to read Canic role build script"))?;
1274    let syntax = syn::parse_file(&source)
1275        .map_err(|_| unsupported_finding("unable to parse Canic role build script"))?;
1276    let mut visitor = BuildMacroVisitor::default();
1277    visitor.visit_file(&syntax);
1278    if visitor.invocations != 1 {
1279        return Err(unsupported_finding(
1280            "the Canic build dependency requires exactly one `canic::build!` invocation",
1281        ));
1282    }
1283    Ok(())
1284}
1285
1286#[derive(Default)]
1287struct BuildMacroVisitor {
1288    invocations: usize,
1289}
1290
1291impl<'ast> Visit<'ast> for BuildMacroVisitor {
1292    fn visit_macro(&mut self, invocation: &'ast syn::Macro) {
1293        let mut segments = invocation.path.segments.iter();
1294        if invocation.path.leading_colon.is_none()
1295            && segments
1296                .next()
1297                .is_some_and(|segment| segment.ident == "canic")
1298            && segments
1299                .next()
1300                .is_some_and(|segment| segment.ident == "build")
1301            && segments.next().is_none()
1302        {
1303            self.invocations += 1;
1304        }
1305        syn::visit::visit_macro(self, invocation);
1306    }
1307}
1308
1309fn validate_runtime_graph(
1310    graph: &CargoGraphEvidence,
1311    direct_edge: &CargoGraphEdge,
1312) -> Result<(), RoleContractFinding> {
1313    let mut canic_package_ids = graph
1314        .packages
1315        .iter()
1316        .filter(|(_, package)| package.name == CANIC_PACKAGE)
1317        .map(|(package_id, _)| package_id.clone())
1318        .collect::<BTreeSet<_>>();
1319    canic_package_ids.insert(direct_edge.package_id.clone());
1320    if canic_package_ids.len() != 1 {
1321        return Err(RoleContractFinding::MultipleCanicPackages {
1322            packages: canic_package_ids
1323                .iter()
1324                .filter_map(|package_id| graph.packages.get(package_id))
1325                .map(|package| normalized_package_description(graph, package))
1326                .collect(),
1327        });
1328    }
1329
1330    let mut protected_paths = Vec::new();
1331    for dependency in graph
1332        .edges
1333        .get(&graph.selected_package_id)
1334        .into_iter()
1335        .flatten()
1336    {
1337        if dependency == direct_edge {
1338            continue;
1339        }
1340        if dependency.package_id == direct_edge.package_id {
1341            return Err(unsupported_finding(
1342                "the role package has more than one normal runtime path to Canic",
1343            ));
1344        }
1345        if let Some(path) = shortest_protected_path(graph, dependency) {
1346            protected_paths.push(path);
1347        }
1348    }
1349
1350    protected_paths.sort_by(|left, right| {
1351        left.edges.len().cmp(&right.edges.len()).then_with(|| {
1352            left.edges
1353                .iter()
1354                .map(|edge| graph_edge_sort_key(graph, edge))
1355                .cmp(
1356                    right
1357                        .edges
1358                        .iter()
1359                        .map(|edge| graph_edge_sort_key(graph, edge)),
1360                )
1361        })
1362    });
1363    if let Some(path) = protected_paths.first() {
1364        return Err(unsupported_finding(render_protected_path(graph, path)));
1365    }
1366
1367    Ok(())
1368}
1369
1370#[derive(Clone, Debug, Eq, PartialEq)]
1371struct DependencyPathEvidence {
1372    edges: Vec<CargoGraphEdge>,
1373    target_reason: &'static str,
1374}
1375
1376fn shortest_protected_path(
1377    graph: &CargoGraphEvidence,
1378    first_edge: &CargoGraphEdge,
1379) -> Option<DependencyPathEvidence> {
1380    let mut queue = VecDeque::from([(first_edge.clone(), vec![first_edge.clone()])]);
1381    let mut visited = BTreeSet::new();
1382
1383    while let Some((edge, path)) = queue.pop_front() {
1384        if !visited.insert(edge.package_id.clone()) {
1385            continue;
1386        }
1387        let package = graph.packages.get(&edge.package_id)?;
1388        if let Some(protected) = protected_canic_package(&package.name) {
1389            return Some(DependencyPathEvidence {
1390                edges: path,
1391                target_reason: protected.reason,
1392            });
1393        }
1394
1395        let mut children = graph
1396            .edges
1397            .get(&edge.package_id)
1398            .cloned()
1399            .unwrap_or_default();
1400        children.sort_by(|left, right| {
1401            graph_edge_sort_key(graph, left).cmp(&graph_edge_sort_key(graph, right))
1402        });
1403        for child in children {
1404            if visited.contains(&child.package_id) {
1405                continue;
1406            }
1407            let mut child_path = path.clone();
1408            child_path.push(child.clone());
1409            queue.push_back((child, child_path));
1410        }
1411    }
1412
1413    None
1414}
1415
1416fn graph_edge_sort_key(
1417    graph: &CargoGraphEvidence,
1418    edge: &CargoGraphEdge,
1419) -> (String, String, String, String, String) {
1420    let Some(package) = graph.packages.get(&edge.package_id) else {
1421        return (
1422            edge.alias.clone(),
1423            String::new(),
1424            String::new(),
1425            String::new(),
1426            String::new(),
1427        );
1428    };
1429    (
1430        edge.alias.clone(),
1431        package.name.clone(),
1432        package.version.clone(),
1433        normalized_source_kind(graph, package).to_string(),
1434        normalized_workspace_path(graph, package),
1435    )
1436}
1437
1438fn normalized_source_kind(
1439    graph: &CargoGraphEvidence,
1440    package: &graph::CargoGraphPackage,
1441) -> &'static str {
1442    match package.source.as_deref() {
1443        None if normalized_workspace_path(graph, package).is_empty() => "external_path",
1444        None => "workspace_path",
1445        Some(source) if source.starts_with("registry+") => "registry",
1446        Some(source) if source.starts_with("git+") => "git",
1447        Some(_) => "other",
1448    }
1449}
1450
1451fn normalized_workspace_path(
1452    graph: &CargoGraphEvidence,
1453    package: &graph::CargoGraphPackage,
1454) -> String {
1455    let workspace_root = graph
1456        .workspace_root
1457        .canonicalize()
1458        .unwrap_or_else(|_| graph.workspace_root.clone());
1459    let manifest_path = package
1460        .manifest_path
1461        .canonicalize()
1462        .unwrap_or_else(|_| package.manifest_path.clone());
1463    let Some(package_directory) = manifest_path.parent() else {
1464        return String::new();
1465    };
1466    let Ok(relative) = package_directory.strip_prefix(workspace_root) else {
1467        return String::new();
1468    };
1469    relative
1470        .components()
1471        .filter_map(|component| component.as_os_str().to_str())
1472        .collect::<Vec<_>>()
1473        .join("/")
1474}
1475
1476fn normalized_package_description(
1477    graph: &CargoGraphEvidence,
1478    package: &graph::CargoGraphPackage,
1479) -> String {
1480    let source_kind = normalized_source_kind(graph, package);
1481    let path = normalized_workspace_path(graph, package);
1482    if path.is_empty() {
1483        format!("{} {} ({source_kind})", package.name, package.version)
1484    } else {
1485        format!(
1486            "{} {} ({source_kind}:{path})",
1487            package.name, package.version
1488        )
1489    }
1490}
1491
1492fn protected_canic_package(name: &str) -> Option<&'static ProtectedCanicPackage> {
1493    PROTECTED_CANIC_PACKAGES
1494        .iter()
1495        .find(|package| package.name == name)
1496}
1497
1498fn render_protected_path(graph: &CargoGraphEvidence, path: &DependencyPathEvidence) -> String {
1499    let role = graph
1500        .packages
1501        .get(&graph.selected_package_id)
1502        .map_or("role package", |package| package.name.as_str());
1503    let mut rendered = vec![role.to_string()];
1504    for edge in &path.edges {
1505        let Some(package) = graph.packages.get(&edge.package_id) else {
1506            continue;
1507        };
1508        if edge.alias == cargo_dependency_edge_name(&package.name) {
1509            rendered.push(package.name.clone());
1510        } else {
1511            rendered.push(format!(
1512                "{} ({} {})",
1513                edge.alias, package.name, package.version
1514            ));
1515        }
1516    }
1517    let target = path
1518        .edges
1519        .last()
1520        .and_then(|edge| graph.packages.get(&edge.package_id))
1521        .map_or("protected Canic package", |package| package.name.as_str());
1522    format!(
1523        "role package `{role}` reaches protected package `{target}` outside its direct Canic subtree: {}; protected ownership: {}",
1524        rendered.join(" -> "),
1525        path.target_reason
1526    )
1527}
1528
1529fn normal_dependencies(
1530    node: &CargoMetadataNode,
1531) -> impl Iterator<Item = &CargoMetadataNodeDependency> {
1532    node.deps
1533        .iter()
1534        .filter(|dependency| dependency.dep_kinds.iter().any(|kind| kind.kind.is_none()))
1535}
1536
1537fn validate_cargo_catalog_parity(
1538    canic_package: &CargoMetadataPackage,
1539    package_by_id: &BTreeMap<&str, &CargoMetadataPackage>,
1540    node_by_id: &BTreeMap<&str, &CargoMetadataNode>,
1541) -> Result<(), RoleContractFinding> {
1542    let cargo_public_features = canic_package
1543        .features
1544        .keys()
1545        .filter(|name| name.as_str() != "default" && !is_non_role_public_feature(name.as_str()))
1546        .cloned()
1547        .collect::<BTreeSet<_>>();
1548    let catalog_public_features = feature_definitions()
1549        .iter()
1550        .map(|definition| definition.cargo_name.to_string())
1551        .collect::<BTreeSet<_>>();
1552    if cargo_public_features != catalog_public_features {
1553        return Err(RoleContractFinding::CargoCatalogDrift {
1554            reason: "resolved Canic public features differ from the role-contract catalog"
1555                .to_string(),
1556        });
1557    }
1558
1559    let cargo_defaults = canic_package
1560        .features
1561        .get("default")
1562        .cloned()
1563        .unwrap_or_default()
1564        .into_iter()
1565        .collect::<BTreeSet<_>>();
1566    let catalog_defaults = default_features()
1567        .iter()
1568        .map(|feature| feature.cargo_name().to_string())
1569        .collect::<BTreeSet<_>>();
1570    if cargo_defaults != catalog_defaults {
1571        return Err(RoleContractFinding::CargoCatalogDrift {
1572            reason: "resolved Canic default features differ from the role-contract catalog"
1573                .to_string(),
1574        });
1575    }
1576
1577    let canic_node = node_by_id
1578        .get(canic_package.id.as_str())
1579        .copied()
1580        .ok_or_else(|| cargo_catalog_drift("resolved Canic graph node is missing"))?;
1581    let core_dependency = canic_package
1582        .dependencies
1583        .iter()
1584        .find(|dependency| {
1585            dependency.name == CANIC_CORE_PACKAGE
1586                && dependency.kind.is_none()
1587                && dependency.target.is_none()
1588        })
1589        .ok_or_else(|| cargo_catalog_drift("resolved Canic core dependency is missing"))?;
1590    let core_key = core_dependency
1591        .rename
1592        .as_deref()
1593        .unwrap_or(CANIC_CORE_PACKAGE);
1594    let core_edge_name = cargo_dependency_edge_name(core_key);
1595    let core_edge = normal_dependencies(canic_node)
1596        .find(|dependency| dependency.name == core_edge_name)
1597        .ok_or_else(|| cargo_catalog_drift("resolved Canic core graph edge is missing"))?;
1598    let core_package = package_by_id
1599        .get(core_edge.pkg.as_str())
1600        .copied()
1601        .ok_or_else(|| cargo_catalog_drift("resolved Canic core package is missing"))?;
1602
1603    let cargo_implications = cargo_public_implications(
1604        &canic_package.features,
1605        &core_package.features,
1606        core_key,
1607        &cargo_public_features,
1608    );
1609    let catalog_implications = CanicFeatureKey::ALL
1610        .iter()
1611        .flat_map(|feature| {
1612            implied_features(*feature).map(|implied| {
1613                (
1614                    feature.cargo_name().to_string(),
1615                    implied.cargo_name().to_string(),
1616                )
1617            })
1618        })
1619        .collect::<BTreeSet<_>>();
1620    if cargo_implications != catalog_implications {
1621        return Err(RoleContractFinding::CargoCatalogDrift {
1622            reason:
1623                "resolved Canic public feature implications differ from the role-contract catalog"
1624                    .to_string(),
1625        });
1626    }
1627
1628    Ok(())
1629}
1630
1631fn cargo_public_implications(
1632    canic_features: &BTreeMap<String, Vec<String>>,
1633    core_features: &BTreeMap<String, Vec<String>>,
1634    core_dependency_key: &str,
1635    public_features: &BTreeSet<String>,
1636) -> BTreeSet<(String, String)> {
1637    let mut implications = BTreeSet::new();
1638    let core_prefix = format!("{core_dependency_key}/");
1639
1640    for feature in public_features {
1641        for member in canic_features.get(feature).into_iter().flatten() {
1642            if public_features.contains(member) {
1643                implications.insert((feature.clone(), member.clone()));
1644                continue;
1645            }
1646            let Some(core_feature) = member.strip_prefix(&core_prefix) else {
1647                continue;
1648            };
1649            for core_member in core_features.get(core_feature).into_iter().flatten() {
1650                if public_features.contains(core_member) {
1651                    implications.insert((feature.clone(), core_member.clone()));
1652                }
1653            }
1654        }
1655    }
1656
1657    implications
1658}
1659
1660fn package_manifest_path(config_path: &Path, package: &str) -> PathBuf {
1661    let package_path = PathBuf::from(package);
1662    let path = if package_path.is_absolute() {
1663        package_path
1664    } else {
1665        config_path
1666            .parent()
1667            .map_or_else(|| PathBuf::from(package), |parent| parent.join(package))
1668    };
1669    if path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml") {
1670        path
1671    } else {
1672        path.join("Cargo.toml")
1673    }
1674}
1675
1676fn cargo_dependency_edge_name(dependency_key: &str) -> String {
1677    dependency_key.replace('-', "_")
1678}
1679
1680const fn unsupported_shape(reason: String) -> RolePackageValidation {
1681    RolePackageValidation::Unsupported(RoleContractFinding::DependencyShapeUnsupported { reason })
1682}
1683
1684fn unsupported_finding(reason: impl Into<String>) -> RoleContractFinding {
1685    RoleContractFinding::DependencyShapeUnsupported {
1686        reason: reason.into(),
1687    }
1688}
1689
1690fn cargo_catalog_drift(reason: impl Into<String>) -> RoleContractFinding {
1691    RoleContractFinding::CargoCatalogDrift {
1692        reason: reason.into(),
1693    }
1694}