use std::collections::HashSet;
use tower_lsp_server::ls_types::{CodeAction, CodeActionKind, Position, Range, Uri, WorkspaceEdit};
use crate::osv::ScanOutcome;
use crate::{Dependency, ParseResult, Registry, VersionReq};
use super::{
EcosystemFormatter, LineOffsetTable, UNSATISFIABLE_DIAGNOSTIC_CODE, VersionData,
is_safe_version_string, literal_span_matches, requirement_is_unsatisfiable, single_file_edit,
slice_for_range, strip_whitespace, warn_rejected_value,
};
struct VulnerabilityFixAction {
version_native: String,
new_text: String,
action: CodeAction,
}
fn build_vulnerability_fix_action(
parse_result: &dyn ParseResult,
dep: &dyn Dependency,
uri: &Uri,
version_range: Range,
versions: VersionData<'_>,
version_req: &str,
formatter: &dyn EcosystemFormatter,
) -> Option<VulnerabilityFixAction> {
let normalized_name = formatter.normalize_package_name(dep.name());
let vuln_key = versions.ecosystem.and_then(|ecosystem| {
crate::osv::vulnerability_keys(parse_result, versions.resolved, formatter, ecosystem)
.remove(&dep.name_range())
});
let outcome = versions.vulnerabilities.and_then(|m| {
vuln_key
.as_deref()
.and_then(|key| m.get(key))
.or_else(|| m.get(&normalized_name))
.or_else(|| m.get(dep.name().as_str()))
})?;
let ScanOutcome::Vulnerable(dv) = outcome else {
return None;
};
let fix = dv.recommended_fix()?;
let version_native = formatter.osv_version_to_native(&fix.version);
if !is_safe_version_string(&version_native) {
warn_rejected_value(
"is_safe_version_string",
"vulnerability fix code action",
&version_native,
);
return None;
}
let new_text = formatter.format_version_replacing(&version_native, version_req);
let literal_target = dep.version_literal().unwrap_or(version_req);
if strip_whitespace(literal_target) == strip_whitespace(&new_text) {
return None;
}
let lockfile_hit = versions
.resolved
.get(normalized_name.as_str())
.or_else(|| versions.resolved.get(dep.name()))
.is_some();
let (first_id, rest_ids) = fix.advisory_ids.split_first()?;
let fixes = if rest_ids.is_empty() {
first_id.clone()
} else {
format!("{first_id} +{} more", rest_ids.len())
};
let title = if lockfile_hit {
format!("Update to {version_native} (fixes {fixes}; update lockfile to apply)")
} else {
format!("Update to {version_native} (fixes {fixes})")
};
let edits = single_file_edit(uri, version_range, new_text.clone());
Some(VulnerabilityFixAction {
version_native,
new_text,
action: CodeAction {
title,
kind: Some(CodeActionKind::QUICKFIX),
edit: Some(WorkspaceEdit {
changes: Some(edits),
..Default::default()
}),
is_preferred: None,
data: Some(serde_json::json!({
"diagnostic_codes": fix.advisory_ids,
"diagnostic_range": version_range,
})),
..Default::default()
},
})
}
struct UnsatisfiableFixAction {
version_native: String,
new_text: String,
action: CodeAction,
}
fn build_unsatisfiable_fix_action(
dep: &dyn Dependency,
uri: &Uri,
version_range: Range,
versions: VersionData<'_>,
version_req: &VersionReq,
formatter: &dyn EcosystemFormatter,
) -> Option<UnsatisfiableFixAction> {
if !dep.source().is_version_resolvable() {
return None;
}
let normalized_name = formatter.normalize_package_name(dep.name());
let package_versions = versions
.cached
.get(normalized_name.as_str())
.or_else(|| versions.cached.get(dep.name()))?;
if !requirement_is_unsatisfiable(formatter, version_req, &package_versions.available) {
return None;
}
let latest = package_versions.latest.clone();
if !is_safe_version_string(&latest) {
warn_rejected_value(
"is_safe_version_string",
"unsatisfiable-requirement fix code action",
&latest,
);
return None;
}
let new_text = formatter.format_version_replacing(&latest, version_req.as_str());
let literal_target = dep.version_literal().unwrap_or(version_req.as_str());
if strip_whitespace(literal_target) == strip_whitespace(&new_text) {
return None;
}
let verification_req = VersionReq::new(new_text.clone());
if requirement_is_unsatisfiable(formatter, &verification_req, &package_versions.available) {
return None;
}
let edits = single_file_edit(uri, version_range, new_text.clone());
Some(UnsatisfiableFixAction {
version_native: latest.clone(),
new_text,
action: CodeAction {
title: format!("Fix unsatisfiable requirement: update to {latest}"),
kind: Some(CodeActionKind::QUICKFIX),
edit: Some(WorkspaceEdit {
changes: Some(edits),
..Default::default()
}),
is_preferred: None,
data: Some(serde_json::json!({
"diagnostic_codes": [UNSATISFIABLE_DIAGNOSTIC_CODE],
"diagnostic_range": version_range,
})),
..Default::default()
},
})
}
pub async fn generate_code_actions<R: Registry + ?Sized>(
parse_result: &dyn ParseResult,
position: Position,
uri: &Uri,
versions: VersionData<'_>,
content: &str,
registry: &R,
formatter: &dyn EcosystemFormatter,
) -> Vec<CodeAction> {
use crate::completion::prepare_version_display_items;
let deps = parse_result.dependencies();
let mut actions = Vec::with_capacity(deps.len().min(5) + 1);
let Some(dep) = deps
.into_iter()
.find(|d| formatter.is_position_on_dependency(*d, position))
else {
return actions;
};
let Some(version_range) = dep.version_range() else {
return actions;
};
let Some(version_req) = dep.version_requirement() else {
return actions;
};
if version_req.as_str().is_empty() {
return actions;
}
let line_offsets = LineOffsetTable::new(content);
let slice = slice_for_range(content, &line_offsets, version_range);
let literal_target = dep
.version_literal()
.unwrap_or_else(|| version_req.as_str());
if !literal_span_matches(slice, literal_target) {
return actions;
}
let fix = build_vulnerability_fix_action(
parse_result,
dep,
uri,
version_range,
versions,
version_req.as_str(),
formatter,
);
let unsat_fix =
build_unsatisfiable_fix_action(dep, uri, version_range, versions, version_req, formatter);
let registry_versions = registry.get_versions(dep.name()).await.ok();
let is_yanked_target = |version_native: &str| {
registry_versions.as_ref().is_some_and(|versions_list| {
versions_list
.iter()
.find(|v| v.version_string() == version_native)
.is_some_and(|v| v.removal_status().blocks_resolution())
})
};
let fix = fix.filter(|f| !is_yanked_target(&f.version_native));
let unsat_fix = unsat_fix.filter(|f| !is_yanked_target(&f.version_native));
let unsat_fix = unsat_fix.filter(|u| {
fix.as_ref()
.is_none_or(|f| strip_whitespace(&f.new_text) != strip_whitespace(&u.new_text))
});
let fix_new_text = fix.as_ref().map(|f| strip_whitespace(&f.new_text));
let unsat_new_text = unsat_fix.as_ref().map(|f| strip_whitespace(&f.new_text));
let mut vuln_idx = None;
if let Some(fix) = fix {
vuln_idx = Some(actions.len());
actions.push(fix.action);
}
let mut unsat_idx = None;
if let Some(unsat_fix) = unsat_fix {
unsat_idx = Some(actions.len());
actions.push(unsat_fix.action);
}
let mut emitted_texts: HashSet<String> = HashSet::new();
emitted_texts.insert(strip_whitespace(literal_target));
if let Some(fix_text) = fix_new_text {
emitted_texts.insert(fix_text);
}
if let Some(unsat_text) = unsat_new_text {
emitted_texts.insert(unsat_text);
}
let mut latest_refactor_idx = None;
if let Some(registry_versions) = ®istry_versions {
let display_items = prepare_version_display_items(registry_versions, dep.name());
for item in display_items {
if !is_safe_version_string(&item.version) {
warn_rejected_value(
"is_safe_version_string",
"update-to-version refactor code action",
&item.version,
);
continue;
}
let new_text = formatter.format_version_replacing(&item.version, version_req.as_str());
if !emitted_texts.insert(strip_whitespace(&new_text)) {
continue;
}
let edits = single_file_edit(uri, version_range, new_text);
if item.is_latest {
latest_refactor_idx = Some(actions.len());
}
actions.push(CodeAction {
title: item.label,
kind: Some(CodeActionKind::REFACTOR),
edit: Some(WorkspaceEdit {
changes: Some(edits),
..Default::default()
}),
is_preferred: None,
..Default::default()
});
}
}
if let Some(i) = vuln_idx.or(unsat_idx).or(latest_refactor_idx) {
actions[i].is_preferred = Some(true);
}
actions
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lsp_helpers::test_support::*;
use crate::lsp_helpers::*;
use crate::{Dependency, PackageName, VersionReq};
use std::any::Any;
use std::collections::HashMap;
use std::sync::Arc;
#[tokio::test]
async fn test_generate_code_actions_combines_advisories_sharing_the_highest_fix() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
use std::collections::HashMap;
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![
std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["1.1.0".to_string()],
url: String::new(),
}),
std::sync::Arc::new(Advisory {
id: "A2".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::Critical,
cvss_vector: None,
fixed_versions: vec!["1.2.0".to_string()],
url: String::new(),
}),
],
total_known: 2,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&MockFormatter,
)
.await;
let titles = quickfix_titles(&actions);
assert_eq!(titles, vec!["Update to 1.2.0 (fixes A2 +1 more)"]);
assert_eq!(actions[0].kind, Some(CodeActionKind::QUICKFIX));
assert_eq!(actions[0].is_preferred, Some(true));
assert_eq!(
actions[0].data,
Some(serde_json::json!({
"diagnostic_codes": ["A2", "A1"],
"diagnostic_range": version_range,
}))
);
}
#[tokio::test]
async fn test_generate_code_actions_fix_target_is_not_inflated_by_a_subtracted_advisory() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
use std::collections::HashMap;
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![
std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["3.0.0".to_string()],
url: String::new(),
}),
std::sync::Arc::new(Advisory {
id: "A2".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::Medium,
cvss_vector: None,
fixed_versions: vec!["1.2.0".to_string()],
url: String::new(),
}),
],
total_known: 2,
upgrade_status: UpgradeStatus::CandidateVulnerable {
version: "3.0.0".to_string(),
advisory_ids: vec!["A1".to_string()],
},
}),
);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&MockFormatter,
)
.await;
let titles = quickfix_titles(&actions);
assert_eq!(titles, vec!["Update to 1.2.0 (fixes A2)"]);
}
#[tokio::test]
async fn test_generate_code_actions_drops_yanked_fix_target() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
use std::collections::HashMap;
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["2.0.0".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let registry = FixedVersionRegistry {
versions: vec![("2.0.0", true), ("1.5.0", false)],
};
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
®istry,
&MockFormatter,
)
.await;
assert!(quickfix_titles(&actions).is_empty());
assert!(
actions
.iter()
.any(|a| a.kind == Some(CodeActionKind::REFACTOR))
);
}
#[tokio::test]
async fn test_generate_code_actions_no_op_edit_is_skipped() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
use std::collections::HashMap;
let (dep, version_range, content) = vulnerable_dep("1.2.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["1.2.0".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&IdentityFormatter,
)
.await;
assert!(quickfix_titles(&actions).is_empty());
}
#[tokio::test]
async fn test_generate_code_actions_no_op_guard_compares_formatted_text_not_bare_version() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
use std::collections::HashMap;
let (dep, version_range, content) = vulnerable_dep("^1.2.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/pubspec.yaml"),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["1.2.0".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&CaretWrappingFormatter,
)
.await;
assert!(quickfix_titles(&actions).is_empty());
}
#[tokio::test]
async fn test_generate_code_actions_refactor_loop_skips_no_op_entry_but_keeps_real_update() {
let (dep, version_range, content) = vulnerable_dep("1.2.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let registry = FixedVersionRegistry {
versions: vec![("1.2.0", false), ("1.1.0", false)],
};
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
®istry,
&IdentityFormatter,
)
.await;
let titles = refactor_titles(&actions);
assert!(
!titles.iter().any(|t| t.starts_with("1.2.0")),
"the already-declared version must not be offered as an update: {titles:?}"
);
assert!(
titles.contains(&"1.1.0"),
"a genuinely different version must still be offered: {titles:?}"
);
}
#[tokio::test]
async fn test_generate_code_actions_refactor_loop_no_op_guard_ignores_whitespace() {
let (dep, version_range, content) = vulnerable_dep("1.2.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let registry = FixedVersionRegistry {
versions: vec![("1.2.0", false)],
};
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
®istry,
&TrailingSpaceFormatter,
)
.await;
assert!(
refactor_titles(&actions).is_empty(),
"a whitespace-only edit-text divergence must still be skipped as a no-op"
);
}
#[tokio::test]
async fn test_generate_code_actions_refactor_loop_skips_unsafe_version_string() {
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let registry = FixedVersionRegistry {
versions: vec![("1.1.0\", \"evil\": \"true", false), ("1.1.0", false)],
};
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
®istry,
&IdentityFormatter,
)
.await;
let titles = refactor_titles(&actions);
assert!(
!titles.iter().any(|t| t.contains("evil")),
"an unsafe version string must never be offered as an update: {titles:?}"
);
assert!(
titles.contains(&"1.1.0"),
"a safe version must still be offered: {titles:?}"
);
}
#[tokio::test]
async fn test_build_vulnerability_fix_action_skips_unsafe_fix_version() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
use std::collections::HashMap;
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["1.2.0\", \"evil\": \"true".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&IdentityFormatter,
)
.await;
assert!(
quickfix_titles(&actions).is_empty(),
"an unsafe fix version must never produce a vulnerability-fix quickfix"
);
}
#[tokio::test]
async fn test_generate_code_actions_vulnerability_fix_not_offered_on_patched_duplicate_occurrence()
{
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
use tower_lsp_server::ls_types::{Position, Range};
let vulnerable_line = "log4j-core = \"=2.14.1\"";
let patched_line = "log4j-core = \"=2.17.1\"";
let content = format!("{vulnerable_line}\n{patched_line}\n");
let name_start = 0u32;
let name_end = "log4j-core".len() as u32;
let version_start = "log4j-core = \"".len() as u32;
let vulnerable_dep = MockDep {
name: pkg("log4j-core"),
version_req: VersionReq::new("=2.14.1"),
version_range: Range::new(
Position::new(0, version_start),
Position::new(0, version_start + "=2.14.1".len() as u32),
),
name_range: Range::new(Position::new(0, name_start), Position::new(0, name_end)),
};
let patched_dep = MockDep {
name: pkg("log4j-core"),
version_req: VersionReq::new("=2.17.1"),
version_range: Range::new(
Position::new(1, version_start),
Position::new(1, version_start + "=2.17.1".len() as u32),
),
name_range: Range::new(Position::new(1, name_start), Position::new(1, name_end)),
};
let parse_result = MockParseResult {
deps: vec![vulnerable_dep, patched_dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = HashMap::new();
let resolved = HashMap::new();
let keys = crate::osv::vulnerability_keys(
&parse_result,
&resolved,
&IdentityFormatter,
crate::EcosystemId::Cargo,
);
let deps = parse_result.dependencies();
let vulnerable_key = keys.get(&deps[0].name_range()).unwrap().clone();
let patched_key = keys.get(&deps[1].name_range()).unwrap().clone();
assert_ne!(vulnerable_key, patched_key);
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
vulnerable_key,
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "GHSA-log4j".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::Critical,
cvss_vector: None,
fixed_versions: vec!["2.17.1".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
vulnerabilities.insert(patched_key, ScanOutcome::Clean);
let versions = VersionData::new(&cached, &resolved)
.with_vulnerabilities(&vulnerabilities)
.with_ecosystem(crate::EcosystemId::Cargo);
let actions_on_vulnerable = generate_code_actions(
&parse_result,
Position::new(0, version_start),
parse_result.uri(),
versions,
&content,
&MockRegistry,
&IdentityFormatter,
)
.await;
assert!(
!quickfix_titles(&actions_on_vulnerable).is_empty(),
"the vulnerable occurrence must get a fix quickfix"
);
let actions_on_patched = generate_code_actions(
&parse_result,
Position::new(1, version_start),
parse_result.uri(),
versions,
&content,
&MockRegistry,
&IdentityFormatter,
)
.await;
assert!(
quickfix_titles(&actions_on_patched).is_empty(),
"the already-patched occurrence must NOT get a fix quickfix, \
even though it shares a name with the vulnerable one: {:?}",
quickfix_titles(&actions_on_patched)
);
}
#[tokio::test]
async fn test_generate_code_actions_refactor_loop_dedups_item_matching_fix_text_by_different_raw_version()
{
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
use std::collections::HashMap;
let (dep, version_range, content) = vulnerable_dep("==1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/requirements.txt"),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["1.2.5".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let registry = FixedVersionRegistry {
versions: vec![("1.2.9", false), ("1.1.0", false)],
};
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
®istry,
&TruncatingFormatter,
)
.await;
assert_eq!(quickfix_titles(&actions).len(), 1);
let titles = refactor_titles(&actions);
assert!(
!titles.iter().any(|t| t.starts_with("1.2.9")),
"an item whose formatted text matches the fix action's text must be \
skipped even though its raw version differs from the fix's: {titles:?}"
);
assert!(titles.iter().any(|t| t.starts_with("1.1.0")));
for action in actions
.iter()
.filter(|a| a.kind == Some(CodeActionKind::REFACTOR))
{
let edit_text = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()
[parse_result.uri()][0]
.new_text;
for other in actions.iter() {
if std::ptr::eq(action, other) {
continue;
}
let other_text = &other.edit.as_ref().unwrap().changes.as_ref().unwrap()
[parse_result.uri()][0]
.new_text;
assert_ne!(
edit_text, other_text,
"no two actions may carry a byte-identical edit"
);
}
}
}
#[tokio::test]
async fn test_generate_code_actions_refactor_loop_dedups_item_matching_another_items_text() {
let (dep, version_range, content) = vulnerable_dep("==1.0.*");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/requirements.txt"),
};
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let registry = FixedVersionRegistry {
versions: vec![("1.1.9", false), ("1.1.5", false), ("1.1.0", false)],
};
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
®istry,
&TruncatingFormatter,
)
.await;
assert!(quickfix_titles(&actions).is_empty());
let titles = refactor_titles(&actions);
assert_eq!(
titles,
vec!["1.1.9 (latest)"],
"identical-text items after the first must be deduped: {titles:?}"
);
}
#[tokio::test]
async fn test_generate_code_actions_lockfile_hit_gets_title_suffix() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
use std::collections::HashMap;
let (dep, version_range, content) = vulnerable_dep("^1.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["1.0.2".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = HashMap::new();
let mut resolved = HashMap::new();
resolved.insert(pkg("pkg"), "1.0.1".to_string());
let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&MockFormatter,
)
.await;
let titles = quickfix_titles(&actions);
assert_eq!(
titles,
vec!["Update to 1.0.2 (fixes A1; update lockfile to apply)"]
);
}
#[tokio::test]
async fn test_generate_code_actions_fix_action_survives_registry_error() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
use std::collections::HashMap;
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["1.2.0".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&ErrorRegistry,
&MockFormatter,
)
.await;
let titles = quickfix_titles(&actions);
assert_eq!(titles, vec!["Update to 1.2.0 (fixes A1)"]);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].is_preferred, Some(true));
}
#[tokio::test]
async fn test_generate_code_actions_coexistence_dedups_fix_version_and_demotes_preferred() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
use std::collections::HashMap;
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["1.2.0".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let registry = FixedVersionRegistry {
versions: vec![("1.2.0", false), ("1.1.0", false), ("1.0.0", false)],
};
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
®istry,
&MockFormatter,
)
.await;
assert_eq!(quickfix_titles(&actions).len(), 1);
let refactor_titles: Vec<&str> = actions
.iter()
.filter(|a| a.kind == Some(CodeActionKind::REFACTOR))
.map(|a| a.title.as_str())
.collect();
assert!(
!refactor_titles.iter().any(|t| t.starts_with("1.2.0")),
"the display item duplicating the fix's own target must be skipped: {refactor_titles:?}"
);
assert!(refactor_titles.iter().any(|t| t.starts_with("1.1.0")));
assert!(
actions
.iter()
.filter(|a| a.kind == Some(CodeActionKind::REFACTOR))
.all(|a| a.is_preferred.is_none()),
"only the fix action may be preferred once it exists"
);
}
#[tokio::test]
async fn test_generate_code_actions_latest_refactor_is_preferred_when_no_fix_exists() {
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let registry = FixedVersionRegistry {
versions: vec![("2.0.0", false), ("1.5.0", false)],
};
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
®istry,
&MockFormatter,
)
.await;
assert!(
quickfix_titles(&actions).is_empty(),
"no vuln/unsat fix should exist: {actions:?}"
);
let refactor_titles = refactor_titles(&actions);
assert_eq!(refactor_titles.len(), 2, "{refactor_titles:?}");
let preferred: Vec<&str> = actions
.iter()
.filter(|a| a.is_preferred == Some(true))
.map(|a| a.title.as_str())
.collect();
assert_eq!(
preferred.len(),
1,
"exactly one action must be preferred: {actions:?}"
);
assert!(
preferred[0].starts_with("2.0.0"),
"the newest (item.is_latest) REFACTOR action must be preferred: {preferred:?}"
);
assert!(
actions
.iter()
.filter(|a| !a.title.starts_with("2.0.0"))
.all(|a| a.is_preferred.is_none()),
"every other action must be None, not Some(false): {actions:?}"
);
}
#[tokio::test]
async fn test_generate_code_actions_fix_uses_ecosystem_format_version_replacing_override() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
use std::collections::HashMap;
let (dep, version_range, content) = vulnerable_dep("==1.0.0");
let uri = crate::test_util::test_uri("/test/requirements.txt");
let parse_result = MockParseResult {
deps: vec![dep],
uri: uri.clone(),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["1.0.2".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&PinPreservingFormatter,
)
.await;
let quickfix = actions
.iter()
.find(|a| a.kind == Some(CodeActionKind::QUICKFIX))
.expect("a vulnerability-fix quickfix should be offered");
let new_text = quickfix
.edit
.as_ref()
.and_then(|e| e.changes.as_ref())
.and_then(|c| c.get(&uri))
.and_then(|edits| edits.first())
.map(|e| e.new_text.as_str())
.expect("quickfix should carry a TextEdit for the document uri");
assert_eq!(
new_text, "==1.0.2",
"the fix action's TextEdit must go through format_version_replacing's \
pin-preserving override, not the default format_version_for_text_edit delegation"
);
}
mod code_actions_guard_tests {
use super::*;
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
struct CaDep {
name: PackageName,
version_req: Option<VersionReq>,
version_range: Option<Range>,
}
impl Dependency for CaDep {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> Range {
Range::default()
}
fn version_requirement(&self) -> Option<&VersionReq> {
self.version_req.as_ref()
}
fn version_range(&self) -> Option<Range> {
self.version_range
}
fn source(&self) -> crate::parser::DependencySource {
crate::parser::DependencySource::Registry
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct CaParseResult {
deps: Vec<CaDep>,
uri: Uri,
}
impl ParseResult for CaParseResult {
fn dependencies(&self) -> Vec<&dyn Dependency> {
self.deps.iter().map(|d| d as &dyn Dependency).collect()
}
fn workspace_root(&self) -> Option<&std::path::Path> {
None
}
fn uri(&self) -> &Uri {
&self.uri
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct CaLiteralDep {
name: PackageName,
version_req: Option<VersionReq>,
version_range: Option<Range>,
version_literal: Option<String>,
}
impl Dependency for CaLiteralDep {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> Range {
Range::default()
}
fn version_requirement(&self) -> Option<&VersionReq> {
self.version_req.as_ref()
}
fn version_range(&self) -> Option<Range> {
self.version_range
}
fn source(&self) -> crate::parser::DependencySource {
crate::parser::DependencySource::Registry
}
fn version_literal(&self) -> Option<&str> {
self.version_literal.as_deref()
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct CaLiteralParseResult {
deps: Vec<CaLiteralDep>,
uri: Uri,
}
impl ParseResult for CaLiteralParseResult {
fn dependencies(&self) -> Vec<&dyn Dependency> {
self.deps.iter().map(|d| d as &dyn Dependency).collect()
}
fn workspace_root(&self) -> Option<&std::path::Path> {
None
}
fn uri(&self) -> &Uri {
&self.uri
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct CaVersion {
version: String,
yanked: bool,
}
crate::impl_version!(CaVersion {
version: version,
status: |v: &CaVersion| crate::RemovalStatus::from_yanked(v.yanked),
});
struct CaRegistry;
impl crate::Registry for CaRegistry {
fn get_versions<'a>(
&'a self,
_name: &'a PackageName,
) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Vec<Box<dyn crate::Version>>>>
{
Box::pin(async move {
Ok(vec![Box::new(CaVersion {
version: "2.0.0".to_string(),
yanked: false,
}) as Box<dyn crate::Version>])
})
}
fn get_latest_matching<'a>(
&'a self,
_name: &'a PackageName,
_req: &'a VersionReq,
) -> crate::ecosystem::BoxFuture<
'a,
crate::error::Result<Option<Box<dyn crate::Version>>>,
> {
Box::pin(async move { Ok(None) })
}
fn search<'a>(
&'a self,
_query: &'a str,
_limit: usize,
) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Vec<Box<dyn crate::Metadata>>>>
{
Box::pin(async move { Ok(Vec::new()) })
}
fn as_any(&self) -> &dyn Any {
self
}
}
fn range(sl: u32, sc: u32, el: u32, ec: u32) -> Range {
Range::new(Position::new(sl, sc), Position::new(el, ec))
}
#[tokio::test]
async fn test_guard_rejects_span_that_does_not_match_requirement() {
let content = "1.0.0 extra";
let dep = CaDep {
name: pkg("serde"),
version_req: Some(VersionReq::new("1.0.0")),
version_range: Some(range(0, 6, 0, 11)), };
let pr = CaParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let position = Position::new(0, 7);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let actions = generate_code_actions(
&pr,
position,
pr.uri(),
versions,
content,
&CaRegistry,
&MockFormatter,
)
.await;
assert!(actions.is_empty());
}
#[tokio::test]
async fn test_guard_rejects_span_even_with_a_pending_vulnerability_fix() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
let content = "1.0.0 extra";
let dep = CaDep {
name: pkg("serde"),
version_req: Some(VersionReq::new("1.0.0")),
version_range: Some(range(0, 6, 0, 11)), };
let pr = CaParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let position = Position::new(0, 7);
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"serde".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["2.0.0".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions =
VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let actions = generate_code_actions(
&pr,
position,
pr.uri(),
versions,
content,
&CaRegistry,
&MockFormatter,
)
.await;
assert!(quickfix_titles(&actions).is_empty());
assert!(actions.is_empty());
}
#[tokio::test]
async fn test_guard_accepts_matching_span() {
let content = "1.0.0";
let dep = CaDep {
name: pkg("serde"),
version_req: Some(VersionReq::new("1.0.0")),
version_range: Some(range(0, 0, 0, 5)),
};
let pr = CaParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let position = Position::new(0, 0);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let actions = generate_code_actions(
&pr,
position,
pr.uri(),
versions,
content,
&CaRegistry,
&MockFormatter,
)
.await;
assert!(!actions.is_empty());
}
#[tokio::test]
async fn test_guard_rejects_empty_requirement() {
let content = "1.0.0";
let dep = CaDep {
name: pkg("serde"),
version_req: Some(VersionReq::new("")),
version_range: Some(range(0, 0, 0, 5)),
};
let pr = CaParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let position = Position::new(0, 0);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let actions = generate_code_actions(
&pr,
position,
pr.uri(),
versions,
content,
&CaRegistry,
&MockFormatter,
)
.await;
assert!(actions.is_empty());
}
#[tokio::test]
async fn test_guard_rejects_synthesized_requirement_with_no_literal_override() {
let content = "4.77.0";
let dep = CaDep {
name: pkg("vapor"),
version_req: Some(VersionReq::new(">=4.77.0, <5.0.0")),
version_range: Some(range(0, 0, 0, 6)),
};
let pr = CaParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Package.swift"),
};
let position = Position::new(0, 0);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let actions = generate_code_actions(
&pr,
position,
pr.uri(),
versions,
content,
&CaRegistry,
&MockFormatter,
)
.await;
assert!(actions.is_empty());
}
#[tokio::test]
async fn test_guard_accepts_synthesized_requirement_via_version_literal_override() {
let content = "4.77.0";
let dep = CaLiteralDep {
name: pkg("vapor"),
version_req: Some(VersionReq::new(">=4.77.0, <5.0.0")),
version_range: Some(range(0, 0, 0, 6)),
version_literal: Some("4.77.0".to_string()),
};
let pr = CaLiteralParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Package.swift"),
};
let position = Position::new(0, 0);
let cached = HashMap::new();
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let actions = generate_code_actions(
&pr,
position,
pr.uri(),
versions,
content,
&CaRegistry,
&MockFormatter,
)
.await;
assert!(!actions.is_empty());
}
}
mod unsatisfiable_fix_action_tests {
use super::*;
use std::collections::HashMap;
struct NonFixingFormatter;
impl EcosystemFormatter for NonFixingFormatter {
fn format_version_for_text_edit(&self, version: &str) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
format!("https://example.com/{name}")
}
fn format_version_replacing(&self, _version: &str, _current: &str) -> String {
"still-bad".to_string()
}
fn compile_requirement(
&self,
requirement: &VersionReq,
) -> Option<Box<dyn RequirementMatcher>> {
Some(Box::new(ExactMatcher(requirement.as_str().to_string())))
}
}
struct CollidingTextFormatter;
impl EcosystemFormatter for CollidingTextFormatter {
fn format_version_for_text_edit(&self, version: &str) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
format!("https://example.com/{name}")
}
fn format_version_replacing(&self, _version: &str, _current: &str) -> String {
"9.9.9".to_string()
}
fn compile_requirement(
&self,
requirement: &VersionReq,
) -> Option<Box<dyn RequirementMatcher>> {
Some(Box::new(ExactMatcher(requirement.as_str().to_string())))
}
}
struct NormalizingExactFormatter;
impl EcosystemFormatter for NormalizingExactFormatter {
fn format_version_for_text_edit(&self, version: &str) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
format!("https://example.com/{name}")
}
fn normalize_package_name(&self, name: &PackageName) -> String {
format!("normalized-{name}")
}
fn compile_requirement(
&self,
requirement: &VersionReq,
) -> Option<Box<dyn RequirementMatcher>> {
Some(Box::new(ExactMatcher(requirement.as_str().to_string())))
}
}
struct UnsatDep {
name: PackageName,
version_req: VersionReq,
version_range: Range,
source: crate::parser::DependencySource,
}
impl Dependency for UnsatDep {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> Range {
Range::default()
}
fn version_requirement(&self) -> Option<&VersionReq> {
Some(&self.version_req)
}
fn version_range(&self) -> Option<Range> {
Some(self.version_range)
}
fn source(&self) -> crate::parser::DependencySource {
self.source.clone()
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct UnsatParseResult {
dep: UnsatDep,
uri: Uri,
}
impl ParseResult for UnsatParseResult {
fn dependencies(&self) -> Vec<&dyn Dependency> {
vec![&self.dep]
}
fn workspace_root(&self) -> Option<&std::path::Path> {
None
}
fn uri(&self) -> &Uri {
&self.uri
}
fn as_any(&self) -> &dyn Any {
self
}
}
fn cached_versions_keyed(
key: &str,
latest: &str,
available: &[&str],
) -> HashMap<PackageName, PackageVersions> {
let mut m = HashMap::new();
m.insert(
pkg(key),
PackageVersions {
latest: latest.to_string(),
available: Arc::from(
available.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
),
yanked: Arc::from(Vec::new()),
published_at: None,
},
);
m
}
fn cached_versions(
latest: &str,
available: &[&str],
) -> HashMap<PackageName, PackageVersions> {
cached_versions_keyed("pkg", latest, available)
}
#[tokio::test]
async fn test_unsat_fix_emitted_for_unsatisfiable_requirement() {
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = cached_versions("9.9.9", &["9.9.9"]);
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&ExactMatchFormatter,
)
.await;
assert_eq!(
quickfix_titles(&actions),
vec!["Fix unsatisfiable requirement: update to 9.9.9"]
);
assert_eq!(actions[0].is_preferred, Some(true));
}
#[tokio::test]
async fn test_unsat_fix_absent_when_requirement_is_satisfied() {
let (dep, version_range, content) = vulnerable_dep("9.9.9");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = cached_versions("9.9.9", &["9.9.9"]);
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&ExactMatchFormatter,
)
.await;
assert!(quickfix_titles(&actions).is_empty());
}
#[tokio::test]
async fn test_unsat_fix_absent_for_unsafe_or_empty_latest() {
for unsafe_latest in ["9.9.9\", \"evil\": \"true", "", " "] {
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = cached_versions(unsafe_latest, &[unsafe_latest]);
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&ExactMatchFormatter,
)
.await;
assert!(
quickfix_titles(&actions).is_empty(),
"expected no unsatisfiable-fix quickfix for latest {unsafe_latest:?}"
);
}
}
#[tokio::test]
async fn test_unsat_fix_absent_for_non_resolvable_source() {
let content = "1.0.0";
let dep = UnsatDep {
name: pkg("pkg"),
version_req: VersionReq::new("1.0.0"),
version_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
source: crate::parser::DependencySource::Path {
path: "../local".into(),
},
};
let pr = UnsatParseResult {
dep,
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = cached_versions("9.9.9", &["9.9.9"]);
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let actions = generate_code_actions(
&pr,
Position::new(0, 0),
pr.uri(),
versions,
content,
&MockRegistry,
&ExactMatchFormatter,
)
.await;
assert!(quickfix_titles(&actions).is_empty());
}
#[tokio::test]
async fn test_unsat_fix_no_op_guard_skips_when_rewrite_equals_declared_text() {
let (dep, version_range, content) = vulnerable_dep("9.9.9");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = cached_versions("9.9.9", &["1.0.0"]);
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&ExactMatchFormatter,
)
.await;
assert!(quickfix_titles(&actions).is_empty());
}
#[tokio::test]
async fn test_unsat_fix_verification_guard_rejects_rewrite_that_stays_unsatisfiable() {
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = cached_versions("9.9.9", &["9.9.9"]);
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&NonFixingFormatter,
)
.await;
assert!(quickfix_titles(&actions).is_empty());
}
#[tokio::test]
async fn test_unsat_fix_resolves_cache_entry_via_raw_name_fallback() {
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = cached_versions_keyed("pkg", "9.9.9", &["9.9.9"]);
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&NormalizingExactFormatter,
)
.await;
assert_eq!(
quickfix_titles(&actions),
vec!["Fix unsatisfiable requirement: update to 9.9.9"]
);
}
#[tokio::test]
async fn test_unsat_fix_dropped_when_target_is_yanked() {
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = cached_versions("9.9.9", &["9.9.9"]);
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let registry = FixedVersionRegistry {
versions: vec![("9.9.9", true)],
};
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
®istry,
&ExactMatchFormatter,
)
.await;
assert!(quickfix_titles(&actions).is_empty());
}
#[tokio::test]
async fn test_unsat_fix_survives_registry_outage_and_is_preferred() {
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = cached_versions("9.9.9", &["9.9.9"]);
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&ErrorRegistry,
&ExactMatchFormatter,
)
.await;
assert_eq!(
quickfix_titles(&actions),
vec!["Fix unsatisfiable requirement: update to 9.9.9"]
);
assert_eq!(actions[0].is_preferred, Some(true));
}
#[tokio::test]
async fn test_vuln_and_unsat_fix_coexist_with_vuln_preferred() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["5.5.5".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = cached_versions("9.9.9", &["9.9.9", "5.5.5"]);
let resolved = HashMap::new();
let versions =
VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&ExactMatchFormatter,
)
.await;
let titles = quickfix_titles(&actions);
assert_eq!(titles.len(), 2, "expected both fixes: {titles:?}");
assert!(titles.iter().any(|t| t.starts_with("Update to 5.5.5")));
assert!(
titles
.iter()
.any(|t| t.starts_with("Fix unsatisfiable requirement"))
);
let vuln_action = actions
.iter()
.find(|a| a.title.starts_with("Update to 5.5.5"))
.unwrap();
let unsat_action = actions
.iter()
.find(|a| a.title.starts_with("Fix unsatisfiable"))
.unwrap();
assert_eq!(vuln_action.is_preferred, Some(true));
assert_eq!(unsat_action.is_preferred, None);
assert_eq!(
actions
.iter()
.filter(|a| a.is_preferred == Some(true))
.count(),
1
);
}
#[tokio::test]
async fn test_unsat_fix_dropped_when_it_collides_with_vuln_fix_text() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["9.9.9".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = cached_versions("9.9.9", &["9.9.9"]);
let resolved = HashMap::new();
let versions =
VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
&MockRegistry,
&ExactMatchFormatter,
)
.await;
let titles = quickfix_titles(&actions);
assert_eq!(titles.len(), 1, "unsat fix must be dropped: {titles:?}");
assert!(titles[0].starts_with("Update to 9.9.9"));
}
#[tokio::test]
async fn test_yank_filter_runs_before_collision_check_so_neither_fix_is_lost() {
use crate::osv::{Advisory, DependencyVulnerabilities, UpgradeStatus, VulnSeverity};
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
vulnerabilities.insert(
"pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![std::sync::Arc::new(Advisory {
id: "A1".to_string(),
modified: "2023-01-01T00:00:00Z".to_string(),
summary: None,
aliases: vec![],
severity: VulnSeverity::High,
cvss_vector: None,
fixed_versions: vec!["9.9.5".to_string()],
url: String::new(),
})],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let cached = cached_versions("9.9.9", &["9.9.9"]);
let resolved = HashMap::new();
let versions =
VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
let registry = FixedVersionRegistry {
versions: vec![("9.9.5", true), ("9.9.9", false)],
};
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
®istry,
&CollidingTextFormatter,
)
.await;
let titles = quickfix_titles(&actions);
assert_eq!(
titles,
vec!["Fix unsatisfiable requirement: update to 9.9.9"],
"vuln fix must be dropped for being yanked, unsat fix must survive: {titles:?}"
);
}
#[tokio::test]
async fn test_unsat_fix_dedups_refactor_item_writing_the_same_text() {
let (dep, version_range, content) = vulnerable_dep("1.0.0");
let parse_result = MockParseResult {
deps: vec![dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached = cached_versions("9.9.9", &["9.9.9"]);
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let registry = FixedVersionRegistry {
versions: vec![("9.9.9", false)],
};
let actions = generate_code_actions(
&parse_result,
version_range.start,
parse_result.uri(),
versions,
&content,
®istry,
&ExactMatchFormatter,
)
.await;
assert_eq!(quickfix_titles(&actions).len(), 1);
assert!(
refactor_titles(&actions).is_empty(),
"the duplicate REFACTOR item must be suppressed by the dedup set: {actions:?}"
);
}
}
}