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