Skip to main content

canic_host/role_contract/package/
mod.rs

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