1use 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#[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;