1use deps_core::diagnostic::{Diagnostic, Severity};
14use deps_core::lsp_helpers::{
15 DEPRECATED_DIAGNOSTIC_CODE, DependencyOutcomes, LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE,
16 UNSATISFIABLE_DIAGNOSTIC_CODE,
17};
18use deps_core::osv::{OsvClient, ScanOutcome, VulnSeverity, VulnerabilityMap};
19use deps_core::policy_config::PolicyConfig;
20use deps_core::position::Range;
21use deps_core::{Dependency, Ecosystem, EcosystemId, HttpCache, PackageName, VersionData};
22use deps_engine::classify::diff::{
23 merge_deprecations_after_fetch, merge_no_comparable_versions_after_fetch,
24};
25use deps_engine::classify::fetch::{
26 apply_fetch_outcomes, composer_minimum_stability, dedup_dependencies_by_source,
27 fetch_latest_versions_parallel,
28};
29use deps_engine::classify::osv::build_scan_targets;
30use deps_engine::classify::resolved::{collect_in_use_versions, load_resolved_versions};
31use std::collections::{BTreeMap, HashMap};
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34use std::time::Duration;
35
36const GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE: &str = "mutable-ref-pin";
48const GITLAB_CI_MUTABLE_REF_PIN_CODE: &str = "gitlab-ci-mutable-ref-pin";
50const GITLAB_CI_UNRESOLVED_HOST_CODE: &str = "unresolved-gitlab-host";
55
56const OSV_SCAN_TIMEOUT_CEILING_SECS: u64 = 30;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, clap::ValueEnum)]
82pub enum Category {
83 Outdated,
85 Yanked,
87 Vulnerable,
89 Unsatisfiable,
91 #[value(name = "mutable-ref")]
94 MutableRefPin,
95 License,
97 Deprecated,
99 #[value(skip)]
101 Other,
102}
103
104impl Category {
105 #[must_use]
107 pub const fn as_str(self) -> &'static str {
108 match self {
109 Self::Outdated => "outdated",
110 Self::Yanked => "yanked",
111 Self::Vulnerable => "vulnerable",
112 Self::Unsatisfiable => "unsatisfiable",
113 Self::MutableRefPin => "mutable-ref",
114 Self::License => "license",
115 Self::Deprecated => "deprecated",
116 Self::Other => "other",
117 }
118 }
119
120 #[must_use]
142 pub const fn description(self) -> &'static str {
143 match self {
144 Self::Outdated => {
145 "A newer version is published for the dependency's declared requirement."
146 }
147 Self::Yanked => "The in-use version has been yanked or retracted from the registry.",
148 Self::Vulnerable => "A known security advisory affects the in-use version.",
149 Self::Unsatisfiable => "No published version satisfies the declared requirement.",
150 Self::MutableRefPin => {
151 "Pinned to a mutable ref (tag or branch) instead of a commit SHA."
152 }
153 Self::License => "The resolved license violates the configured allow/deny policy.",
154 Self::Deprecated => {
155 "The registry reports the package itself as deprecated or abandoned."
156 }
157 Self::Other => "A finding that does not map to any of the other categories.",
158 }
159 }
160}
161
162impl std::fmt::Display for Category {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 f.write_str(self.as_str())
165 }
166}
167
168#[derive(Debug, Clone)]
170pub struct CheckFinding {
171 pub ecosystem: EcosystemId,
173 pub manifest_path: PathBuf,
175 pub dependency_name: Option<String>,
179 pub requirement: Option<String>,
181 pub category: Category,
183 pub code: Option<String>,
191 pub advisory_url: Option<String>,
200 pub advisory_severity: Option<VulnSeverity>,
208 pub severity: Severity,
210 pub range: Range,
212 pub message: String,
214}
215
216#[derive(Debug, Clone, Default)]
218pub struct CheckReport {
219 pub findings: Vec<CheckFinding>,
221}
222
223impl CheckReport {
224 #[must_use]
254 pub fn summary(&self) -> BTreeMap<Category, usize> {
255 let mut counts = BTreeMap::new();
256 for finding in &self.findings {
257 *counts.entry(finding.category).or_insert(0_usize) += 1;
258 }
259 counts
260 }
261}
262
263#[derive(Debug, Clone)]
266pub struct FailOnPolicy {
267 categories: Vec<Category>,
268}
269
270impl FailOnPolicy {
271 #[must_use]
273 pub const fn new(categories: Vec<Category>) -> Self {
274 Self { categories }
275 }
276
277 #[must_use]
290 pub fn default_categories() -> Self {
291 Self::new(vec![
292 Category::Vulnerable,
293 Category::Yanked,
294 Category::Unsatisfiable,
295 ])
296 }
297
298 #[must_use]
300 pub fn categories(&self) -> &[Category] {
301 &self.categories
302 }
303
304 #[must_use]
306 pub fn matches(&self, findings: &[CheckFinding]) -> bool {
307 findings
308 .iter()
309 .any(|finding| self.categories.contains(&finding.category))
310 }
311}
312
313impl Default for FailOnPolicy {
314 fn default() -> Self {
315 Self::default_categories()
316 }
317}
318
319#[derive(Clone)]
324pub struct CheckContext {
325 pub cache: Arc<HttpCache>,
327 pub osv: Arc<OsvClient>,
329 pub lockfile_cache: Arc<deps_core::lockfile::LockFileCache>,
331 pub policy: PolicyConfig,
333}
334
335#[derive(Debug, thiserror::Error)]
337pub enum CheckError {
338 #[error("failed to parse {path}: {source}")]
340 Parse {
341 path: PathBuf,
343 #[source]
345 source: deps_core::DepsError,
346 },
347 #[error("could not build a file URI for {path}")]
350 InvalidPath {
351 path: PathBuf,
353 },
354}
355
356#[derive(Debug, Clone)]
358pub struct ManifestCheckResult {
359 pub findings: Vec<CheckFinding>,
361 pub registry_unreachable: bool,
366}
367
368pub async fn check_manifest(
385 ecosystem: &Arc<dyn Ecosystem>,
386 manifest_path: &Path,
387 display_path: &Path,
388 content: &str,
389 ctx: &CheckContext,
390) -> Result<ManifestCheckResult, CheckError> {
391 let uri = path_to_uri(manifest_path).ok_or_else(|| CheckError::InvalidPath {
392 path: manifest_path.to_path_buf(),
393 })?;
394 let parse_result = deps_core::parse_manifest_blocking(ecosystem, content, &uri)
395 .await
396 .map_err(|source| CheckError::Parse {
397 path: manifest_path.to_path_buf(),
398 source,
399 })?;
400 let formatter = ecosystem.formatter();
401 let ecosystem_id = ecosystem.ecosystem_id();
402
403 let (resolved_versions, resolved_version_candidates) =
404 load_resolved_versions(&uri, &ctx.lockfile_cache, ecosystem.as_ref()).await;
405
406 let (dep_sources, collided_names) =
407 dedup_dependencies_by_source(parse_result.as_ref(), formatter);
408 let in_use = collect_in_use_versions(
409 parse_result.as_ref(),
410 &resolved_versions,
411 &resolved_version_candidates,
412 formatter,
413 ecosystem_id,
414 );
415 let minimum_stability = composer_minimum_stability(parse_result.as_ref());
416 let attempted_names: Vec<PackageName> = dep_sources.keys().cloned().collect();
417
418 let fetch_result = fetch_latest_versions_parallel(
419 ecosystem.registry(),
420 dep_sources.into_iter().collect(),
421 &in_use,
422 None,
423 ctx.policy.freshness.to_settings(),
424 ctx.policy.cache.fetch_timeout_secs,
425 ctx.policy.cache.max_concurrent_fetches,
426 minimum_stability.as_deref(),
427 )
428 .await;
429 let registry_unreachable = !ctx.policy.network.offline && !fetch_result.fetch_failed.is_empty();
432
433 let mut outcomes = DependencyOutcomes::new();
434 let fetched_names: Vec<PackageName> = fetch_result.versions.keys().cloned().collect();
435 apply_fetch_outcomes(
436 &mut outcomes,
437 fetch_result.yanked_versions,
438 fetch_result.fetch_failed,
439 collided_names,
440 formatter,
441 );
442 merge_deprecations_after_fetch(
443 &mut outcomes,
444 &fetched_names,
445 fetch_result.deprecations,
446 formatter,
447 );
448 merge_no_comparable_versions_after_fetch(
449 &mut outcomes,
450 &attempted_names,
451 fetch_result.no_comparable_versions,
452 formatter,
453 );
454 let cached_versions = fetch_result.versions;
455 let licenses = fetch_result.licenses;
458
459 let vulnerabilities: Option<VulnerabilityMap> =
460 if ctx.policy.diagnostics.vulnerabilities_enabled && !ctx.policy.network.offline {
461 let (targets, skipped) = build_scan_targets(
462 parse_result.as_ref(),
463 &resolved_versions,
464 &resolved_version_candidates,
465 formatter,
466 ecosystem_id,
467 );
468 let mut vulns = skipped;
469 if !targets.is_empty() {
470 let timeout = Duration::from_secs(
471 ctx.policy
472 .cache
473 .fetch_timeout_secs
474 .min(OSV_SCAN_TIMEOUT_CEILING_SECS),
475 );
476 let scanned = ctx.osv.scan(ecosystem_id, &targets, timeout).await;
477 vulns.extend(scanned);
478 }
479 Some(vulns)
480 } else {
481 None
482 };
483
484 let license_policy = ctx.policy.license_policy.to_policy();
487 let mut version_data = VersionData::new(&cached_versions, &resolved_versions)
488 .with_resolved_version_candidates(&resolved_version_candidates)
489 .with_outcomes(&outcomes)
490 .with_ecosystem(ecosystem_id)
491 .with_offline(ctx.policy.network.offline)
492 .with_license_source(ecosystem.license_source())
493 .with_license_policy(&license_policy)
494 .with_license_prefetch(&licenses);
495 if let Some(vulnerabilities) = vulnerabilities.as_ref() {
496 version_data = version_data.with_vulnerabilities(vulnerabilities);
497 }
498
499 let severities = ctx.policy.diagnostics.to_severities();
500 let diagnostics = ecosystem
501 .generate_diagnostics(
502 parse_result.as_ref(),
503 version_data,
504 &uri,
505 ctx.policy.freshness.to_settings(),
506 severities,
507 )
508 .await;
509
510 let dep_index = DependencyIndex::build(parse_result.as_ref());
511 let advisory_severities = advisory_severity_index(vulnerabilities.as_ref());
512 let vuln_keys = deps_core::osv::vulnerability_keys(
515 parse_result.as_ref(),
516 &resolved_versions,
517 Some(&resolved_version_candidates),
518 formatter,
519 ecosystem_id,
520 );
521 let findings = diagnostics
522 .into_iter()
523 .map(|diagnostic| {
524 to_finding(
525 ecosystem_id,
526 display_path,
527 &dep_index,
528 formatter,
529 diagnostic,
530 &advisory_severities,
531 &vuln_keys,
532 )
533 })
534 .collect();
535 Ok(ManifestCheckResult {
536 findings,
537 registry_unreachable,
538 })
539}
540
541struct DependencyIndex<'a> {
551 by_name_range: HashMap<Range, &'a dyn Dependency>,
552 by_version_range: HashMap<Range, &'a dyn Dependency>,
553}
554
555impl<'a> DependencyIndex<'a> {
556 fn build(parse_result: &'a dyn deps_core::ParseResult) -> Self {
557 let mut by_name_range = HashMap::new();
558 let mut by_version_range = HashMap::new();
559 for dep in parse_result.dependencies() {
560 if !dep.name_range_is_synthetic() {
561 by_name_range.insert(dep.name_range(), dep);
562 }
563 if let Some(version_range) = dep.version_range() {
564 by_version_range.insert(version_range, dep);
565 }
566 }
567 Self {
568 by_name_range,
569 by_version_range,
570 }
571 }
572
573 fn lookup(&self, range: Range) -> Option<&'a dyn Dependency> {
577 self.by_name_range
578 .get(&range)
579 .or_else(|| self.by_version_range.get(&range))
580 .copied()
581 }
582}
583
584fn advisory_severity_index(
602 vulnerabilities: Option<&VulnerabilityMap>,
603) -> HashMap<(String, String), VulnSeverity> {
604 let mut index = HashMap::new();
605 let Some(vulnerabilities) = vulnerabilities else {
606 return index;
607 };
608 for (dependency_key, outcome) in vulnerabilities {
609 if let ScanOutcome::Vulnerable(dv) = outcome {
610 for advisory in dv.advisories.items() {
611 index.insert(
612 (dependency_key.clone(), advisory.id.clone()),
613 advisory.severity,
614 );
615 }
616 }
617 }
618 index
619}
620
621fn to_finding(
627 ecosystem: EcosystemId,
628 display_path: &Path,
629 dep_index: &DependencyIndex<'_>,
630 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
631 diagnostic: Diagnostic,
632 advisory_severities: &HashMap<(String, String), VulnSeverity>,
633 vuln_keys: &HashMap<Range, String>,
634) -> CheckFinding {
635 let category = classify(&diagnostic, formatter);
636 let dep = dep_index.lookup(diagnostic.range);
637 let code = diagnostic.code.clone();
638 let advisory_url = diagnostic
639 .code_description
640 .as_ref()
641 .map(|code_description| code_description.href.as_str().to_string());
642 let advisory_severity = code.as_deref().zip(dep).and_then(|(code, dep)| {
643 let dependency_key = vuln_keys.get(&dep.name_range())?;
644 advisory_severities
645 .get(&(dependency_key.clone(), code.to_string()))
646 .copied()
647 });
648 CheckFinding {
649 ecosystem,
650 manifest_path: display_path.to_path_buf(),
651 dependency_name: dep.map(|d| d.name().to_string()),
652 requirement: dep
653 .and_then(Dependency::version_requirement)
654 .map(ToString::to_string),
655 category,
656 code,
657 advisory_url,
658 advisory_severity,
659 severity: diagnostic.severity.unwrap_or(Severity::Warning),
660 range: diagnostic.range,
661 message: diagnostic.message,
662 }
663}
664
665fn classify(
682 diagnostic: &Diagnostic,
683 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
684) -> Category {
685 if let Some(code) = &diagnostic.code {
686 return match code.as_str() {
687 UNSATISFIABLE_DIAGNOSTIC_CODE => Category::Unsatisfiable,
688 LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE => Category::License,
689 DEPRECATED_DIAGNOSTIC_CODE => Category::Deprecated,
690 GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE | GITLAB_CI_MUTABLE_REF_PIN_CODE => {
691 Category::MutableRefPin
692 }
693 GITLAB_CI_UNRESOLVED_HOST_CODE => Category::Other,
694 _ => Category::Vulnerable,
695 };
696 }
697 if diagnostic.message.starts_with("Newer version available") {
698 return Category::Outdated;
699 }
700 if diagnostic.message.contains(formatter.yanked_message()) {
701 return Category::Yanked;
702 }
703 if diagnostic.message.ends_with("more advisories") {
706 return Category::Vulnerable;
707 }
708 Category::Other
709}
710
711fn path_to_uri(path: &Path) -> Option<url::Url> {
719 let absolute = if path.is_absolute() {
720 path.to_path_buf()
721 } else {
722 std::env::current_dir()
723 .map(|cwd| cwd.join(path))
724 .unwrap_or_else(|_| path.to_path_buf())
725 };
726 url::Url::from_file_path(&absolute).ok()
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 fn finding(category: Category) -> CheckFinding {
734 CheckFinding {
735 ecosystem: EcosystemId::Cargo,
736 manifest_path: PathBuf::from("Cargo.toml"),
737 dependency_name: Some("serde".to_string()),
738 requirement: Some("1.0".to_string()),
739 category,
740 code: None,
741 advisory_url: None,
742 advisory_severity: None,
743 severity: Severity::Warning,
744 range: Range::default(),
745 message: "test".to_string(),
746 }
747 }
748
749 #[test]
750 fn test_category_as_str_matches_fr009_tokens() {
751 assert_eq!(Category::Outdated.as_str(), "outdated");
752 assert_eq!(Category::Yanked.as_str(), "yanked");
753 assert_eq!(Category::Vulnerable.as_str(), "vulnerable");
754 assert_eq!(Category::Unsatisfiable.as_str(), "unsatisfiable");
755 assert_eq!(Category::MutableRefPin.as_str(), "mutable-ref");
756 assert_eq!(Category::License.as_str(), "license");
757 assert_eq!(Category::Deprecated.as_str(), "deprecated");
758 assert_eq!(Category::Other.as_str(), "other");
759 }
760
761 #[test]
762 fn test_fail_on_policy_default_categories() {
763 let policy = FailOnPolicy::default_categories();
764 assert!(policy.matches(&[finding(Category::Vulnerable)]));
765 assert!(policy.matches(&[finding(Category::Yanked)]));
766 assert!(policy.matches(&[finding(Category::Unsatisfiable)]));
767 assert!(!policy.matches(&[finding(Category::Outdated)]));
768 assert!(!policy.matches(&[finding(Category::License)]));
769 assert!(!policy.matches(&[finding(Category::Deprecated)]));
770 assert!(!policy.matches(&[finding(Category::MutableRefPin)]));
771 assert!(!policy.matches(&[finding(Category::Other)]));
772 }
773
774 #[test]
775 fn test_fail_on_policy_custom_categories() {
776 let policy = FailOnPolicy::new(vec![Category::License]);
777 assert!(policy.matches(&[finding(Category::License)]));
778 assert!(!policy.matches(&[finding(Category::Vulnerable)]));
779 }
780
781 #[test]
782 fn test_fail_on_policy_empty_findings_never_matches() {
783 assert!(!FailOnPolicy::default_categories().matches(&[]));
784 }
785
786 #[test]
787 fn test_check_report_summary_counts_per_category() {
788 let report = CheckReport {
789 findings: vec![
790 finding(Category::Outdated),
791 finding(Category::Outdated),
792 finding(Category::Vulnerable),
793 ],
794 };
795 let summary = report.summary();
796 assert_eq!(summary.get(&Category::Outdated), Some(&2));
797 assert_eq!(summary.get(&Category::Vulnerable), Some(&1));
798 assert_eq!(summary.get(&Category::License), None);
799 }
800
801 #[test]
802 fn test_check_report_summary_empty_for_no_findings() {
803 let report = CheckReport::default();
804 assert!(report.summary().is_empty());
805 }
806
807 struct StubFormatter;
808 impl deps_core::lsp_helpers::PackageNaming for StubFormatter {}
809 impl deps_core::lsp_helpers::PackageRendering for StubFormatter {
810 fn format_version_for_text_edit(&self, version: &deps_core::ConcreteVersion) -> String {
811 version.to_string()
812 }
813 fn package_url(&self, name: &PackageName) -> String {
814 name.to_string()
815 }
816 }
817 impl deps_core::lsp_helpers::RequirementResolution for StubFormatter {}
818 impl deps_core::lsp_helpers::DiagnosticMessages for StubFormatter {}
819 impl deps_core::lsp_helpers::DiagnosticPolicy for StubFormatter {}
820 impl deps_core::lsp_helpers::SourcePolicy for StubFormatter {}
821 impl deps_core::lsp_helpers::OsvNaming for StubFormatter {}
822
823 fn diagnostic_with(code: Option<&str>, message: &str) -> Diagnostic {
824 let diagnostic =
825 Diagnostic::new(Range::default(), message).with_severity(Severity::Warning);
826 match code {
827 Some(code) => diagnostic.with_code(code),
828 None => diagnostic,
829 }
830 }
831
832 #[test]
833 fn test_classify_unsatisfiable_by_code() {
834 let d = diagnostic_with(Some(UNSATISFIABLE_DIAGNOSTIC_CODE), "no matching version");
835 assert_eq!(classify(&d, &StubFormatter), Category::Unsatisfiable);
836 }
837
838 #[test]
839 fn test_classify_license_by_code() {
840 let d = diagnostic_with(
841 Some(LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE),
842 "GPL-3.0 denied",
843 );
844 assert_eq!(classify(&d, &StubFormatter), Category::License);
845 }
846
847 #[test]
848 fn test_classify_deprecated_by_code() {
849 let d = diagnostic_with(Some(DEPRECATED_DIAGNOSTIC_CODE), "package deprecated");
850 assert_eq!(classify(&d, &StubFormatter), Category::Deprecated);
851 }
852
853 #[test]
854 fn test_classify_mutable_ref_pin_by_code() {
855 let d = diagnostic_with(Some(GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE), "pinned to a tag");
856 assert_eq!(classify(&d, &StubFormatter), Category::MutableRefPin);
857 let d = diagnostic_with(Some(GITLAB_CI_MUTABLE_REF_PIN_CODE), "pinned to a tag");
858 assert_eq!(classify(&d, &StubFormatter), Category::MutableRefPin);
859 }
860
861 #[test]
862 fn test_classify_advisory_code_is_vulnerable() {
863 let d = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
864 assert_eq!(classify(&d, &StubFormatter), Category::Vulnerable);
865 }
866
867 #[test]
868 fn test_classify_outdated_by_message_prefix() {
869 let d = diagnostic_with(None, "Newer version available: 2.0.0");
870 assert_eq!(classify(&d, &StubFormatter), Category::Outdated);
871 }
872
873 #[test]
874 fn test_classify_yanked_by_formatter_message() {
875 let d = diagnostic_with(None, "This version has been yanked (1.0.0)");
876 assert_eq!(classify(&d, &StubFormatter), Category::Yanked);
877 }
878
879 #[test]
880 fn test_classify_unknown_package_is_other() {
881 let d = diagnostic_with(None, "Unknown package 'left-pad'");
882 assert_eq!(classify(&d, &StubFormatter), Category::Other);
883 }
884
885 #[test]
888 fn test_classify_advisory_overflow_summary_is_vulnerable() {
889 let d = diagnostic_with(None, "+5 more advisories");
890 assert_eq!(classify(&d, &StubFormatter), Category::Vulnerable);
891 }
892
893 #[test]
897 fn test_classify_gitlab_unresolved_host_is_other_not_vulnerable() {
898 let d = diagnostic_with(
899 Some(GITLAB_CI_UNRESOLVED_HOST_CODE),
900 "registries.gitlab_instance_host is unset; skipping component/project host resolution",
901 );
902 assert_eq!(classify(&d, &StubFormatter), Category::Other);
903 }
904
905 fn dep_index_with_one_dependency() -> Box<dyn deps_core::ParseResult> {
912 deps_core::test_util::stub_parse_result_with_dependencies(1)
913 }
914
915 fn empty_dep_index() -> Box<dyn deps_core::ParseResult> {
916 deps_core::test_util::stub_parse_result_with_dependencies(0)
917 }
918
919 #[test]
923 fn test_to_finding_extracts_string_code_from_diagnostic() {
924 let parse_result = empty_dep_index();
925 let dep_index = DependencyIndex::build(parse_result.as_ref());
926 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
927 let finding = to_finding(
928 EcosystemId::Cargo,
929 Path::new("Cargo.toml"),
930 &dep_index,
931 &StubFormatter,
932 diagnostic,
933 &HashMap::new(),
934 &HashMap::new(),
935 );
936 assert_eq!(finding.code.as_deref(), Some("RUSTSEC-2024-0001"));
937 }
938
939 #[test]
940 fn test_to_finding_code_is_none_without_a_diagnostic_code() {
941 let parse_result = empty_dep_index();
942 let dep_index = DependencyIndex::build(parse_result.as_ref());
943 let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
944 let finding = to_finding(
945 EcosystemId::Cargo,
946 Path::new("Cargo.toml"),
947 &dep_index,
948 &StubFormatter,
949 diagnostic,
950 &HashMap::new(),
951 &HashMap::new(),
952 );
953 assert!(finding.code.is_none());
954 }
955
956 #[test]
957 fn test_to_finding_extracts_advisory_url_from_code_description() {
958 let parse_result = empty_dep_index();
959 let dep_index = DependencyIndex::build(parse_result.as_ref());
960 let href: url::Url = "https://osv.dev/vulnerability/RUSTSEC-2024-0001"
961 .parse()
962 .expect("valid URL");
963 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary")
964 .with_code_description(deps_core::diagnostic::CodeDescription::new(href));
965 let finding = to_finding(
966 EcosystemId::Cargo,
967 Path::new("Cargo.toml"),
968 &dep_index,
969 &StubFormatter,
970 diagnostic,
971 &HashMap::new(),
972 &HashMap::new(),
973 );
974 assert_eq!(
975 finding.advisory_url.as_deref(),
976 Some("https://osv.dev/vulnerability/RUSTSEC-2024-0001")
977 );
978 }
979
980 #[test]
981 fn test_to_finding_advisory_url_is_none_without_code_description() {
982 let parse_result = empty_dep_index();
983 let dep_index = DependencyIndex::build(parse_result.as_ref());
984 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
985 let finding = to_finding(
986 EcosystemId::Cargo,
987 Path::new("Cargo.toml"),
988 &dep_index,
989 &StubFormatter,
990 diagnostic,
991 &HashMap::new(),
992 &HashMap::new(),
993 );
994 assert!(finding.advisory_url.is_none());
995 }
996
997 #[test]
998 fn test_to_finding_resolves_advisory_severity_from_index() {
999 let parse_result = dep_index_with_one_dependency();
1000 let dep_index = DependencyIndex::build(parse_result.as_ref());
1001 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1002
1003 let mut vuln_keys = HashMap::new();
1004 vuln_keys.insert(Range::default(), "dep-0".to_string());
1005 let mut severities = HashMap::new();
1006 severities.insert(
1007 ("dep-0".to_string(), "RUSTSEC-2024-0001".to_string()),
1008 VulnSeverity::Critical,
1009 );
1010
1011 let finding = to_finding(
1012 EcosystemId::Cargo,
1013 Path::new("Cargo.toml"),
1014 &dep_index,
1015 &StubFormatter,
1016 diagnostic,
1017 &severities,
1018 &vuln_keys,
1019 );
1020 assert_eq!(finding.advisory_severity, Some(VulnSeverity::Critical));
1021 }
1022
1023 #[test]
1024 fn test_to_finding_advisory_severity_is_none_for_an_unindexed_code() {
1025 let parse_result = dep_index_with_one_dependency();
1026 let dep_index = DependencyIndex::build(parse_result.as_ref());
1027 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1028 let mut vuln_keys = HashMap::new();
1029 vuln_keys.insert(Range::default(), "dep-0".to_string());
1030
1031 let finding = to_finding(
1032 EcosystemId::Cargo,
1033 Path::new("Cargo.toml"),
1034 &dep_index,
1035 &StubFormatter,
1036 diagnostic,
1037 &HashMap::new(),
1038 &vuln_keys,
1039 );
1040 assert!(finding.advisory_severity.is_none());
1041 }
1042
1043 #[test]
1047 fn test_to_finding_advisory_severity_is_none_without_a_matched_dependency() {
1048 let parse_result = empty_dep_index();
1049 let dep_index = DependencyIndex::build(parse_result.as_ref());
1050 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1051 let mut severities = HashMap::new();
1052 severities.insert(
1053 ("dep-0".to_string(), "RUSTSEC-2024-0001".to_string()),
1054 VulnSeverity::Critical,
1055 );
1056
1057 let finding = to_finding(
1058 EcosystemId::Cargo,
1059 Path::new("Cargo.toml"),
1060 &dep_index,
1061 &StubFormatter,
1062 diagnostic,
1063 &severities,
1064 &HashMap::new(),
1065 );
1066 assert!(finding.advisory_severity.is_none());
1067 }
1068
1069 #[test]
1070 fn test_advisory_severity_index_collects_from_vulnerable_outcomes() {
1071 use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
1072 use std::sync::Arc;
1073
1074 let advisory = Advisory::new(
1075 "RUSTSEC-2024-0001".to_string(),
1076 "2024-01-01T00:00:00Z".to_string(),
1077 VulnSeverity::High,
1078 "https://osv.dev/vulnerability/RUSTSEC-2024-0001".to_string(),
1079 );
1080 let dv = DependencyVulnerabilities::new(Capped::new(vec![Arc::new(advisory)], 1));
1081 let mut map: VulnerabilityMap = HashMap::new();
1082 map.insert("serde".to_string(), ScanOutcome::Vulnerable(dv));
1083
1084 let index = advisory_severity_index(Some(&map));
1085 assert_eq!(
1086 index.get(&("serde".to_string(), "RUSTSEC-2024-0001".to_string())),
1087 Some(&VulnSeverity::High)
1088 );
1089 }
1090
1091 #[test]
1095 fn test_advisory_severity_index_does_not_collide_across_dependencies_sharing_an_advisory_id() {
1096 use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
1097 use std::sync::Arc;
1098
1099 let advisory_for = |severity: VulnSeverity| {
1100 Arc::new(Advisory::new(
1101 "GHSA-shared-id".to_string(),
1102 "2024-01-01T00:00:00Z".to_string(),
1103 severity,
1104 "https://osv.dev/vulnerability/GHSA-shared-id".to_string(),
1105 ))
1106 };
1107 let mut map: VulnerabilityMap = HashMap::new();
1108 map.insert(
1109 "package-a".to_string(),
1110 ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
1111 vec![advisory_for(VulnSeverity::Critical)],
1112 1,
1113 ))),
1114 );
1115 map.insert(
1116 "package-b".to_string(),
1117 ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
1118 vec![advisory_for(VulnSeverity::Low)],
1119 1,
1120 ))),
1121 );
1122
1123 let index = advisory_severity_index(Some(&map));
1124 assert_eq!(
1125 index.get(&("package-a".to_string(), "GHSA-shared-id".to_string())),
1126 Some(&VulnSeverity::Critical)
1127 );
1128 assert_eq!(
1129 index.get(&("package-b".to_string(), "GHSA-shared-id".to_string())),
1130 Some(&VulnSeverity::Low)
1131 );
1132 }
1133
1134 #[test]
1135 fn test_advisory_severity_index_empty_without_vulnerabilities() {
1136 assert!(advisory_severity_index(None).is_empty());
1137 }
1138}