1use std::time::Duration;
6
7use deps_core::Ecosystem;
8use deps_core::edit::{VulnFixSkip, plan_verified_fix, resolve_verified_fix};
9use deps_core::lsp_helpers::{RequirementGate, resolve_in_use_version, resolve_scan_outcome};
10use deps_core::osv::{OsvClient, ScanOutcome};
11
12use crate::analyze::ManifestAnalysis;
13use crate::update::ignore::IgnoreRules;
14use crate::update::{
15 CurrentVersion, Outcome, PlannedUpdateItem, UnfixableReason, UpdatePlan, is_requested,
16};
17
18const OSV_CHECK_TIMEOUT_CEILING_SECS: u64 = 30;
22
23pub async fn plan_security_updates(
92 analysis: &ManifestAnalysis,
93 ecosystem: &dyn Ecosystem,
94 osv: &OsvClient,
95 package_filter: &[String],
96 ignore_rules: &IgnoreRules,
97 fetch_timeout_secs: u64,
98) -> UpdatePlan {
99 let formatter = ecosystem.formatter();
100 let ecosystem_id = analysis.ecosystem_id;
101
102 let Some(vulnerabilities) = analysis.vulnerabilities.as_ref() else {
103 return UpdatePlan::default();
104 };
105
106 let vulnerable_keys: Vec<deps_core::osv::VulnKey> = vulnerabilities
107 .iter()
108 .filter(|(_, outcome)| matches!(outcome, ScanOutcome::Vulnerable(_)))
109 .map(|(key, _)| key.clone())
110 .collect();
111 if vulnerable_keys.is_empty() {
112 return UpdatePlan::default();
113 }
114
115 let (targets, _skipped) = deps_engine::classify::osv::build_scan_targets(
116 analysis.parse_result.as_ref(),
117 &analysis.resolved_versions,
118 &analysis.resolved_version_candidates,
119 formatter,
120 ecosystem_id,
121 );
122 let osv_name_by_key = deps_engine::classify::osv::osv_name_by_key(&targets);
123
124 static EMPTY_LATEST_STATUS: std::sync::LazyLock<deps_core::osv::LatestStatusMap> =
132 std::sync::LazyLock::new(deps_core::osv::LatestStatusMap::new);
133 let latest_status = analysis
134 .latest_status
135 .as_ref()
136 .unwrap_or(&EMPTY_LATEST_STATUS);
137 let (resolved, live_check_candidates) =
138 deps_engine::classify::osv::collect_fix_target_resolutions(
139 vulnerabilities,
140 &vulnerable_keys,
141 &osv_name_by_key,
142 latest_status,
143 formatter,
144 );
145
146 let mut vulnerabilities = vulnerabilities.clone();
147 for (key, status) in resolved {
148 if let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get_mut(&key) {
149 dv.fix_target_status = status;
150 }
151 }
152 if !live_check_candidates.is_empty() {
153 let timeout = Duration::from_secs(fetch_timeout_secs.min(OSV_CHECK_TIMEOUT_CEILING_SECS));
154 let statuses = osv
155 .check_candidates(ecosystem_id, &live_check_candidates, timeout)
156 .await;
157 deps_engine::classify::osv::apply_live_fix_target_statuses(&mut vulnerabilities, statuses);
158 }
159
160 let vuln_key_by_range = deps_core::osv::vulnerability_keys(
161 analysis.parse_result.as_ref(),
162 &analysis.resolved_versions,
163 Some(&analysis.resolved_version_candidates),
164 formatter,
165 ecosystem_id,
166 );
167
168 let mut items = Vec::new();
169 for dep in analysis.parse_result.dependencies() {
170 let normalized_name = formatter.normalize_package_name(dep.name());
171 let Some(ScanOutcome::Vulnerable(dv)) = resolve_scan_outcome(
172 &vulnerabilities,
173 dep,
174 Some(&vuln_key_by_range),
175 &normalized_name,
176 ) else {
177 continue;
178 };
179
180 if !is_requested(package_filter, &normalized_name, formatter) {
181 items.push(skipped_not_requested(
182 dep,
183 &normalized_name,
184 ecosystem_id,
185 analysis,
186 formatter,
187 ));
188 continue;
189 }
190
191 let latest = analysis.latest_status.as_ref().and_then(|map| {
192 deps_core::lsp_helpers::resolve_latest_status(
193 map,
194 dep,
195 Some(&vuln_key_by_range),
196 &normalized_name,
197 )
198 });
199
200 items.push(classify_vulnerable_dependency(
201 dep,
202 dv,
203 latest,
204 &normalized_name,
205 analysis,
206 formatter,
207 ecosystem_id,
208 ignore_rules,
209 ));
210 }
211
212 UpdatePlan { items }
213}
214
215#[expect(
222 clippy::too_many_arguments,
223 reason = "issue #1517 added `latest` alongside seven pre-existing parameters; mirrors \
224 `deps-lsp::document::osv_scan::run_osv_fix_target_verification`'s identical \
225 precedent for the same OSV-plumbing reason"
226)]
227fn classify_vulnerable_dependency(
228 dep: &dyn deps_core::Dependency,
229 dv: &deps_core::osv::DependencyVulnerabilities,
230 latest: Option<&deps_core::osv::UpgradeStatus>,
231 normalized_name: &str,
232 analysis: &ManifestAnalysis,
233 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
234 ecosystem_id: deps_core::EcosystemId,
235 ignore_rules: &IgnoreRules,
236) -> PlannedUpdateItem {
237 let current = security_current(dep, normalized_name, analysis, formatter, ecosystem_id);
255 let ignore_rule_overridden = ignore_override(ignore_rules, normalized_name);
256
257 let cached = analysis
267 .cached_versions
268 .get(normalized_name)
269 .or_else(|| analysis.cached_versions.get(dep.name()));
270 if analysis.fetch_failed.contains(dep.name()) || cached.is_none() {
271 return unfixable_item(
272 dep,
273 current,
274 UnfixableReason::FetchFailedOrAbsent,
275 ignore_rule_overridden,
276 );
277 }
278
279 let (fix, version_native) = match resolve_verified_fix(dv, latest, formatter) {
287 Ok(pair) => pair,
288 Err(_) => {
289 return unfixable_item(
290 dep,
291 current,
292 UnfixableReason::NoVerifiedFix,
293 ignore_rule_overridden,
294 );
295 }
296 };
297
298 let Some(version_req) = dep.version_requirement() else {
304 return requires_lockfile_update_item(
305 dep,
306 current,
307 deps_core::ConcreteVersion::new(version_native),
308 &fix.advisory_ids,
309 ignore_rule_overridden,
310 );
311 };
312
313 let Some(version_range) = dep.version_range() else {
314 return requires_lockfile_update_item(
315 dep,
316 current,
317 deps_core::ConcreteVersion::new(version_native),
318 &fix.advisory_ids,
319 ignore_rule_overridden,
320 );
321 };
322
323 let yanked = cached.is_some_and(|pv| {
330 pv.yanked
331 .iter()
332 .any(|(v, status)| v.as_str() == version_native && status.blocks_resolution())
333 });
334 if yanked {
335 return unfixable_item(
336 dep,
337 current,
338 UnfixableReason::Yanked {
339 target: deps_core::ConcreteVersion::new(version_native),
340 },
341 ignore_rule_overridden,
342 );
343 }
344
345 match plan_verified_fix(
350 dep,
351 version_range,
352 version_req.as_str(),
353 &version_native,
354 formatter,
355 ) {
356 Ok(planned) => PlannedUpdateItem::new(
360 dep.name().as_str().to_string(),
361 current,
362 Outcome::Applied {
363 edit: planned.edit,
364 target: deps_core::ConcreteVersion::new(version_native),
365 },
366 fix.advisory_ids,
367 ignore_rule_overridden,
368 None,
369 None,
370 ),
371 Err(VulnFixSkip::RequirementAlreadyResolves) => requires_lockfile_update_item(
378 dep,
379 current,
380 deps_core::ConcreteVersion::new(version_native),
381 &fix.advisory_ids,
382 ignore_rule_overridden,
383 ),
384 Err(VulnFixSkip::NoOpRewrite) => {
409 let fix_concrete = deps_core::ConcreteVersion::new(version_native.as_str());
410 let confirmed_excluded = formatter
411 .compile_requirement(version_req)
412 .is_some_and(|matcher| matcher.matches(&fix_concrete) == Some(false));
413 if confirmed_excluded {
414 unfixable_item(
415 dep,
416 current,
417 UnfixableReason::UnsupportedRequirementShape {
418 target: fix_concrete,
419 },
420 ignore_rule_overridden,
421 )
422 } else {
423 requires_lockfile_update_item(
424 dep,
425 current,
426 fix_concrete,
427 &fix.advisory_ids,
428 ignore_rule_overridden,
429 )
430 }
431 }
432 Err(VulnFixSkip::OversizedRequirement) => unfixable_item(
436 dep,
437 current,
438 UnfixableReason::OversizedRequirement {
439 target: deps_core::ConcreteVersion::new(version_native.as_str()),
440 },
441 ignore_rule_overridden,
442 ),
443 Err(
448 VulnFixSkip::UnverifiedTarget
449 | VulnFixSkip::NoRecommendedFix
450 | VulnFixSkip::UnsafeVersion
451 | VulnFixSkip::UnresolvedPlaceholder,
452 ) => unfixable_item(
453 dep,
454 current,
455 UnfixableReason::NoVerifiedFix,
456 ignore_rule_overridden,
457 ),
458 }
459}
460
461fn security_current(
466 dep: &dyn deps_core::Dependency,
467 normalized_name: &str,
468 analysis: &ManifestAnalysis,
469 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
470 ecosystem_id: deps_core::EcosystemId,
471) -> CurrentVersion {
472 resolve_in_use_version(
473 dep,
474 normalized_name,
475 &analysis.resolved_versions,
476 Some(&analysis.resolved_version_candidates),
477 formatter,
478 ecosystem_id,
479 )
480 .map(CurrentVersion::Resolved)
481 .or_else(|| {
482 dep.version_requirement()
483 .cloned()
484 .map(CurrentVersion::Declared)
485 })
486 .unwrap_or(CurrentVersion::Unknown)
487}
488
489fn ignore_override(ignore_rules: &IgnoreRules, normalized_name: &str) -> bool {
492 ignore_rules.matches_name(normalized_name)
493}
494
495fn skipped_not_requested(
496 dep: &dyn deps_core::Dependency,
497 normalized_name: &str,
498 ecosystem_id: deps_core::EcosystemId,
499 analysis: &ManifestAnalysis,
500 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
501) -> PlannedUpdateItem {
502 let current = security_current(dep, normalized_name, analysis, formatter, ecosystem_id);
503 PlannedUpdateItem::new(
504 dep.name().as_str().to_string(),
505 current,
506 Outcome::Skipped {
507 reason: crate::update::SkipReason::NotRequested,
508 target: None,
509 },
510 Vec::new(),
511 false,
512 None,
513 None,
514 )
515}
516
517fn unfixable_item(
518 dep: &dyn deps_core::Dependency,
519 current: CurrentVersion,
520 reason: UnfixableReason,
521 ignore_rule_overridden: bool,
522) -> PlannedUpdateItem {
523 PlannedUpdateItem::new(
524 dep.name().as_str().to_string(),
525 current,
526 Outcome::Unfixable(reason),
527 Vec::new(),
528 ignore_rule_overridden,
529 None,
530 None,
531 )
532}
533
534fn requires_lockfile_update_item(
535 dep: &dyn deps_core::Dependency,
536 current: CurrentVersion,
537 target: deps_core::ConcreteVersion,
538 advisory_ids: &[String],
539 ignore_rule_overridden: bool,
540) -> PlannedUpdateItem {
541 PlannedUpdateItem::new(
542 dep.name().as_str().to_string(),
543 current,
544 Outcome::RequiresLockfileUpdate { target },
545 advisory_ids.to_vec(),
546 ignore_rule_overridden,
547 None,
548 None,
549 )
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use crate::config::IgnoreRule;
556 use deps_core::licenses::LicensePolicy;
557 use deps_core::lsp_helpers::{
558 DiagnosticMessages, DiagnosticPolicy, MAX_REQUIREMENT_LEN, OsvNaming, PackageNaming,
559 PackageRendering, RequirementMatcher, RequirementResolution, SourcePolicy,
560 };
561 use deps_core::osv::{Advisory, Capped, OsvVersion, UpgradeStatus, VulnSeverity};
562 use deps_core::parser::DependencySource;
563 use deps_core::position::{Position, Range};
564 use deps_core::{
565 ConcreteVersion, Dependency, EcosystemId, PackageName, PackageVersions, RemovalStatus,
566 VersionReq,
567 };
568 use std::any::Any;
569 use std::collections::{HashMap, HashSet};
570
571 struct MockDep {
572 name: PackageName,
573 version_req: Option<VersionReq>,
574 version_range: Option<Range>,
575 }
576
577 impl Dependency for MockDep {
578 fn name(&self) -> &PackageName {
579 &self.name
580 }
581 fn name_range(&self) -> Range {
582 Range::default()
583 }
584 fn version_requirement(&self) -> Option<&VersionReq> {
585 self.version_req.as_ref()
586 }
587 fn version_range(&self) -> Option<Range> {
588 self.version_range
589 }
590 fn source(&self) -> DependencySource {
591 DependencySource::Registry
592 }
593 fn as_any(&self) -> &dyn Any {
594 self
595 }
596 }
597
598 fn dep(name: &str, req: &str) -> MockDep {
599 MockDep {
600 name: PackageName::new(name),
601 version_req: Some(VersionReq::new(req)),
602 version_range: Some(Range::new(
603 Position::new(0, 9),
604 Position::new(0, 9 + req.len() as u32),
605 )),
606 }
607 }
608
609 struct FixedMatcher(bool);
614 impl RequirementMatcher for FixedMatcher {
615 fn matches(&self, _version: &deps_core::ConcreteVersion) -> Option<bool> {
616 Some(self.0)
617 }
618
619 fn strict_prerelease_exclusion(&self) -> bool {
620 false
621 }
622 }
623
624 struct TestFormatter {
627 requirement_already_admits_fix: bool,
628 osv_native_differs: bool,
629 }
630 impl PackageNaming for TestFormatter {}
631 impl PackageRendering for TestFormatter {
632 fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
633 v.to_string()
634 }
635 fn package_url(&self, name: &PackageName) -> String {
636 name.as_str().to_string()
637 }
638 }
639 impl RequirementResolution for TestFormatter {
640 fn compile_bounded_requirement(
641 &self,
642 _requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
643 ) -> Option<Box<dyn RequirementMatcher>> {
644 Some(Box::new(FixedMatcher(self.requirement_already_admits_fix)))
645 }
646 }
647 impl DiagnosticMessages for TestFormatter {}
648 impl DiagnosticPolicy for TestFormatter {}
649 impl SourcePolicy for TestFormatter {}
650 impl OsvNaming for TestFormatter {
651 fn osv_version_to_native(
652 &self,
653 version: &deps_core::osv::OsvVersion,
654 ) -> deps_core::ConcreteVersion {
655 let version = version.as_str();
659 if self.osv_native_differs {
660 deps_core::ConcreteVersion::new(format!("v{version}"))
661 } else {
662 deps_core::ConcreteVersion::new(version)
663 }
664 }
665 }
666
667 struct UnsupportedShapeFormatter;
673 impl PackageNaming for UnsupportedShapeFormatter {}
674 impl PackageRendering for UnsupportedShapeFormatter {
675 fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
676 v.to_string()
677 }
678 fn package_url(&self, name: &PackageName) -> String {
679 name.as_str().to_string()
680 }
681 fn format_version_replacing(
682 &self,
683 _version: &deps_core::ConcreteVersion,
684 current: &str,
685 ) -> String {
686 current.to_string()
687 }
688 }
689 impl RequirementResolution for UnsupportedShapeFormatter {
690 fn compile_bounded_requirement(
691 &self,
692 _requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
693 ) -> Option<Box<dyn RequirementMatcher>> {
694 Some(Box::new(FixedMatcher(false)))
695 }
696 }
697 impl DiagnosticMessages for UnsupportedShapeFormatter {}
698 impl DiagnosticPolicy for UnsupportedShapeFormatter {}
699 impl SourcePolicy for UnsupportedShapeFormatter {}
700 impl OsvNaming for UnsupportedShapeFormatter {}
701
702 struct FloorLikeFormatter;
710 impl PackageNaming for FloorLikeFormatter {}
711 impl PackageRendering for FloorLikeFormatter {
712 fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
713 v.to_string()
714 }
715 fn package_url(&self, name: &PackageName) -> String {
716 name.as_str().to_string()
717 }
718 fn format_version_replacing(
719 &self,
720 _version: &deps_core::ConcreteVersion,
721 current: &str,
722 ) -> String {
723 current.to_string()
724 }
725 }
726 impl RequirementResolution for FloorLikeFormatter {
727 fn compile_bounded_requirement(
728 &self,
729 _requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
730 ) -> Option<Box<dyn RequirementMatcher>> {
731 Some(Box::new(FixedMatcher(true)))
732 }
733 fn bounded_requirement_already_resolves_to(
734 &self,
735 _requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
736 _target: &deps_core::ConcreteVersion,
737 ) -> bool {
738 false
739 }
740 }
741 impl DiagnosticMessages for FloorLikeFormatter {}
742 impl DiagnosticPolicy for FloorLikeFormatter {}
743 impl SourcePolicy for FloorLikeFormatter {}
744 impl OsvNaming for FloorLikeFormatter {}
745
746 struct IndeterminateMatcher;
749 impl RequirementMatcher for IndeterminateMatcher {
750 fn matches(&self, _version: &deps_core::ConcreteVersion) -> Option<bool> {
751 None
752 }
753 fn strict_prerelease_exclusion(&self) -> bool {
754 false
755 }
756 }
757
758 struct IndeterminateFormatter;
763 impl PackageNaming for IndeterminateFormatter {}
764 impl PackageRendering for IndeterminateFormatter {
765 fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
766 v.to_string()
767 }
768 fn package_url(&self, name: &PackageName) -> String {
769 name.as_str().to_string()
770 }
771 fn format_version_replacing(
772 &self,
773 _version: &deps_core::ConcreteVersion,
774 current: &str,
775 ) -> String {
776 current.to_string()
777 }
778 }
779 impl RequirementResolution for IndeterminateFormatter {
780 fn compile_bounded_requirement(
781 &self,
782 _requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
783 ) -> Option<Box<dyn RequirementMatcher>> {
784 Some(Box::new(IndeterminateMatcher))
785 }
786 }
787 impl DiagnosticMessages for IndeterminateFormatter {}
788 impl DiagnosticPolicy for IndeterminateFormatter {}
789 impl SourcePolicy for IndeterminateFormatter {}
790 impl OsvNaming for IndeterminateFormatter {}
791
792 struct PanicsIfCompiledFormatter;
797 impl PackageNaming for PanicsIfCompiledFormatter {}
798 impl PackageRendering for PanicsIfCompiledFormatter {
799 fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
800 v.to_string()
801 }
802 fn package_url(&self, name: &PackageName) -> String {
803 name.as_str().to_string()
804 }
805 fn format_version_replacing(
806 &self,
807 _version: &deps_core::ConcreteVersion,
808 current: &str,
809 ) -> String {
810 current.to_string()
811 }
812 }
813 impl RequirementResolution for PanicsIfCompiledFormatter {
814 fn compile_bounded_requirement(
815 &self,
816 _requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
817 ) -> Option<Box<dyn RequirementMatcher>> {
818 panic!(
819 "compile_bounded_requirement must not be called for an oversized requirement (#1578)"
820 );
821 }
822 }
823 impl DiagnosticMessages for PanicsIfCompiledFormatter {}
824 impl DiagnosticPolicy for PanicsIfCompiledFormatter {}
825 impl SourcePolicy for PanicsIfCompiledFormatter {}
826 impl OsvNaming for PanicsIfCompiledFormatter {}
827
828 const NO_COMPILE_REQUIREMENT_FORMATTER: deps_core::test_util::StubFormatter =
831 deps_core::test_util::StubFormatter::new().with_package_url_prefix("");
832
833 fn advisory(id: &str, fixed_version: &str) -> std::sync::Arc<Advisory> {
834 std::sync::Arc::new(
835 Advisory::new(
836 id.to_string(),
837 "2024-01-01T00:00:00Z".to_string(),
838 VulnSeverity::High,
839 )
840 .expect("valid osv id")
841 .with_fixed_versions(vec![OsvVersion::new(fixed_version)]),
842 )
843 }
844
845 fn verified_dv(fixed_version: &str) -> deps_core::osv::DependencyVulnerabilities {
846 deps_core::osv::DependencyVulnerabilities::new(Capped::new(
847 vec![advisory("RUSTSEC-2024-0001", fixed_version)],
848 1,
849 ))
850 .with_fix_target_status(UpgradeStatus::CandidateClean {
851 version: ConcreteVersion::new(fixed_version),
852 })
853 }
854
855 fn test_analysis(
856 cached_versions: HashMap<PackageName, PackageVersions>,
857 fetch_failed: HashSet<PackageName>,
858 ) -> ManifestAnalysis {
859 ManifestAnalysis {
860 parse_result: deps_core::test_util::stub_parse_result_with_dependencies(0),
861 uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
862 now: deps_core::PublishTime::now(),
863 ecosystem_id: EcosystemId::Cargo,
864 cached_versions,
865 resolved_versions: HashMap::new(),
866 resolved_version_candidates: HashMap::new(),
867 outcomes: deps_core::lsp_helpers::DependencyOutcomes::new(),
868 vulnerabilities: None,
869 latest_status: None,
870 fallback_status: None,
871 cooldown_fallback_view: None,
872 gossip_findings: HashMap::new(),
873 licenses: HashMap::new(),
874 license_policy: LicensePolicy::default(),
875 license_source: deps_core::LicenseSource::default(),
876 network: deps_core::NetworkMode::Online,
877 fetch_failed,
878 registry_unreachable: false,
879 license_fetch_incomplete: false,
880 }
881 }
882
883 fn cached_with(name: &str, latest: &str) -> HashMap<PackageName, PackageVersions> {
884 let mut map = HashMap::new();
885 map.insert(PackageName::new(name), PackageVersions::latest_only(latest));
886 map
887 }
888
889 #[test]
890 fn test_classify_happy_path_is_applied() {
891 let dep = dep("serde", "0.9");
892 let dv = verified_dv("1.0.2");
893 let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
894 let formatter = TestFormatter {
895 requirement_already_admits_fix: false,
896 osv_native_differs: false,
897 };
898 let item = classify_vulnerable_dependency(
899 &dep,
900 &dv,
901 None,
902 "serde",
903 &analysis,
904 &formatter,
905 EcosystemId::Cargo,
906 &IgnoreRules::empty(),
907 );
908 assert!(matches!(item.outcome, Outcome::Applied { .. }));
909 assert_eq!(
910 item.target(),
911 Some(&deps_core::ConcreteVersion::from("1.0.2"))
912 );
913 }
914
915 #[test]
918 fn test_classify_no_version_requirement_is_requires_lockfile_update() {
919 let dep = MockDep {
920 name: PackageName::new("serde"),
921 version_req: None,
922 version_range: None,
923 };
924 let dv = verified_dv("1.0.2");
925 let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
926 let formatter = TestFormatter {
927 requirement_already_admits_fix: false,
928 osv_native_differs: false,
929 };
930 let item = classify_vulnerable_dependency(
931 &dep,
932 &dv,
933 None,
934 "serde",
935 &analysis,
936 &formatter,
937 EcosystemId::Cargo,
938 &IgnoreRules::empty(),
939 );
940 assert!(matches!(
941 item.outcome,
942 Outcome::RequiresLockfileUpdate { .. }
943 ));
944 }
945
946 #[test]
947 fn test_classify_no_version_range_is_requires_lockfile_update() {
948 let dep = MockDep {
949 name: PackageName::new("serde"),
950 version_req: Some(VersionReq::new("1")),
951 version_range: None,
952 };
953 let dv = verified_dv("1.0.2");
954 let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
955 let formatter = TestFormatter {
956 requirement_already_admits_fix: false,
957 osv_native_differs: false,
958 };
959 let item = classify_vulnerable_dependency(
960 &dep,
961 &dv,
962 None,
963 "serde",
964 &analysis,
965 &formatter,
966 EcosystemId::Cargo,
967 &IgnoreRules::empty(),
968 );
969 assert!(matches!(
970 item.outcome,
971 Outcome::RequiresLockfileUpdate { .. }
972 ));
973 }
974
975 #[test]
978 fn test_classify_requirement_already_admits_fix_is_requires_lockfile_update() {
979 let dep = dep("serde", "1");
980 let dv = verified_dv("1.0.2");
981 let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
982 let formatter = TestFormatter {
983 requirement_already_admits_fix: true,
984 osv_native_differs: false,
985 };
986 let item = classify_vulnerable_dependency(
987 &dep,
988 &dv,
989 None,
990 "serde",
991 &analysis,
992 &formatter,
993 EcosystemId::Cargo,
994 &IgnoreRules::empty(),
995 );
996 assert!(matches!(
997 item.outcome,
998 Outcome::RequiresLockfileUpdate { .. }
999 ));
1000 assert_eq!(
1001 item.target(),
1002 Some(&deps_core::ConcreteVersion::from("1.0.2"))
1003 );
1004 }
1005
1006 #[test]
1010 fn test_classify_no_compile_requirement_falls_back_to_no_op_guard() {
1011 let dep = dep("serde", "1.0.2");
1012 let dv = verified_dv("1.0.2");
1013 let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1014 let item = classify_vulnerable_dependency(
1015 &dep,
1016 &dv,
1017 None,
1018 "serde",
1019 &analysis,
1020 &NO_COMPILE_REQUIREMENT_FORMATTER,
1021 EcosystemId::Cargo,
1022 &IgnoreRules::empty(),
1023 );
1024 assert!(matches!(
1025 item.outcome,
1026 Outcome::RequiresLockfileUpdate { .. }
1027 ));
1028 }
1029
1030 #[test]
1036 fn test_classify_unsupported_requirement_shape_is_unfixable_not_requires_lockfile_update() {
1037 let dep = dep("foo", ">=1.2, <1.5");
1038 let dv = verified_dv("1.5.2");
1039 let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
1040 let item = classify_vulnerable_dependency(
1041 &dep,
1042 &dv,
1043 None,
1044 "foo",
1045 &analysis,
1046 &UnsupportedShapeFormatter,
1047 EcosystemId::Cargo,
1048 &IgnoreRules::empty(),
1049 );
1050 assert!(
1051 matches!(
1052 item.outcome,
1053 Outcome::Unfixable(UnfixableReason::UnsupportedRequirementShape { .. })
1054 ),
1055 "expected Unfixable(UnsupportedRequirementShape), got {:?}",
1056 item.outcome
1057 );
1058 assert_eq!(
1059 item.target(),
1060 Some(&deps_core::ConcreteVersion::from("1.5.2")),
1061 "an Unfixable item with a rejected fix target must report it (#1614)"
1062 );
1063 assert!(
1064 !item.reason().contains("regenerate the lock file"),
1065 "message must not claim the fix is already admitted: {}",
1066 item.reason()
1067 );
1068 }
1069
1070 #[test]
1077 fn test_classify_floor_requirement_matching_matcher_is_requires_lockfile_update_not_unfixable()
1078 {
1079 let dep = dep("foo", "1.5.2");
1080 let dv = verified_dv("1.5.2");
1081 let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
1082 let item = classify_vulnerable_dependency(
1083 &dep,
1084 &dv,
1085 None,
1086 "foo",
1087 &analysis,
1088 &FloorLikeFormatter,
1089 EcosystemId::Cargo,
1090 &IgnoreRules::empty(),
1091 );
1092 assert!(
1093 matches!(item.outcome, Outcome::RequiresLockfileUpdate { .. }),
1094 "expected RequiresLockfileUpdate, got {:?}",
1095 item.outcome
1096 );
1097 }
1098
1099 #[test]
1104 fn test_classify_indeterminate_matcher_is_requires_lockfile_update_not_unfixable() {
1105 let dep = dep("foo", "1.5.2");
1106 let dv = verified_dv("1.5.2");
1107 let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
1108 let item = classify_vulnerable_dependency(
1109 &dep,
1110 &dv,
1111 None,
1112 "foo",
1113 &analysis,
1114 &IndeterminateFormatter,
1115 EcosystemId::Cargo,
1116 &IgnoreRules::empty(),
1117 );
1118 assert!(
1119 matches!(item.outcome, Outcome::RequiresLockfileUpdate { .. }),
1120 "expected RequiresLockfileUpdate, got {:?}",
1121 item.outcome
1122 );
1123 }
1124
1125 #[test]
1135 fn test_classify_us003_mixed_outcomes_are_both_reachable() {
1136 let rewrite_dep = dep("serde", "0.9");
1137 let rewrite_dv = verified_dv("1.0.2");
1138 let rewrite_analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1139 let rewrite_formatter = TestFormatter {
1140 requirement_already_admits_fix: false,
1141 osv_native_differs: false,
1142 };
1143 let rewrite_item = classify_vulnerable_dependency(
1144 &rewrite_dep,
1145 &rewrite_dv,
1146 None,
1147 "serde",
1148 &rewrite_analysis,
1149 &rewrite_formatter,
1150 EcosystemId::Cargo,
1151 &IgnoreRules::empty(),
1152 );
1153
1154 let lockfile_dep = dep("tokio", "1");
1155 let lockfile_dv = verified_dv("1.0.2");
1156 let lockfile_analysis = test_analysis(cached_with("tokio", "1.0.2"), HashSet::new());
1157 let lockfile_formatter = TestFormatter {
1158 requirement_already_admits_fix: true,
1159 osv_native_differs: false,
1160 };
1161 let lockfile_item = classify_vulnerable_dependency(
1162 &lockfile_dep,
1163 &lockfile_dv,
1164 None,
1165 "tokio",
1166 &lockfile_analysis,
1167 &lockfile_formatter,
1168 EcosystemId::Cargo,
1169 &IgnoreRules::empty(),
1170 );
1171
1172 assert!(
1173 matches!(rewrite_item.outcome, Outcome::Applied { .. }),
1174 "serde's requirement (\"0.9\") does not yet admit 1.0.2, so it must be rewritten"
1175 );
1176 assert_eq!(
1177 rewrite_item.target(),
1178 Some(&deps_core::ConcreteVersion::from("1.0.2"))
1179 );
1180 assert!(
1181 matches!(
1182 lockfile_item.outcome,
1183 Outcome::RequiresLockfileUpdate { .. }
1184 ),
1185 "tokio's requirement (\"1\") already admits 1.0.2 per spec.md's US-003 fixture, so \
1186 it must be reported, not rewritten"
1187 );
1188 assert_eq!(
1189 lockfile_item.target(),
1190 Some(&deps_core::ConcreteVersion::from("1.0.2"))
1191 );
1192 }
1193
1194 #[test]
1196 fn test_classify_fetch_failed_is_unfixable() {
1197 let dep = dep("serde", "0.9");
1198 let dv = verified_dv("1.0.2");
1199 let mut fetch_failed = HashSet::new();
1200 fetch_failed.insert(PackageName::new("serde"));
1201 let analysis = test_analysis(cached_with("serde", "1.0.2"), fetch_failed);
1202 let formatter = TestFormatter {
1203 requirement_already_admits_fix: false,
1204 osv_native_differs: false,
1205 };
1206 let item = classify_vulnerable_dependency(
1207 &dep,
1208 &dv,
1209 None,
1210 "serde",
1211 &analysis,
1212 &formatter,
1213 EcosystemId::Cargo,
1214 &IgnoreRules::empty(),
1215 );
1216 assert!(matches!(
1217 item.outcome,
1218 Outcome::Unfixable(UnfixableReason::FetchFailedOrAbsent)
1219 ));
1220 }
1221
1222 #[test]
1225 fn test_classify_absent_package_versions_is_unfixable() {
1226 let dep = dep("serde", "0.9");
1227 let dv = verified_dv("1.0.2");
1228 let analysis = test_analysis(HashMap::new(), HashSet::new());
1229 let formatter = TestFormatter {
1230 requirement_already_admits_fix: false,
1231 osv_native_differs: false,
1232 };
1233 let item = classify_vulnerable_dependency(
1234 &dep,
1235 &dv,
1236 None,
1237 "serde",
1238 &analysis,
1239 &formatter,
1240 EcosystemId::Cargo,
1241 &IgnoreRules::empty(),
1242 );
1243 assert!(matches!(
1244 item.outcome,
1245 Outcome::Unfixable(UnfixableReason::FetchFailedOrAbsent)
1246 ));
1247 }
1248
1249 #[test]
1250 fn test_classify_no_recommended_fix_is_unfixable() {
1251 let dep = dep("serde", "0.9");
1252 let advisory = std::sync::Arc::new(
1255 Advisory::new(
1256 "RUSTSEC-2024-0001".to_string(),
1257 "2024-01-01T00:00:00Z".to_string(),
1258 VulnSeverity::High,
1259 )
1260 .expect("valid osv id"),
1261 );
1262 let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(vec![advisory], 1));
1263 let analysis = test_analysis(cached_with("serde", "1.2.0"), HashSet::new());
1264 let formatter = TestFormatter {
1265 requirement_already_admits_fix: false,
1266 osv_native_differs: false,
1267 };
1268 let item = classify_vulnerable_dependency(
1269 &dep,
1270 &dv,
1271 None,
1272 "serde",
1273 &analysis,
1274 &formatter,
1275 EcosystemId::Cargo,
1276 &IgnoreRules::empty(),
1277 );
1278 assert!(matches!(
1279 item.outcome,
1280 Outcome::Unfixable(UnfixableReason::NoVerifiedFix)
1281 ));
1282 }
1283
1284 #[test]
1285 fn test_classify_unverified_fix_target_is_unfixable() {
1286 let dep = dep("serde", "0.9");
1287 let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(
1289 vec![advisory("RUSTSEC-2024-0001", "1.0.2")],
1290 1,
1291 ));
1292 let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1293 let formatter = TestFormatter {
1294 requirement_already_admits_fix: false,
1295 osv_native_differs: false,
1296 };
1297 let item = classify_vulnerable_dependency(
1298 &dep,
1299 &dv,
1300 None,
1301 "serde",
1302 &analysis,
1303 &formatter,
1304 EcosystemId::Cargo,
1305 &IgnoreRules::empty(),
1306 );
1307 assert!(matches!(
1308 item.outcome,
1309 Outcome::Unfixable(UnfixableReason::NoVerifiedFix)
1310 ));
1311 }
1312
1313 #[test]
1319 fn test_classify_unverified_target_with_no_version_requirement_is_unfixable_not_lockfile() {
1320 let dep = MockDep {
1321 name: PackageName::new("serde"),
1322 version_req: None,
1323 version_range: None,
1324 };
1325 let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(
1327 vec![advisory("RUSTSEC-2024-0001", "1.0.2")],
1328 1,
1329 ));
1330 let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1331 let formatter = TestFormatter {
1332 requirement_already_admits_fix: false,
1333 osv_native_differs: false,
1334 };
1335 let item = classify_vulnerable_dependency(
1336 &dep,
1337 &dv,
1338 None,
1339 "serde",
1340 &analysis,
1341 &formatter,
1342 EcosystemId::Cargo,
1343 &IgnoreRules::empty(),
1344 );
1345 assert!(
1346 matches!(
1347 item.outcome,
1348 Outcome::Unfixable(UnfixableReason::NoVerifiedFix)
1349 ),
1350 "got {:?}",
1351 item.outcome
1352 );
1353 }
1354
1355 #[test]
1360 fn test_classify_unverified_target_and_yanked_is_unfixable_no_verified_fix_not_yanked() {
1361 let dep = dep("serde", "0.9");
1362 let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(
1364 vec![advisory("RUSTSEC-2024-0001", "1.0.2")],
1365 1,
1366 ));
1367 let mut cached = HashMap::new();
1368 cached.insert(
1369 PackageName::new("serde"),
1370 PackageVersions::new("1.0.2".into(), std::sync::Arc::from([])).with_yanked(
1371 std::sync::Arc::from([("1.0.2".into(), RemovalStatus::from_yanked(true))]),
1372 ),
1373 );
1374 let analysis = test_analysis(cached, HashSet::new());
1375 let formatter = TestFormatter {
1376 requirement_already_admits_fix: false,
1377 osv_native_differs: false,
1378 };
1379 let item = classify_vulnerable_dependency(
1380 &dep,
1381 &dv,
1382 None,
1383 "serde",
1384 &analysis,
1385 &formatter,
1386 EcosystemId::Cargo,
1387 &IgnoreRules::empty(),
1388 );
1389 assert!(
1390 matches!(
1391 item.outcome,
1392 Outcome::Unfixable(UnfixableReason::NoVerifiedFix)
1393 ),
1394 "got {:?}",
1395 item.outcome
1396 );
1397 }
1398
1399 #[test]
1402 fn test_classify_yanked_fix_target_is_unfixable() {
1403 let dep = dep("serde", "0.9");
1404 let dv = verified_dv("1.0.2");
1405 let mut cached = HashMap::new();
1406 cached.insert(
1407 PackageName::new("serde"),
1408 PackageVersions::new("1.0.2".into(), std::sync::Arc::from([])).with_yanked(
1409 std::sync::Arc::from([("1.0.2".into(), RemovalStatus::from_yanked(true))]),
1410 ),
1411 );
1412 let analysis = test_analysis(cached, HashSet::new());
1413 let formatter = TestFormatter {
1414 requirement_already_admits_fix: false,
1415 osv_native_differs: false,
1416 };
1417 let item = classify_vulnerable_dependency(
1418 &dep,
1419 &dv,
1420 None,
1421 "serde",
1422 &analysis,
1423 &formatter,
1424 EcosystemId::Cargo,
1425 &IgnoreRules::empty(),
1426 );
1427 assert!(matches!(
1428 item.outcome,
1429 Outcome::Unfixable(UnfixableReason::Yanked { .. })
1430 ));
1431 assert_eq!(
1432 item.target(),
1433 Some(&deps_core::ConcreteVersion::from("1.0.2")),
1434 "a yanked-unfixable item must report the rejected fix target (#1614)"
1435 );
1436 }
1437
1438 #[test]
1447 fn test_classify_requirement_admits_fix_and_yanked_is_unfixable_yanked_not_lockfile() {
1448 let dep = dep("serde", "1");
1449 let dv = verified_dv("1.0.2");
1450 let mut cached = HashMap::new();
1451 cached.insert(
1452 PackageName::new("serde"),
1453 PackageVersions::new("1.0.2".into(), std::sync::Arc::from([])).with_yanked(
1454 std::sync::Arc::from([("1.0.2".into(), RemovalStatus::from_yanked(true))]),
1455 ),
1456 );
1457 let analysis = test_analysis(cached, HashSet::new());
1458 let formatter = TestFormatter {
1459 requirement_already_admits_fix: true,
1460 osv_native_differs: false,
1461 };
1462 let item = classify_vulnerable_dependency(
1463 &dep,
1464 &dv,
1465 None,
1466 "serde",
1467 &analysis,
1468 &formatter,
1469 EcosystemId::Cargo,
1470 &IgnoreRules::empty(),
1471 );
1472 assert!(matches!(
1473 item.outcome,
1474 Outcome::Unfixable(UnfixableReason::Yanked { .. })
1475 ));
1476 }
1477
1478 #[test]
1482 fn test_classify_yank_filter_uses_native_form_not_osv_wire_form() {
1483 let dep = dep("serde", "0.9");
1484 let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(
1488 vec![advisory("RUSTSEC-2024-0001", "1.0.2")],
1489 1,
1490 ))
1491 .with_fix_target_status(UpgradeStatus::CandidateClean {
1492 version: ConcreteVersion::new("v1.0.2"),
1493 });
1494 let mut cached = HashMap::new();
1495 cached.insert(
1496 PackageName::new("serde"),
1497 PackageVersions::new("v1.0.2".into(), std::sync::Arc::from([])).with_yanked(
1498 std::sync::Arc::from([("v1.0.2".into(), RemovalStatus::from_yanked(true))]),
1499 ),
1500 );
1501 let analysis = test_analysis(cached, HashSet::new());
1502 let formatter = TestFormatter {
1503 requirement_already_admits_fix: false,
1504 osv_native_differs: true,
1505 };
1506 let item = classify_vulnerable_dependency(
1507 &dep,
1508 &dv,
1509 None,
1510 "serde",
1511 &analysis,
1512 &formatter,
1513 EcosystemId::Cargo,
1514 &IgnoreRules::empty(),
1515 );
1516 assert!(
1517 matches!(
1518 item.outcome,
1519 Outcome::Unfixable(UnfixableReason::Yanked { .. })
1520 ),
1521 "the yanked native-form entry (v1.0.2) must match the converted native-form fix \
1522 target, not the raw OSV wire form (1.0.2), which was never in the yanked list"
1523 );
1524 }
1525
1526 #[test]
1530 fn test_classify_empty_yanked_list_is_fail_open() {
1531 let dep = dep("serde", "0.9");
1532 let dv = verified_dv("1.0.2");
1533 let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1534 let formatter = TestFormatter {
1535 requirement_already_admits_fix: false,
1536 osv_native_differs: false,
1537 };
1538 let item = classify_vulnerable_dependency(
1539 &dep,
1540 &dv,
1541 None,
1542 "serde",
1543 &analysis,
1544 &formatter,
1545 EcosystemId::Cargo,
1546 &IgnoreRules::empty(),
1547 );
1548 assert!(matches!(item.outcome, Outcome::Applied { .. }));
1549 }
1550
1551 #[test]
1553 fn test_classify_ignore_rule_is_overridden_not_suppressed() {
1554 let dep = dep("serde", "0.9");
1555 let dv = verified_dv("1.0.2");
1556 let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1557 let formatter = TestFormatter {
1558 requirement_already_admits_fix: false,
1559 osv_native_differs: false,
1560 };
1561 let ignore_rules = IgnoreRules::new(
1562 vec![IgnoreRule {
1563 name: "serde".to_string(),
1564 update_types: None,
1565 }],
1566 &formatter,
1567 );
1568 let item = classify_vulnerable_dependency(
1569 &dep,
1570 &dv,
1571 None,
1572 "serde",
1573 &analysis,
1574 &formatter,
1575 EcosystemId::Cargo,
1576 &ignore_rules,
1577 );
1578 assert!(matches!(item.outcome, Outcome::Applied { .. }));
1579 assert!(
1580 item.ignore_rule_overridden,
1581 "a matching rule must be reported as overridden, not silently applied"
1582 );
1583 }
1584
1585 #[test]
1588 fn test_classify_current_falls_back_to_declared_requirement_when_unresolvable() {
1589 let dep = dep("serde", "0.9");
1590 let dv = verified_dv("1.0.2");
1591 let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1592 let formatter = TestFormatter {
1593 requirement_already_admits_fix: false,
1594 osv_native_differs: false,
1595 };
1596 let item = classify_vulnerable_dependency(
1597 &dep,
1598 &dv,
1599 None,
1600 "serde",
1601 &analysis,
1602 &formatter,
1603 EcosystemId::Cargo,
1604 &IgnoreRules::empty(),
1605 );
1606 assert_eq!(
1610 item.current,
1611 CurrentVersion::Declared(VersionReq::new("0.9"))
1612 );
1613 }
1614
1615 #[test]
1626 fn test_classify_real_cargo_formatter_compound_requirement_confirms_exclusion() {
1627 let dep = dep("foo", ">=1.2, <1.5");
1628 let dv = verified_dv("1.5.2");
1629 let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
1630 let item = classify_vulnerable_dependency(
1631 &dep,
1632 &dv,
1633 None,
1634 "foo",
1635 &analysis,
1636 &deps_engine::setup::CargoFormatter,
1637 EcosystemId::Cargo,
1638 &IgnoreRules::empty(),
1639 );
1640 assert!(
1641 matches!(
1642 item.outcome,
1643 Outcome::Unfixable(UnfixableReason::UnsupportedRequirementShape { .. })
1644 ),
1645 "real Cargo semver matcher must confirm 1.5.2 is excluded by \">=1.2, <1.5\", got {:?}",
1646 item.outcome
1647 );
1648 }
1649
1650 #[test]
1655 fn test_classify_oversized_requirement_never_reaches_compile_requirement() {
1656 let oversized_req = "1".repeat(MAX_REQUIREMENT_LEN + 1);
1657 let dep = dep("foo", &oversized_req);
1658 let dv = verified_dv("1.5.2");
1659 let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
1660 let item = classify_vulnerable_dependency(
1661 &dep,
1662 &dv,
1663 None,
1664 "foo",
1665 &analysis,
1666 &PanicsIfCompiledFormatter,
1667 EcosystemId::Cargo,
1668 &IgnoreRules::empty(),
1669 );
1670 assert!(
1674 matches!(
1675 item.outcome,
1676 Outcome::Unfixable(UnfixableReason::OversizedRequirement { .. })
1677 ),
1678 "got {:?}",
1679 item.outcome
1680 );
1681 assert_eq!(
1682 item.target(),
1683 Some(&deps_core::ConcreteVersion::from("1.5.2")),
1684 "an oversized-requirement-unfixable item must report the rejected fix target (#1614)"
1685 );
1686 }
1687
1688 #[test]
1693 fn test_classify_requirement_at_exact_cap_is_not_oversized() {
1694 let head = ">=1.2,";
1695 let tail = " <1.5";
1696 let padding = " ".repeat(MAX_REQUIREMENT_LEN - head.len() - tail.len());
1697 let requirement = format!("{head}{padding}{tail}");
1698 assert_eq!(requirement.len(), MAX_REQUIREMENT_LEN);
1699
1700 let dep = dep("foo", &requirement);
1701 let dv = verified_dv("1.5.2");
1702 let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
1703 let item = classify_vulnerable_dependency(
1704 &dep,
1705 &dv,
1706 None,
1707 "foo",
1708 &analysis,
1709 &deps_engine::setup::CargoFormatter,
1710 EcosystemId::Cargo,
1711 &IgnoreRules::empty(),
1712 );
1713 assert!(
1714 matches!(
1715 item.outcome,
1716 Outcome::Unfixable(UnfixableReason::UnsupportedRequirementShape { .. })
1717 ),
1718 "expected the matcher to run at the exact cap and confirm exclusion, got {:?}",
1719 item.outcome
1720 );
1721 }
1722}