Skip to main content

canic_host/role_contract/package/
mod.rs

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