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