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
7use crate::cargo_metadata::{
8    CargoMetadata, CargoMetadataDependency, CargoMetadataNode, CargoMetadataNodeDependency,
9    CargoMetadataPackage, cargo_metadata, cargo_metadata_for_manifest,
10};
11use canic_core::{
12    bootstrap::parse_config_model,
13    ids::CanisterRole,
14    role_contract::{
15        BuiltInRoleKind, CanicFeatureKey, RoleContractFinding,
16        catalog::{default_features, feature_definitions, implied_features, validate_catalog},
17    },
18};
19use std::{
20    collections::{BTreeMap, BTreeSet},
21    fs,
22    path::{Path, PathBuf},
23};
24
25const WASM_TARGET: &str = "wasm32-unknown-unknown";
26const CANIC_PACKAGE: &str = "canic";
27const CANIC_CORE_PACKAGE: &str = "canic-core";
28
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub enum PackageValidationMode {
31    Build,
32    Passive,
33}
34
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct RolePackageEvidence {
37    pub fleet: String,
38    pub role: CanisterRole,
39    pub role_package_name: String,
40    pub role_package_id: String,
41    pub role_manifest_path: PathBuf,
42    pub canic_package_id: String,
43    pub canic_version: String,
44    pub canic_source: Option<String>,
45    pub canic_manifest_path: PathBuf,
46    pub dependency_key: String,
47    pub default_features_enabled: bool,
48    pub direct_features: BTreeSet<CanicFeatureKey>,
49}
50
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub enum RolePackageValidation {
53    Supported(RolePackageEvidence),
54    Unsupported(RoleContractFinding),
55}
56
57pub fn declared_role_manifest_path(
58    config_path: &Path,
59    role: &CanisterRole,
60) -> Result<PathBuf, RoleContractFinding> {
61    let config_source = fs::read_to_string(config_path)
62        .map_err(|_| RoleContractFinding::PackageMissing { role: role.clone() })?;
63    let config = parse_config_model(&config_source).map_err(|error| {
64        RoleContractFinding::DependencyShapeUnsupported {
65            reason: format!(
66                "invalid role configuration {}: {error}",
67                config_path.display()
68            ),
69        }
70    })?;
71    let declaration = config
72        .roles
73        .get(role)
74        .ok_or_else(|| RoleContractFinding::RoleUnknown { role: role.clone() })?;
75    let manifest_path = package_manifest_path(config_path, &declaration.package);
76    if !manifest_path.is_file() {
77        return Err(RoleContractFinding::PackageMissing { role: role.clone() });
78    }
79    Ok(manifest_path)
80}
81
82#[must_use]
83pub fn validate_declared_role_package(
84    config_path: &Path,
85    role: &CanisterRole,
86    mode: PackageValidationMode,
87) -> RolePackageValidation {
88    let Ok(config_source) = fs::read_to_string(config_path) else {
89        return RolePackageValidation::Unsupported(RoleContractFinding::PackageMissing {
90            role: role.clone(),
91        });
92    };
93    let config = match parse_config_model(&config_source) {
94        Ok(config) => config,
95        Err(error) => {
96            return unsupported_shape(format!(
97                "invalid role configuration {}: {error}",
98                config_path.display()
99            ));
100        }
101    };
102    let Some(declaration) = config.roles.get(role) else {
103        return RolePackageValidation::Unsupported(RoleContractFinding::RoleUnknown {
104            role: role.clone(),
105        });
106    };
107    let Some(fleet) = config.fleet_name() else {
108        return unsupported_shape(format!("missing [fleet].name in {}", config_path.display()));
109    };
110    let manifest_path = package_manifest_path(config_path, &declaration.package);
111    if !manifest_path.is_file() {
112        return RolePackageValidation::Unsupported(RoleContractFinding::PackageMissing {
113            role: role.clone(),
114        });
115    }
116
117    validate_package_manifest(&manifest_path, fleet, role, mode, false)
118}
119
120#[must_use]
121pub fn validate_built_in_wasm_store_package(
122    manifest_path: &Path,
123    mode: PackageValidationMode,
124) -> RolePackageValidation {
125    if !manifest_path.is_file() {
126        return RolePackageValidation::Unsupported(
127            RoleContractFinding::BuiltInPackageUnavailable {
128                role: BuiltInRoleKind::WasmStore,
129            },
130        );
131    }
132
133    validate_package_manifest(
134        manifest_path,
135        "wasm_store",
136        &CanisterRole::WASM_STORE,
137        mode,
138        true,
139    )
140}
141
142/// Validate every Canic role package before an internal PocketIC Cargo build.
143///
144/// Packages without Canic role metadata are test stubs and are ignored. The
145/// private build marker may be granted only after this function succeeds.
146#[doc(hidden)]
147pub fn validate_internal_test_wasm_packages(
148    workspace_root: &Path,
149    package_names: &[&str],
150) -> Result<(), RoleContractFinding> {
151    let metadata = cargo_metadata(workspace_root, false).map_err(|error| {
152        unsupported_finding(format!("unable to inspect internal test packages: {error}"))
153    })?;
154
155    for package_name in package_names {
156        let matches = metadata
157            .packages
158            .iter()
159            .filter(|package| package.name == *package_name)
160            .collect::<Vec<_>>();
161        let [package] = matches.as_slice() else {
162            return Err(unsupported_finding(format!(
163                "internal test package `{package_name}` did not resolve exactly once"
164            )));
165        };
166        let Some((fleet, role)) = package_canic_identity(package)? else {
167            continue;
168        };
169        let config_path = package_role_config_path(package, &fleet, &role).ok_or_else(|| {
170            unsupported_finding(format!(
171                "internal test package `{package_name}` has no matching ancestor canic.toml"
172            ))
173        })?;
174        let evidence =
175            match validate_declared_role_package(&config_path, &role, PackageValidationMode::Build)
176            {
177                RolePackageValidation::Supported(evidence) => evidence,
178                RolePackageValidation::Unsupported(finding) => return Err(finding),
179            };
180        if evidence.role_package_name != *package_name
181            || normalized_manifest_path(&evidence.role_manifest_path)
182                != normalized_manifest_path(&package.manifest_path)
183        {
184            return Err(unsupported_finding(format!(
185                "internal test package `{package_name}` does not match the package selected by {}",
186                config_path.display()
187            )));
188        }
189        match super::resolve_declared_role_package_contract(&config_path, &evidence) {
190            canic_core::role_contract::RoleContractResolution::Resolved { .. } => {}
191            canic_core::role_contract::RoleContractResolution::Rejected { errors } => {
192                return Err(errors
193                    .into_iter()
194                    .next()
195                    .expect("rejected role contract has a blocking finding"));
196            }
197        }
198    }
199
200    Ok(())
201}
202
203fn package_canic_identity(
204    package: &CargoMetadataPackage,
205) -> Result<Option<(String, CanisterRole)>, RoleContractFinding> {
206    let canic = package
207        .metadata
208        .as_ref()
209        .and_then(|metadata| metadata.get("canic"));
210    let fleet = canic
211        .and_then(|metadata| metadata.get("fleet"))
212        .and_then(serde_json::Value::as_str);
213    let role = canic
214        .and_then(|metadata| metadata.get("role"))
215        .and_then(serde_json::Value::as_str);
216    match (canic, fleet, role) {
217        (None, _, _) => Ok(None),
218        (Some(_), Some(fleet), Some(role)) => Ok(Some((
219            fleet.to_string(),
220            CanisterRole::owned(role.to_string()),
221        ))),
222        (Some(_), _, _) => Err(unsupported_finding(format!(
223            "internal test package `{}` has incomplete Canic package metadata",
224            package.name
225        ))),
226    }
227}
228
229fn package_role_config_path(
230    package: &CargoMetadataPackage,
231    expected_fleet: &str,
232    expected_role: &CanisterRole,
233) -> Option<PathBuf> {
234    let manifest_dir = package.manifest_path.parent()?;
235    for ancestor in manifest_dir.ancestors() {
236        let candidate = ancestor.join("canic.toml");
237        let Ok(source) = fs::read_to_string(&candidate) else {
238            continue;
239        };
240        let Ok(config) = parse_config_model(&source) else {
241            continue;
242        };
243        if config.fleet_name() != Some(expected_fleet) {
244            continue;
245        }
246        let Some(declaration) = config.roles.get(expected_role) else {
247            continue;
248        };
249        if normalized_manifest_path(&package_manifest_path(&candidate, &declaration.package))
250            == normalized_manifest_path(&package.manifest_path)
251        {
252            return Some(candidate);
253        }
254    }
255    None
256}
257
258fn normalized_manifest_path(path: &Path) -> PathBuf {
259    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
260}
261
262fn validate_package_manifest(
263    manifest_path: &Path,
264    expected_fleet: &str,
265    expected_role: &CanisterRole,
266    mode: PackageValidationMode,
267    built_in: bool,
268) -> RolePackageValidation {
269    let metadata = match cargo_metadata_for_manifest(
270        manifest_path,
271        WASM_TARGET,
272        mode == PackageValidationMode::Passive,
273    ) {
274        Ok(metadata) => metadata,
275        Err(error) => {
276            let finding = if built_in {
277                RoleContractFinding::BuiltInPackageUnavailable {
278                    role: BuiltInRoleKind::WasmStore,
279                }
280            } else {
281                RoleContractFinding::DependencyShapeUnsupported {
282                    reason: format!(
283                        "unable to inspect the local wasm runtime graph for {}: {error}",
284                        manifest_path.display()
285                    ),
286                }
287            };
288            return RolePackageValidation::Unsupported(finding);
289        }
290    };
291
292    match validate_metadata_package(&metadata, manifest_path, expected_fleet, expected_role) {
293        Ok(evidence) => RolePackageValidation::Supported(evidence),
294        Err(finding) => RolePackageValidation::Unsupported(finding),
295    }
296}
297
298fn validate_metadata_package(
299    metadata: &CargoMetadata,
300    manifest_path: &Path,
301    expected_fleet: &str,
302    expected_role: &CanisterRole,
303) -> Result<RolePackageEvidence, RoleContractFinding> {
304    validate_catalog()?;
305    let selected = exact_manifest_package(metadata, manifest_path, expected_role)?;
306    validate_package_metadata(selected, expected_fleet, expected_role)?;
307
308    let direct_dependency = direct_canic_dependency(selected, expected_role)?;
309    let dependency_key = direct_dependency
310        .rename
311        .as_deref()
312        .unwrap_or(CANIC_PACKAGE)
313        .to_string();
314    reject_package_feature_forwarding(selected, &dependency_key)?;
315
316    let package_by_id = metadata
317        .packages
318        .iter()
319        .map(|package| (package.id.as_str(), package))
320        .collect::<BTreeMap<_, _>>();
321    let node_by_id = metadata
322        .resolve
323        .as_ref()
324        .ok_or_else(|| unsupported_finding("cargo metadata omitted the resolved graph"))?
325        .nodes
326        .iter()
327        .map(|node| (node.id.as_str(), node))
328        .collect::<BTreeMap<_, _>>();
329    let selected_node = node_by_id
330        .get(selected.id.as_str())
331        .copied()
332        .ok_or_else(|| unsupported_finding("selected role package is absent from the graph"))?;
333    let dependency_edge_name = cargo_dependency_edge_name(&dependency_key);
334    let direct_edges = normal_dependencies(selected_node)
335        .filter(|dependency| dependency.name == dependency_edge_name)
336        .collect::<Vec<_>>();
337    let [direct_edge] = direct_edges.as_slice() else {
338        return Err(unsupported_finding(
339            "the direct Canic dependency did not resolve to exactly one wasm runtime edge",
340        ));
341    };
342    let canic_package = package_by_id
343        .get(direct_edge.pkg.as_str())
344        .copied()
345        .ok_or_else(|| unsupported_finding("resolved Canic package metadata is missing"))?;
346    if canic_package.name != CANIC_PACKAGE {
347        return Err(unsupported_finding(
348            "the direct runtime dependency does not resolve to package `canic`",
349        ));
350    }
351
352    validate_runtime_graph(selected_node, direct_edge, &package_by_id, &node_by_id)?;
353    if canic_package.version != env!("CARGO_PKG_VERSION") {
354        return Err(RoleContractFinding::CanicVersionMismatch {
355            expected: env!("CARGO_PKG_VERSION").to_string(),
356            actual: canic_package.version.clone(),
357        });
358    }
359    validate_cargo_catalog_parity(canic_package, &package_by_id, &node_by_id)?;
360
361    let mut default_features_enabled = direct_dependency.uses_default_features;
362    let mut direct_features = BTreeSet::new();
363    for feature_name in &direct_dependency.features {
364        if feature_name == "default" {
365            default_features_enabled = true;
366            continue;
367        }
368        let Some(feature) = CanicFeatureKey::from_cargo_name(feature_name) else {
369            return Err(RoleContractFinding::CargoCatalogDrift {
370                reason: format!(
371                    "direct Canic dependency enables unclassified public feature `{feature_name}`"
372                ),
373            });
374        };
375        direct_features.insert(feature);
376    }
377
378    Ok(RolePackageEvidence {
379        fleet: expected_fleet.to_string(),
380        role: expected_role.clone(),
381        role_package_name: selected.name.clone(),
382        role_package_id: selected.id.clone(),
383        role_manifest_path: selected.manifest_path.clone(),
384        canic_package_id: canic_package.id.clone(),
385        canic_version: canic_package.version.clone(),
386        canic_source: canic_package.source.clone(),
387        canic_manifest_path: canic_package.manifest_path.clone(),
388        dependency_key,
389        default_features_enabled,
390        direct_features,
391    })
392}
393
394fn exact_manifest_package<'a>(
395    metadata: &'a CargoMetadata,
396    manifest_path: &Path,
397    role: &CanisterRole,
398) -> Result<&'a CargoMetadataPackage, RoleContractFinding> {
399    let expected = manifest_path
400        .canonicalize()
401        .unwrap_or_else(|_| manifest_path.to_path_buf());
402    let matches = metadata
403        .packages
404        .iter()
405        .filter(|package| {
406            package
407                .manifest_path
408                .canonicalize()
409                .unwrap_or_else(|_| package.manifest_path.clone())
410                == expected
411        })
412        .collect::<Vec<_>>();
413
414    match matches.as_slice() {
415        [package] => Ok(*package),
416        [] => Err(RoleContractFinding::PackageMissing { role: role.clone() }),
417        _ => Err(RoleContractFinding::PackageAmbiguous { role: role.clone() }),
418    }
419}
420
421fn validate_package_metadata(
422    package: &CargoMetadataPackage,
423    expected_fleet: &str,
424    expected_role: &CanisterRole,
425) -> Result<(), RoleContractFinding> {
426    let canic = package
427        .metadata
428        .as_ref()
429        .and_then(|metadata| metadata.get("canic"));
430    let actual_fleet = canic
431        .and_then(|metadata| metadata.get("fleet"))
432        .and_then(serde_json::Value::as_str)
433        .map(ToString::to_string);
434    let actual_role = canic
435        .and_then(|metadata| metadata.get("role"))
436        .and_then(serde_json::Value::as_str)
437        .map(ToString::to_string);
438
439    if actual_fleet.as_deref() == Some(expected_fleet)
440        && actual_role.as_deref() == Some(expected_role.as_str())
441    {
442        return Ok(());
443    }
444
445    Err(RoleContractFinding::PackageMetadataMismatch {
446        expected_fleet: expected_fleet.to_string(),
447        expected_role: expected_role.clone(),
448        actual_fleet,
449        actual_role,
450    })
451}
452
453fn direct_canic_dependency<'a>(
454    package: &'a CargoMetadataPackage,
455    role: &CanisterRole,
456) -> Result<&'a CargoMetadataDependency, RoleContractFinding> {
457    let dependencies = package
458        .dependencies
459        .iter()
460        .filter(|dependency| dependency.name == CANIC_PACKAGE && dependency.kind.is_none())
461        .collect::<Vec<_>>();
462    let [dependency] = dependencies.as_slice() else {
463        return if dependencies.is_empty() {
464            Err(RoleContractFinding::RuntimeCanicDependencyMissing { role: role.clone() })
465        } else {
466            Err(unsupported_finding(
467                "the role package declares more than one normal Canic dependency",
468            ))
469        };
470    };
471    if dependency.optional {
472        return Err(unsupported_finding(
473            "the direct normal Canic dependency must not be optional",
474        ));
475    }
476    if dependency.target.is_some() {
477        return Err(unsupported_finding(
478            "the direct normal Canic dependency must be unconditional",
479        ));
480    }
481    Ok(*dependency)
482}
483
484fn reject_package_feature_forwarding(
485    package: &CargoMetadataPackage,
486    dependency_key: &str,
487) -> Result<(), RoleContractFinding> {
488    let strong_prefix = format!("{dependency_key}/");
489    let weak_prefix = format!("{dependency_key}?/");
490    let optional_dependency = format!("dep:{dependency_key}");
491    if package.features.values().flatten().any(|member| {
492        member.starts_with(&strong_prefix)
493            || member.starts_with(&weak_prefix)
494            || member == &optional_dependency
495    }) {
496        return Err(unsupported_finding(
497            "package features must not forward features into the Canic dependency",
498        ));
499    }
500    Ok(())
501}
502
503fn validate_runtime_graph(
504    selected_node: &CargoMetadataNode,
505    direct_edge: &CargoMetadataNodeDependency,
506    package_by_id: &BTreeMap<&str, &CargoMetadataPackage>,
507    node_by_id: &BTreeMap<&str, &CargoMetadataNode>,
508) -> Result<(), RoleContractFinding> {
509    let reachable = reachable_normal_packages(selected_node, node_by_id);
510    let mut canic_package_ids = reachable
511        .iter()
512        .filter_map(|package_id| package_by_id.get(package_id.as_str()))
513        .filter(|package| package.name == CANIC_PACKAGE)
514        .map(|package| package.id.clone())
515        .collect::<BTreeSet<_>>();
516    canic_package_ids.insert(direct_edge.pkg.clone());
517    if canic_package_ids.len() != 1 {
518        return Err(RoleContractFinding::MultipleCanicPackages {
519            package_ids: canic_package_ids.into_iter().collect(),
520        });
521    }
522
523    for dependency in normal_dependencies(selected_node) {
524        if dependency.name == direct_edge.name && dependency.pkg == direct_edge.pkg {
525            continue;
526        }
527        if dependency.pkg == direct_edge.pkg {
528            return Err(unsupported_finding(
529                "the role package has more than one normal runtime path to Canic",
530            ));
531        }
532        let Some(node) = node_by_id.get(dependency.pkg.as_str()).copied() else {
533            continue;
534        };
535        if reachable_normal_packages(node, node_by_id).contains(&direct_edge.pkg) {
536            return Err(unsupported_finding(
537                "a transitive normal runtime dependency also contributes Canic",
538            ));
539        }
540    }
541
542    Ok(())
543}
544
545fn reachable_normal_packages(
546    start: &CargoMetadataNode,
547    node_by_id: &BTreeMap<&str, &CargoMetadataNode>,
548) -> BTreeSet<String> {
549    let mut reachable = BTreeSet::new();
550    let mut frontier = normal_dependencies(start)
551        .map(|dependency| dependency.pkg.clone())
552        .collect::<Vec<_>>();
553    while let Some(package_id) = frontier.pop() {
554        if !reachable.insert(package_id.clone()) {
555            continue;
556        }
557        if let Some(node) = node_by_id.get(package_id.as_str()) {
558            frontier.extend(normal_dependencies(node).map(|dependency| dependency.pkg.clone()));
559        }
560    }
561    reachable
562}
563
564fn normal_dependencies(
565    node: &CargoMetadataNode,
566) -> impl Iterator<Item = &CargoMetadataNodeDependency> {
567    node.deps
568        .iter()
569        .filter(|dependency| dependency.dep_kinds.iter().any(|kind| kind.kind.is_none()))
570}
571
572fn validate_cargo_catalog_parity(
573    canic_package: &CargoMetadataPackage,
574    package_by_id: &BTreeMap<&str, &CargoMetadataPackage>,
575    node_by_id: &BTreeMap<&str, &CargoMetadataNode>,
576) -> Result<(), RoleContractFinding> {
577    let cargo_public_features = canic_package
578        .features
579        .keys()
580        .filter(|name| name.as_str() != "default")
581        .cloned()
582        .collect::<BTreeSet<_>>();
583    let catalog_public_features = feature_definitions()
584        .iter()
585        .map(|definition| definition.cargo_name.to_string())
586        .collect::<BTreeSet<_>>();
587    if cargo_public_features != catalog_public_features {
588        return Err(RoleContractFinding::CargoCatalogDrift {
589            reason: "resolved Canic public features differ from the role-contract catalog"
590                .to_string(),
591        });
592    }
593
594    let cargo_defaults = canic_package
595        .features
596        .get("default")
597        .cloned()
598        .unwrap_or_default()
599        .into_iter()
600        .collect::<BTreeSet<_>>();
601    let catalog_defaults = default_features()
602        .iter()
603        .map(|feature| feature.cargo_name().to_string())
604        .collect::<BTreeSet<_>>();
605    if cargo_defaults != catalog_defaults {
606        return Err(RoleContractFinding::CargoCatalogDrift {
607            reason: "resolved Canic default features differ from the role-contract catalog"
608                .to_string(),
609        });
610    }
611
612    let canic_node = node_by_id
613        .get(canic_package.id.as_str())
614        .copied()
615        .ok_or_else(|| cargo_catalog_drift("resolved Canic graph node is missing"))?;
616    let core_dependency = canic_package
617        .dependencies
618        .iter()
619        .find(|dependency| {
620            dependency.name == CANIC_CORE_PACKAGE
621                && dependency.kind.is_none()
622                && dependency.target.is_none()
623        })
624        .ok_or_else(|| cargo_catalog_drift("resolved Canic core dependency is missing"))?;
625    let core_key = core_dependency
626        .rename
627        .as_deref()
628        .unwrap_or(CANIC_CORE_PACKAGE);
629    let core_edge_name = cargo_dependency_edge_name(core_key);
630    let core_edge = normal_dependencies(canic_node)
631        .find(|dependency| dependency.name == core_edge_name)
632        .ok_or_else(|| cargo_catalog_drift("resolved Canic core graph edge is missing"))?;
633    let core_package = package_by_id
634        .get(core_edge.pkg.as_str())
635        .copied()
636        .ok_or_else(|| cargo_catalog_drift("resolved Canic core package is missing"))?;
637
638    let cargo_implications = cargo_public_implications(
639        &canic_package.features,
640        &core_package.features,
641        core_key,
642        &cargo_public_features,
643    );
644    let catalog_implications = CanicFeatureKey::ALL
645        .iter()
646        .flat_map(|feature| {
647            implied_features(*feature).map(|implied| {
648                (
649                    feature.cargo_name().to_string(),
650                    implied.cargo_name().to_string(),
651                )
652            })
653        })
654        .collect::<BTreeSet<_>>();
655    if cargo_implications != catalog_implications {
656        return Err(RoleContractFinding::CargoCatalogDrift {
657            reason:
658                "resolved Canic public feature implications differ from the role-contract catalog"
659                    .to_string(),
660        });
661    }
662
663    Ok(())
664}
665
666fn cargo_public_implications(
667    canic_features: &BTreeMap<String, Vec<String>>,
668    core_features: &BTreeMap<String, Vec<String>>,
669    core_dependency_key: &str,
670    public_features: &BTreeSet<String>,
671) -> BTreeSet<(String, String)> {
672    let mut implications = BTreeSet::new();
673    let core_prefix = format!("{core_dependency_key}/");
674
675    for feature in public_features {
676        for member in canic_features.get(feature).into_iter().flatten() {
677            if public_features.contains(member) {
678                implications.insert((feature.clone(), member.clone()));
679                continue;
680            }
681            let Some(core_feature) = member.strip_prefix(&core_prefix) else {
682                continue;
683            };
684            for core_member in core_features.get(core_feature).into_iter().flatten() {
685                if public_features.contains(core_member) {
686                    implications.insert((feature.clone(), core_member.clone()));
687                }
688            }
689        }
690    }
691
692    implications
693}
694
695fn package_manifest_path(config_path: &Path, package: &str) -> PathBuf {
696    let package_path = PathBuf::from(package);
697    let path = if package_path.is_absolute() {
698        package_path
699    } else {
700        config_path
701            .parent()
702            .map_or_else(|| PathBuf::from(package), |parent| parent.join(package))
703    };
704    if path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml") {
705        path
706    } else {
707        path.join("Cargo.toml")
708    }
709}
710
711fn cargo_dependency_edge_name(dependency_key: &str) -> String {
712    dependency_key.replace('-', "_")
713}
714
715const fn unsupported_shape(reason: String) -> RolePackageValidation {
716    RolePackageValidation::Unsupported(RoleContractFinding::DependencyShapeUnsupported { reason })
717}
718
719fn unsupported_finding(reason: impl Into<String>) -> RoleContractFinding {
720    RoleContractFinding::DependencyShapeUnsupported {
721        reason: reason.into(),
722    }
723}
724
725fn cargo_catalog_drift(reason: impl Into<String>) -> RoleContractFinding {
726    RoleContractFinding::CargoCatalogDrift {
727        reason: reason.into(),
728    }
729}
730
731#[cfg(test)]
732mod tests;