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