1use deps_core::ConcreteVersion;
12use deps_core::EcosystemId;
13use deps_core::PackageName;
14use deps_core::lsp_helpers::{
15 OsvNameAvailability, has_unqueryable_resolved_pin, resolve_in_use_versions,
16};
17use std::collections::HashMap;
18
19pub fn build_scan_targets(
110 parse_result: &dyn deps_core::ParseResult,
111 resolved_versions: &HashMap<PackageName, ConcreteVersion>,
112 resolved_version_candidates: &HashMap<PackageName, Vec<ConcreteVersion>>,
113 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
114 ecosystem: EcosystemId,
115) -> (
116 Vec<deps_core::osv::ScanTarget>,
117 deps_core::osv::VulnerabilityMap,
118) {
119 use deps_core::osv::{OsvQueryName, ScanOutcome, SkipReason};
120 use std::collections::hash_map::Entry;
121
122 let mut targets: Vec<deps_core::osv::ScanTarget> = Vec::new();
123 let mut target_index: HashMap<deps_core::osv::VulnKey, usize> = HashMap::new();
124 let mut skipped = deps_core::osv::VulnerabilityMap::new();
125 let keys = deps_core::osv::vulnerability_keys(
126 parse_result,
127 resolved_versions,
128 Some(resolved_version_candidates),
129 formatter,
130 ecosystem,
131 );
132
133 for dep in parse_result.dependencies() {
134 let normalized_name = formatter.normalize_package_name(dep.name());
135 let key = deps_core::osv::vuln_key_for(dep, Some(&keys), formatter);
136
137 if !formatter.source_is_public_registry_content(&dep.source()) {
138 skipped.insert(key, ScanOutcome::Skipped(SkipReason::NonRegistrySource));
139 continue;
140 }
141
142 let versions = resolve_in_use_versions(
147 dep,
148 &normalized_name,
149 resolved_versions,
150 Some(resolved_version_candidates),
151 formatter,
152 ecosystem,
153 );
154
155 let Some(versions) = versions else {
156 let reason = if has_unqueryable_resolved_pin(dep, formatter, ecosystem) {
157 SkipReason::ResolvedTagNotFullVersion
158 } else {
159 SkipReason::NoConcreteVersion
160 };
161 skipped.insert(key, ScanOutcome::Skipped(reason));
162 continue;
163 };
164
165 let osv_name = match formatter.osv_name_availability(dep) {
166 OsvNameAvailability::Ready => match formatter.osv_package_name(dep) {
167 Some(name) => OsvQueryName::Confirmed(name),
168 None => {
169 skipped.insert(key, ScanOutcome::Skipped(SkipReason::UnmappableName));
170 continue;
171 }
172 },
173 OsvNameAvailability::AwaitingRegistryData {
174 written_fallback: Some(name),
175 } => OsvQueryName::Provisional(name),
176 OsvNameAvailability::AwaitingRegistryData {
177 written_fallback: None,
178 } => {
179 skipped.insert(
180 key,
181 ScanOutcome::Skipped(SkipReason::CanonicalNameUnconfirmed),
182 );
183 continue;
184 }
185 };
186
187 let target = deps_core::osv::ScanTarget::from_native(
188 key,
189 osv_name,
190 versions.primary().clone(),
191 formatter,
192 )
193 .with_siblings(&versions, formatter);
194 match target_index.entry(target.key.clone()) {
195 Entry::Vacant(slot) => {
196 slot.insert(targets.len());
197 targets.push(target);
198 }
199 Entry::Occupied(slot) => {
200 if let Some(existing) = targets.get_mut(*slot.get())
201 && matches!(existing.osv_name, OsvQueryName::Provisional(_))
202 && matches!(target.osv_name, OsvQueryName::Confirmed(_))
203 {
204 *existing = target;
205 }
206 }
207 }
208 }
209
210 (targets, skipped)
211}
212enum DepCheckClassification<'a> {
217 NonRegistrySource,
219 NoCachedVersions,
223 AwaitingOsvName {
226 cached: &'a deps_core::lsp_helpers::PackageVersions,
228 },
229 UnmappableName {
231 cached: &'a deps_core::lsp_helpers::PackageVersions,
234 },
235 Target {
238 osv_name: deps_core::osv::OsvPackageName,
240 cached: &'a deps_core::lsp_helpers::PackageVersions,
242 },
243}
244
245fn classify_dep_for_check_targets<'a>(
248 dep: &dyn deps_core::Dependency,
249 cached_versions: &'a HashMap<PackageName, deps_core::lsp_helpers::PackageVersions>,
250 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
251) -> DepCheckClassification<'a> {
252 if !formatter.source_is_public_registry_content(&dep.source()) {
253 return DepCheckClassification::NonRegistrySource;
254 }
255
256 let normalized_name = formatter.normalize_package_name(dep.name());
257 let Some(cached) = cached_versions
258 .get(normalized_name.as_str())
259 .or_else(|| cached_versions.get(dep.name()))
260 else {
261 return DepCheckClassification::NoCachedVersions;
262 };
263
264 if matches!(
265 formatter.osv_name_availability(dep),
266 OsvNameAvailability::AwaitingRegistryData { .. }
267 ) {
268 return DepCheckClassification::AwaitingOsvName { cached };
269 }
270
271 match formatter.osv_package_name(dep) {
272 Some(osv_name) => DepCheckClassification::Target { osv_name, cached },
273 None => DepCheckClassification::UnmappableName { cached },
274 }
275}
276
277pub fn build_latest_check_targets(
358 parse_result: &dyn deps_core::ParseResult,
359 cached_versions: &HashMap<PackageName, deps_core::lsp_helpers::PackageVersions>,
360 vuln_keys: &deps_core::osv::VulnKeys,
361 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
362) -> (
363 Vec<deps_core::osv::ScanTarget>,
364 deps_core::osv::LatestStatusMap,
365) {
366 use deps_core::osv::{SkipReason, StructuralSkipReason, UpgradeStatus, vuln_key_for};
367
368 let mut targets = Vec::new();
370 let mut structural = deps_core::osv::LatestStatusMap::new();
371 let mut seen = std::collections::HashSet::new();
372
373 for dep in parse_result.dependencies() {
374 let key = vuln_key_for(dep, Some(vuln_keys), formatter);
375
376 match classify_dep_for_check_targets(dep, cached_versions, formatter) {
377 DepCheckClassification::NonRegistrySource => {
378 structural.insert(
379 key,
380 UpgradeStatus::StructurallyUnchecked(StructuralSkipReason::NonRegistrySource),
381 );
382 }
383 DepCheckClassification::NoCachedVersions => {}
385 DepCheckClassification::AwaitingOsvName { cached } => {
386 structural.insert(
387 key,
388 UpgradeStatus::CandidateUnverified {
389 version: cached.latest.clone(),
390 reason: SkipReason::CanonicalNameUnconfirmed,
391 },
392 );
393 }
394 DepCheckClassification::UnmappableName { cached } => {
395 structural.insert(
396 key,
397 UpgradeStatus::CandidateUnverified {
398 version: cached.latest.clone(),
399 reason: SkipReason::UnmappableName,
400 },
401 );
402 }
403 DepCheckClassification::Target { osv_name, cached } => {
404 if !seen.insert(key.clone()) {
406 continue;
407 }
408 targets.push(deps_core::osv::ScanTarget::from_native(
409 key,
410 deps_core::osv::OsvQueryName::Confirmed(osv_name),
411 cached.latest.clone(),
412 formatter,
413 ));
414 }
415 }
416 }
417
418 (targets, structural)
419}
420
421const MAX_CANDIDATE_CHECK_VERSIONS: usize = 6;
427
428pub fn build_candidate_check_targets(
456 parse_result: &dyn deps_core::ParseResult,
457 cached_versions: &HashMap<PackageName, deps_core::lsp_helpers::PackageVersions>,
458 vuln_keys: &deps_core::osv::VulnKeys,
459 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
460) -> (
461 Vec<Vec<deps_core::osv::ScanTarget>>,
462 deps_core::osv::CandidateStatusMap,
463) {
464 use deps_core::osv::{CandidateStatuses, StructuralSkipReason, vuln_key_for};
465
466 let mut rounds: Vec<Vec<deps_core::osv::ScanTarget>> =
467 vec![Vec::new(); MAX_CANDIDATE_CHECK_VERSIONS];
468 let mut structural = deps_core::osv::CandidateStatusMap::new();
469 let mut seen = std::collections::HashSet::new();
470
471 for dep in parse_result.dependencies() {
472 let key = vuln_key_for(dep, Some(vuln_keys), formatter);
473
474 let (osv_name, package_versions) =
475 match classify_dep_for_check_targets(dep, cached_versions, formatter) {
476 DepCheckClassification::NonRegistrySource => {
477 structural.insert(
478 key,
479 CandidateStatuses::Structural(StructuralSkipReason::NonRegistrySource),
480 );
481 continue;
482 }
483 DepCheckClassification::NoCachedVersions => continue,
486 DepCheckClassification::AwaitingOsvName { .. } => continue,
488 DepCheckClassification::UnmappableName { .. } => {
489 structural.insert(
490 key,
491 CandidateStatuses::Structural(StructuralSkipReason::UnmappableName),
492 );
493 continue;
494 }
495 DepCheckClassification::Target { osv_name, cached } => (osv_name, cached),
496 };
497
498 if !seen.insert(key.clone()) {
499 continue;
500 }
501
502 let yanked: std::collections::HashSet<&ConcreteVersion> = package_versions
503 .yanked
504 .iter()
505 .map(|(version, _)| version)
506 .collect();
507
508 for (rank, version) in package_versions
509 .available
510 .iter()
511 .filter(|v| !yanked.contains(v))
512 .take(MAX_CANDIDATE_CHECK_VERSIONS)
513 .enumerate()
514 {
515 let Some(bucket) = rounds.get_mut(rank) else {
519 continue;
520 };
521 bucket.push(deps_core::osv::ScanTarget::from_native(
522 key.clone(),
523 deps_core::osv::OsvQueryName::Confirmed(osv_name.clone()),
524 version.clone(),
525 formatter,
526 ));
527 }
528 }
529
530 (rounds, structural)
531}
532
533#[derive(Debug, PartialEq, Eq)]
535enum FixTargetResolution {
536 Skip,
541 Resolved(deps_core::osv::UpgradeStatus),
544 NeedsLiveCheck(deps_core::osv::ScanTarget),
551}
552fn resolve_fix_target(
556 dv: &deps_core::osv::DependencyVulnerabilities,
557 key: &deps_core::osv::VulnKey,
558 latest_status: &deps_core::osv::LatestStatusMap,
559 osv_name_by_key: &HashMap<deps_core::osv::VulnKey, deps_core::osv::OsvQueryName>,
560 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
561) -> FixTargetResolution {
562 use deps_core::edit::{VulnFixSkip, resolve_recommended_fix};
563 use deps_core::osv::{OsvQueryName, ScanTarget, UpgradeStatus};
564
565 let latest = latest_status.get(key);
566
567 let (fix, version_native) = match resolve_recommended_fix(dv, latest, formatter) {
573 Ok(pair) => pair,
574 Err(VulnFixSkip::NoRecommendedFix) => return FixTargetResolution::Skip,
575 Err(VulnFixSkip::UnsafeVersion) => {
580 tracing::debug!(
581 key = %key,
582 "OSV #462: fix-target version failed validation, skipping verification"
583 );
584 return FixTargetResolution::Skip;
585 }
586 Err(
594 VulnFixSkip::UnverifiedTarget
595 | VulnFixSkip::RequirementAlreadyResolves
596 | VulnFixSkip::NoOpRewrite
597 | VulnFixSkip::UnresolvedPlaceholder
598 | VulnFixSkip::OversizedRequirement,
599 ) => return FixTargetResolution::Skip,
600 };
601
602 let reused_latest_version: Option<&str> = match latest {
607 Some(
608 UpgradeStatus::CandidateClean { version }
609 | UpgradeStatus::CandidateVulnerable { version, .. },
610 ) => Some(version.as_str()),
611 Some(_) | None => None,
617 };
618 if reused_latest_version == Some(version_native.as_str()) {
619 return FixTargetResolution::Resolved(latest.cloned().unwrap_or(UpgradeStatus::NotChecked));
620 }
621
622 let Some(osv_name) = osv_name_by_key.get(key) else {
623 tracing::debug!(
624 key = %key,
625 "OSV #462: no osv_name on record for fix-target verification, skipping"
626 );
627 return FixTargetResolution::Skip;
628 };
629 let osv_name = match osv_name {
630 OsvQueryName::Confirmed(name) => name.clone(),
631 OsvQueryName::Provisional(_) => {
632 tracing::debug!(
633 key = %key,
634 "OSV #1694: unconfirmed package name, fix target stays unverified"
635 );
636 return FixTargetResolution::Skip;
637 }
638 };
639 FixTargetResolution::NeedsLiveCheck(ScanTarget::new(
640 key.clone(),
641 OsvQueryName::Confirmed(osv_name),
642 fix.version,
643 ConcreteVersion::new(version_native),
644 ))
645}
646pub fn collect_fix_target_resolutions(
723 vulnerabilities: &deps_core::osv::VulnerabilityMap,
724 vulnerable_keys: &[deps_core::osv::VulnKey],
725 osv_name_by_key: &HashMap<deps_core::osv::VulnKey, deps_core::osv::OsvQueryName>,
726 latest_status: &deps_core::osv::LatestStatusMap,
727 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
728) -> (
729 Vec<(deps_core::osv::VulnKey, deps_core::osv::UpgradeStatus)>,
730 Vec<deps_core::osv::ScanTarget>,
731) {
732 use deps_core::osv::ScanOutcome;
733
734 let mut resolved = Vec::new();
735 let mut live_check_candidates = Vec::new();
736
737 for key in vulnerable_keys {
738 let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get(key) else {
739 continue;
740 };
741 match resolve_fix_target(dv, key, latest_status, osv_name_by_key, formatter) {
742 FixTargetResolution::Skip => {}
743 FixTargetResolution::Resolved(status) => resolved.push((key.clone(), status)),
744 FixTargetResolution::NeedsLiveCheck(target) => live_check_candidates.push(target),
745 }
746 }
747
748 (resolved, live_check_candidates)
749}
750pub fn apply_live_fix_target_statuses(
808 vulnerabilities: &mut deps_core::osv::VulnerabilityMap,
809 statuses: HashMap<deps_core::osv::VulnKey, deps_core::osv::UpgradeStatus>,
810) {
811 use deps_core::osv::ScanOutcome;
812
813 for (key, status) in statuses {
814 if let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get_mut(&key) {
815 dv.fix_target_status = status;
816 }
817 }
818}
819
820#[must_use]
846pub fn osv_name_by_key(
847 targets: &[deps_core::osv::ScanTarget],
848) -> HashMap<deps_core::osv::VulnKey, deps_core::osv::OsvQueryName> {
849 targets
850 .iter()
851 .map(|t| (t.key.clone(), t.osv_name.clone()))
852 .collect()
853}
854
855#[cfg(test)]
856mod tests {
857 use super::*;
858 use crate::classify::resolved::collect_in_use_versions;
859 use deps_core::VersionReq;
860 use std::assert_matches;
861
862 mod osv_scan_target_tests {
863 use super::*;
864 use deps_core::Dependency;
865 use deps_core::lsp_helpers::{
866 DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
867 RequirementResolution, SourcePolicy,
868 };
869 use deps_core::parser::DependencySource;
870 use deps_core::position::{Position, Range};
871 use deps_core::test_util::StubFormatter;
872 use std::any::Any;
873
874 struct MockDep {
875 name: PackageName,
876 version_req: Option<VersionReq>,
877 source: DependencySource,
878 }
879
880 impl Dependency for MockDep {
881 fn name(&self) -> &PackageName {
882 &self.name
883 }
884 fn name_range(&self) -> Range {
885 let addr = std::ptr::from_ref(self) as u32;
889 Range::new(Position::new(0, addr), Position::new(0, addr + 1))
890 }
891 fn version_requirement(&self) -> Option<&VersionReq> {
892 self.version_req.as_ref()
893 }
894 fn version_range(&self) -> Option<Range> {
895 None
896 }
897 fn source(&self) -> DependencySource {
898 self.source.clone()
899 }
900 fn as_any(&self) -> &dyn Any {
901 self
902 }
903 }
904
905 struct MockParseResult {
906 deps: Vec<MockDep>,
907 }
908
909 impl deps_core::ParseResult for MockParseResult {
910 fn dependencies(&self) -> Vec<&dyn Dependency> {
911 self.deps.iter().map(|d| d as &dyn Dependency).collect()
912 }
913 fn workspace_root(&self) -> Option<&std::path::Path> {
914 None
915 }
916 fn uri(&self) -> &url::Url {
917 static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
918 URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
919 }
920 fn as_any(&self) -> &dyn Any {
921 self
922 }
923 }
924
925 use deps_core::osv::{ScanOutcome, SkipReason};
926
927 #[test]
932 fn build_scan_targets_step0_skips_non_registry_source_even_with_lockfile_version() {
933 let parse_result = MockParseResult {
937 deps: vec![MockDep {
938 name: PackageName::new("time"),
939 version_req: Some(VersionReq::new("0.1.43")),
940 source: DependencySource::Git {
941 url: "https://github.com/example/time".to_string(),
942 rev: None,
943 },
944 }],
945 };
946 let mut resolved = HashMap::new();
947 resolved.insert(PackageName::new("time"), "0.1.43".into());
948
949 let (targets, skipped) = build_scan_targets(
950 &parse_result,
951 &resolved,
952 &HashMap::new(),
953 &StubFormatter::DEFAULT,
954 EcosystemId::Cargo,
955 );
956 assert!(targets.is_empty());
957 assert_matches!(
958 skipped.get(&deps_core::test_util::vuln_key("time")),
959 Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
960 );
961 }
962
963 #[test]
964 fn build_scan_targets_step1_prefers_lockfile_resolved_version() {
965 let parse_result = MockParseResult {
966 deps: vec![MockDep {
967 name: PackageName::new("serde"),
968 version_req: Some(VersionReq::new("^1.0")),
969 source: DependencySource::Registry,
970 }],
971 };
972 let mut resolved = HashMap::new();
973 resolved.insert(PackageName::new("serde"), "1.0.195".into());
974
975 let (targets, skipped) = build_scan_targets(
976 &parse_result,
977 &resolved,
978 &HashMap::new(),
979 &StubFormatter::DEFAULT,
980 EcosystemId::Cargo,
981 );
982 assert_eq!(targets.len(), 1);
983 assert_eq!(targets[0].version, "1.0.195");
984 assert!(skipped.is_empty());
985 }
986
987 const MOCK_GO_FORMATTER: StubFormatter = StubFormatter::new()
991 .with_package_url_prefix("https://pkg.go.dev/")
992 .with_manifest_requirement_as_resolved_version();
993
994 struct MockVPrefixFormatter;
995 impl PackageNaming for MockVPrefixFormatter {}
996
997 impl PackageRendering for MockVPrefixFormatter {
998 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
999 version.to_string()
1000 }
1001
1002 fn package_url(&self, name: &PackageName) -> String {
1003 format!("https://example.com/{}", name.as_str())
1004 }
1005 }
1006
1007 impl RequirementResolution for MockVPrefixFormatter {}
1008
1009 impl DiagnosticMessages for MockVPrefixFormatter {}
1010
1011 impl DiagnosticPolicy for MockVPrefixFormatter {}
1012
1013 impl SourcePolicy for MockVPrefixFormatter {}
1014
1015 impl OsvNaming for MockVPrefixFormatter {
1016 fn osv_version(&self, version: &ConcreteVersion) -> deps_core::osv::OsvVersion {
1017 let version = version.as_str();
1018 deps_core::osv::OsvVersion::new(version.strip_prefix('v').unwrap_or(version))
1019 }
1020 }
1021
1022 #[test]
1023 fn build_scan_targets_normalizes_version_via_formatter_osv_version_hook() {
1024 let parse_result = MockParseResult {
1027 deps: vec![MockDep {
1028 name: PackageName::new("github.com/gin-gonic/gin"),
1029 version_req: Some(VersionReq::new("v1.9.0")),
1030 source: DependencySource::Registry,
1031 }],
1032 };
1033 let mut resolved = HashMap::new();
1034 resolved.insert(
1035 PackageName::new("github.com/gin-gonic/gin"),
1036 "v1.9.0".into(),
1037 );
1038
1039 let (targets, skipped) = build_scan_targets(
1040 &parse_result,
1041 &resolved,
1042 &HashMap::new(),
1043 &MockVPrefixFormatter,
1044 EcosystemId::Go,
1045 );
1046 assert_eq!(targets.len(), 1);
1047 assert_eq!(targets[0].version, "1.9.0");
1048 assert_eq!(targets[0].display_version, "v1.9.0");
1051 assert!(skipped.is_empty());
1052 }
1053
1054 #[test]
1055 fn build_scan_targets_leaves_version_unaffected_for_default_identity_formatter() {
1056 let parse_result = MockParseResult {
1060 deps: vec![MockDep {
1061 name: PackageName::new("serde"),
1062 version_req: Some(VersionReq::new("^1.0")),
1063 source: DependencySource::Registry,
1064 }],
1065 };
1066 let mut resolved = HashMap::new();
1067 resolved.insert(PackageName::new("serde"), "1.0.195".into());
1068
1069 let (targets, skipped) = build_scan_targets(
1070 &parse_result,
1071 &resolved,
1072 &HashMap::new(),
1073 &StubFormatter::DEFAULT,
1074 EcosystemId::Cargo,
1075 );
1076 assert_eq!(targets.len(), 1);
1077 assert_eq!(targets[0].version, "1.0.195");
1078 assert_eq!(targets[0].display_version, "1.0.195");
1079 assert!(skipped.is_empty());
1080 }
1081
1082 #[test]
1083 fn build_scan_targets_go_ignores_stale_lockfile_version_uses_go_mod_requirement() {
1084 let parse_result = MockParseResult {
1090 deps: vec![MockDep {
1091 name: PackageName::new("github.com/pkg/errors"),
1092 version_req: Some(VersionReq::new("v0.8.1")),
1093 source: DependencySource::Registry,
1094 }],
1095 };
1096 let mut resolved = HashMap::new();
1097 resolved.insert(PackageName::new("github.com/pkg/errors"), "v0.9.1".into());
1101
1102 let (targets, skipped) = build_scan_targets(
1103 &parse_result,
1104 &resolved,
1105 &HashMap::new(),
1106 &MOCK_GO_FORMATTER,
1107 EcosystemId::Go,
1108 );
1109 assert_eq!(targets.len(), 1);
1110 assert_eq!(targets[0].version, "0.8.1");
1116 assert_eq!(targets[0].display_version, "v0.8.1");
1117 assert!(skipped.is_empty());
1118 }
1119
1120 #[cfg(feature = "deno")]
1129 #[test]
1130 fn build_scan_targets_deno_bare_pinned_jsr_dep_is_unmappable_name_skip() {
1131 let parse_result = MockParseResult {
1132 deps: vec![MockDep {
1133 name: PackageName::new("jsr:@std/fs"),
1134 version_req: Some(VersionReq::new("1.0.0")),
1135 source: DependencySource::Registry,
1136 }],
1137 };
1138
1139 let (targets, skipped) = build_scan_targets(
1140 &parse_result,
1141 &HashMap::new(),
1142 &HashMap::new(),
1143 &deps_deno::DenoFormatter,
1144 EcosystemId::Deno,
1145 );
1146
1147 assert!(targets.is_empty(), "jsr: dep must never reach an OSV query");
1148 assert_eq!(skipped.len(), 1);
1149 assert_matches!(
1153 skipped.get(&deps_core::test_util::vuln_key("jsr:@std/fs")),
1154 Some(ScanOutcome::Skipped(SkipReason::UnmappableName))
1155 );
1156 }
1157
1158 #[cfg(feature = "composer")]
1167 #[test]
1168 fn build_scan_targets_composer_mixed_case_name_is_lowercased_for_osv() {
1169 let parse_result = MockParseResult {
1170 deps: vec![MockDep {
1171 name: PackageName::new("Symfony/Http-Kernel"),
1172 version_req: Some(VersionReq::new("4.4.0")),
1173 source: DependencySource::Registry,
1174 }],
1175 };
1176
1177 let (targets, skipped) = build_scan_targets(
1178 &parse_result,
1179 &HashMap::new(),
1180 &HashMap::new(),
1181 &deps_composer::ComposerFormatter,
1182 EcosystemId::Composer,
1183 );
1184
1185 assert_eq!(targets.len(), 1);
1186 assert_eq!(targets[0].osv_name.name(), "symfony/http-kernel");
1187 assert!(skipped.is_empty());
1188 }
1189
1190 #[cfg(feature = "pypi")]
1194 #[test]
1195 fn build_scan_targets_pypi_separator_only_key_is_skipped_and_sibling_still_queried() {
1196 let dep = |name: &str| MockDep {
1197 name: PackageName::new(name),
1198 version_req: Some(VersionReq::new("==1.0.0")),
1199 source: DependencySource::Registry,
1200 };
1201 let parse_result = MockParseResult {
1202 deps: vec![dep("Werkzeug"), dep("---")],
1203 };
1204
1205 let (targets, skipped) = build_scan_targets(
1206 &parse_result,
1207 &HashMap::new(),
1208 &HashMap::new(),
1209 &deps_pypi::PypiFormatter,
1210 EcosystemId::Pypi,
1211 );
1212
1213 assert_eq!(targets.len(), 1);
1214 assert_eq!(targets[0].osv_name.name(), "werkzeug");
1215 assert_eq!(skipped.len(), 1);
1216 assert_matches!(
1218 skipped.get(&deps_core::test_util::vuln_key("")),
1219 Some(ScanOutcome::Skipped(SkipReason::UnmappableName))
1220 );
1221 }
1222
1223 #[cfg(feature = "dart")]
1225 #[test]
1226 fn build_scan_targets_dart_plus_build_number_pin_without_lockfile_is_queried() {
1227 let dep = |name: &str, req: &str| MockDep {
1228 name: PackageName::new(name),
1229 version_req: Some(VersionReq::new(req)),
1230 source: DependencySource::Registry,
1231 };
1232 let parse_result = MockParseResult {
1233 deps: vec![
1234 dep("image_picker_android", "0.8.13+1"),
1235 dep("caret_pkg", "^0.8.13+1"),
1236 ],
1237 };
1238
1239 let (targets, skipped) = build_scan_targets(
1240 &parse_result,
1241 &HashMap::new(),
1242 &HashMap::new(),
1243 &deps_dart::DartFormatter,
1244 EcosystemId::Dart,
1245 );
1246
1247 assert_eq!(targets.len(), 1, "{skipped:?}");
1248 assert_eq!(targets[0].display_version, "0.8.13+1");
1249 }
1250
1251 #[cfg(feature = "github-actions")]
1261 #[test]
1262 fn build_scan_targets_github_actions_sha_pin_with_moving_major_comment_stays_skipped() {
1263 use deps_core::lsp_helpers::{CommitSha, TagIndex};
1264 use deps_core::osv::{ScanOutcome, SkipReason};
1265 use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
1266 use std::sync::Arc;
1267
1268 let sha = "d".repeat(40);
1269 let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
1270 let content = format!("steps:\n - uses: actions/checkout@{sha} # v1\n");
1271 let parse_result =
1272 deps_github_actions::parse_workflow_yaml(&content, &uri).expect("valid yaml");
1273
1274 let cache = Arc::new(deps_core::HttpCache::new());
1275 let registry = GithubActionsRegistry::new(cache);
1276 let tag_index = registry.tag_index();
1277 let mut index = TagIndex::default();
1278 index.insert_sha_pin(
1279 CommitSha::parse(&sha).unwrap(),
1280 deps_core::lsp_helpers::ResolvedPin::most_specific(
1281 deps_core::ConcreteVersion::new("v1"),
1282 ),
1283 );
1284 tag_index.insert(PackageName::new("actions/checkout"), Arc::new(index));
1285 let formatter = GithubActionsFormatter::new(tag_index);
1286
1287 let (targets, skipped) = build_scan_targets(
1288 &parse_result,
1289 &HashMap::new(),
1290 &HashMap::new(),
1291 &formatter,
1292 EcosystemId::GithubActions,
1293 );
1294
1295 assert!(
1296 targets.is_empty(),
1297 "a moving-major TagIndex-resolved tag must not reach OSV as a fabricated version: {targets:?}"
1298 );
1299 assert_matches!(
1300 skipped.get(&deps_core::test_util::vuln_key("actions/checkout")),
1301 Some(ScanOutcome::Skipped(SkipReason::ResolvedTagNotFullVersion))
1302 );
1303 }
1304
1305 #[cfg(feature = "github-actions")]
1309 #[test]
1310 fn build_scan_targets_github_actions_sha_pin_two_component_release_tag() {
1311 use deps_core::lsp_helpers::{CommitSha, TagIndex};
1312 use deps_core::osv::{ScanOutcome, SkipReason};
1313 use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
1314 use std::sync::Arc;
1315
1316 let sha = "f".repeat(40);
1317 let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
1318 let content = format!("steps:\n - uses: actions/checkout@{sha} # v2.9\n");
1319 let parse_result =
1320 deps_github_actions::parse_workflow_yaml(&content, &uri).expect("valid yaml");
1321 let commit = CommitSha::parse(&sha).unwrap();
1322
1323 for (tags, expected) in [
1324 (vec!["v2", "v2.9"], Some("v2.9")),
1325 (vec!["v2.9", "v2.9.1"], Some("v2.9.1")),
1326 (vec!["v2.9"], Some("v2.9")),
1327 (vec!["v2"], None),
1328 ] {
1329 let cache = Arc::new(deps_core::HttpCache::new());
1330 let registry = GithubActionsRegistry::new(cache);
1331 let tag_index = registry.tag_index();
1332 tag_index.insert(
1333 PackageName::new("actions/checkout"),
1334 Arc::new(
1335 TagIndex::from_tags(tags.iter().map(|t| (*t, &commit)))
1336 .with_canonical_repo_name(
1337 deps_core::github::CanonicalRepoName::from_commit_url(
1338 "https://api.github.com/repos/actions/checkout/commits/abc",
1339 ),
1340 ),
1341 ),
1342 );
1343 let formatter = GithubActionsFormatter::new(tag_index);
1344
1345 let (targets, skipped) = build_scan_targets(
1346 &parse_result,
1347 &HashMap::new(),
1348 &HashMap::new(),
1349 &formatter,
1350 EcosystemId::GithubActions,
1351 );
1352
1353 match expected {
1354 Some(version) => {
1355 assert_eq!(targets.len(), 1, "{tags:?}: {skipped:?}");
1356 assert_eq!(targets[0].display_version, version);
1357 assert!(skipped.is_empty());
1358 }
1359 None => {
1360 assert!(targets.is_empty(), "{tags:?}: {targets:?}");
1361 assert_matches!(
1362 skipped.get(&deps_core::test_util::vuln_key("actions/checkout")),
1363 Some(ScanOutcome::Skipped(SkipReason::ResolvedTagNotFullVersion))
1364 );
1365 }
1366 }
1367 }
1368 }
1369
1370 #[cfg(feature = "github-actions")]
1379 #[test]
1380 fn build_scan_targets_github_actions_sha_pin_tag_index_resolves_to_full_semver_tag() {
1381 use deps_core::lsp_helpers::{CommitSha, TagIndex};
1382 use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
1383 use std::sync::Arc;
1384
1385 let sha = "e".repeat(40);
1386 let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
1387 let content = format!("steps:\n - uses: actions/checkout@{sha} # v1\n");
1388 let parse_result =
1389 deps_github_actions::parse_workflow_yaml(&content, &uri).expect("valid yaml");
1390
1391 let cache = Arc::new(deps_core::HttpCache::new());
1392 let registry = GithubActionsRegistry::new(cache);
1393 let tag_index = registry.tag_index();
1394 let mut index = TagIndex::default();
1395 index.insert_sha_pin(
1396 CommitSha::parse(&sha).unwrap(),
1397 deps_core::lsp_helpers::ResolvedPin::most_specific(
1398 deps_core::ConcreteVersion::new("v1.3.0"),
1399 ),
1400 );
1401 let index = index.with_canonical_repo_name(
1402 deps_core::github::CanonicalRepoName::from_commit_url(
1403 "https://api.github.com/repos/actions/checkout/commits/abc",
1404 ),
1405 );
1406 tag_index.insert(PackageName::new("actions/checkout"), Arc::new(index));
1407 let formatter = GithubActionsFormatter::new(tag_index);
1408
1409 let (targets, skipped) = build_scan_targets(
1410 &parse_result,
1411 &HashMap::new(),
1412 &HashMap::new(),
1413 &formatter,
1414 EcosystemId::GithubActions,
1415 );
1416
1417 assert_eq!(
1418 targets.len(),
1419 1,
1420 "expected the SHA pin to reach a real OSV scan target: {skipped:?}"
1421 );
1422 assert!(skipped.is_empty());
1423 assert_eq!(targets[0].display_version, "v1.3.0");
1424 }
1425
1426 #[cfg(feature = "github-actions")]
1429 #[test]
1430 fn build_scan_targets_github_actions_attaches_sibling_release_tags() {
1431 use deps_core::lsp_helpers::{CommitSha, TagIndex};
1432 use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
1433 use std::sync::Arc;
1434
1435 let sha = "e".repeat(40);
1436 let commit = CommitSha::parse(&sha).unwrap();
1437 let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
1438 let content = format!(
1439 "steps:\n - uses: actions/checkout@{sha}\n - uses: other/action@v4.9.0\n"
1440 );
1441 let parse_result =
1442 deps_github_actions::parse_workflow_yaml(&content, &uri).expect("valid yaml");
1443
1444 let registry = GithubActionsRegistry::new(Arc::new(deps_core::HttpCache::new()));
1445 let tag_index = registry.tag_index();
1446 let canonical = |repo: &str| {
1447 deps_core::github::CanonicalRepoName::from_commit_url(&format!(
1448 "https://api.github.com/repos/{repo}/commits/abc"
1449 ))
1450 };
1451 tag_index.insert(
1452 PackageName::new("actions/checkout"),
1453 Arc::new(
1454 TagIndex::from_tags([("v4.8.0", &commit), ("v4.9.0", &commit)])
1455 .with_canonical_repo_name(canonical("actions/checkout")),
1456 ),
1457 );
1458 tag_index.insert(
1459 PackageName::new("other/action"),
1460 Arc::new(
1461 TagIndex::from_tags([("v4.9.0", &commit), ("v4.10.0", &commit)])
1462 .with_canonical_repo_name(canonical("other/action")),
1463 ),
1464 );
1465 let formatter = GithubActionsFormatter::new(tag_index);
1466
1467 let (targets, skipped) = build_scan_targets(
1468 &parse_result,
1469 &HashMap::new(),
1470 &HashMap::new(),
1471 &formatter,
1472 EcosystemId::GithubActions,
1473 );
1474
1475 assert!(skipped.is_empty(), "{skipped:?}");
1476 let versions = |name: &str| {
1477 let target = targets
1478 .iter()
1479 .find(|t| t.key.as_str() == name)
1480 .expect("target for name");
1481 (
1482 target.display_version.to_string(),
1483 target
1484 .siblings()
1485 .iter()
1486 .map(|s| s.display_version().to_string())
1487 .collect::<Vec<_>>(),
1488 )
1489 };
1490 assert_eq!(
1491 versions("actions/checkout"),
1492 ("v4.8.0".to_string(), vec!["v4.9.0".to_string()])
1493 );
1494 assert_eq!(
1495 versions("other/action"),
1496 ("v4.9.0".to_string(), vec!["v4.10.0".to_string()])
1497 );
1498 }
1499
1500 #[test]
1501 fn build_scan_targets_without_a_tag_pin_has_no_siblings() {
1502 let parse_result = MockParseResult {
1503 deps: vec![MockDep {
1504 name: PackageName::new("log4j-core"),
1505 version_req: Some(VersionReq::new("2.14.1")),
1506 source: DependencySource::Registry,
1507 }],
1508 };
1509 let (targets, _) = build_scan_targets(
1510 &parse_result,
1511 &HashMap::new(),
1512 &HashMap::new(),
1513 &StubFormatter::DEFAULT,
1514 EcosystemId::Maven,
1515 );
1516 assert!(targets.iter().all(|t| t.siblings().is_empty()));
1517 }
1518
1519 #[test]
1520 fn build_scan_targets_step2_uses_concrete_requirement_verbatim() {
1521 let parse_result = MockParseResult {
1522 deps: vec![MockDep {
1523 name: PackageName::new("log4j-core"),
1524 version_req: Some(VersionReq::new("2.14.1")),
1525 source: DependencySource::Registry,
1526 }],
1527 };
1528 let resolved = HashMap::new();
1529
1530 let (targets, skipped) = build_scan_targets(
1531 &parse_result,
1532 &resolved,
1533 &HashMap::new(),
1534 &StubFormatter::DEFAULT,
1535 EcosystemId::Maven,
1536 );
1537 assert_eq!(targets.len(), 1);
1538 assert_eq!(targets[0].version, "2.14.1");
1539 assert!(skipped.is_empty());
1540 }
1541
1542 #[test]
1543 fn build_scan_targets_step2_strips_pin_marker_for_operator_prefixed_requirements() {
1544 let cargo_result = MockParseResult {
1547 deps: vec![MockDep {
1548 name: PackageName::new("time"),
1549 version_req: Some(VersionReq::new("=1.2.3")),
1550 source: DependencySource::Registry,
1551 }],
1552 };
1553 let (targets, skipped) = build_scan_targets(
1554 &cargo_result,
1555 &HashMap::new(),
1556 &HashMap::new(),
1557 &StubFormatter::DEFAULT,
1558 EcosystemId::Cargo,
1559 );
1560 assert_eq!(targets.len(), 1);
1561 assert_eq!(targets[0].version, "1.2.3");
1562 assert!(skipped.is_empty());
1563
1564 let nuget_result = MockParseResult {
1565 deps: vec![MockDep {
1566 name: PackageName::new("Newtonsoft.Json"),
1567 version_req: Some(VersionReq::new("[1.0.0]")),
1568 source: DependencySource::Registry,
1569 }],
1570 };
1571 let (targets, skipped) = build_scan_targets(
1572 &nuget_result,
1573 &HashMap::new(),
1574 &HashMap::new(),
1575 &StubFormatter::DEFAULT,
1576 EcosystemId::NuGet,
1577 );
1578 assert_eq!(targets.len(), 1);
1579 assert_eq!(targets[0].version, "1.0.0");
1580 assert!(skipped.is_empty());
1581 }
1582
1583 #[test]
1584 fn build_scan_targets_step3_skips_caret_range_with_no_lockfile_entry() {
1585 let parse_result = MockParseResult {
1586 deps: vec![MockDep {
1587 name: PackageName::new("serde"),
1588 version_req: Some(VersionReq::new("^1.0")),
1589 source: DependencySource::Registry,
1590 }],
1591 };
1592 let resolved = HashMap::new();
1593
1594 let (targets, skipped) = build_scan_targets(
1595 &parse_result,
1596 &resolved,
1597 &HashMap::new(),
1598 &StubFormatter::DEFAULT,
1599 EcosystemId::Cargo,
1600 );
1601 assert!(targets.is_empty());
1602 assert_matches!(
1603 skipped.get(&deps_core::test_util::vuln_key("serde")),
1604 Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
1605 );
1606 }
1607
1608 #[test]
1609 fn build_scan_targets_step3_skips_wildcard_with_no_lockfile_entry() {
1610 let parse_result = MockParseResult {
1611 deps: vec![MockDep {
1612 name: PackageName::new("serde"),
1613 version_req: Some(VersionReq::new("*")),
1614 source: DependencySource::Registry,
1615 }],
1616 };
1617 let resolved = HashMap::new();
1618
1619 let (targets, skipped) = build_scan_targets(
1620 &parse_result,
1621 &resolved,
1622 &HashMap::new(),
1623 &StubFormatter::DEFAULT,
1624 EcosystemId::Cargo,
1625 );
1626 assert!(targets.is_empty());
1627 assert_matches!(
1628 skipped.get(&deps_core::test_util::vuln_key("serde")),
1629 Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
1630 );
1631 }
1632
1633 #[test]
1634 fn build_scan_targets_all_non_registry_sources_are_skipped() {
1635 let sources = vec![
1636 DependencySource::Path {
1637 path: "../local".to_string(),
1638 },
1639 DependencySource::Url {
1640 url: "https://example.com/pkg.tgz".to_string(),
1641 },
1642 DependencySource::Sdk {
1643 sdk: "flutter".to_string(),
1644 },
1645 DependencySource::Workspace,
1646 DependencySource::CustomRegistry {
1647 url: "https://private.example.com".to_string(),
1648 },
1649 ];
1650
1651 for source in sources {
1652 let parse_result = MockParseResult {
1653 deps: vec![MockDep {
1654 name: PackageName::new("pkg"),
1655 version_req: Some(VersionReq::new("1.0.0")),
1656 source: source.clone(),
1657 }],
1658 };
1659 let mut resolved = HashMap::new();
1660 resolved.insert(PackageName::new("pkg"), "1.0.0".into());
1661
1662 let (targets, skipped) = build_scan_targets(
1663 &parse_result,
1664 &resolved,
1665 &HashMap::new(),
1666 &StubFormatter::DEFAULT,
1667 EcosystemId::Cargo,
1668 );
1669 assert!(targets.is_empty(), "{source:?} must be skipped (step 0)");
1670 assert_matches!(
1671 skipped.get(&deps_core::test_util::vuln_key("pkg")),
1672 Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
1673 );
1674 }
1675 }
1676
1677 #[test]
1678 fn build_scan_targets_never_drops_a_dependency_silently() {
1679 let parse_result = MockParseResult {
1682 deps: vec![
1683 MockDep {
1684 name: PackageName::new("concrete"),
1685 version_req: Some(VersionReq::new("2.14.1")),
1686 source: DependencySource::Registry,
1687 },
1688 MockDep {
1689 name: PackageName::new("range-only"),
1690 version_req: Some(VersionReq::new("^1.0")),
1691 source: DependencySource::Registry,
1692 },
1693 MockDep {
1694 name: PackageName::new("git-dep"),
1695 version_req: Some(VersionReq::new("1.0.0")),
1696 source: DependencySource::Git {
1697 url: "https://example.com/git-dep".to_string(),
1698 rev: None,
1699 },
1700 },
1701 ],
1702 };
1703 let resolved = HashMap::new();
1704
1705 let (targets, skipped) = build_scan_targets(
1706 &parse_result,
1707 &resolved,
1708 &HashMap::new(),
1709 &StubFormatter::DEFAULT,
1710 EcosystemId::Maven,
1711 );
1712
1713 assert_eq!(targets.len(), 1);
1714 assert_eq!(targets[0].key.as_str(), "concrete");
1715 assert_eq!(skipped.len(), 2);
1716 assert_matches!(
1717 skipped.get(&deps_core::test_util::vuln_key("range-only")),
1718 Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
1719 );
1720 assert_matches!(
1721 skipped.get(&deps_core::test_util::vuln_key("git-dep")),
1722 Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
1723 );
1724 }
1725
1726 #[test]
1731 fn collect_in_use_versions_prefers_lockfile_resolved_version() {
1732 let parse_result = MockParseResult {
1733 deps: vec![MockDep {
1734 name: PackageName::new("serde"),
1735 version_req: Some(VersionReq::new("^1.0")),
1736 source: DependencySource::Registry,
1737 }],
1738 };
1739 let mut resolved = HashMap::new();
1740 resolved.insert(PackageName::new("serde"), "1.0.195".into());
1741
1742 let in_use = collect_in_use_versions(
1743 &parse_result,
1744 &resolved,
1745 &HashMap::new(),
1746 &StubFormatter::DEFAULT,
1747 EcosystemId::Cargo,
1748 );
1749 assert_eq!(
1750 in_use.get(&PackageName::new("serde")),
1751 Some(&vec![ConcreteVersion::from("1.0.195")])
1752 );
1753 }
1754
1755 #[test]
1756 fn collect_in_use_versions_concrete_pin_without_lockfile() {
1757 let parse_result = MockParseResult {
1760 deps: vec![MockDep {
1761 name: PackageName::new("log4j-core"),
1762 version_req: Some(VersionReq::new("2.14.1")),
1763 source: DependencySource::Registry,
1764 }],
1765 };
1766 let resolved = HashMap::new();
1767
1768 let in_use = collect_in_use_versions(
1769 &parse_result,
1770 &resolved,
1771 &HashMap::new(),
1772 &StubFormatter::DEFAULT,
1773 EcosystemId::Maven,
1774 );
1775 assert_eq!(
1776 in_use.get(&PackageName::new("log4j-core")),
1777 Some(&vec![ConcreteVersion::from("2.14.1")])
1778 );
1779 }
1780
1781 #[test]
1782 fn collect_in_use_versions_strips_pep440_double_equals_pin_for_pypi() {
1783 let parse_result = MockParseResult {
1788 deps: vec![MockDep {
1789 name: PackageName::new("typing_extensions"),
1790 version_req: Some(VersionReq::new("==4.9.0")),
1791 source: DependencySource::Registry,
1792 }],
1793 };
1794 let resolved = HashMap::new();
1795
1796 let in_use = collect_in_use_versions(
1797 &parse_result,
1798 &resolved,
1799 &HashMap::new(),
1800 &StubFormatter::DEFAULT,
1801 EcosystemId::Pypi,
1802 );
1803 assert_eq!(
1804 in_use.get(&PackageName::new("typing_extensions")),
1805 Some(&vec![ConcreteVersion::from("4.9.0")]),
1806 "pep440 '==' comparator must be stripped, not carried into the in-use version"
1807 );
1808 }
1809
1810 #[test]
1811 fn collect_in_use_versions_skips_non_concrete_requirement_with_no_lockfile() {
1812 let parse_result = MockParseResult {
1813 deps: vec![MockDep {
1814 name: PackageName::new("serde"),
1815 version_req: Some(VersionReq::new("^1.0")),
1816 source: DependencySource::Registry,
1817 }],
1818 };
1819 let resolved = HashMap::new();
1820
1821 let in_use = collect_in_use_versions(
1822 &parse_result,
1823 &resolved,
1824 &HashMap::new(),
1825 &StubFormatter::DEFAULT,
1826 EcosystemId::Cargo,
1827 );
1828 assert!(in_use.is_empty());
1829 }
1830
1831 #[test]
1832 fn collect_in_use_versions_excludes_non_registry_source_even_with_lockfile_version() {
1833 let parse_result = MockParseResult {
1836 deps: vec![MockDep {
1837 name: PackageName::new("time"),
1838 version_req: Some(VersionReq::new("0.1.43")),
1839 source: DependencySource::Git {
1840 url: "https://github.com/example/time".to_string(),
1841 rev: None,
1842 },
1843 }],
1844 };
1845 let mut resolved = HashMap::new();
1846 resolved.insert(PackageName::new("time"), "0.1.43".into());
1847
1848 let in_use = collect_in_use_versions(
1849 &parse_result,
1850 &resolved,
1851 &HashMap::new(),
1852 &StubFormatter::DEFAULT,
1853 EcosystemId::Cargo,
1854 );
1855 assert!(in_use.is_empty());
1856 }
1857
1858 #[test]
1859 fn collect_in_use_versions_tracks_all_occurrences_of_duplicate_name() {
1860 let parse_result = MockParseResult {
1864 deps: vec![
1865 MockDep {
1866 name: PackageName::new("time"),
1867 version_req: Some(VersionReq::new("=0.1.43")),
1868 source: DependencySource::Registry,
1869 },
1870 MockDep {
1871 name: PackageName::new("time"),
1872 version_req: Some(VersionReq::new("=0.1.44")),
1873 source: DependencySource::Registry,
1874 },
1875 ],
1876 };
1877 let resolved = HashMap::new();
1878
1879 let in_use = collect_in_use_versions(
1880 &parse_result,
1881 &resolved,
1882 &HashMap::new(),
1883 &StubFormatter::DEFAULT,
1884 EcosystemId::Cargo,
1885 );
1886 assert_eq!(
1887 in_use.get(&PackageName::new("time")),
1888 Some(&vec![
1889 ConcreteVersion::from("0.1.43"),
1890 ConcreteVersion::from("0.1.44")
1891 ]),
1892 "both occurrences' in-use versions must be tracked, not just the last one"
1893 );
1894 }
1895 }
1896
1897 mod build_check_targets_tests {
1902 use super::*;
1903 use deps_core::Dependency;
1904 use deps_core::lsp_helpers::{
1905 DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
1906 PackageVersions, RequirementResolution, SourcePolicy,
1907 };
1908 use deps_core::osv::{CandidateStatuses, StructuralSkipReason, UpgradeStatus};
1909 use deps_core::parser::DependencySource;
1910 use deps_core::position::{Position, Range};
1911 use deps_core::test_util::StubFormatter;
1912 use std::any::Any;
1913 use std::sync::Arc;
1914
1915 struct MockDep {
1916 name: PackageName,
1917 source: DependencySource,
1918 }
1919
1920 impl Dependency for MockDep {
1921 fn name(&self) -> &PackageName {
1922 &self.name
1923 }
1924 fn name_range(&self) -> Range {
1925 let addr = std::ptr::from_ref(self) as u32;
1926 Range::new(Position::new(0, addr), Position::new(0, addr + 1))
1927 }
1928 fn version_requirement(&self) -> Option<&VersionReq> {
1929 None
1930 }
1931 fn version_range(&self) -> Option<Range> {
1932 None
1933 }
1934 fn source(&self) -> DependencySource {
1935 self.source.clone()
1936 }
1937 fn as_any(&self) -> &dyn Any {
1938 self
1939 }
1940 }
1941
1942 struct MockParseResult {
1943 deps: Vec<MockDep>,
1944 }
1945
1946 impl deps_core::ParseResult for MockParseResult {
1947 fn dependencies(&self) -> Vec<&dyn Dependency> {
1948 self.deps.iter().map(|d| d as &dyn Dependency).collect()
1949 }
1950 fn workspace_root(&self) -> Option<&std::path::Path> {
1951 None
1952 }
1953 fn uri(&self) -> &url::Url {
1954 static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
1955 URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
1956 }
1957 fn as_any(&self) -> &dyn Any {
1958 self
1959 }
1960 }
1961
1962 struct UnmappableNameFormatter;
1966 impl PackageNaming for UnmappableNameFormatter {}
1967 impl PackageRendering for UnmappableNameFormatter {
1968 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1969 version.to_string()
1970 }
1971 fn package_url(&self, name: &PackageName) -> String {
1972 name.as_str().to_string()
1973 }
1974 }
1975 impl RequirementResolution for UnmappableNameFormatter {}
1976 impl DiagnosticMessages for UnmappableNameFormatter {}
1977 impl DiagnosticPolicy for UnmappableNameFormatter {}
1978 impl SourcePolicy for UnmappableNameFormatter {}
1979 impl OsvNaming for UnmappableNameFormatter {
1980 fn osv_package_name(
1981 &self,
1982 _dep: &dyn Dependency,
1983 ) -> Option<deps_core::osv::OsvPackageName> {
1984 None
1985 }
1986 }
1987
1988 #[derive(Default)]
1991 struct AwaitingNameFormatter {
1992 with_fallback: bool,
1993 }
1994 impl PackageNaming for AwaitingNameFormatter {}
1995 impl PackageRendering for AwaitingNameFormatter {
1996 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1997 version.to_string()
1998 }
1999 fn package_url(&self, name: &PackageName) -> String {
2000 name.as_str().to_string()
2001 }
2002 }
2003 impl RequirementResolution for AwaitingNameFormatter {}
2004 impl DiagnosticMessages for AwaitingNameFormatter {}
2005 impl DiagnosticPolicy for AwaitingNameFormatter {}
2006 impl SourcePolicy for AwaitingNameFormatter {}
2007 impl OsvNaming for AwaitingNameFormatter {
2008 fn osv_name_availability(
2009 &self,
2010 dep: &dyn Dependency,
2011 ) -> deps_core::lsp_helpers::OsvNameAvailability {
2012 deps_core::lsp_helpers::OsvNameAvailability::AwaitingRegistryData {
2013 written_fallback: self
2014 .with_fallback
2015 .then(|| deps_core::osv::OsvPackageName::new_or_skip(dep.name().as_str()))
2016 .flatten(),
2017 }
2018 }
2019 }
2020
2021 fn awaiting_name_fixture() -> (
2022 MockParseResult,
2023 deps_core::osv::VulnKeys,
2024 HashMap<PackageName, PackageVersions>,
2025 ) {
2026 let parse_result = MockParseResult {
2027 deps: vec![MockDep {
2028 name: PackageName::new("gha-action"),
2029 source: DependencySource::Registry,
2030 }],
2031 };
2032 let vuln_keys = vuln_keys_for(&parse_result, &AwaitingNameFormatter::default());
2033 let mut cached_versions = HashMap::new();
2034 cached_versions.insert(
2035 PackageName::new("gha-action"),
2036 PackageVersions::latest_only("1.0.0"),
2037 );
2038 (parse_result, vuln_keys, cached_versions)
2039 }
2040
2041 #[test]
2042 fn build_latest_check_targets_awaiting_osv_name_is_unverified_not_structural() {
2043 let (parse_result, vuln_keys, cached_versions) = awaiting_name_fixture();
2044
2045 let (targets, statuses) = build_latest_check_targets(
2046 &parse_result,
2047 &cached_versions,
2048 &vuln_keys,
2049 &AwaitingNameFormatter::default(),
2050 );
2051
2052 assert!(targets.is_empty());
2053 assert_eq!(
2054 statuses.get(&deps_core::test_util::vuln_key("gha-action")),
2055 Some(&UpgradeStatus::CandidateUnverified {
2056 version: ConcreteVersion::new("1.0.0"),
2057 reason: deps_core::osv::SkipReason::CanonicalNameUnconfirmed,
2058 })
2059 );
2060 }
2061
2062 #[test]
2063 fn build_candidate_check_targets_awaiting_osv_name_records_no_entry() {
2064 let (parse_result, vuln_keys, cached_versions) = awaiting_name_fixture();
2065
2066 let (rounds, statuses) = build_candidate_check_targets(
2067 &parse_result,
2068 &cached_versions,
2069 &vuln_keys,
2070 &AwaitingNameFormatter::default(),
2071 );
2072
2073 assert!(rounds.iter().all(Vec::is_empty));
2074 assert!(
2075 statuses.is_empty(),
2076 "a transient name gap must never be stored as structural: {statuses:?}"
2077 );
2078 }
2079
2080 #[cfg(feature = "github-actions")]
2083 #[test]
2084 fn build_scan_targets_github_actions_unconfirmed_name_is_provisional() {
2085 use deps_core::lsp_helpers::TagIndex;
2086 use deps_core::osv::OsvQueryName;
2087 use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
2088 use std::sync::Arc;
2089
2090 let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
2091 let parse_result = deps_github_actions::parse_workflow_yaml(
2092 "steps:\n - uses: azure/setup-kubectl@v4.1.2\n",
2093 &uri,
2094 )
2095 .expect("valid yaml");
2096 let registry = GithubActionsRegistry::new(Arc::new(deps_core::HttpCache::new()));
2097 let written = deps_core::osv::OsvPackageName::new("azure/setup-kubectl").unwrap();
2098 let canonical = deps_core::osv::OsvPackageName::new("Azure/setup-kubectl").unwrap();
2099
2100 for warm_without_canonical in [false, true] {
2101 let tag_index = registry.tag_index();
2102 tag_index.clear();
2103 if warm_without_canonical {
2104 tag_index.insert(
2105 PackageName::new("azure/setup-kubectl"),
2106 Arc::new(TagIndex::default()),
2107 );
2108 }
2109 let formatter = GithubActionsFormatter::new(tag_index);
2110
2111 let (targets, skipped) = build_scan_targets(
2112 &parse_result,
2113 &HashMap::new(),
2114 &HashMap::new(),
2115 &formatter,
2116 EcosystemId::GithubActions,
2117 );
2118
2119 assert!(skipped.is_empty(), "{skipped:?}");
2120 assert_eq!(targets.len(), 1);
2121 assert_eq!(
2122 targets[0].osv_name,
2123 OsvQueryName::Provisional(written.clone()),
2124 "warm_without_canonical={warm_without_canonical}"
2125 );
2126 }
2127
2128 let tag_index = registry.tag_index();
2129 tag_index.insert(
2130 PackageName::new("azure/setup-kubectl"),
2131 Arc::new(TagIndex::default().with_canonical_repo_name(
2132 deps_core::github::CanonicalRepoName::from_commit_url(
2133 "https://api.github.com/repos/Azure/setup-kubectl/commits/abc",
2134 ),
2135 )),
2136 );
2137 let formatter = GithubActionsFormatter::new(tag_index);
2138 let (targets, _) = build_scan_targets(
2139 &parse_result,
2140 &HashMap::new(),
2141 &HashMap::new(),
2142 &formatter,
2143 EcosystemId::GithubActions,
2144 );
2145 assert_eq!(targets[0].osv_name, OsvQueryName::Confirmed(canonical));
2146 }
2147
2148 #[cfg(feature = "github-actions")]
2151 #[test]
2152 fn build_scan_targets_github_actions_floating_tag_resolves_via_commit() {
2153 use deps_core::lsp_helpers::{CommitSha, TagIndex};
2154 use deps_core::osv::{OsvQueryName, ScanOutcome, SkipReason};
2155 use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
2156 use std::sync::Arc;
2157
2158 let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
2159 let parse_result = deps_github_actions::parse_workflow_yaml(
2160 "steps:\n - uses: actions/checkout@v4\n",
2161 &uri,
2162 )
2163 .expect("valid yaml");
2164 let registry = GithubActionsRegistry::new(Arc::new(deps_core::HttpCache::new()));
2165 let key = deps_core::test_util::vuln_key("actions/checkout");
2166 let commit = CommitSha::parse(&"a".repeat(40)).unwrap();
2167 let scan = |tags: &[&str]| {
2168 let tag_index = registry.tag_index();
2169 tag_index.clear();
2170 if !tags.is_empty() {
2171 let index = TagIndex::from_tags(tags.iter().map(|t| (*t, &commit)))
2172 .with_canonical_repo_name(
2173 deps_core::github::CanonicalRepoName::from_commit_url(
2174 "https://api.github.com/repos/actions/checkout/commits/abc",
2175 ),
2176 );
2177 tag_index.insert(PackageName::new("actions/checkout"), Arc::new(index));
2178 }
2179 build_scan_targets(
2180 &parse_result,
2181 &HashMap::new(),
2182 &HashMap::new(),
2183 &GithubActionsFormatter::new(tag_index),
2184 EcosystemId::GithubActions,
2185 )
2186 };
2187
2188 let (targets, skipped) = scan(&["v4", "v4.2.2"]);
2189 assert!(skipped.is_empty(), "{skipped:?}");
2190 assert_eq!(targets.len(), 1);
2191 assert_eq!(targets[0].version, "4.2.2");
2192 assert_matches!(targets[0].osv_name, OsvQueryName::Confirmed(_));
2193
2194 let (targets, skipped) = scan(&[]);
2195 assert!(targets.is_empty());
2196 assert_matches!(
2197 skipped.get(&key),
2198 Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
2199 );
2200
2201 let (targets, skipped) = scan(&["v4"]);
2202 assert!(targets.is_empty());
2203 assert_matches!(
2204 skipped.get(&key),
2205 Some(ScanOutcome::Skipped(SkipReason::ResolvedTagNotFullVersion))
2206 );
2207 }
2208
2209 #[test]
2211 fn build_scan_targets_awaiting_name_without_fallback_is_unconfirmed_skip() {
2212 let parse_result = MockParseResult {
2213 deps: vec![MockDep {
2214 name: PackageName::new("gha-action"),
2215 source: DependencySource::Registry,
2216 }],
2217 };
2218 let mut resolved = HashMap::new();
2219 resolved.insert(
2220 PackageName::new("gha-action"),
2221 ConcreteVersion::new("1.0.0"),
2222 );
2223
2224 let (targets, skipped) = build_scan_targets(
2225 &parse_result,
2226 &resolved,
2227 &HashMap::new(),
2228 &AwaitingNameFormatter::default(),
2229 EcosystemId::Cargo,
2230 );
2231
2232 assert!(targets.is_empty());
2233 assert_matches!(
2234 skipped.get(&deps_core::test_util::vuln_key("gha-action")),
2235 Some(deps_core::osv::ScanOutcome::Skipped(
2236 deps_core::osv::SkipReason::CanonicalNameUnconfirmed
2237 ))
2238 );
2239 }
2240
2241 struct SequencedNameFormatter {
2243 ready: [bool; 2],
2244 calls: std::sync::atomic::AtomicUsize,
2245 }
2246 impl PackageNaming for SequencedNameFormatter {}
2247 impl PackageRendering for SequencedNameFormatter {
2248 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2249 version.to_string()
2250 }
2251 fn package_url(&self, name: &PackageName) -> String {
2252 name.as_str().to_string()
2253 }
2254 }
2255 impl RequirementResolution for SequencedNameFormatter {}
2256 impl DiagnosticMessages for SequencedNameFormatter {}
2257 impl DiagnosticPolicy for SequencedNameFormatter {}
2258 impl SourcePolicy for SequencedNameFormatter {}
2259 impl OsvNaming for SequencedNameFormatter {
2260 fn osv_name_availability(
2261 &self,
2262 dep: &dyn Dependency,
2263 ) -> deps_core::lsp_helpers::OsvNameAvailability {
2264 let idx = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2265 if self.ready[idx] {
2266 deps_core::lsp_helpers::OsvNameAvailability::Ready
2267 } else {
2268 deps_core::lsp_helpers::OsvNameAvailability::AwaitingRegistryData {
2269 written_fallback: deps_core::osv::OsvPackageName::new_or_skip(
2270 dep.name().as_str(),
2271 ),
2272 }
2273 }
2274 }
2275 }
2276
2277 #[test]
2280 fn build_scan_targets_dedups_per_key_preferring_confirmed_name() {
2281 use deps_core::osv::OsvQueryName;
2282
2283 let mut resolved = HashMap::new();
2284 resolved.insert(PackageName::new("dup"), ConcreteVersion::new("1.0.0"));
2285 let name = deps_core::osv::OsvPackageName::new("dup").unwrap();
2286
2287 for (ready, expected) in [
2288 ([false, true], OsvQueryName::Confirmed(name.clone())),
2289 ([true, false], OsvQueryName::Confirmed(name.clone())),
2290 ([false, false], OsvQueryName::Provisional(name)),
2291 ] {
2292 let parse_result = MockParseResult {
2293 deps: ["dup", "dup"]
2294 .into_iter()
2295 .map(|n| MockDep {
2296 name: PackageName::new(n),
2297 source: DependencySource::Registry,
2298 })
2299 .collect(),
2300 };
2301 let formatter = SequencedNameFormatter {
2302 ready,
2303 calls: std::sync::atomic::AtomicUsize::new(0),
2304 };
2305 let (targets, _) = build_scan_targets(
2306 &parse_result,
2307 &resolved,
2308 &HashMap::new(),
2309 &formatter,
2310 EcosystemId::Cargo,
2311 );
2312 assert_eq!(targets.len(), 1, "ready={ready:?}");
2313 assert_eq!(targets[0].osv_name, expected, "ready={ready:?}");
2314 }
2315 }
2316
2317 fn vuln_keys_for(
2318 parse_result: &dyn deps_core::ParseResult,
2319 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
2320 ) -> deps_core::osv::VulnKeys {
2321 deps_core::osv::vulnerability_keys(
2322 parse_result,
2323 &HashMap::new(),
2324 None,
2325 formatter,
2326 EcosystemId::Cargo,
2327 )
2328 }
2329
2330 #[test]
2331 fn build_latest_check_targets_non_registry_source_is_structurally_unchecked() {
2332 let parse_result = MockParseResult {
2333 deps: vec![MockDep {
2334 name: PackageName::new("vendored"),
2335 source: DependencySource::Path {
2336 path: "../vendored".to_string(),
2337 },
2338 }],
2339 };
2340 let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2341
2342 let (targets, structural) = build_latest_check_targets(
2343 &parse_result,
2344 &HashMap::new(),
2345 &vuln_keys,
2346 &StubFormatter::DEFAULT,
2347 );
2348
2349 assert!(targets.is_empty());
2350 assert_eq!(
2351 structural.get(&deps_core::test_util::vuln_key("vendored")),
2352 Some(&UpgradeStatus::StructurallyUnchecked(
2353 StructuralSkipReason::NonRegistrySource
2354 ))
2355 );
2356 }
2357
2358 #[test]
2359 fn build_candidate_check_targets_non_registry_source_is_structural() {
2360 let parse_result = MockParseResult {
2361 deps: vec![MockDep {
2362 name: PackageName::new("vendored"),
2363 source: DependencySource::Path {
2364 path: "../vendored".to_string(),
2365 },
2366 }],
2367 };
2368 let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2369
2370 let (rounds, structural) = build_candidate_check_targets(
2371 &parse_result,
2372 &HashMap::new(),
2373 &vuln_keys,
2374 &StubFormatter::DEFAULT,
2375 );
2376
2377 assert!(
2378 rounds.iter().all(Vec::is_empty),
2379 "no round may hold a non-registry dependency"
2380 );
2381 assert_eq!(
2382 structural.get(&deps_core::test_util::vuln_key("vendored")),
2383 Some(&CandidateStatuses::Structural(
2384 StructuralSkipReason::NonRegistrySource
2385 ))
2386 );
2387 }
2388
2389 #[test]
2390 fn build_candidate_check_targets_unmappable_name_is_structural() {
2391 let parse_result = MockParseResult {
2392 deps: vec![MockDep {
2393 name: PackageName::new("jsr-pinned"),
2394 source: DependencySource::Registry,
2395 }],
2396 };
2397 let vuln_keys = vuln_keys_for(&parse_result, &UnmappableNameFormatter);
2398 let mut cached_versions = HashMap::new();
2399 cached_versions.insert(
2400 PackageName::new("jsr-pinned"),
2401 PackageVersions::latest_only("1.0.0"),
2402 );
2403
2404 let (rounds, structural) = build_candidate_check_targets(
2405 &parse_result,
2406 &cached_versions,
2407 &vuln_keys,
2408 &UnmappableNameFormatter,
2409 );
2410
2411 assert!(
2412 rounds.iter().all(Vec::is_empty),
2413 "an unmappable name has no OSV-checkable round targets"
2414 );
2415 assert_eq!(
2416 structural.get(&deps_core::test_util::vuln_key("jsr-pinned")),
2417 Some(&CandidateStatuses::Structural(
2418 StructuralSkipReason::UnmappableName
2419 ))
2420 );
2421 }
2422
2423 fn duplicated_registry_dep() -> MockParseResult {
2424 MockParseResult {
2425 deps: vec![
2426 MockDep {
2427 name: PackageName::new("lodash"),
2428 source: DependencySource::Registry,
2429 },
2430 MockDep {
2431 name: PackageName::new("lodash"),
2432 source: DependencySource::Registry,
2433 },
2434 ],
2435 }
2436 }
2437
2438 #[test]
2439 fn build_latest_check_targets_dedups_duplicate_key_occurrences() {
2440 let parse_result = duplicated_registry_dep();
2441 let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2442 let mut cached_versions = HashMap::new();
2443 cached_versions.insert(
2444 PackageName::new("lodash"),
2445 PackageVersions::latest_only("4.17.21"),
2446 );
2447
2448 let (targets, structural) = build_latest_check_targets(
2449 &parse_result,
2450 &cached_versions,
2451 &vuln_keys,
2452 &StubFormatter::DEFAULT,
2453 );
2454
2455 assert_eq!(
2456 targets.len(),
2457 1,
2458 "two same-key occurrences are checked once"
2459 );
2460 assert!(structural.is_empty());
2461 }
2462
2463 #[test]
2464 fn build_candidate_check_targets_dedups_duplicate_key_occurrences() {
2465 let parse_result = duplicated_registry_dep();
2466 let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2467 let mut cached_versions = HashMap::new();
2468 let available: Arc<[ConcreteVersion]> = Arc::from(vec![
2469 ConcreteVersion::new("2.0.0"),
2470 ConcreteVersion::new("1.0.0"),
2471 ]);
2472 cached_versions.insert(
2473 PackageName::new("lodash"),
2474 PackageVersions::new(ConcreteVersion::new("2.0.0"), available),
2475 );
2476
2477 let (rounds, _) = build_candidate_check_targets(
2478 &parse_result,
2479 &cached_versions,
2480 &vuln_keys,
2481 &StubFormatter::DEFAULT,
2482 );
2483
2484 assert_eq!(rounds[0].len(), 1);
2485 assert_eq!(rounds[1].len(), 1);
2486 }
2487
2488 fn dup_other_dup() -> MockParseResult {
2489 let dep = |name: &str| MockDep {
2490 name: PackageName::new(name),
2491 source: DependencySource::Registry,
2492 };
2493 MockParseResult {
2494 deps: vec![dep("dup"), dep("other"), dep("dup")],
2495 }
2496 }
2497
2498 #[test]
2499 fn build_latest_check_targets_keeps_first_occurrence_order() {
2500 let parse_result = dup_other_dup();
2501 let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2502 let mut cached_versions = HashMap::new();
2503 cached_versions.insert(
2504 PackageName::new("dup"),
2505 PackageVersions::latest_only("2.0.0"),
2506 );
2507 cached_versions.insert(
2508 PackageName::new("other"),
2509 PackageVersions::latest_only("3.0.0"),
2510 );
2511
2512 let (targets, _) = build_latest_check_targets(
2513 &parse_result,
2514 &cached_versions,
2515 &vuln_keys,
2516 &StubFormatter::DEFAULT,
2517 );
2518
2519 let keys: Vec<_> = targets.iter().map(|t| t.key.clone()).collect();
2520 assert_eq!(
2521 keys,
2522 [
2523 deps_core::test_util::vuln_key("dup"),
2524 deps_core::test_util::vuln_key("other")
2525 ]
2526 );
2527 assert_eq!(targets[0].display_version, "2.0.0");
2528 assert_eq!(targets[1].display_version, "3.0.0");
2529 }
2530
2531 #[test]
2532 fn build_candidate_check_targets_keeps_first_occurrence_order() {
2533 let parse_result = dup_other_dup();
2534 let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2535 let mut cached_versions = HashMap::new();
2536 for (name, newest) in [("dup", "2.0.0"), ("other", "3.0.0")] {
2537 let available: Arc<[ConcreteVersion]> =
2538 Arc::from(vec![ConcreteVersion::new(newest)]);
2539 cached_versions.insert(
2540 PackageName::new(name),
2541 PackageVersions::new(ConcreteVersion::new(newest), available),
2542 );
2543 }
2544
2545 let (rounds, _) = build_candidate_check_targets(
2546 &parse_result,
2547 &cached_versions,
2548 &vuln_keys,
2549 &StubFormatter::DEFAULT,
2550 );
2551
2552 let keys: Vec<_> = rounds[0].iter().map(|t| t.key.clone()).collect();
2553 assert_eq!(
2554 keys,
2555 [
2556 deps_core::test_util::vuln_key("dup"),
2557 deps_core::test_util::vuln_key("other")
2558 ]
2559 );
2560 assert_eq!(rounds[0][0].display_version, "2.0.0");
2561 assert_eq!(rounds[0][1].display_version, "3.0.0");
2562 assert!(rounds[1..].iter().all(Vec::is_empty));
2563 }
2564
2565 #[test]
2566 fn build_candidate_check_targets_buckets_newest_first_by_round() {
2567 let parse_result = MockParseResult {
2568 deps: vec![MockDep {
2569 name: PackageName::new("pkg"),
2570 source: DependencySource::Registry,
2571 }],
2572 };
2573 let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2574 let mut cached_versions = HashMap::new();
2575 let available: Arc<[ConcreteVersion]> = Arc::from(vec![
2576 ConcreteVersion::new("3.0.0"),
2577 ConcreteVersion::new("2.0.0"),
2578 ConcreteVersion::new("1.0.0"),
2579 ]);
2580 cached_versions.insert(
2581 PackageName::new("pkg"),
2582 PackageVersions::new(ConcreteVersion::new("3.0.0"), available),
2583 );
2584
2585 let (rounds, structural) = build_candidate_check_targets(
2586 &parse_result,
2587 &cached_versions,
2588 &vuln_keys,
2589 &StubFormatter::DEFAULT,
2590 );
2591
2592 assert!(structural.is_empty());
2593 assert_eq!(
2594 rounds[0].len(),
2595 1,
2596 "round 0 must hold this dependency's newest candidate"
2597 );
2598 assert_eq!(rounds[0][0].display_version, "3.0.0");
2599 assert_eq!(rounds[1].len(), 1);
2600 assert_eq!(rounds[1][0].display_version, "2.0.0");
2601 assert_eq!(rounds[2].len(), 1);
2602 assert_eq!(rounds[2][0].display_version, "1.0.0");
2603 assert!(
2604 rounds[3..].iter().all(Vec::is_empty),
2605 "only 3 candidate versions were available, so later rounds must stay empty"
2606 );
2607 }
2608 }
2609
2610 mod fix_target_verification_tests {
2614 use super::*;
2615 use deps_core::osv::{
2616 Advisory, Capped, DependencyVulnerabilities, OsvVersion, ScanOutcome, UpgradeStatus,
2617 VulnSeverity, VulnerabilityMap,
2618 };
2619 use deps_core::test_util::StubFormatter;
2620 use std::sync::Arc;
2621
2622 fn advisory(id: &str, fixed_versions: &[&str]) -> Arc<Advisory> {
2623 Arc::new(
2624 Advisory::new(
2625 id.to_string(),
2626 "2023-01-01T00:00:00Z".to_string(),
2627 VulnSeverity::High,
2628 )
2629 .expect("valid osv id")
2630 .with_fixed_versions(
2631 fixed_versions
2632 .iter()
2633 .copied()
2634 .map(OsvVersion::new)
2635 .collect(),
2636 ),
2637 )
2638 }
2639
2640 fn dv(advisories: Vec<Arc<Advisory>>) -> DependencyVulnerabilities {
2641 let total = advisories.len();
2642 DependencyVulnerabilities::new(Capped::new(advisories, total))
2643 }
2644
2645 fn latest_status_map(status: UpgradeStatus) -> deps_core::osv::LatestStatusMap {
2647 let mut map = deps_core::osv::LatestStatusMap::new();
2648 map.insert(deps_core::test_util::vuln_key("pkg"), status);
2649 map
2650 }
2651
2652 #[test]
2653 fn resolve_fix_target_skips_when_no_fix_is_recommended() {
2654 let dv = dv(vec![advisory("A1", &[])]);
2656 let resolution = resolve_fix_target(
2657 &dv,
2658 &deps_core::test_util::vuln_key("pkg"),
2659 &HashMap::new(),
2660 &HashMap::new(),
2661 &StubFormatter::DEFAULT,
2662 );
2663 assert_eq!(resolution, FixTargetResolution::Skip);
2664 }
2665
2666 #[test]
2667 fn resolve_fix_target_reuses_latest_when_f_equals_latest() {
2668 let latest_status = UpgradeStatus::CandidateClean {
2671 version: ConcreteVersion::new("1.2.0"),
2672 };
2673 let dv = dv(vec![advisory("A1", &["1.2.0"])]);
2674 let latest_status_map = latest_status_map(latest_status.clone());
2675
2676 let resolution = resolve_fix_target(
2677 &dv,
2678 &deps_core::test_util::vuln_key("pkg"),
2679 &latest_status_map,
2680 &HashMap::new(),
2681 &StubFormatter::DEFAULT,
2682 );
2683 assert_eq!(resolution, FixTargetResolution::Resolved(latest_status));
2684 }
2685
2686 #[test]
2687 fn resolve_fix_target_always_needs_live_check_when_f_differs_from_latest() {
2688 let dv = dv(vec![advisory("A1", &["1.2.0"])]);
2693 let latest_status_map = latest_status_map(UpgradeStatus::CandidateClean {
2694 version: ConcreteVersion::new("3.0.0"),
2695 });
2696 let mut osv_name_by_key = HashMap::new();
2697 osv_name_by_key.insert(
2698 deps_core::test_util::vuln_key("pkg"),
2699 deps_core::osv::OsvQueryName::Confirmed(
2700 deps_core::osv::OsvPackageName::new("pkg").unwrap(),
2701 ),
2702 );
2703
2704 let resolution = resolve_fix_target(
2705 &dv,
2706 &deps_core::test_util::vuln_key("pkg"),
2707 &latest_status_map,
2708 &osv_name_by_key,
2709 &StubFormatter::DEFAULT,
2710 );
2711 assert_eq!(
2712 resolution,
2713 FixTargetResolution::NeedsLiveCheck(deps_core::osv::ScanTarget::new(
2714 deps_core::test_util::vuln_key("pkg"),
2715 deps_core::osv::OsvQueryName::Confirmed(
2716 deps_core::osv::OsvPackageName::new("pkg").unwrap()
2717 ),
2718 OsvVersion::new("1.2.0"),
2719 ConcreteVersion::new("1.2.0"),
2720 ))
2721 );
2722 }
2723
2724 #[test]
2725 fn resolve_fix_target_never_reuses_a_candidate_unverified_latest() {
2726 let dv = dv(vec![advisory("A1", &["1.2.0"])]);
2731 let latest_status_map = latest_status_map(UpgradeStatus::CandidateUnverified {
2732 version: ConcreteVersion::new("1.2.0"),
2733 reason: deps_core::osv::SkipReason::QueryFailed,
2734 });
2735 let mut osv_name_by_key = HashMap::new();
2736 osv_name_by_key.insert(
2737 deps_core::test_util::vuln_key("pkg"),
2738 deps_core::osv::OsvQueryName::Confirmed(
2739 deps_core::osv::OsvPackageName::new("pkg").unwrap(),
2740 ),
2741 );
2742
2743 let resolution = resolve_fix_target(
2744 &dv,
2745 &deps_core::test_util::vuln_key("pkg"),
2746 &latest_status_map,
2747 &osv_name_by_key,
2748 &StubFormatter::DEFAULT,
2749 );
2750 assert_eq!(
2751 resolution,
2752 FixTargetResolution::NeedsLiveCheck(deps_core::osv::ScanTarget::new(
2753 deps_core::test_util::vuln_key("pkg"),
2754 deps_core::osv::OsvQueryName::Confirmed(
2755 deps_core::osv::OsvPackageName::new("pkg").unwrap()
2756 ),
2757 OsvVersion::new("1.2.0"),
2758 ConcreteVersion::new("1.2.0"),
2759 ))
2760 );
2761 }
2762
2763 #[test]
2766 fn resolve_fix_target_skips_provisional_name_and_queues_no_live_check() {
2767 let dv = dv(vec![advisory("A1", &["1.2.0"])]);
2768 let key = deps_core::test_util::vuln_key("pkg");
2769 let latest_status_map = latest_status_map(UpgradeStatus::CandidateClean {
2770 version: ConcreteVersion::new("3.0.0"),
2771 });
2772 let mut osv_name_by_key = HashMap::new();
2773 osv_name_by_key.insert(
2774 key.clone(),
2775 deps_core::osv::OsvQueryName::Provisional(
2776 deps_core::osv::OsvPackageName::new("pkg").unwrap(),
2777 ),
2778 );
2779
2780 assert_eq!(
2781 resolve_fix_target(
2782 &dv,
2783 &key,
2784 &latest_status_map,
2785 &osv_name_by_key,
2786 &StubFormatter::DEFAULT,
2787 ),
2788 FixTargetResolution::Skip
2789 );
2790
2791 let mut vulnerabilities = VulnerabilityMap::new();
2792 vulnerabilities.insert(key.clone(), ScanOutcome::Vulnerable(dv));
2793 let (resolved, live_check_candidates) = collect_fix_target_resolutions(
2794 &vulnerabilities,
2795 &[key],
2796 &osv_name_by_key,
2797 &latest_status_map,
2798 &StubFormatter::DEFAULT,
2799 );
2800 assert!(resolved.is_empty(), "{resolved:?}");
2801 assert!(
2802 live_check_candidates.is_empty(),
2803 "{live_check_candidates:?}"
2804 );
2805 }
2806
2807 #[test]
2808 fn resolve_fix_target_skips_when_osv_name_is_unavailable() {
2809 let dv = dv(vec![advisory("A1", &["1.0.0"])]);
2813 let resolution = resolve_fix_target(
2814 &dv,
2815 &deps_core::test_util::vuln_key("pkg"),
2816 &HashMap::new(),
2817 &HashMap::new(),
2818 &StubFormatter::DEFAULT,
2819 );
2820 assert_eq!(resolution, FixTargetResolution::Skip);
2821 }
2822
2823 #[test]
2824 fn resolve_fix_target_skips_when_f_is_not_a_safe_version_string() {
2825 let dv = dv(vec![advisory("A1", &["1.2.0\", \"evil\": \"true"])]);
2830 let resolution = resolve_fix_target(
2831 &dv,
2832 &deps_core::test_util::vuln_key("pkg"),
2833 &HashMap::new(),
2834 &HashMap::new(),
2835 &StubFormatter::DEFAULT,
2836 );
2837 assert_eq!(resolution, FixTargetResolution::Skip);
2838 }
2839
2840 #[test]
2841 fn collect_fix_target_resolutions_batches_multiple_dependencies_needing_live_check_into_one_vec()
2842 {
2843 let mut vulnerabilities = VulnerabilityMap::new();
2849 vulnerabilities.insert(
2850 deps_core::test_util::vuln_key("reused"),
2851 ScanOutcome::Vulnerable(dv(vec![advisory("A1", &["1.0.0"])])),
2852 );
2853 vulnerabilities.insert(
2854 deps_core::test_util::vuln_key("live-a"),
2855 ScanOutcome::Vulnerable(dv(vec![advisory("A2", &["1.2.0"])])),
2856 );
2857 vulnerabilities.insert(
2858 deps_core::test_util::vuln_key("live-b"),
2859 ScanOutcome::Vulnerable(dv(vec![advisory("A3", &["2.2.0"])])),
2860 );
2861
2862 let vulnerable_keys = vec![
2863 deps_core::test_util::vuln_key("reused"),
2864 deps_core::test_util::vuln_key("live-a"),
2865 deps_core::test_util::vuln_key("live-b"),
2866 ];
2867 let mut latest_status: deps_core::osv::LatestStatusMap = HashMap::new();
2868 latest_status.insert(
2869 deps_core::test_util::vuln_key("reused"),
2870 UpgradeStatus::CandidateClean {
2871 version: ConcreteVersion::new("1.0.0"),
2872 },
2873 );
2874 latest_status.insert(
2875 deps_core::test_util::vuln_key("live-a"),
2876 UpgradeStatus::CandidateClean {
2877 version: ConcreteVersion::new("9.0.0"),
2878 },
2879 );
2880 latest_status.insert(
2881 deps_core::test_util::vuln_key("live-b"),
2882 UpgradeStatus::CandidateClean {
2883 version: ConcreteVersion::new("9.0.0"),
2884 },
2885 );
2886 let mut osv_name_by_key = HashMap::new();
2887 osv_name_by_key.insert(
2888 deps_core::test_util::vuln_key("reused"),
2889 deps_core::osv::OsvQueryName::Confirmed(
2890 deps_core::osv::OsvPackageName::new("reused").unwrap(),
2891 ),
2892 );
2893 osv_name_by_key.insert(
2894 deps_core::test_util::vuln_key("live-a"),
2895 deps_core::osv::OsvQueryName::Confirmed(
2896 deps_core::osv::OsvPackageName::new("live-a").unwrap(),
2897 ),
2898 );
2899 osv_name_by_key.insert(
2900 deps_core::test_util::vuln_key("live-b"),
2901 deps_core::osv::OsvQueryName::Confirmed(
2902 deps_core::osv::OsvPackageName::new("live-b").unwrap(),
2903 ),
2904 );
2905
2906 let (resolved, live_check_candidates) = collect_fix_target_resolutions(
2907 &vulnerabilities,
2908 &vulnerable_keys,
2909 &osv_name_by_key,
2910 &latest_status,
2911 &StubFormatter::DEFAULT,
2912 );
2913
2914 assert_eq!(resolved.len(), 1, "{resolved:?}");
2915 assert_eq!(resolved[0].0.as_str(), "reused");
2916
2917 assert_eq!(live_check_candidates.len(), 2, "{live_check_candidates:?}");
2918 let keys: std::collections::HashSet<&str> = live_check_candidates
2919 .iter()
2920 .map(|t| t.key.as_str())
2921 .collect();
2922 assert!(keys.contains("live-a"));
2923 assert!(keys.contains("live-b"));
2924 }
2925
2926 #[test]
2927 fn apply_live_fix_target_statuses_sets_only_matching_keys_leaving_others_untouched() {
2928 let mut vulnerabilities = VulnerabilityMap::new();
2933 vulnerabilities.insert(
2934 deps_core::test_util::vuln_key("checked"),
2935 ScanOutcome::Vulnerable(dv(vec![advisory("A1", &["1.0.0"])])),
2936 );
2937 vulnerabilities.insert(
2938 deps_core::test_util::vuln_key("timed-out"),
2939 ScanOutcome::Vulnerable(dv(vec![advisory("A2", &["1.0.0"])])),
2940 );
2941
2942 let mut statuses = HashMap::new();
2943 statuses.insert(
2944 deps_core::test_util::vuln_key("checked"),
2945 UpgradeStatus::CandidateClean {
2946 version: ConcreteVersion::new("1.0.0"),
2947 },
2948 );
2949 apply_live_fix_target_statuses(&mut vulnerabilities, statuses);
2952
2953 let ScanOutcome::Vulnerable(checked) = vulnerabilities
2954 .get(&deps_core::test_util::vuln_key("checked"))
2955 .unwrap()
2956 else {
2957 panic!("expected Vulnerable");
2958 };
2959 assert_eq!(
2960 checked.fix_target_status,
2961 UpgradeStatus::CandidateClean {
2962 version: ConcreteVersion::new("1.0.0")
2963 }
2964 );
2965
2966 let ScanOutcome::Vulnerable(timed_out) = vulnerabilities
2967 .get(&deps_core::test_util::vuln_key("timed-out"))
2968 .unwrap()
2969 else {
2970 panic!("expected Vulnerable");
2971 };
2972 assert_eq!(timed_out.fix_target_status, UpgradeStatus::NotChecked);
2973 }
2974 }
2975}