1use std::collections::HashSet;
2use tower_lsp_server::ls_types::{CodeAction, CodeActionKind, Position, Range, Uri, WorkspaceEdit};
3
4use crate::osv::{ScanOutcome, UpgradeStatus};
5use crate::{ConcreteVersion, Dependency, ParseResult, Registry, VersionReq};
6
7use super::{
8 DEPRECATED_DIAGNOSTIC_CODE, EcosystemFormatter, LineOffsetTable, UNSATISFIABLE_DIAGNOSTIC_CODE,
9 VersionData, is_safe_version_string, literal_span_matches, requirement_is_unsatisfiable,
10 single_file_edit, slice_for_range, strip_whitespace, warn_rejected_value,
11};
12
13struct VulnerabilityFixAction {
18 version_native: String,
21 new_text: String,
27 action: CodeAction,
28}
29
30fn fix_target_is_verified(
54 dv: &crate::osv::DependencyVulnerabilities,
55 fix: &crate::osv::FixRecommendation,
56 version_native: &str,
57) -> bool {
58 match &dv.fix_target_status {
59 UpgradeStatus::CandidateClean { version } => version == version_native,
60 UpgradeStatus::CandidateVulnerable {
61 version,
62 advisory_ids,
63 } => {
64 if version != version_native || !advisory_ids.is_complete() {
65 return false;
66 }
67 let known_ids: HashSet<&str> = dv
68 .advisories
69 .items()
70 .iter()
71 .map(|a| a.id.as_str())
72 .collect();
73 advisory_ids
74 .items()
75 .iter()
76 .all(|id| known_ids.contains(id.as_str()) && !fix.advisory_ids.contains(id))
77 }
78 UpgradeStatus::NotChecked => false,
79 }
80}
81
82fn build_vulnerability_fix_action(
98 parse_result: &dyn ParseResult,
99 dep: &dyn Dependency,
100 uri: &Uri,
101 version_range: Range,
102 versions: VersionData<'_>,
103 version_req: &str,
104 formatter: &dyn EcosystemFormatter,
105) -> Option<VulnerabilityFixAction> {
106 let normalized_name = formatter.normalize_package_name(dep.name());
107 let vuln_key = versions.ecosystem.and_then(|ecosystem| {
111 crate::osv::vulnerability_keys(parse_result, versions.resolved, formatter, ecosystem)
112 .remove(&dep.name_range())
113 });
114 let outcome = versions.vulnerabilities.and_then(|m| {
115 vuln_key
116 .as_deref()
117 .and_then(|key| m.get(key))
118 .or_else(|| m.get(&normalized_name))
119 .or_else(|| m.get(dep.name().as_str()))
120 })?;
121 let ScanOutcome::Vulnerable(dv) = outcome else {
122 return None;
123 };
124 let fix = dv.recommended_fix()?;
125 let version_native = formatter.osv_version_to_native(&fix.version);
126 if !is_safe_version_string(&version_native) {
127 warn_rejected_value(
128 "is_safe_version_string",
129 "vulnerability fix code action",
130 &version_native,
131 );
132 return None;
133 }
134
135 if !fix_target_is_verified(dv, &fix, &version_native) {
139 return None;
140 }
141
142 let new_text = formatter.format_version_replacing_for(
149 dep,
150 &ConcreteVersion::new(version_native.as_str()),
151 version_req,
152 );
153
154 let literal_target = dep.version_literal().unwrap_or(version_req);
167 if strip_whitespace(literal_target) == strip_whitespace(&new_text) {
168 return None;
169 }
170
171 let lockfile_hit = versions
176 .resolved
177 .get(normalized_name.as_str())
178 .or_else(|| versions.resolved.get(dep.name()))
179 .is_some();
180
181 let (first_id, rest_ids) = fix.advisory_ids.split_first()?;
186 let fixes = if rest_ids.is_empty() {
187 first_id.clone()
188 } else {
189 format!("{first_id} +{} more", rest_ids.len())
190 };
191 let title = if lockfile_hit {
192 format!("Update to {version_native} (fixes {fixes}; update lockfile to apply)")
193 } else {
194 format!("Update to {version_native} (fixes {fixes})")
195 };
196
197 let edits = single_file_edit(uri, version_range, new_text.clone());
198
199 Some(VulnerabilityFixAction {
200 version_native,
201 new_text,
202 action: CodeAction {
203 title,
204 kind: Some(CodeActionKind::QUICKFIX),
205 edit: Some(WorkspaceEdit {
206 changes: Some(edits),
207 ..Default::default()
208 }),
209 is_preferred: None,
210 data: Some(serde_json::json!({
217 "diagnostic_codes": fix.advisory_ids,
218 "diagnostic_range": version_range,
219 })),
220 ..Default::default()
221 },
222 })
223}
224
225struct UnsatisfiableFixAction {
230 version_native: String,
233 new_text: String,
236 action: CodeAction,
237}
238
239fn build_unsatisfiable_fix_action(
265 dep: &dyn Dependency,
266 uri: &Uri,
267 version_range: Range,
268 versions: VersionData<'_>,
269 version_req: &VersionReq,
270 formatter: &dyn EcosystemFormatter,
271) -> Option<UnsatisfiableFixAction> {
272 if !formatter.can_resolve_source(&dep.source()) {
273 return None;
274 }
275
276 let normalized_name = formatter.normalize_package_name(dep.name());
277 let package_versions = versions
278 .cached
279 .get(normalized_name.as_str())
280 .or_else(|| versions.cached.get(dep.name()))?;
281
282 if !requirement_is_unsatisfiable(formatter, version_req, &package_versions.available) {
283 return None;
284 }
285
286 let latest = package_versions.latest.clone();
287 if !is_safe_version_string(latest.as_str()) {
288 warn_rejected_value(
289 "is_safe_version_string",
290 "unsatisfiable-requirement fix code action",
291 latest.as_str(),
292 );
293 return None;
294 }
295 let new_text = formatter.format_version_replacing_for(dep, &latest, version_req.as_str());
296
297 let literal_target = dep.version_literal().unwrap_or(version_req.as_str());
301 if strip_whitespace(literal_target) == strip_whitespace(&new_text) {
302 return None;
303 }
304
305 let verification_req = VersionReq::new(new_text.clone());
306 if requirement_is_unsatisfiable(formatter, &verification_req, &package_versions.available) {
307 return None;
308 }
309
310 let edits = single_file_edit(uri, version_range, new_text.clone());
311
312 Some(UnsatisfiableFixAction {
313 version_native: latest.to_string(),
314 new_text,
315 action: CodeAction {
316 title: format!("Fix unsatisfiable requirement: update to {latest}"),
317 kind: Some(CodeActionKind::QUICKFIX),
318 edit: Some(WorkspaceEdit {
319 changes: Some(edits),
320 ..Default::default()
321 }),
322 is_preferred: None,
323 data: Some(serde_json::json!({
327 "diagnostic_codes": [UNSATISFIABLE_DIAGNOSTIC_CODE],
328 "diagnostic_range": version_range,
329 })),
330 ..Default::default()
331 },
332 })
333}
334
335fn build_replacement_action(
351 dep: &dyn Dependency,
352 uri: &Uri,
353 version_range: Range,
354 versions: VersionData<'_>,
355 content: &str,
356 line_offsets: &LineOffsetTable,
357 formatter: &dyn EcosystemFormatter,
358) -> Option<CodeAction> {
359 if !formatter.supports_package_rename() {
360 return None;
361 }
362
363 let normalized_name = formatter.normalize_package_name(dep.name());
364 let replacement = versions
365 .outcomes
366 .and_then(|o| o.deprecation(&normalized_name))
367 .and_then(|dep_info| dep_info.replacement.as_deref())
368 .filter(|r| !r.is_empty())?;
369
370 if formatter.validate_package_name(replacement).is_err() {
371 return None;
372 }
373
374 let name_range = dep.name_range();
375 let name_slice = slice_for_range(content, line_offsets, name_range);
376 if !literal_span_matches(name_slice, dep.name().as_str()) {
382 return None;
383 }
384
385 let edits = single_file_edit(uri, name_range, replacement.to_string());
386
387 Some(CodeAction {
388 title: format!("Replace with {replacement}"),
389 kind: Some(CodeActionKind::QUICKFIX),
390 edit: Some(WorkspaceEdit {
391 changes: Some(edits),
392 ..Default::default()
393 }),
394 is_preferred: None,
395 data: Some(serde_json::json!({
400 "diagnostic_codes": [DEPRECATED_DIAGNOSTIC_CODE],
401 "diagnostic_range": version_range,
402 })),
403 ..Default::default()
404 })
405}
406
407pub async fn generate_code_actions<R: Registry + ?Sized>(
498 parse_result: &dyn ParseResult,
499 position: Position,
500 uri: &Uri,
501 versions: VersionData<'_>,
502 content: &str,
503 registry: &R,
504 formatter: &dyn EcosystemFormatter,
505) -> Vec<CodeAction> {
506 use crate::completion::prepare_version_display_items;
507
508 let deps = parse_result.dependencies();
509 let mut actions = Vec::with_capacity(deps.len().min(5) + 1);
510
511 let Some(dep) = deps
512 .into_iter()
513 .find(|d| formatter.is_position_on_dependency(*d, position))
514 else {
515 return actions;
516 };
517
518 let Some(version_range) = dep.version_range() else {
519 return actions;
520 };
521
522 let Some(version_req) = dep.version_requirement() else {
523 return actions;
524 };
525 if version_req.as_str().is_empty() {
526 return actions;
529 }
530
531 let line_offsets = LineOffsetTable::new(content);
532 let slice = slice_for_range(content, &line_offsets, version_range);
533 let literal_target = dep
534 .version_literal()
535 .unwrap_or_else(|| version_req.as_str());
536 if !literal_span_matches(slice, literal_target) {
537 return actions;
546 }
547
548 let fix = build_vulnerability_fix_action(
551 parse_result,
552 dep,
553 uri,
554 version_range,
555 versions,
556 version_req.as_str(),
557 formatter,
558 );
559 let unsat_fix =
560 build_unsatisfiable_fix_action(dep, uri, version_range, versions, version_req, formatter);
561 let replacement_action = build_replacement_action(
564 dep,
565 uri,
566 version_range,
567 versions,
568 content,
569 &line_offsets,
570 formatter,
571 );
572
573 let dep_source = dep.source();
581 let registry_versions = if formatter.can_resolve_source(&dep_source) {
582 registry
583 .get_versions_from(
584 dep.name(),
585 &dep_source,
586 crate::freshness::FreshnessSettings {
587 enabled: false,
588 ..Default::default()
589 },
590 )
591 .await
592 .ok()
593 } else {
594 None
595 };
596
597 let is_yanked_target = |version_native: &str| {
604 registry_versions.as_ref().is_some_and(|versions_list| {
605 versions_list
606 .iter()
607 .find(|v| v.version_string() == version_native)
608 .is_some_and(|v| v.removal_status().blocks_resolution())
609 })
610 };
611 let fix = fix.filter(|f| !is_yanked_target(&f.version_native));
612 let unsat_fix = unsat_fix.filter(|f| !is_yanked_target(&f.version_native));
613
614 let unsat_fix = unsat_fix.filter(|u| {
620 fix.as_ref()
621 .is_none_or(|f| strip_whitespace(&f.new_text) != strip_whitespace(&u.new_text))
622 });
623
624 let fix_new_text = fix.as_ref().map(|f| strip_whitespace(&f.new_text));
628 let unsat_new_text = unsat_fix.as_ref().map(|f| strip_whitespace(&f.new_text));
629
630 let mut vuln_idx = None;
631 if let Some(fix) = fix {
632 vuln_idx = Some(actions.len());
633 actions.push(fix.action);
634 }
635 let mut unsat_idx = None;
636 if let Some(unsat_fix) = unsat_fix {
637 unsat_idx = Some(actions.len());
638 actions.push(unsat_fix.action);
639 }
640 if let Some(replacement_action) = replacement_action {
641 actions.push(replacement_action);
642 }
643
644 let mut emitted_texts: HashSet<String> = HashSet::new();
659 emitted_texts.insert(strip_whitespace(literal_target));
660 if let Some(fix_text) = fix_new_text {
661 emitted_texts.insert(fix_text);
662 }
663 if let Some(unsat_text) = unsat_new_text {
664 emitted_texts.insert(unsat_text);
665 }
666
667 let mut latest_refactor_idx = None;
668 if let Some(registry_versions) = ®istry_versions {
669 let display_items = prepare_version_display_items(registry_versions, dep.name());
670 for item in display_items {
671 if !is_safe_version_string(item.version.as_str()) {
672 warn_rejected_value(
673 "is_safe_version_string",
674 "update-to-version refactor code action",
675 item.version.as_str(),
676 );
677 continue;
678 }
679 let new_text =
680 formatter.format_version_replacing_for(dep, &item.version, version_req.as_str());
681
682 if !emitted_texts.insert(strip_whitespace(&new_text)) {
683 continue;
684 }
685
686 let edits = single_file_edit(uri, version_range, new_text);
687
688 if item.is_latest {
689 latest_refactor_idx = Some(actions.len());
690 }
691 actions.push(CodeAction {
692 title: item.label,
693 kind: Some(CodeActionKind::REFACTOR),
694 edit: Some(WorkspaceEdit {
695 changes: Some(edits),
696 ..Default::default()
697 }),
698 is_preferred: None,
700 ..Default::default()
701 });
702 }
703 }
704
705 if let Some(i) = vuln_idx.or(unsat_idx).or(latest_refactor_idx) {
711 actions[i].is_preferred = Some(true);
712 }
713
714 actions
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720 use crate::lsp_helpers::test_support::*;
721 use crate::lsp_helpers::*;
722 use crate::{Dependency, PackageName, VersionReq};
723 use std::any::Any;
724 use std::collections::HashMap;
725 use std::sync::Arc;
726
727 #[tokio::test]
728 async fn test_generate_code_actions_combines_advisories_sharing_the_highest_fix() {
729 use crate::osv::{
730 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
731 };
732 use std::collections::HashMap;
733
734 let (dep, version_range, content) = vulnerable_dep("1.0.0");
735 let parse_result = MockParseResult {
736 deps: vec![dep],
737 uri: crate::test_util::test_uri("/test/Cargo.toml"),
738 };
739
740 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
741 vulnerabilities.insert(
742 "pkg".to_string(),
743 ScanOutcome::Vulnerable(DependencyVulnerabilities {
744 advisories: Capped::new(
745 vec![
746 std::sync::Arc::new(Advisory {
747 id: "A1".to_string(),
748 modified: "2023-01-01T00:00:00Z".to_string(),
749 summary: None,
750 aliases: vec![],
751 severity: VulnSeverity::High,
752 cvss_vector: None,
753 fixed_versions: vec!["1.1.0".to_string()],
754 url: String::new(),
755 }),
756 std::sync::Arc::new(Advisory {
757 id: "A2".to_string(),
758 modified: "2023-01-01T00:00:00Z".to_string(),
759 summary: None,
760 aliases: vec![],
761 severity: VulnSeverity::Critical,
762 cvss_vector: None,
763 fixed_versions: vec!["1.2.0".to_string()],
764 url: String::new(),
765 }),
766 ],
767 2,
768 ),
769 fix_target_status: UpgradeStatus::CandidateClean {
770 version: "1.2.0".to_string(),
771 },
772 upgrade_status: UpgradeStatus::NotChecked,
773 }),
774 );
775
776 let cached = HashMap::new();
777 let resolved = HashMap::new();
778 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
779
780 let actions = generate_code_actions(
781 &parse_result,
782 version_range.start,
783 parse_result.uri(),
784 versions,
785 &content,
786 &MockRegistry,
787 &MockFormatter,
788 )
789 .await;
790
791 let titles = quickfix_titles(&actions);
792 assert_eq!(titles, vec!["Update to 1.2.0 (fixes A2 +1 more)"]);
793 assert_eq!(actions[0].kind, Some(CodeActionKind::QUICKFIX));
794 assert_eq!(actions[0].is_preferred, Some(true));
795 assert_eq!(
798 actions[0].data,
799 Some(serde_json::json!({
800 "diagnostic_codes": ["A2", "A1"],
801 "diagnostic_range": version_range,
802 }))
803 );
804 }
805
806 #[tokio::test]
807 async fn test_generate_code_actions_fix_target_is_not_inflated_by_a_subtracted_advisory() {
808 use crate::osv::{
823 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
824 };
825 use std::collections::HashMap;
826
827 let (dep, version_range, content) = vulnerable_dep("1.0.0");
828 let parse_result = MockParseResult {
829 deps: vec![dep],
830 uri: crate::test_util::test_uri("/test/Cargo.toml"),
831 };
832
833 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
834 vulnerabilities.insert(
835 "pkg".to_string(),
836 ScanOutcome::Vulnerable(DependencyVulnerabilities {
837 advisories: Capped::new(
838 vec![
839 std::sync::Arc::new(Advisory {
840 id: "A1".to_string(),
841 modified: "2023-01-01T00:00:00Z".to_string(),
842 summary: None,
843 aliases: vec![],
844 severity: VulnSeverity::High,
845 cvss_vector: None,
846 fixed_versions: vec!["3.0.0".to_string()],
847 url: String::new(),
848 }),
849 std::sync::Arc::new(Advisory {
850 id: "A2".to_string(),
851 modified: "2023-01-01T00:00:00Z".to_string(),
852 summary: None,
853 aliases: vec![],
854 severity: VulnSeverity::Medium,
855 cvss_vector: None,
856 fixed_versions: vec!["1.2.0".to_string()],
857 url: String::new(),
858 }),
859 ],
860 2,
861 ),
862 fix_target_status: UpgradeStatus::CandidateVulnerable {
863 version: "1.2.0".to_string(),
864 advisory_ids: Capped::new(vec!["A1".to_string()], 1),
865 },
866 upgrade_status: UpgradeStatus::CandidateVulnerable {
867 version: "3.0.0".to_string(),
868 advisory_ids: Capped::new(vec!["A1".to_string()], 1),
869 },
870 }),
871 );
872
873 let cached = HashMap::new();
874 let resolved = HashMap::new();
875 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
876
877 let actions = generate_code_actions(
878 &parse_result,
879 version_range.start,
880 parse_result.uri(),
881 versions,
882 &content,
883 &MockRegistry,
884 &MockFormatter,
885 )
886 .await;
887
888 let titles = quickfix_titles(&actions);
889 assert_eq!(titles, vec!["Update to 1.2.0 (fixes A2)"]);
890 }
891
892 #[tokio::test]
893 async fn test_generate_code_actions_fix_target_is_not_suppressed_by_an_open_ended_advisory() {
894 use crate::osv::{
903 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
904 };
905 use std::collections::HashMap;
906
907 let (dep, version_range, content) = vulnerable_dep("1.0.0");
908 let parse_result = MockParseResult {
909 deps: vec![dep],
910 uri: crate::test_util::test_uri("/test/Cargo.toml"),
911 };
912
913 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
914 vulnerabilities.insert(
915 "pkg".to_string(),
916 ScanOutcome::Vulnerable(DependencyVulnerabilities {
917 advisories: Capped::new(
918 vec![
919 std::sync::Arc::new(Advisory {
920 id: "A1".to_string(),
921 modified: "2023-01-01T00:00:00Z".to_string(),
922 summary: None,
923 aliases: vec![],
924 severity: VulnSeverity::High,
925 cvss_vector: None,
926 fixed_versions: vec![],
927 url: String::new(),
928 }),
929 std::sync::Arc::new(Advisory {
930 id: "A2".to_string(),
931 modified: "2023-01-01T00:00:00Z".to_string(),
932 summary: None,
933 aliases: vec![],
934 severity: VulnSeverity::Medium,
935 cvss_vector: None,
936 fixed_versions: vec!["1.2.0".to_string()],
937 url: String::new(),
938 }),
939 ],
940 2,
941 ),
942 fix_target_status: UpgradeStatus::CandidateVulnerable {
943 version: "1.2.0".to_string(),
944 advisory_ids: Capped::new(vec!["A1".to_string()], 1),
945 },
946 upgrade_status: UpgradeStatus::NotChecked,
947 }),
948 );
949
950 let cached = HashMap::new();
951 let resolved = HashMap::new();
952 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
953
954 let actions = generate_code_actions(
955 &parse_result,
956 version_range.start,
957 parse_result.uri(),
958 versions,
959 &content,
960 &MockRegistry,
961 &MockFormatter,
962 )
963 .await;
964
965 let titles = quickfix_titles(&actions);
966 assert_eq!(titles, vec!["Update to 1.2.0 (fixes A2)"]);
967 }
968
969 #[tokio::test]
970 async fn test_generate_code_actions_omits_fix_when_fix_target_status_reports_still_vulnerable()
971 {
972 use crate::osv::{
982 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
983 };
984 use std::collections::HashMap;
985
986 let (dep, version_range, content) = vulnerable_dep("1.0.0");
987 let parse_result = MockParseResult {
988 deps: vec![dep],
989 uri: crate::test_util::test_uri("/test/Cargo.toml"),
990 };
991
992 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
993 vulnerabilities.insert(
994 "pkg".to_string(),
995 ScanOutcome::Vulnerable(DependencyVulnerabilities {
996 advisories: Capped::new(
997 vec![std::sync::Arc::new(Advisory {
998 id: "A1".to_string(),
999 modified: "2023-01-01T00:00:00Z".to_string(),
1000 summary: None,
1001 aliases: vec![],
1002 severity: VulnSeverity::High,
1003 cvss_vector: None,
1004 fixed_versions: vec!["1.2.0".to_string()],
1005 url: String::new(),
1006 })],
1007 1,
1008 ),
1009 fix_target_status: UpgradeStatus::CandidateVulnerable {
1010 version: "1.2.0".to_string(),
1011 advisory_ids: Capped::new(vec!["A2".to_string()], 1),
1012 },
1013 upgrade_status: UpgradeStatus::NotChecked,
1014 }),
1015 );
1016
1017 let cached = HashMap::new();
1018 let resolved = HashMap::new();
1019 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1020
1021 let actions = generate_code_actions(
1022 &parse_result,
1023 version_range.start,
1024 parse_result.uri(),
1025 versions,
1026 &content,
1027 &MockRegistry,
1028 &MockFormatter,
1029 )
1030 .await;
1031
1032 assert!(
1033 quickfix_titles(&actions).is_empty(),
1034 "a fix target OSV found still vulnerable must never be presented as a verified fix"
1035 );
1036 }
1037
1038 #[tokio::test]
1039 async fn test_generate_code_actions_omits_fix_when_fix_target_status_reports_a_claimed_advisory_still_applies()
1040 {
1041 use crate::osv::{
1047 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1048 };
1049 use std::collections::HashMap;
1050
1051 let (dep, version_range, content) = vulnerable_dep("1.0.0");
1052 let parse_result = MockParseResult {
1053 deps: vec![dep],
1054 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1055 };
1056
1057 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1058 vulnerabilities.insert(
1059 "pkg".to_string(),
1060 ScanOutcome::Vulnerable(DependencyVulnerabilities {
1061 advisories: Capped::new(
1062 vec![std::sync::Arc::new(Advisory {
1063 id: "A1".to_string(),
1064 modified: "2023-01-01T00:00:00Z".to_string(),
1065 summary: None,
1066 aliases: vec![],
1067 severity: VulnSeverity::High,
1068 cvss_vector: None,
1069 fixed_versions: vec!["1.2.0".to_string()],
1070 url: String::new(),
1071 })],
1072 1,
1073 ),
1074 fix_target_status: UpgradeStatus::CandidateVulnerable {
1078 version: "1.2.0".to_string(),
1079 advisory_ids: Capped::new(vec!["A1".to_string()], 1),
1080 },
1081 upgrade_status: UpgradeStatus::NotChecked,
1082 }),
1083 );
1084
1085 let cached = HashMap::new();
1086 let resolved = HashMap::new();
1087 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1088
1089 let actions = generate_code_actions(
1090 &parse_result,
1091 version_range.start,
1092 parse_result.uri(),
1093 versions,
1094 &content,
1095 &MockRegistry,
1096 &MockFormatter,
1097 )
1098 .await;
1099
1100 assert!(
1101 quickfix_titles(&actions).is_empty(),
1102 "a fix must never claim to resolve an advisory the live check found it doesn't"
1103 );
1104 }
1105
1106 #[tokio::test]
1107 async fn test_generate_code_actions_omits_fix_when_fix_target_status_advisory_ids_are_truncated()
1108 {
1109 use crate::osv::{
1118 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1119 };
1120 use std::collections::HashMap;
1121
1122 let (dep, version_range, content) = vulnerable_dep("1.0.0");
1123 let parse_result = MockParseResult {
1124 deps: vec![dep],
1125 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1126 };
1127
1128 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1129 vulnerabilities.insert(
1130 "pkg".to_string(),
1131 ScanOutcome::Vulnerable(DependencyVulnerabilities {
1132 advisories: Capped::new(
1133 vec![
1134 std::sync::Arc::new(Advisory {
1135 id: "A1".to_string(),
1136 modified: "2023-01-01T00:00:00Z".to_string(),
1137 summary: None,
1138 aliases: vec![],
1139 severity: VulnSeverity::High,
1140 cvss_vector: None,
1141 fixed_versions: vec!["1.2.0".to_string()],
1142 url: String::new(),
1143 }),
1144 std::sync::Arc::new(Advisory {
1145 id: "A2".to_string(),
1146 modified: "2023-01-01T00:00:00Z".to_string(),
1147 summary: None,
1148 aliases: vec![],
1149 severity: VulnSeverity::Medium,
1150 cvss_vector: None,
1151 fixed_versions: vec![],
1152 url: String::new(),
1153 }),
1154 ],
1155 2,
1156 ),
1157 fix_target_status: UpgradeStatus::CandidateVulnerable {
1162 version: "1.2.0".to_string(),
1163 advisory_ids: Capped::new(vec!["A2".to_string()], 2),
1164 },
1165 upgrade_status: UpgradeStatus::NotChecked,
1166 }),
1167 );
1168
1169 let cached = HashMap::new();
1170 let resolved = HashMap::new();
1171 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1172
1173 let actions = generate_code_actions(
1174 &parse_result,
1175 version_range.start,
1176 parse_result.uri(),
1177 versions,
1178 &content,
1179 &MockRegistry,
1180 &MockFormatter,
1181 )
1182 .await;
1183
1184 assert!(
1185 quickfix_titles(&actions).is_empty(),
1186 "a truncated advisory_ids list must never be trusted as exhaustive"
1187 );
1188 }
1189
1190 #[tokio::test]
1191 async fn test_generate_code_actions_omits_fix_when_fix_target_status_is_unresolved() {
1192 use crate::osv::{
1196 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1197 };
1198 use std::collections::HashMap;
1199
1200 let (dep, version_range, content) = vulnerable_dep("1.0.0");
1201 let parse_result = MockParseResult {
1202 deps: vec![dep],
1203 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1204 };
1205
1206 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1207 vulnerabilities.insert(
1208 "pkg".to_string(),
1209 ScanOutcome::Vulnerable(DependencyVulnerabilities {
1210 advisories: Capped::new(
1211 vec![std::sync::Arc::new(Advisory {
1212 id: "A1".to_string(),
1213 modified: "2023-01-01T00:00:00Z".to_string(),
1214 summary: None,
1215 aliases: vec![],
1216 severity: VulnSeverity::High,
1217 cvss_vector: None,
1218 fixed_versions: vec!["1.2.0".to_string()],
1219 url: String::new(),
1220 })],
1221 1,
1222 ),
1223 fix_target_status: UpgradeStatus::NotChecked,
1224 upgrade_status: UpgradeStatus::NotChecked,
1225 }),
1226 );
1227
1228 let cached = HashMap::new();
1229 let resolved = HashMap::new();
1230 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1231
1232 let actions = generate_code_actions(
1233 &parse_result,
1234 version_range.start,
1235 parse_result.uri(),
1236 versions,
1237 &content,
1238 &MockRegistry,
1239 &MockFormatter,
1240 )
1241 .await;
1242
1243 assert!(
1244 quickfix_titles(&actions).is_empty(),
1245 "an unverified fix target must never be presented as a fix, timed out or not yet checked"
1246 );
1247 }
1248
1249 #[tokio::test]
1250 async fn test_generate_code_actions_drops_yanked_fix_target() {
1251 use crate::osv::{
1252 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1253 };
1254 use std::collections::HashMap;
1255
1256 let (dep, version_range, content) = vulnerable_dep("1.0.0");
1257 let parse_result = MockParseResult {
1258 deps: vec![dep],
1259 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1260 };
1261
1262 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1263 vulnerabilities.insert(
1264 "pkg".to_string(),
1265 ScanOutcome::Vulnerable(DependencyVulnerabilities {
1266 advisories: Capped::new(
1267 vec![std::sync::Arc::new(Advisory {
1268 id: "A1".to_string(),
1269 modified: "2023-01-01T00:00:00Z".to_string(),
1270 summary: None,
1271 aliases: vec![],
1272 severity: VulnSeverity::High,
1273 cvss_vector: None,
1274 fixed_versions: vec!["2.0.0".to_string()],
1275 url: String::new(),
1276 })],
1277 1,
1278 ),
1279 fix_target_status: UpgradeStatus::CandidateClean {
1280 version: "2.0.0".to_string(),
1281 },
1282 upgrade_status: UpgradeStatus::NotChecked,
1283 }),
1284 );
1285
1286 let cached = HashMap::new();
1287 let resolved = HashMap::new();
1288 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1289 let registry = FixedVersionRegistry {
1290 versions: vec![("2.0.0", true), ("1.5.0", false)],
1291 };
1292
1293 let actions = generate_code_actions(
1294 &parse_result,
1295 version_range.start,
1296 parse_result.uri(),
1297 versions,
1298 &content,
1299 ®istry,
1300 &MockFormatter,
1301 )
1302 .await;
1303
1304 assert!(quickfix_titles(&actions).is_empty());
1305 assert!(
1306 actions
1307 .iter()
1308 .any(|a| a.kind == Some(CodeActionKind::REFACTOR))
1309 );
1310 }
1311
1312 #[tokio::test]
1313 async fn test_generate_code_actions_no_op_edit_is_skipped() {
1314 use crate::osv::{
1315 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1316 };
1317 use std::collections::HashMap;
1318
1319 let (dep, version_range, content) = vulnerable_dep("1.2.0");
1321 let parse_result = MockParseResult {
1322 deps: vec![dep],
1323 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1324 };
1325
1326 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1327 vulnerabilities.insert(
1328 "pkg".to_string(),
1329 ScanOutcome::Vulnerable(DependencyVulnerabilities {
1330 advisories: Capped::new(
1331 vec![std::sync::Arc::new(Advisory {
1332 id: "A1".to_string(),
1333 modified: "2023-01-01T00:00:00Z".to_string(),
1334 summary: None,
1335 aliases: vec![],
1336 severity: VulnSeverity::High,
1337 cvss_vector: None,
1338 fixed_versions: vec!["1.2.0".to_string()],
1339 url: String::new(),
1340 })],
1341 1,
1342 ),
1343 fix_target_status: UpgradeStatus::NotChecked,
1344 upgrade_status: UpgradeStatus::NotChecked,
1345 }),
1346 );
1347
1348 let cached = HashMap::new();
1349 let resolved = HashMap::new();
1350 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1351
1352 let actions = generate_code_actions(
1353 &parse_result,
1354 version_range.start,
1355 parse_result.uri(),
1356 versions,
1357 &content,
1358 &MockRegistry,
1359 &IdentityFormatter,
1360 )
1361 .await;
1362
1363 assert!(quickfix_titles(&actions).is_empty());
1364 }
1365
1366 #[tokio::test]
1367 async fn test_generate_code_actions_no_op_guard_compares_formatted_text_not_bare_version() {
1368 use crate::osv::{
1375 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1376 };
1377 use std::collections::HashMap;
1378
1379 let (dep, version_range, content) = vulnerable_dep("^1.2.0");
1380 let parse_result = MockParseResult {
1381 deps: vec![dep],
1382 uri: crate::test_util::test_uri("/test/pubspec.yaml"),
1383 };
1384
1385 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1386 vulnerabilities.insert(
1387 "pkg".to_string(),
1388 ScanOutcome::Vulnerable(DependencyVulnerabilities {
1389 advisories: Capped::new(
1390 vec![std::sync::Arc::new(Advisory {
1391 id: "A1".to_string(),
1392 modified: "2023-01-01T00:00:00Z".to_string(),
1393 summary: None,
1394 aliases: vec![],
1395 severity: VulnSeverity::High,
1396 cvss_vector: None,
1397 fixed_versions: vec!["1.2.0".to_string()],
1398 url: String::new(),
1399 })],
1400 1,
1401 ),
1402 fix_target_status: UpgradeStatus::NotChecked,
1403 upgrade_status: UpgradeStatus::NotChecked,
1404 }),
1405 );
1406
1407 let cached = HashMap::new();
1408 let resolved = HashMap::new();
1409 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1410
1411 let actions = generate_code_actions(
1412 &parse_result,
1413 version_range.start,
1414 parse_result.uri(),
1415 versions,
1416 &content,
1417 &MockRegistry,
1418 &CaretWrappingFormatter,
1419 )
1420 .await;
1421
1422 assert!(quickfix_titles(&actions).is_empty());
1423 }
1424
1425 #[tokio::test]
1426 async fn test_generate_code_actions_refactor_loop_skips_no_op_entry_but_keeps_real_update() {
1427 let (dep, version_range, content) = vulnerable_dep("1.2.0");
1435 let parse_result = MockParseResult {
1436 deps: vec![dep],
1437 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1438 };
1439
1440 let cached = HashMap::new();
1441 let resolved = HashMap::new();
1442 let versions = VersionData::new(&cached, &resolved);
1443 let registry = FixedVersionRegistry {
1444 versions: vec![("1.2.0", false), ("1.1.0", false)],
1445 };
1446
1447 let actions = generate_code_actions(
1448 &parse_result,
1449 version_range.start,
1450 parse_result.uri(),
1451 versions,
1452 &content,
1453 ®istry,
1454 &IdentityFormatter,
1455 )
1456 .await;
1457
1458 let titles = refactor_titles(&actions);
1459 assert!(
1460 !titles.iter().any(|t| t.starts_with("1.2.0")),
1461 "the already-declared version must not be offered as an update: {titles:?}"
1462 );
1463 assert!(
1464 titles.contains(&"1.1.0"),
1465 "a genuinely different version must still be offered: {titles:?}"
1466 );
1467 }
1468
1469 #[tokio::test]
1470 async fn test_generate_code_actions_refactor_loop_no_op_guard_ignores_whitespace() {
1471 let (dep, version_range, content) = vulnerable_dep("1.2.0");
1476 let parse_result = MockParseResult {
1477 deps: vec![dep],
1478 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1479 };
1480
1481 let cached = HashMap::new();
1482 let resolved = HashMap::new();
1483 let versions = VersionData::new(&cached, &resolved);
1484 let registry = FixedVersionRegistry {
1485 versions: vec![("1.2.0", false)],
1486 };
1487
1488 let actions = generate_code_actions(
1489 &parse_result,
1490 version_range.start,
1491 parse_result.uri(),
1492 versions,
1493 &content,
1494 ®istry,
1495 &TrailingSpaceFormatter,
1496 )
1497 .await;
1498
1499 assert!(
1500 refactor_titles(&actions).is_empty(),
1501 "a whitespace-only edit-text divergence must still be skipped as a no-op"
1502 );
1503 }
1504
1505 #[tokio::test]
1506 async fn test_generate_code_actions_refactor_loop_skips_unsafe_version_string() {
1507 let (dep, version_range, content) = vulnerable_dep("1.0.0");
1511 let parse_result = MockParseResult {
1512 deps: vec![dep],
1513 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1514 };
1515
1516 let cached = HashMap::new();
1517 let resolved = HashMap::new();
1518 let versions = VersionData::new(&cached, &resolved);
1519 let registry = FixedVersionRegistry {
1520 versions: vec![("1.1.0\", \"evil\": \"true", false), ("1.1.0", false)],
1521 };
1522
1523 let actions = generate_code_actions(
1524 &parse_result,
1525 version_range.start,
1526 parse_result.uri(),
1527 versions,
1528 &content,
1529 ®istry,
1530 &IdentityFormatter,
1531 )
1532 .await;
1533
1534 let titles = refactor_titles(&actions);
1535 assert!(
1536 !titles.iter().any(|t| t.contains("evil")),
1537 "an unsafe version string must never be offered as an update: {titles:?}"
1538 );
1539 assert!(
1540 titles.contains(&"1.1.0"),
1541 "a safe version must still be offered: {titles:?}"
1542 );
1543 }
1544
1545 #[tokio::test]
1546 async fn test_build_vulnerability_fix_action_skips_unsafe_fix_version() {
1547 use crate::osv::{
1551 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1552 };
1553 use std::collections::HashMap;
1554
1555 let (dep, version_range, content) = vulnerable_dep("1.0.0");
1556 let parse_result = MockParseResult {
1557 deps: vec![dep],
1558 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1559 };
1560
1561 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1562 vulnerabilities.insert(
1563 "pkg".to_string(),
1564 ScanOutcome::Vulnerable(DependencyVulnerabilities {
1565 advisories: Capped::new(
1566 vec![std::sync::Arc::new(Advisory {
1567 id: "A1".to_string(),
1568 modified: "2023-01-01T00:00:00Z".to_string(),
1569 summary: None,
1570 aliases: vec![],
1571 severity: VulnSeverity::High,
1572 cvss_vector: None,
1573 fixed_versions: vec!["1.2.0\", \"evil\": \"true".to_string()],
1574 url: String::new(),
1575 })],
1576 1,
1577 ),
1578 fix_target_status: UpgradeStatus::NotChecked,
1579 upgrade_status: UpgradeStatus::NotChecked,
1580 }),
1581 );
1582
1583 let cached = HashMap::new();
1584 let resolved = HashMap::new();
1585 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1586
1587 let actions = generate_code_actions(
1588 &parse_result,
1589 version_range.start,
1590 parse_result.uri(),
1591 versions,
1592 &content,
1593 &MockRegistry,
1594 &IdentityFormatter,
1595 )
1596 .await;
1597
1598 assert!(
1599 quickfix_titles(&actions).is_empty(),
1600 "an unsafe fix version must never produce a vulnerability-fix quickfix"
1601 );
1602 }
1603
1604 #[tokio::test]
1605 async fn test_generate_code_actions_vulnerability_fix_not_offered_on_patched_duplicate_occurrence()
1606 {
1607 use crate::osv::{
1616 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1617 };
1618 use tower_lsp_server::ls_types::{Position, Range};
1619
1620 let vulnerable_line = "log4j-core = \"=2.14.1\"";
1621 let patched_line = "log4j-core = \"=2.17.1\"";
1622 let content = format!("{vulnerable_line}\n{patched_line}\n");
1623
1624 let name_start = 0u32;
1625 let name_end = "log4j-core".len() as u32;
1626 let version_start = "log4j-core = \"".len() as u32;
1627
1628 let vulnerable_dep = MockDep {
1629 name: pkg("log4j-core"),
1630 version_req: VersionReq::new("=2.14.1"),
1631 version_range: Range::new(
1632 Position::new(0, version_start),
1633 Position::new(0, version_start + "=2.14.1".len() as u32),
1634 ),
1635 name_range: Range::new(Position::new(0, name_start), Position::new(0, name_end)),
1636 };
1637 let patched_dep = MockDep {
1638 name: pkg("log4j-core"),
1639 version_req: VersionReq::new("=2.17.1"),
1640 version_range: Range::new(
1641 Position::new(1, version_start),
1642 Position::new(1, version_start + "=2.17.1".len() as u32),
1643 ),
1644 name_range: Range::new(Position::new(1, name_start), Position::new(1, name_end)),
1645 };
1646 let parse_result = MockParseResult {
1647 deps: vec![vulnerable_dep, patched_dep],
1648 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1649 };
1650
1651 let cached = HashMap::new();
1652 let resolved = HashMap::new();
1653
1654 let keys = crate::osv::vulnerability_keys(
1655 &parse_result,
1656 &resolved,
1657 &IdentityFormatter,
1658 crate::EcosystemId::Cargo,
1659 );
1660 let deps = parse_result.dependencies();
1661 let vulnerable_key = keys.get(&deps[0].name_range()).unwrap().clone();
1662 let patched_key = keys.get(&deps[1].name_range()).unwrap().clone();
1663 assert_ne!(vulnerable_key, patched_key);
1664
1665 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1666 vulnerabilities.insert(
1667 vulnerable_key,
1668 ScanOutcome::Vulnerable(DependencyVulnerabilities {
1669 advisories: Capped::new(
1670 vec![std::sync::Arc::new(Advisory {
1671 id: "GHSA-log4j".to_string(),
1672 modified: "2023-01-01T00:00:00Z".to_string(),
1673 summary: None,
1674 aliases: vec![],
1675 severity: VulnSeverity::Critical,
1676 cvss_vector: None,
1677 fixed_versions: vec!["2.17.1".to_string()],
1678 url: String::new(),
1679 })],
1680 1,
1681 ),
1682 fix_target_status: UpgradeStatus::CandidateClean {
1683 version: "2.17.1".to_string(),
1684 },
1685 upgrade_status: UpgradeStatus::NotChecked,
1686 }),
1687 );
1688 vulnerabilities.insert(patched_key, ScanOutcome::Clean);
1689
1690 let versions = VersionData::new(&cached, &resolved)
1691 .with_vulnerabilities(&vulnerabilities)
1692 .with_ecosystem(crate::EcosystemId::Cargo);
1693
1694 let actions_on_vulnerable = generate_code_actions(
1695 &parse_result,
1696 Position::new(0, version_start),
1697 parse_result.uri(),
1698 versions,
1699 &content,
1700 &MockRegistry,
1701 &IdentityFormatter,
1702 )
1703 .await;
1704 assert!(
1705 !quickfix_titles(&actions_on_vulnerable).is_empty(),
1706 "the vulnerable occurrence must get a fix quickfix"
1707 );
1708
1709 let actions_on_patched = generate_code_actions(
1710 &parse_result,
1711 Position::new(1, version_start),
1712 parse_result.uri(),
1713 versions,
1714 &content,
1715 &MockRegistry,
1716 &IdentityFormatter,
1717 )
1718 .await;
1719 assert!(
1720 quickfix_titles(&actions_on_patched).is_empty(),
1721 "the already-patched occurrence must NOT get a fix quickfix, \
1722 even though it shares a name with the vulnerable one: {:?}",
1723 quickfix_titles(&actions_on_patched)
1724 );
1725 }
1726
1727 #[tokio::test]
1728 async fn test_generate_code_actions_refactor_loop_dedups_item_matching_fix_text_by_different_raw_version()
1729 {
1730 use crate::osv::{
1737 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1738 };
1739 use std::collections::HashMap;
1740
1741 let (dep, version_range, content) = vulnerable_dep("==1.0.0");
1742 let parse_result = MockParseResult {
1743 deps: vec![dep],
1744 uri: crate::test_util::test_uri("/test/requirements.txt"),
1745 };
1746
1747 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1748 vulnerabilities.insert(
1749 "pkg".to_string(),
1750 ScanOutcome::Vulnerable(DependencyVulnerabilities {
1751 advisories: Capped::new(
1752 vec![std::sync::Arc::new(Advisory {
1753 id: "A1".to_string(),
1754 modified: "2023-01-01T00:00:00Z".to_string(),
1755 summary: None,
1756 aliases: vec![],
1757 severity: VulnSeverity::High,
1758 cvss_vector: None,
1759 fixed_versions: vec!["1.2.5".to_string()],
1760 url: String::new(),
1761 })],
1762 1,
1763 ),
1764 fix_target_status: UpgradeStatus::CandidateClean {
1765 version: "1.2.5".to_string(),
1766 },
1767 upgrade_status: UpgradeStatus::NotChecked,
1768 }),
1769 );
1770
1771 let cached = HashMap::new();
1772 let resolved = HashMap::new();
1773 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1774 let registry = FixedVersionRegistry {
1775 versions: vec![("1.2.9", false), ("1.1.0", false)],
1776 };
1777
1778 let actions = generate_code_actions(
1779 &parse_result,
1780 version_range.start,
1781 parse_result.uri(),
1782 versions,
1783 &content,
1784 ®istry,
1785 &TruncatingFormatter,
1786 )
1787 .await;
1788
1789 assert_eq!(quickfix_titles(&actions).len(), 1);
1790
1791 let titles = refactor_titles(&actions);
1792 assert!(
1793 !titles.iter().any(|t| t.starts_with("1.2.9")),
1794 "an item whose formatted text matches the fix action's text must be \
1795 skipped even though its raw version differs from the fix's: {titles:?}"
1796 );
1797 assert!(titles.iter().any(|t| t.starts_with("1.1.0")));
1798
1799 for action in actions
1800 .iter()
1801 .filter(|a| a.kind == Some(CodeActionKind::REFACTOR))
1802 {
1803 let edit_text = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()
1804 [parse_result.uri()][0]
1805 .new_text;
1806 for other in actions.iter() {
1807 if std::ptr::eq(action, other) {
1808 continue;
1809 }
1810 let other_text = &other.edit.as_ref().unwrap().changes.as_ref().unwrap()
1811 [parse_result.uri()][0]
1812 .new_text;
1813 assert_ne!(
1814 edit_text, other_text,
1815 "no two actions may carry a byte-identical edit"
1816 );
1817 }
1818 }
1819 }
1820
1821 #[tokio::test]
1822 async fn test_generate_code_actions_refactor_loop_dedups_item_matching_another_items_text() {
1823 let (dep, version_range, content) = vulnerable_dep("==1.0.*");
1829 let parse_result = MockParseResult {
1830 deps: vec![dep],
1831 uri: crate::test_util::test_uri("/test/requirements.txt"),
1832 };
1833
1834 let cached = HashMap::new();
1835 let resolved = HashMap::new();
1836 let versions = VersionData::new(&cached, &resolved);
1837 let registry = FixedVersionRegistry {
1838 versions: vec![("1.1.9", false), ("1.1.5", false), ("1.1.0", false)],
1839 };
1840
1841 let actions = generate_code_actions(
1842 &parse_result,
1843 version_range.start,
1844 parse_result.uri(),
1845 versions,
1846 &content,
1847 ®istry,
1848 &TruncatingFormatter,
1849 )
1850 .await;
1851
1852 assert!(quickfix_titles(&actions).is_empty());
1853
1854 let titles = refactor_titles(&actions);
1855 assert_eq!(
1856 titles,
1857 vec!["1.1.9 (latest)"],
1858 "identical-text items after the first must be deduped: {titles:?}"
1859 );
1860 }
1861
1862 #[tokio::test]
1863 async fn test_generate_code_actions_lockfile_hit_gets_title_suffix() {
1864 use crate::osv::{
1865 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1866 };
1867 use std::collections::HashMap;
1868
1869 let (dep, version_range, content) = vulnerable_dep("^1.0");
1870 let parse_result = MockParseResult {
1871 deps: vec![dep],
1872 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1873 };
1874
1875 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1876 vulnerabilities.insert(
1877 "pkg".to_string(),
1878 ScanOutcome::Vulnerable(DependencyVulnerabilities {
1879 advisories: Capped::new(
1880 vec![std::sync::Arc::new(Advisory {
1881 id: "A1".to_string(),
1882 modified: "2023-01-01T00:00:00Z".to_string(),
1883 summary: None,
1884 aliases: vec![],
1885 severity: VulnSeverity::High,
1886 cvss_vector: None,
1887 fixed_versions: vec!["1.0.2".to_string()],
1888 url: String::new(),
1889 })],
1890 1,
1891 ),
1892 fix_target_status: UpgradeStatus::CandidateClean {
1893 version: "1.0.2".to_string(),
1894 },
1895 upgrade_status: UpgradeStatus::NotChecked,
1896 }),
1897 );
1898
1899 let cached = HashMap::new();
1900 let mut resolved = HashMap::new();
1901 resolved.insert(pkg("pkg"), "1.0.1".into());
1902 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1903
1904 let actions = generate_code_actions(
1905 &parse_result,
1906 version_range.start,
1907 parse_result.uri(),
1908 versions,
1909 &content,
1910 &MockRegistry,
1911 &MockFormatter,
1912 )
1913 .await;
1914
1915 let titles = quickfix_titles(&actions);
1916 assert_eq!(
1917 titles,
1918 vec!["Update to 1.0.2 (fixes A1; update lockfile to apply)"]
1919 );
1920 }
1921
1922 #[tokio::test]
1923 async fn test_generate_code_actions_fix_action_survives_registry_error() {
1924 use crate::osv::{
1929 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1930 };
1931 use std::collections::HashMap;
1932
1933 let (dep, version_range, content) = vulnerable_dep("1.0.0");
1934 let parse_result = MockParseResult {
1935 deps: vec![dep],
1936 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1937 };
1938
1939 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1940 vulnerabilities.insert(
1941 "pkg".to_string(),
1942 ScanOutcome::Vulnerable(DependencyVulnerabilities {
1943 advisories: Capped::new(
1944 vec![std::sync::Arc::new(Advisory {
1945 id: "A1".to_string(),
1946 modified: "2023-01-01T00:00:00Z".to_string(),
1947 summary: None,
1948 aliases: vec![],
1949 severity: VulnSeverity::High,
1950 cvss_vector: None,
1951 fixed_versions: vec!["1.2.0".to_string()],
1952 url: String::new(),
1953 })],
1954 1,
1955 ),
1956 fix_target_status: UpgradeStatus::CandidateClean {
1957 version: "1.2.0".to_string(),
1958 },
1959 upgrade_status: UpgradeStatus::NotChecked,
1960 }),
1961 );
1962
1963 let cached = HashMap::new();
1964 let resolved = HashMap::new();
1965 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1966
1967 let actions = generate_code_actions(
1968 &parse_result,
1969 version_range.start,
1970 parse_result.uri(),
1971 versions,
1972 &content,
1973 &ErrorRegistry,
1974 &MockFormatter,
1975 )
1976 .await;
1977
1978 let titles = quickfix_titles(&actions);
1979 assert_eq!(titles, vec!["Update to 1.2.0 (fixes A1)"]);
1980 assert_eq!(actions.len(), 1);
1983 assert_eq!(actions[0].is_preferred, Some(true));
1986 }
1987
1988 #[tokio::test]
1989 async fn test_generate_code_actions_coexistence_dedups_fix_version_and_demotes_preferred() {
1990 use crate::osv::{
1996 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1997 };
1998 use std::collections::HashMap;
1999
2000 let (dep, version_range, content) = vulnerable_dep("1.0.0");
2001 let parse_result = MockParseResult {
2002 deps: vec![dep],
2003 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2004 };
2005
2006 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
2007 vulnerabilities.insert(
2008 "pkg".to_string(),
2009 ScanOutcome::Vulnerable(DependencyVulnerabilities {
2010 advisories: Capped::new(
2011 vec![std::sync::Arc::new(Advisory {
2012 id: "A1".to_string(),
2013 modified: "2023-01-01T00:00:00Z".to_string(),
2014 summary: None,
2015 aliases: vec![],
2016 severity: VulnSeverity::High,
2017 cvss_vector: None,
2018 fixed_versions: vec!["1.2.0".to_string()],
2019 url: String::new(),
2020 })],
2021 1,
2022 ),
2023 fix_target_status: UpgradeStatus::CandidateClean {
2024 version: "1.2.0".to_string(),
2025 },
2026 upgrade_status: UpgradeStatus::NotChecked,
2027 }),
2028 );
2029
2030 let cached = HashMap::new();
2031 let resolved = HashMap::new();
2032 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
2033 let registry = FixedVersionRegistry {
2036 versions: vec![("1.2.0", false), ("1.1.0", false), ("1.0.0", false)],
2037 };
2038
2039 let actions = generate_code_actions(
2040 &parse_result,
2041 version_range.start,
2042 parse_result.uri(),
2043 versions,
2044 &content,
2045 ®istry,
2046 &MockFormatter,
2047 )
2048 .await;
2049
2050 assert_eq!(quickfix_titles(&actions).len(), 1);
2051
2052 let refactor_titles: Vec<&str> = actions
2053 .iter()
2054 .filter(|a| a.kind == Some(CodeActionKind::REFACTOR))
2055 .map(|a| a.title.as_str())
2056 .collect();
2057 assert!(
2058 !refactor_titles.iter().any(|t| t.starts_with("1.2.0")),
2059 "the display item duplicating the fix's own target must be skipped: {refactor_titles:?}"
2060 );
2061 assert!(refactor_titles.iter().any(|t| t.starts_with("1.1.0")));
2062
2063 assert!(
2064 actions
2065 .iter()
2066 .filter(|a| a.kind == Some(CodeActionKind::REFACTOR))
2067 .all(|a| a.is_preferred.is_none()),
2068 "only the fix action may be preferred once it exists"
2069 );
2070 }
2071
2072 #[tokio::test]
2073 async fn test_generate_code_actions_latest_refactor_is_preferred_when_no_fix_exists() {
2074 let (dep, version_range, content) = vulnerable_dep("1.0.0");
2084 let parse_result = MockParseResult {
2085 deps: vec![dep],
2086 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2087 };
2088
2089 let cached = HashMap::new();
2090 let resolved = HashMap::new();
2091 let versions = VersionData::new(&cached, &resolved);
2092 let registry = FixedVersionRegistry {
2093 versions: vec![("2.0.0", false), ("1.5.0", false)],
2094 };
2095
2096 let actions = generate_code_actions(
2097 &parse_result,
2098 version_range.start,
2099 parse_result.uri(),
2100 versions,
2101 &content,
2102 ®istry,
2103 &MockFormatter,
2104 )
2105 .await;
2106
2107 assert!(
2108 quickfix_titles(&actions).is_empty(),
2109 "no vuln/unsat fix should exist: {actions:?}"
2110 );
2111 let refactor_titles = refactor_titles(&actions);
2112 assert_eq!(refactor_titles.len(), 2, "{refactor_titles:?}");
2113
2114 let preferred: Vec<&str> = actions
2115 .iter()
2116 .filter(|a| a.is_preferred == Some(true))
2117 .map(|a| a.title.as_str())
2118 .collect();
2119 assert_eq!(
2120 preferred.len(),
2121 1,
2122 "exactly one action must be preferred: {actions:?}"
2123 );
2124 assert!(
2125 preferred[0].starts_with("2.0.0"),
2126 "the newest (item.is_latest) REFACTOR action must be preferred: {preferred:?}"
2127 );
2128 assert!(
2129 actions
2130 .iter()
2131 .filter(|a| !a.title.starts_with("2.0.0"))
2132 .all(|a| a.is_preferred.is_none()),
2133 "every other action must be None, not Some(false): {actions:?}"
2134 );
2135 }
2136
2137 #[tokio::test]
2138 async fn test_generate_code_actions_fix_uses_ecosystem_format_version_replacing_override() {
2139 use crate::osv::{
2147 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
2148 };
2149 use std::collections::HashMap;
2150
2151 let (dep, version_range, content) = vulnerable_dep("==1.0.0");
2152 let uri = crate::test_util::test_uri("/test/requirements.txt");
2153 let parse_result = MockParseResult {
2154 deps: vec![dep],
2155 uri: uri.clone(),
2156 };
2157
2158 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
2159 vulnerabilities.insert(
2160 "pkg".to_string(),
2161 ScanOutcome::Vulnerable(DependencyVulnerabilities {
2162 advisories: Capped::new(
2163 vec![std::sync::Arc::new(Advisory {
2164 id: "A1".to_string(),
2165 modified: "2023-01-01T00:00:00Z".to_string(),
2166 summary: None,
2167 aliases: vec![],
2168 severity: VulnSeverity::High,
2169 cvss_vector: None,
2170 fixed_versions: vec!["1.0.2".to_string()],
2171 url: String::new(),
2172 })],
2173 1,
2174 ),
2175 fix_target_status: UpgradeStatus::CandidateClean {
2176 version: "1.0.2".to_string(),
2177 },
2178 upgrade_status: UpgradeStatus::NotChecked,
2179 }),
2180 );
2181
2182 let cached = HashMap::new();
2183 let resolved = HashMap::new();
2184 let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
2185
2186 let actions = generate_code_actions(
2187 &parse_result,
2188 version_range.start,
2189 parse_result.uri(),
2190 versions,
2191 &content,
2192 &MockRegistry,
2193 &PinPreservingFormatter,
2194 )
2195 .await;
2196
2197 let quickfix = actions
2198 .iter()
2199 .find(|a| a.kind == Some(CodeActionKind::QUICKFIX))
2200 .expect("a vulnerability-fix quickfix should be offered");
2201 let new_text = quickfix
2202 .edit
2203 .as_ref()
2204 .and_then(|e| e.changes.as_ref())
2205 .and_then(|c| c.get(&uri))
2206 .and_then(|edits| edits.first())
2207 .map(|e| e.new_text.as_str())
2208 .expect("quickfix should carry a TextEdit for the document uri");
2209
2210 assert_eq!(
2211 new_text, "==1.0.2",
2212 "the fix action's TextEdit must go through format_version_replacing's \
2213 pin-preserving override, not the default format_version_for_text_edit delegation"
2214 );
2215 }
2216
2217 mod code_actions_guard_tests {
2222 use super::*;
2223 use std::collections::HashMap;
2224 use tower_lsp_server::ls_types::{Position, Range};
2225
2226 struct CaDep {
2227 name: PackageName,
2228 version_req: Option<VersionReq>,
2229 version_range: Option<Range>,
2230 }
2231
2232 impl Dependency for CaDep {
2233 fn name(&self) -> &PackageName {
2234 &self.name
2235 }
2236 fn name_range(&self) -> Range {
2237 Range::default()
2238 }
2239 fn version_requirement(&self) -> Option<&VersionReq> {
2240 self.version_req.as_ref()
2241 }
2242 fn version_range(&self) -> Option<Range> {
2243 self.version_range
2244 }
2245 fn source(&self) -> crate::parser::DependencySource {
2246 crate::parser::DependencySource::Registry
2247 }
2248 fn as_any(&self) -> &dyn Any {
2249 self
2250 }
2251 }
2252
2253 struct CaParseResult {
2254 deps: Vec<CaDep>,
2255 uri: Uri,
2256 }
2257
2258 impl ParseResult for CaParseResult {
2259 fn dependencies(&self) -> Vec<&dyn Dependency> {
2260 self.deps.iter().map(|d| d as &dyn Dependency).collect()
2261 }
2262 fn workspace_root(&self) -> Option<&std::path::Path> {
2263 None
2264 }
2265 fn uri(&self) -> &Uri {
2266 &self.uri
2267 }
2268 fn as_any(&self) -> &dyn Any {
2269 self
2270 }
2271 }
2272
2273 struct CaLiteralDep {
2279 name: PackageName,
2280 version_req: Option<VersionReq>,
2281 version_range: Option<Range>,
2282 version_literal: Option<String>,
2283 }
2284
2285 impl Dependency for CaLiteralDep {
2286 fn name(&self) -> &PackageName {
2287 &self.name
2288 }
2289 fn name_range(&self) -> Range {
2290 Range::default()
2291 }
2292 fn version_requirement(&self) -> Option<&VersionReq> {
2293 self.version_req.as_ref()
2294 }
2295 fn version_range(&self) -> Option<Range> {
2296 self.version_range
2297 }
2298 fn source(&self) -> crate::parser::DependencySource {
2299 crate::parser::DependencySource::Registry
2300 }
2301 fn version_literal(&self) -> Option<&str> {
2302 self.version_literal.as_deref()
2303 }
2304 fn as_any(&self) -> &dyn Any {
2305 self
2306 }
2307 }
2308
2309 struct CaLiteralParseResult {
2310 deps: Vec<CaLiteralDep>,
2311 uri: Uri,
2312 }
2313
2314 impl ParseResult for CaLiteralParseResult {
2315 fn dependencies(&self) -> Vec<&dyn Dependency> {
2316 self.deps.iter().map(|d| d as &dyn Dependency).collect()
2317 }
2318 fn workspace_root(&self) -> Option<&std::path::Path> {
2319 None
2320 }
2321 fn uri(&self) -> &Uri {
2322 &self.uri
2323 }
2324 fn as_any(&self) -> &dyn Any {
2325 self
2326 }
2327 }
2328
2329 struct CaVersion {
2330 version: ConcreteVersion,
2331 yanked: bool,
2332 }
2333
2334 crate::impl_version!(CaVersion {
2335 version: version,
2336 status: |v: &CaVersion| crate::RemovalStatus::from_yanked(v.yanked),
2337 });
2338
2339 struct CaRegistry;
2340
2341 impl crate::Registry for CaRegistry {
2342 fn get_versions<'a>(
2343 &'a self,
2344 _name: &'a PackageName,
2345 ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Vec<Box<dyn crate::Version>>>>
2346 {
2347 Box::pin(async move {
2348 Ok(vec![Box::new(CaVersion {
2349 version: "2.0.0".into(),
2350 yanked: false,
2351 }) as Box<dyn crate::Version>])
2352 })
2353 }
2354
2355 fn get_latest_matching<'a>(
2356 &'a self,
2357 _name: &'a PackageName,
2358 _req: &'a VersionReq,
2359 ) -> crate::ecosystem::BoxFuture<
2360 'a,
2361 crate::error::Result<Option<Box<dyn crate::Version>>>,
2362 > {
2363 Box::pin(async move { Ok(None) })
2364 }
2365
2366 fn search<'a>(
2367 &'a self,
2368 _query: &'a str,
2369 _limit: usize,
2370 ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Vec<Box<dyn crate::Metadata>>>>
2371 {
2372 Box::pin(async move { Ok(Vec::new()) })
2373 }
2374
2375 fn as_any(&self) -> &dyn Any {
2376 self
2377 }
2378 }
2379
2380 fn range(sl: u32, sc: u32, el: u32, ec: u32) -> Range {
2381 Range::new(Position::new(sl, sc), Position::new(el, ec))
2382 }
2383
2384 #[tokio::test]
2385 async fn test_guard_rejects_span_that_does_not_match_requirement() {
2386 let content = "1.0.0 extra";
2390 let dep = CaDep {
2391 name: pkg("serde"),
2392 version_req: Some(VersionReq::new("1.0.0")),
2393 version_range: Some(range(0, 6, 0, 11)), };
2395 let pr = CaParseResult {
2396 deps: vec![dep],
2397 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2398 };
2399 let position = Position::new(0, 7);
2404 let cached = HashMap::new();
2405 let resolved = HashMap::new();
2406 let versions = VersionData::new(&cached, &resolved);
2407
2408 let actions = generate_code_actions(
2409 &pr,
2410 position,
2411 pr.uri(),
2412 versions,
2413 content,
2414 &CaRegistry,
2415 &MockFormatter,
2416 )
2417 .await;
2418
2419 assert!(actions.is_empty());
2420 }
2421
2422 #[tokio::test]
2423 async fn test_guard_rejects_span_even_with_a_pending_vulnerability_fix() {
2424 use crate::osv::{
2434 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
2435 };
2436
2437 let content = "1.0.0 extra";
2438 let dep = CaDep {
2439 name: pkg("serde"),
2440 version_req: Some(VersionReq::new("1.0.0")),
2441 version_range: Some(range(0, 6, 0, 11)), };
2443 let pr = CaParseResult {
2444 deps: vec![dep],
2445 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2446 };
2447 let position = Position::new(0, 7);
2448
2449 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
2450 vulnerabilities.insert(
2451 "serde".to_string(),
2452 ScanOutcome::Vulnerable(DependencyVulnerabilities {
2453 advisories: Capped::new(
2454 vec![std::sync::Arc::new(Advisory {
2455 id: "A1".to_string(),
2456 modified: "2023-01-01T00:00:00Z".to_string(),
2457 summary: None,
2458 aliases: vec![],
2459 severity: VulnSeverity::High,
2460 cvss_vector: None,
2461 fixed_versions: vec!["2.0.0".to_string()],
2462 url: String::new(),
2463 })],
2464 1,
2465 ),
2466 fix_target_status: UpgradeStatus::NotChecked,
2467 upgrade_status: UpgradeStatus::NotChecked,
2468 }),
2469 );
2470 let cached = HashMap::new();
2471 let resolved = HashMap::new();
2472 let versions =
2473 VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
2474
2475 let actions = generate_code_actions(
2476 &pr,
2477 position,
2478 pr.uri(),
2479 versions,
2480 content,
2481 &CaRegistry,
2482 &MockFormatter,
2483 )
2484 .await;
2485
2486 assert!(quickfix_titles(&actions).is_empty());
2487 assert!(actions.is_empty());
2488 }
2489
2490 #[tokio::test]
2491 async fn test_guard_accepts_matching_span() {
2492 let content = "1.0.0";
2493 let dep = CaDep {
2494 name: pkg("serde"),
2495 version_req: Some(VersionReq::new("1.0.0")),
2496 version_range: Some(range(0, 0, 0, 5)),
2497 };
2498 let pr = CaParseResult {
2499 deps: vec![dep],
2500 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2501 };
2502 let position = Position::new(0, 0);
2503 let cached = HashMap::new();
2504 let resolved = HashMap::new();
2505 let versions = VersionData::new(&cached, &resolved);
2506
2507 let actions = generate_code_actions(
2508 &pr,
2509 position,
2510 pr.uri(),
2511 versions,
2512 content,
2513 &CaRegistry,
2514 &MockFormatter,
2515 )
2516 .await;
2517
2518 assert!(!actions.is_empty());
2519 }
2520
2521 #[tokio::test]
2522 async fn test_guard_rejects_empty_requirement() {
2523 let content = "1.0.0";
2524 let dep = CaDep {
2525 name: pkg("serde"),
2526 version_req: Some(VersionReq::new("")),
2527 version_range: Some(range(0, 0, 0, 5)),
2528 };
2529 let pr = CaParseResult {
2530 deps: vec![dep],
2531 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2532 };
2533 let position = Position::new(0, 0);
2534 let cached = HashMap::new();
2535 let resolved = HashMap::new();
2536 let versions = VersionData::new(&cached, &resolved);
2537
2538 let actions = generate_code_actions(
2539 &pr,
2540 position,
2541 pr.uri(),
2542 versions,
2543 content,
2544 &CaRegistry,
2545 &MockFormatter,
2546 )
2547 .await;
2548
2549 assert!(actions.is_empty());
2550 }
2551
2552 #[tokio::test]
2553 async fn test_guard_rejects_synthesized_requirement_with_no_literal_override() {
2554 let content = "4.77.0";
2561 let dep = CaDep {
2562 name: pkg("vapor"),
2563 version_req: Some(VersionReq::new(">=4.77.0, <5.0.0")),
2564 version_range: Some(range(0, 0, 0, 6)),
2565 };
2566 let pr = CaParseResult {
2567 deps: vec![dep],
2568 uri: crate::test_util::test_uri("/test/Package.swift"),
2569 };
2570 let position = Position::new(0, 0);
2571 let cached = HashMap::new();
2572 let resolved = HashMap::new();
2573 let versions = VersionData::new(&cached, &resolved);
2574
2575 let actions = generate_code_actions(
2576 &pr,
2577 position,
2578 pr.uri(),
2579 versions,
2580 content,
2581 &CaRegistry,
2582 &MockFormatter,
2583 )
2584 .await;
2585
2586 assert!(actions.is_empty());
2587 }
2588
2589 #[tokio::test]
2590 async fn test_guard_accepts_synthesized_requirement_via_version_literal_override() {
2591 let content = "4.77.0";
2597 let dep = CaLiteralDep {
2598 name: pkg("vapor"),
2599 version_req: Some(VersionReq::new(">=4.77.0, <5.0.0")),
2600 version_range: Some(range(0, 0, 0, 6)),
2601 version_literal: Some("4.77.0".to_string()),
2602 };
2603 let pr = CaLiteralParseResult {
2604 deps: vec![dep],
2605 uri: crate::test_util::test_uri("/test/Package.swift"),
2606 };
2607 let position = Position::new(0, 0);
2608 let cached = HashMap::new();
2609 let resolved = HashMap::new();
2610 let versions = VersionData::new(&cached, &resolved);
2611
2612 let actions = generate_code_actions(
2613 &pr,
2614 position,
2615 pr.uri(),
2616 versions,
2617 content,
2618 &CaRegistry,
2619 &MockFormatter,
2620 )
2621 .await;
2622
2623 assert!(!actions.is_empty());
2624 }
2625 }
2626
2627 mod unsatisfiable_fix_action_tests {
2631 use super::*;
2632 use std::collections::HashMap;
2633
2634 struct NonFixingFormatter;
2640
2641 impl PackageNaming for NonFixingFormatter {}
2642
2643 impl PackageRendering for NonFixingFormatter {
2644 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2645 version.to_string()
2646 }
2647
2648 fn package_url(&self, name: &PackageName) -> String {
2649 format!("https://example.com/{name}")
2650 }
2651
2652 fn format_version_replacing(
2653 &self,
2654 _version: &ConcreteVersion,
2655 _current: &str,
2656 ) -> String {
2657 "still-bad".to_string()
2658 }
2659 }
2660
2661 impl RequirementResolution for NonFixingFormatter {
2662 fn compile_requirement(
2663 &self,
2664 requirement: &VersionReq,
2665 ) -> Option<Box<dyn RequirementMatcher>> {
2666 Some(Box::new(ExactMatcher(requirement.as_str().to_string())))
2667 }
2668 }
2669
2670 impl DiagnosticMessages for NonFixingFormatter {}
2671
2672 impl DiagnosticPolicy for NonFixingFormatter {}
2673
2674 impl SourcePolicy for NonFixingFormatter {}
2675
2676 impl OsvNaming for NonFixingFormatter {}
2677
2678 struct CollidingTextFormatter;
2686
2687 impl PackageNaming for CollidingTextFormatter {}
2688
2689 impl PackageRendering for CollidingTextFormatter {
2690 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2691 version.to_string()
2692 }
2693
2694 fn package_url(&self, name: &PackageName) -> String {
2695 format!("https://example.com/{name}")
2696 }
2697
2698 fn format_version_replacing(
2699 &self,
2700 _version: &ConcreteVersion,
2701 _current: &str,
2702 ) -> String {
2703 "9.9.9".to_string()
2704 }
2705 }
2706
2707 impl RequirementResolution for CollidingTextFormatter {
2708 fn compile_requirement(
2709 &self,
2710 requirement: &VersionReq,
2711 ) -> Option<Box<dyn RequirementMatcher>> {
2712 Some(Box::new(ExactMatcher(requirement.as_str().to_string())))
2713 }
2714 }
2715
2716 impl DiagnosticMessages for CollidingTextFormatter {}
2717
2718 impl DiagnosticPolicy for CollidingTextFormatter {}
2719
2720 impl SourcePolicy for CollidingTextFormatter {}
2721
2722 impl OsvNaming for CollidingTextFormatter {}
2723
2724 struct NormalizingExactFormatter;
2727
2728 impl PackageNaming for NormalizingExactFormatter {
2729 fn normalize_package_name(&self, name: &PackageName) -> String {
2730 format!("normalized-{name}")
2731 }
2732 }
2733
2734 impl PackageRendering for NormalizingExactFormatter {
2735 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2736 version.to_string()
2737 }
2738
2739 fn package_url(&self, name: &PackageName) -> String {
2740 format!("https://example.com/{name}")
2741 }
2742 }
2743
2744 impl RequirementResolution for NormalizingExactFormatter {
2745 fn compile_requirement(
2746 &self,
2747 requirement: &VersionReq,
2748 ) -> Option<Box<dyn RequirementMatcher>> {
2749 Some(Box::new(ExactMatcher(requirement.as_str().to_string())))
2750 }
2751 }
2752
2753 impl DiagnosticMessages for NormalizingExactFormatter {}
2754
2755 impl DiagnosticPolicy for NormalizingExactFormatter {}
2756
2757 impl SourcePolicy for NormalizingExactFormatter {}
2758
2759 impl OsvNaming for NormalizingExactFormatter {}
2760
2761 struct UnsatDep {
2762 name: PackageName,
2763 version_req: VersionReq,
2764 version_range: Range,
2765 source: crate::parser::DependencySource,
2766 }
2767
2768 impl Dependency for UnsatDep {
2769 fn name(&self) -> &PackageName {
2770 &self.name
2771 }
2772 fn name_range(&self) -> Range {
2773 Range::default()
2774 }
2775 fn version_requirement(&self) -> Option<&VersionReq> {
2776 Some(&self.version_req)
2777 }
2778 fn version_range(&self) -> Option<Range> {
2779 Some(self.version_range)
2780 }
2781 fn source(&self) -> crate::parser::DependencySource {
2782 self.source.clone()
2783 }
2784 fn as_any(&self) -> &dyn Any {
2785 self
2786 }
2787 }
2788
2789 struct UnsatParseResult {
2791 dep: UnsatDep,
2792 uri: Uri,
2793 }
2794
2795 impl ParseResult for UnsatParseResult {
2796 fn dependencies(&self) -> Vec<&dyn Dependency> {
2797 vec![&self.dep]
2798 }
2799 fn workspace_root(&self) -> Option<&std::path::Path> {
2800 None
2801 }
2802 fn uri(&self) -> &Uri {
2803 &self.uri
2804 }
2805 fn as_any(&self) -> &dyn Any {
2806 self
2807 }
2808 }
2809
2810 fn cached_versions_keyed(
2811 key: &str,
2812 latest: &str,
2813 available: &[&str],
2814 ) -> HashMap<PackageName, PackageVersions> {
2815 let mut m = HashMap::new();
2816 m.insert(
2817 pkg(key),
2818 PackageVersions {
2819 latest: latest.into(),
2820 available: Arc::from(
2821 available
2822 .iter()
2823 .map(|s| ConcreteVersion::new(*s))
2824 .collect::<Vec<_>>(),
2825 ),
2826 yanked: Arc::from(Vec::new()),
2827 published_at: None,
2828 },
2829 );
2830 m
2831 }
2832
2833 fn cached_versions(
2834 latest: &str,
2835 available: &[&str],
2836 ) -> HashMap<PackageName, PackageVersions> {
2837 cached_versions_keyed("pkg", latest, available)
2838 }
2839
2840 #[tokio::test]
2841 async fn test_unsat_fix_emitted_for_unsatisfiable_requirement() {
2842 let (dep, version_range, content) = vulnerable_dep("1.0.0");
2843 let parse_result = MockParseResult {
2844 deps: vec![dep],
2845 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2846 };
2847
2848 let cached = cached_versions("9.9.9", &["9.9.9"]);
2849 let resolved = HashMap::new();
2850 let versions = VersionData::new(&cached, &resolved);
2851
2852 let actions = generate_code_actions(
2853 &parse_result,
2854 version_range.start,
2855 parse_result.uri(),
2856 versions,
2857 &content,
2858 &MockRegistry,
2859 &ExactMatchFormatter,
2860 )
2861 .await;
2862
2863 assert_eq!(
2864 quickfix_titles(&actions),
2865 vec!["Fix unsatisfiable requirement: update to 9.9.9"]
2866 );
2867 assert_eq!(actions[0].is_preferred, Some(true));
2868 }
2869
2870 #[tokio::test]
2871 async fn test_unsat_fix_absent_when_requirement_is_satisfied() {
2872 let (dep, version_range, content) = vulnerable_dep("9.9.9");
2873 let parse_result = MockParseResult {
2874 deps: vec![dep],
2875 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2876 };
2877
2878 let cached = cached_versions("9.9.9", &["9.9.9"]);
2879 let resolved = HashMap::new();
2880 let versions = VersionData::new(&cached, &resolved);
2881
2882 let actions = generate_code_actions(
2883 &parse_result,
2884 version_range.start,
2885 parse_result.uri(),
2886 versions,
2887 &content,
2888 &MockRegistry,
2889 &ExactMatchFormatter,
2890 )
2891 .await;
2892
2893 assert!(quickfix_titles(&actions).is_empty());
2894 }
2895
2896 #[tokio::test]
2897 async fn test_unsat_fix_absent_for_unsafe_or_empty_latest() {
2898 for unsafe_latest in ["9.9.9\", \"evil\": \"true", "", " "] {
2903 let (dep, version_range, content) = vulnerable_dep("1.0.0");
2904 let parse_result = MockParseResult {
2905 deps: vec![dep],
2906 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2907 };
2908
2909 let cached = cached_versions(unsafe_latest, &[unsafe_latest]);
2910 let resolved = HashMap::new();
2911 let versions = VersionData::new(&cached, &resolved);
2912
2913 let actions = generate_code_actions(
2914 &parse_result,
2915 version_range.start,
2916 parse_result.uri(),
2917 versions,
2918 &content,
2919 &MockRegistry,
2920 &ExactMatchFormatter,
2921 )
2922 .await;
2923
2924 assert!(
2925 quickfix_titles(&actions).is_empty(),
2926 "expected no unsatisfiable-fix quickfix for latest {unsafe_latest:?}"
2927 );
2928 }
2929 }
2930
2931 #[tokio::test]
2932 async fn test_unsat_fix_absent_for_non_resolvable_source() {
2933 let content = "1.0.0";
2937 let dep = UnsatDep {
2938 name: pkg("pkg"),
2939 version_req: VersionReq::new("1.0.0"),
2940 version_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2941 source: crate::parser::DependencySource::Path {
2942 path: "../local".into(),
2943 },
2944 };
2945 let pr = UnsatParseResult {
2946 dep,
2947 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2948 };
2949
2950 let cached = cached_versions("9.9.9", &["9.9.9"]);
2951 let resolved = HashMap::new();
2952 let versions = VersionData::new(&cached, &resolved);
2953
2954 let actions = generate_code_actions(
2955 &pr,
2956 Position::new(0, 0),
2957 pr.uri(),
2958 versions,
2959 content,
2960 &MockRegistry,
2961 &ExactMatchFormatter,
2962 )
2963 .await;
2964
2965 assert!(quickfix_titles(&actions).is_empty());
2966 }
2967
2968 #[tokio::test]
2969 async fn test_unsat_fix_no_op_guard_skips_when_rewrite_equals_declared_text() {
2970 let (dep, version_range, content) = vulnerable_dep("9.9.9");
2973 let parse_result = MockParseResult {
2974 deps: vec![dep],
2975 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2976 };
2977 let cached = cached_versions("9.9.9", &["1.0.0"]);
2981 let resolved = HashMap::new();
2982 let versions = VersionData::new(&cached, &resolved);
2983
2984 let actions = generate_code_actions(
2985 &parse_result,
2986 version_range.start,
2987 parse_result.uri(),
2988 versions,
2989 &content,
2990 &MockRegistry,
2991 &ExactMatchFormatter,
2992 )
2993 .await;
2994
2995 assert!(quickfix_titles(&actions).is_empty());
2998 }
2999
3000 #[tokio::test]
3001 async fn test_unsat_fix_verification_guard_rejects_rewrite_that_stays_unsatisfiable() {
3002 let (dep, version_range, content) = vulnerable_dep("1.0.0");
3006 let parse_result = MockParseResult {
3007 deps: vec![dep],
3008 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3009 };
3010
3011 let cached = cached_versions("9.9.9", &["9.9.9"]);
3012 let resolved = HashMap::new();
3013 let versions = VersionData::new(&cached, &resolved);
3014
3015 let actions = generate_code_actions(
3016 &parse_result,
3017 version_range.start,
3018 parse_result.uri(),
3019 versions,
3020 &content,
3021 &MockRegistry,
3022 &NonFixingFormatter,
3023 )
3024 .await;
3025
3026 assert!(quickfix_titles(&actions).is_empty());
3027 }
3028
3029 #[tokio::test]
3030 async fn test_unsat_fix_resolves_cache_entry_via_raw_name_fallback() {
3031 let (dep, version_range, content) = vulnerable_dep("1.0.0");
3035 let parse_result = MockParseResult {
3036 deps: vec![dep],
3037 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3038 };
3039
3040 let cached = cached_versions_keyed("pkg", "9.9.9", &["9.9.9"]);
3042 let resolved = HashMap::new();
3043 let versions = VersionData::new(&cached, &resolved);
3044
3045 let actions = generate_code_actions(
3046 &parse_result,
3047 version_range.start,
3048 parse_result.uri(),
3049 versions,
3050 &content,
3051 &MockRegistry,
3052 &NormalizingExactFormatter,
3053 )
3054 .await;
3055
3056 assert_eq!(
3057 quickfix_titles(&actions),
3058 vec!["Fix unsatisfiable requirement: update to 9.9.9"]
3059 );
3060 }
3061
3062 #[tokio::test]
3063 async fn test_unsat_fix_dropped_when_target_is_yanked() {
3064 let (dep, version_range, content) = vulnerable_dep("1.0.0");
3065 let parse_result = MockParseResult {
3066 deps: vec![dep],
3067 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3068 };
3069
3070 let cached = cached_versions("9.9.9", &["9.9.9"]);
3071 let resolved = HashMap::new();
3072 let versions = VersionData::new(&cached, &resolved);
3073 let registry = FixedVersionRegistry {
3074 versions: vec![("9.9.9", true)],
3075 };
3076
3077 let actions = generate_code_actions(
3078 &parse_result,
3079 version_range.start,
3080 parse_result.uri(),
3081 versions,
3082 &content,
3083 ®istry,
3084 &ExactMatchFormatter,
3085 )
3086 .await;
3087
3088 assert!(quickfix_titles(&actions).is_empty());
3089 }
3090
3091 #[tokio::test]
3092 async fn test_unsat_fix_survives_registry_outage_and_is_preferred() {
3093 let (dep, version_range, content) = vulnerable_dep("1.0.0");
3096 let parse_result = MockParseResult {
3097 deps: vec![dep],
3098 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3099 };
3100
3101 let cached = cached_versions("9.9.9", &["9.9.9"]);
3102 let resolved = HashMap::new();
3103 let versions = VersionData::new(&cached, &resolved);
3104
3105 let actions = generate_code_actions(
3106 &parse_result,
3107 version_range.start,
3108 parse_result.uri(),
3109 versions,
3110 &content,
3111 &ErrorRegistry,
3112 &ExactMatchFormatter,
3113 )
3114 .await;
3115
3116 assert_eq!(
3117 quickfix_titles(&actions),
3118 vec!["Fix unsatisfiable requirement: update to 9.9.9"]
3119 );
3120 assert_eq!(actions[0].is_preferred, Some(true));
3121 }
3122
3123 #[tokio::test]
3124 async fn test_vuln_and_unsat_fix_coexist_with_vuln_preferred() {
3125 use crate::osv::{
3126 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
3127 };
3128
3129 let (dep, version_range, content) = vulnerable_dep("1.0.0");
3130 let parse_result = MockParseResult {
3131 deps: vec![dep],
3132 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3133 };
3134
3135 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
3136 vulnerabilities.insert(
3137 "pkg".to_string(),
3138 ScanOutcome::Vulnerable(DependencyVulnerabilities {
3139 advisories: Capped::new(
3140 vec![std::sync::Arc::new(Advisory {
3141 id: "A1".to_string(),
3142 modified: "2023-01-01T00:00:00Z".to_string(),
3143 summary: None,
3144 aliases: vec![],
3145 severity: VulnSeverity::High,
3146 cvss_vector: None,
3147 fixed_versions: vec!["5.5.5".to_string()],
3148 url: String::new(),
3149 })],
3150 1,
3151 ),
3152 fix_target_status: UpgradeStatus::CandidateClean {
3153 version: "5.5.5".to_string(),
3154 },
3155 upgrade_status: UpgradeStatus::NotChecked,
3156 }),
3157 );
3158 let cached = cached_versions("9.9.9", &["9.9.9", "5.5.5"]);
3161 let resolved = HashMap::new();
3162 let versions =
3163 VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
3164
3165 let actions = generate_code_actions(
3166 &parse_result,
3167 version_range.start,
3168 parse_result.uri(),
3169 versions,
3170 &content,
3171 &MockRegistry,
3172 &ExactMatchFormatter,
3173 )
3174 .await;
3175
3176 let titles = quickfix_titles(&actions);
3177 assert_eq!(titles.len(), 2, "expected both fixes: {titles:?}");
3178 assert!(titles.iter().any(|t| t.starts_with("Update to 5.5.5")));
3179 assert!(
3180 titles
3181 .iter()
3182 .any(|t| t.starts_with("Fix unsatisfiable requirement"))
3183 );
3184
3185 let vuln_action = actions
3186 .iter()
3187 .find(|a| a.title.starts_with("Update to 5.5.5"))
3188 .unwrap();
3189 let unsat_action = actions
3190 .iter()
3191 .find(|a| a.title.starts_with("Fix unsatisfiable"))
3192 .unwrap();
3193 assert_eq!(vuln_action.is_preferred, Some(true));
3194 assert_eq!(unsat_action.is_preferred, None);
3195 assert_eq!(
3196 actions
3197 .iter()
3198 .filter(|a| a.is_preferred == Some(true))
3199 .count(),
3200 1
3201 );
3202 }
3203
3204 #[tokio::test]
3205 async fn test_unsat_fix_dropped_when_it_collides_with_vuln_fix_text() {
3206 use crate::osv::{
3209 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
3210 };
3211
3212 let (dep, version_range, content) = vulnerable_dep("1.0.0");
3213 let parse_result = MockParseResult {
3214 deps: vec![dep],
3215 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3216 };
3217
3218 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
3219 vulnerabilities.insert(
3220 "pkg".to_string(),
3221 ScanOutcome::Vulnerable(DependencyVulnerabilities {
3222 advisories: Capped::new(
3223 vec![std::sync::Arc::new(Advisory {
3224 id: "A1".to_string(),
3225 modified: "2023-01-01T00:00:00Z".to_string(),
3226 summary: None,
3227 aliases: vec![],
3228 severity: VulnSeverity::High,
3229 cvss_vector: None,
3230 fixed_versions: vec!["9.9.9".to_string()],
3232 url: String::new(),
3233 })],
3234 1,
3235 ),
3236 fix_target_status: UpgradeStatus::CandidateClean {
3237 version: "9.9.9".to_string(),
3238 },
3239 upgrade_status: UpgradeStatus::NotChecked,
3240 }),
3241 );
3242 let cached = cached_versions("9.9.9", &["9.9.9"]);
3243 let resolved = HashMap::new();
3244 let versions =
3245 VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
3246
3247 let actions = generate_code_actions(
3248 &parse_result,
3249 version_range.start,
3250 parse_result.uri(),
3251 versions,
3252 &content,
3253 &MockRegistry,
3254 &ExactMatchFormatter,
3255 )
3256 .await;
3257
3258 let titles = quickfix_titles(&actions);
3259 assert_eq!(titles.len(), 1, "unsat fix must be dropped: {titles:?}");
3260 assert!(titles[0].starts_with("Update to 9.9.9"));
3261 }
3262
3263 #[tokio::test]
3264 async fn test_yank_filter_runs_before_collision_check_so_neither_fix_is_lost() {
3265 use crate::osv::{
3274 Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
3275 };
3276
3277 let (dep, version_range, content) = vulnerable_dep("1.0.0");
3278 let parse_result = MockParseResult {
3279 deps: vec![dep],
3280 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3281 };
3282
3283 let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
3284 vulnerabilities.insert(
3285 "pkg".to_string(),
3286 ScanOutcome::Vulnerable(DependencyVulnerabilities {
3287 advisories: Capped::new(
3288 vec![std::sync::Arc::new(Advisory {
3289 id: "A1".to_string(),
3290 modified: "2023-01-01T00:00:00Z".to_string(),
3291 summary: None,
3292 aliases: vec![],
3293 severity: VulnSeverity::High,
3294 cvss_vector: None,
3295 fixed_versions: vec!["9.9.5".to_string()],
3299 url: String::new(),
3300 })],
3301 1,
3302 ),
3303 fix_target_status: UpgradeStatus::CandidateClean {
3304 version: "9.9.5".to_string(),
3305 },
3306 upgrade_status: UpgradeStatus::NotChecked,
3307 }),
3308 );
3309 let cached = cached_versions("9.9.9", &["9.9.9"]);
3313 let resolved = HashMap::new();
3314 let versions =
3315 VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
3316 let registry = FixedVersionRegistry {
3319 versions: vec![("9.9.5", true), ("9.9.9", false)],
3320 };
3321
3322 let actions = generate_code_actions(
3323 &parse_result,
3324 version_range.start,
3325 parse_result.uri(),
3326 versions,
3327 &content,
3328 ®istry,
3329 &CollidingTextFormatter,
3330 )
3331 .await;
3332
3333 let titles = quickfix_titles(&actions);
3334 assert_eq!(
3335 titles,
3336 vec!["Fix unsatisfiable requirement: update to 9.9.9"],
3337 "vuln fix must be dropped for being yanked, unsat fix must survive: {titles:?}"
3338 );
3339 }
3340
3341 #[tokio::test]
3342 async fn test_unsat_fix_dedups_refactor_item_writing_the_same_text() {
3343 let (dep, version_range, content) = vulnerable_dep("1.0.0");
3344 let parse_result = MockParseResult {
3345 deps: vec![dep],
3346 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3347 };
3348
3349 let cached = cached_versions("9.9.9", &["9.9.9"]);
3350 let resolved = HashMap::new();
3351 let versions = VersionData::new(&cached, &resolved);
3352 let registry = FixedVersionRegistry {
3355 versions: vec![("9.9.9", false)],
3356 };
3357
3358 let actions = generate_code_actions(
3359 &parse_result,
3360 version_range.start,
3361 parse_result.uri(),
3362 versions,
3363 &content,
3364 ®istry,
3365 &ExactMatchFormatter,
3366 )
3367 .await;
3368
3369 assert_eq!(quickfix_titles(&actions).len(), 1);
3370 assert!(
3371 refactor_titles(&actions).is_empty(),
3372 "the duplicate REFACTOR item must be suppressed by the dedup set: {actions:?}"
3373 );
3374 }
3375 }
3376}