use crate::lsp_helpers::{
EcosystemFormatter, LineOffsetTable, RequirementGate, RequirementStatus, VersionData,
is_safe_version_string, literal_span_matches, requirement_is_oversized,
requirement_len_exceeds_cap, resolve_in_use_version, slice_for_range, strip_whitespace,
warn_rejected_value,
};
use crate::{ConcreteVersion, Dependency, ParseResult, VersionReq};
#[must_use]
pub fn requirement_is_placeholder_for(
formatter: &dyn EcosystemFormatter,
dep: &dyn Dependency,
current: &str,
) -> bool {
formatter.requirement_is_placeholder(&VersionReq::new(current))
|| dep
.version_literal()
.is_some_and(|literal| formatter.requirement_is_placeholder(&VersionReq::new(literal)))
}
#[must_use]
pub fn replacement_text(
formatter: &dyn EcosystemFormatter,
dep: &dyn Dependency,
version: &ConcreteVersion,
current: &str,
) -> Option<String> {
if requirement_is_placeholder_for(formatter, dep, current) {
return None;
}
Some(formatter.format_version_replacing_for(dep, version, current))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestEdit {
pub range: crate::position::Range,
pub new_text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlannedUpdate {
pub name: String,
pub normalized_name: String,
pub name_range: crate::position::Range,
pub current: Option<ConcreteVersion>,
pub target: ConcreteVersion,
pub edit: ManifestEdit,
}
pub trait EditSpan {
fn start(&self) -> (u32, u32);
fn end(&self) -> (u32, u32);
}
impl EditSpan for ManifestEdit {
fn start(&self) -> (u32, u32) {
(self.range.start.line, self.range.start.character)
}
fn end(&self) -> (u32, u32) {
(self.range.end.line, self.range.end.character)
}
}
impl EditSpan for PlannedUpdate {
fn start(&self) -> (u32, u32) {
self.edit.start()
}
fn end(&self) -> (u32, u32) {
self.edit.end()
}
}
pub fn dedup_overlapping_edits<E: EditSpan>(mut edits: Vec<E>, caller: &str) -> Vec<E> {
edits.sort_by_key(EditSpan::start);
let mut non_overlapping: Vec<E> = Vec::with_capacity(edits.len());
for edit in edits {
let overlaps_prev = non_overlapping
.last()
.is_some_and(|prev: &E| edit.start() < prev.end());
if overlaps_prev {
tracing::warn!(
start = ?edit.start(),
end = ?edit.end(),
caller,
"dropping overlapping edit"
);
continue;
}
non_overlapping.push(edit);
}
non_overlapping
}
#[must_use]
pub fn apply_edits(content: &str, edits: &[ManifestEdit]) -> String {
let table = LineOffsetTable::new(content);
let mut byte_edits: Vec<(usize, usize, &str)> = edits
.iter()
.filter_map(|edit| {
let start = table.position_to_byte_offset(content, edit.range.start);
let end = table.position_to_byte_offset(content, edit.range.end);
if start > end {
tracing::warn!(range = ?edit.range, "dropping edit with an inverted range");
return None;
}
Some((start, end, edit.new_text.as_str()))
})
.collect();
byte_edits.sort_by_key(|&(start, ..)| start);
let mut result = content.to_string();
for &(start, end, new_text) in byte_edits.iter().rev() {
result.replace_range(start..end, new_text);
}
result
}
pub trait ManifestReparse {
fn reparse(&self, content: &str) -> Option<Box<dyn ParseResult>>;
}
impl<F: Fn(&str) -> Option<Box<dyn ParseResult>>> ManifestReparse for F {
fn reparse(&self, content: &str) -> Option<Box<dyn ParseResult>> {
self(content)
}
}
pub struct EcosystemReparse<'a> {
pub ecosystem: &'a dyn crate::Ecosystem,
pub uri: &'a url::Url,
}
impl ManifestReparse for EcosystemReparse<'_> {
fn reparse(&self, content: &str) -> Option<Box<dyn ParseResult>> {
crate::ecosystem::parse_manifest_now(self.ecosystem, content, self.uri)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateKind {
Major,
Minor,
Patch,
Unknown,
}
#[must_use]
pub fn classify_update(from: &str, to: &str) -> UpdateKind {
match (
parse_leading_dotted_numeric(from),
parse_leading_dotted_numeric(to),
) {
(Some(f), Some(t)) => {
if f.0 != t.0 {
UpdateKind::Major
} else if f.1 != t.1 {
UpdateKind::Minor
} else {
UpdateKind::Patch
}
}
_ => UpdateKind::Unknown,
}
}
fn is_go_pseudo_version(s: &str) -> bool {
let body = s.strip_prefix(['v', 'V']).unwrap_or(s);
let mut parts = body.rsplitn(3, '-');
let Some(hash) = parts.next() else {
return false;
};
let Some(timestamp) = parts.next() else {
return false;
};
if parts.next().is_none() {
return false;
}
let timestamp = timestamp.strip_prefix("0.").unwrap_or(timestamp);
hash.len() == 12
&& hash.bytes().all(|b| b.is_ascii_hexdigit())
&& timestamp.len() == 14
&& timestamp.bytes().all(|b| b.is_ascii_digit())
}
fn parse_leading_dotted_numeric(s: &str) -> Option<(u64, u64, u64)> {
if is_go_pseudo_version(s) {
return None;
}
let stripped = s.strip_prefix(['v', 'V']).unwrap_or(s);
let core = match stripped.find(['-', '+']) {
Some(idx) => stripped.get(..idx)?,
None => stripped,
};
if core.is_empty() {
return None;
}
let mut major = 0u64;
let mut minor = 0u64;
let mut patch = 0u64;
let mut count = 0usize;
for part in core.split('.') {
if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let n: u64 = part.parse().ok()?;
match count {
0 => major = n,
1 => minor = n,
2 => patch = n,
_ => {}
}
count += 1;
}
if count == 0 {
return None;
}
Some((major, minor, patch))
}
#[must_use]
pub fn collect_update_edits(
parse_result: &dyn ParseResult,
content: &str,
versions: VersionData<'_>,
formatter: &dyn EcosystemFormatter,
) -> Vec<PlannedUpdate> {
let planned: Vec<PlannedUpdate> =
collect_update_candidates(parse_result, content, versions, formatter)
.into_iter()
.filter_map(|candidate| match candidate {
UpdateCandidate::Planned(planned) => Some(planned),
UpdateCandidate::Unplannable { .. } => None,
})
.collect();
dedup_overlapping_edits(planned, "collect_update_edits")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnplannableReason {
UnsafeLatestVersion,
NonLiteralSpan,
NoOpRewrite,
LatestFlaggedByOsv,
LatestUnverified,
}
#[derive(Debug, Clone)]
pub enum UpdateCandidate {
Planned(PlannedUpdate),
Unplannable {
name: String,
normalized_name: String,
name_range: crate::position::Range,
reason: UnplannableReason,
},
}
#[must_use]
pub fn collect_update_candidates(
parse_result: &dyn ParseResult,
content: &str,
versions: VersionData<'_>,
formatter: &dyn EcosystemFormatter,
) -> Vec<UpdateCandidate> {
let deps = parse_result.dependencies();
let mut candidates: Vec<UpdateCandidate> = Vec::with_capacity(deps.len());
let line_offsets = LineOffsetTable::new(content);
let vuln_keys = versions.ecosystem.map(|ecosystem| {
crate::osv::vulnerability_keys(
parse_result,
versions.resolved,
versions.resolved_version_candidates,
formatter,
ecosystem,
)
});
for dep in deps {
let Some(version_range) = dep.version_range() else {
continue;
};
let normalized_name = formatter.normalize_package_name(dep.name());
let Some(latest) = versions
.cached
.get(normalized_name.as_str())
.or_else(|| versions.cached.get(dep.name()))
.map(|v| &v.latest)
else {
continue;
};
let Some(version_req) = dep.version_requirement() else {
continue;
};
if version_req.as_str().is_empty() {
continue;
}
if requirement_is_placeholder_for(formatter, dep, version_req.as_str()) {
continue;
}
if formatter.requirement_status_for(dep, version_req, latest) != RequirementStatus::Outdated
{
continue;
}
let name = dep.name().as_str().to_string();
let name_range = dep.name_range();
if !is_safe_version_string(latest.as_str()) {
warn_rejected_value(
"is_safe_version_string",
"update edit plan",
latest.as_str(),
);
candidates.push(UpdateCandidate::Unplannable {
name,
normalized_name,
name_range,
reason: UnplannableReason::UnsafeLatestVersion,
});
continue;
}
match crate::lsp_helpers::latest_verdict(
versions.latest_status,
dep,
vuln_keys.as_ref(),
&normalized_name,
latest.as_str(),
formatter,
) {
crate::lsp_helpers::LatestVerdict::Verified
| crate::lsp_helpers::LatestVerdict::NotApplicable => {}
crate::lsp_helpers::LatestVerdict::Flagged { .. } => {
candidates.push(UpdateCandidate::Unplannable {
name,
normalized_name,
name_range,
reason: UnplannableReason::LatestFlaggedByOsv,
});
continue;
}
crate::lsp_helpers::LatestVerdict::Unverified => {
candidates.push(UpdateCandidate::Unplannable {
name,
normalized_name,
name_range,
reason: UnplannableReason::LatestUnverified,
});
continue;
}
}
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) {
candidates.push(UpdateCandidate::Unplannable {
name,
normalized_name,
name_range,
reason: UnplannableReason::NonLiteralSpan,
});
continue;
}
let Some(new_text) = replacement_text(formatter, dep, latest, version_req.as_str()) else {
continue;
};
if strip_whitespace(&new_text) == strip_whitespace(literal_target) {
candidates.push(UpdateCandidate::Unplannable {
name,
normalized_name,
name_range,
reason: UnplannableReason::NoOpRewrite,
});
continue;
}
let current = versions.ecosystem.and_then(|ecosystem| {
resolve_in_use_version(
dep,
&normalized_name,
versions.resolved,
versions.resolved_version_candidates,
formatter,
ecosystem,
)
});
candidates.push(UpdateCandidate::Planned(PlannedUpdate {
name,
normalized_name,
name_range,
current,
target: latest.clone(),
edit: ManifestEdit {
range: version_range,
new_text,
},
}));
}
candidates
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VulnFixSkip {
NoRecommendedFix,
UnsafeVersion,
UnverifiedTarget,
RequirementAlreadyResolves,
NoOpRewrite,
UnresolvedPlaceholder,
OversizedRequirement,
}
pub fn resolve_recommended_fix(
dv: &crate::osv::DependencyVulnerabilities,
latest: Option<&crate::osv::UpgradeStatus>,
formatter: &dyn EcosystemFormatter,
) -> Result<(crate::osv::FixRecommendation, String), VulnFixSkip> {
let fix = dv
.recommended_fix(latest)
.ok_or(VulnFixSkip::NoRecommendedFix)?;
let version_native = formatter.osv_version_to_native(&fix.version).into_string();
if !is_safe_version_string(&version_native) {
warn_rejected_value(
"is_safe_version_string",
"vulnerability fix plan",
&version_native,
);
return Err(VulnFixSkip::UnsafeVersion);
}
Ok((fix, version_native))
}
#[must_use]
pub(crate) fn fix_target_is_verified(
dv: &crate::osv::DependencyVulnerabilities,
fix: &crate::osv::FixRecommendation,
version_native: &str,
) -> bool {
use crate::osv::UpgradeStatus;
use std::collections::HashSet;
match &dv.fix_target_status {
UpgradeStatus::CandidateClean { version } => version == version_native,
UpgradeStatus::CandidateVulnerable {
version,
advisory_ids,
..
} => {
if version != version_native || !advisory_ids.is_complete() {
return false;
}
let known_ids: HashSet<&str> = dv
.advisories
.items()
.iter()
.map(|a| a.id.as_str())
.collect();
advisory_ids
.items()
.iter()
.all(|id| known_ids.contains(id.as_str()) && !fix.advisory_ids.contains(id))
}
UpgradeStatus::NotChecked
| UpgradeStatus::CandidateUnverified { .. }
| UpgradeStatus::StructurallyUnchecked(_) => false,
}
}
pub fn resolve_verified_fix(
dv: &crate::osv::DependencyVulnerabilities,
latest: Option<&crate::osv::UpgradeStatus>,
formatter: &dyn EcosystemFormatter,
) -> Result<(crate::osv::FixRecommendation, String), VulnFixSkip> {
let (fix, version_native) = resolve_recommended_fix(dv, latest, formatter)?;
if !fix_target_is_verified(dv, &fix, &version_native) {
return Err(VulnFixSkip::UnverifiedTarget);
}
Ok((fix, version_native))
}
pub fn plan_vulnerability_fix(
dep: &dyn Dependency,
version_range: crate::position::Range,
current: &str,
dv: &crate::osv::DependencyVulnerabilities,
latest: Option<&crate::osv::UpgradeStatus>,
formatter: &dyn EcosystemFormatter,
) -> Result<PlannedUpdate, VulnFixSkip> {
let (_fix, version_native) = resolve_verified_fix(dv, latest, formatter)?;
plan_verified_fix(dep, version_range, current, &version_native, formatter)
}
pub fn plan_verified_fix(
dep: &dyn Dependency,
version_range: crate::position::Range,
current: &str,
version_native: &str,
formatter: &dyn EcosystemFormatter,
) -> Result<PlannedUpdate, VulnFixSkip> {
if requirement_len_exceeds_cap(current)
|| dep
.version_requirement()
.is_some_and(requirement_is_oversized)
|| dep
.version_literal()
.is_some_and(requirement_len_exceeds_cap)
{
return Err(VulnFixSkip::OversizedRequirement);
}
if requirement_is_placeholder_for(formatter, dep, current) {
return Err(VulnFixSkip::UnresolvedPlaceholder);
}
let fix_concrete = ConcreteVersion::new(version_native);
let requirement_already_resolves_to_fix =
dep.version_requirement().is_some_and(|version_req| {
formatter.requirement_already_resolves_to(version_req, &fix_concrete)
});
if requirement_already_resolves_to_fix {
return Err(VulnFixSkip::RequirementAlreadyResolves);
}
let Some(new_text) = replacement_text(formatter, dep, &fix_concrete, current) else {
return Err(VulnFixSkip::UnresolvedPlaceholder);
};
let literal_target = dep.version_literal().unwrap_or(current);
if strip_whitespace(literal_target) == strip_whitespace(&new_text) {
return Err(VulnFixSkip::NoOpRewrite);
}
let normalized_name = formatter.normalize_package_name(dep.name());
Ok(PlannedUpdate {
name: dep.name().as_str().to_string(),
normalized_name,
name_range: dep.name_range(),
current: None,
target: ConcreteVersion::new(version_native),
edit: ManifestEdit {
range: version_range,
new_text,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::position::Position;
fn range(sl: u32, sc: u32, el: u32, ec: u32) -> crate::position::Range {
crate::position::Range::new(Position::new(sl, sc), Position::new(el, ec))
}
#[test]
fn test_classify_update_major() {
assert_eq!(classify_update("1.2.3", "2.0.0"), UpdateKind::Major);
}
#[test]
fn test_classify_update_minor() {
assert_eq!(classify_update("1.2.3", "1.3.0"), UpdateKind::Minor);
}
#[test]
fn test_classify_update_patch() {
assert_eq!(classify_update("1.2.3", "1.2.4"), UpdateKind::Patch);
}
#[test]
fn test_classify_update_v_prefix() {
assert_eq!(classify_update("v1.2.3", "v2.0.0"), UpdateKind::Major);
}
#[test]
fn test_classify_update_prerelease_and_build_suffix_ignored() {
assert_eq!(
classify_update("1.2.3-alpha.1", "1.2.3+build.5"),
UpdateKind::Patch
);
}
#[test]
fn test_classify_update_github_actions_sha_pin_is_unknown() {
assert_eq!(
classify_update(
"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3"
),
UpdateKind::Unknown
);
}
#[test]
fn test_classify_update_go_pseudo_version_is_unknown() {
assert_eq!(
classify_update("v0.0.0-20210101000000-abcdef123456", "v1.0.0"),
UpdateKind::Unknown
);
assert_eq!(
classify_update(
"v1.2.3-0.20210101000000-abcdef123456",
"v1.2.3-0.20210102000000-abcdef654321"
),
UpdateKind::Unknown
);
}
#[test]
fn test_classify_update_maven_nuget_range_syntax_is_unknown() {
assert_eq!(classify_update("[1.0,2.0)", "2.0.0"), UpdateKind::Unknown);
assert_eq!(classify_update("1.0.0", "[1.0,2.0)"), UpdateKind::Unknown);
}
#[test]
fn test_classify_update_wildcard_latest_workspace_star_is_unknown() {
assert_eq!(classify_update("*", "1.0.0"), UpdateKind::Unknown);
assert_eq!(classify_update("latest", "1.0.0"), UpdateKind::Unknown);
assert_eq!(classify_update("workspace:*", "1.0.0"), UpdateKind::Unknown);
}
#[test]
fn test_classify_update_gradle_strictly_preferred_syntax_is_unknown() {
assert_eq!(
classify_update("{strictly 1.0}!!1.2", "1.3.0"),
UpdateKind::Unknown
);
}
#[test]
fn test_classify_update_equal_versions_is_patch() {
assert_eq!(classify_update("1.2.3", "1.2.3"), UpdateKind::Patch);
}
#[test]
fn test_classify_update_four_component_version() {
assert_eq!(classify_update("1.0.0.0", "1.0.1.0"), UpdateKind::Patch);
}
#[test]
fn test_dedup_overlapping_manifest_edits_drops_the_later_overlap() {
let edits = vec![
ManifestEdit {
range: range(0, 0, 0, 5),
new_text: "a".to_string(),
},
ManifestEdit {
range: range(0, 2, 0, 7),
new_text: "b".to_string(),
},
];
let kept = dedup_overlapping_edits(edits, "test");
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].new_text, "a");
}
#[test]
fn test_dedup_non_overlapping_manifest_edits_keeps_both() {
let edits = vec![
ManifestEdit {
range: range(0, 0, 0, 5),
new_text: "a".to_string(),
},
ManifestEdit {
range: range(1, 0, 1, 5),
new_text: "b".to_string(),
},
];
let kept = dedup_overlapping_edits(edits, "test");
assert_eq!(kept.len(), 2);
}
#[test]
fn test_apply_edits_single_line() {
let content = "serde = \"1.0.0\"\n";
let edits = vec![ManifestEdit {
range: range(0, 9, 0, 14),
new_text: "1.2.0".to_string(),
}];
assert_eq!(apply_edits(content, &edits), "serde = \"1.2.0\"\n");
}
#[test]
fn test_apply_edits_multiple_lines_reverse_splice() {
let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\n";
let edits = vec![
ManifestEdit {
range: range(0, 9, 0, 14),
new_text: "1.2.0".to_string(),
},
ManifestEdit {
range: range(1, 9, 1, 14),
new_text: "1.3.0".to_string(),
},
];
assert_eq!(
apply_edits(content, &edits),
"serde = \"1.2.0\"\ntokio = \"1.3.0\"\n"
);
}
#[test]
fn test_apply_edits_empty_slice_is_a_no_op() {
let content = "serde = \"1.0.0\"\n";
assert_eq!(apply_edits(content, &[]), content);
}
#[test]
fn test_apply_edits_drops_inverted_range_instead_of_panicking() {
let content = "serde = \"1.0.0\"\n";
let edits = vec![ManifestEdit {
range: range(0, 14, 0, 9),
new_text: "evil".to_string(),
}];
assert_eq!(apply_edits(content, &edits), content);
}
#[test]
fn test_apply_edits_valid_edit_still_applies_alongside_a_dropped_inverted_one() {
let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\n";
let edits = vec![
ManifestEdit {
range: range(0, 14, 0, 9),
new_text: "evil".to_string(),
},
ManifestEdit {
range: range(1, 9, 1, 14),
new_text: "1.3.0".to_string(),
},
];
assert_eq!(
apply_edits(content, &edits),
"serde = \"1.0.0\"\ntokio = \"1.3.0\"\n"
);
}
#[test]
fn test_apply_edits_non_ascii_line_with_several_edits() {
let content = "h\u{e9}llo \u{1f600} a = \"1.0.0\", b = \"2.0.0\"\n";
let edit = |sc, ec, text: &str| ManifestEdit {
range: range(0, sc, 0, ec),
new_text: text.to_string(),
};
let edits = vec![
edit(27, 32, "2.2.2"),
edit(6, 8, "X"),
edit(14, 19, "1.1.0"),
];
assert_eq!(
apply_edits(content, &edits),
"h\u{e9}llo X a = \"1.1.0\", b = \"2.2.2\"\n"
);
}
#[test]
fn test_apply_edits_many_edits_on_one_long_non_ascii_line() {
const UNITS: usize = 5000;
const UNIT_UTF16_LEN: u32 = 18;
const VERSION_COL: u32 = 10;
let unit = |version: &str| format!("\u{e9}\u{1f600}dep = \"{version}\", ");
let content = "\u{e9}\u{1f600}dep = \"1.0.0\", ".repeat(UNITS);
let edits: Vec<ManifestEdit> = (0..UNITS)
.filter(|i| i % 30 == 0)
.map(|i| {
let start = VERSION_COL + UNIT_UTF16_LEN * u32::try_from(i).unwrap();
ManifestEdit {
range: range(0, start, 0, start + 5),
new_text: "9.9.9".to_string(),
}
})
.collect();
assert!(edits.len() >= 100);
let expected: String = (0..UNITS)
.map(|i| unit(if i % 30 == 0 { "9.9.9" } else { "1.0.0" }))
.collect();
assert_eq!(apply_edits(&content, &edits), expected);
}
mod collect_update_tests {
use super::*;
use crate::lsp_helpers::PackageVersions;
use crate::lsp_helpers::test_support::{MOCK_FORMATTER, MockDep, MockParseResult, pkg};
use crate::{EcosystemId, PackageName};
use std::collections::HashMap;
fn dep(name: &str, req: &str, version_range: crate::position::Range) -> MockDep {
MockDep {
name: pkg(name),
version_req: crate::VersionReq::new(req),
version_range,
name_range: crate::position::Range::default(),
}
}
fn parse_result(deps: Vec<MockDep>) -> MockParseResult {
MockParseResult {
deps,
uri: crate::test_util::test_uri("/test/Cargo.toml"),
}
}
#[test]
fn test_collect_update_edits_renamed_occurrence_resolves_its_own_pin() {
let content = "serde = \"0.9\"\nserde = \"1.0\"\n";
let pr = parse_result(vec![
dep("serde", "0.9", range(0, 9, 0, 12)),
dep("serde", "1.0", range(1, 9, 1, 12)),
]);
let mut cached = HashMap::new();
cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
let resolved_versions: HashMap<PackageName, ConcreteVersion> = HashMap::new();
let mut candidates = HashMap::new();
candidates.insert(
pkg("serde"),
vec![
ConcreteVersion::from("0.9.15"),
ConcreteVersion::from("1.0.219"),
],
);
let versions = VersionData::new(&cached, &resolved_versions)
.with_resolved_version_candidates(&candidates)
.with_ecosystem(EcosystemId::Cargo);
let mut planned = collect_update_edits(&pr, content, versions, &MOCK_FORMATTER);
planned.sort_by_key(|p| p.edit.range.start.line);
assert_eq!(planned.len(), 2);
assert_eq!(planned[0].current, Some(ConcreteVersion::from("0.9.15")));
assert_eq!(planned[1].current, Some(ConcreteVersion::from("1.0.219")));
}
#[test]
fn test_collect_update_candidates_non_literal_span_is_unplannable() {
let content = "<version>${slf4j.version}</version>";
let pr = parse_result(vec![dep("slf4j-api", "2.0.16", range(0, 9, 0, 25))]);
let mut cached = HashMap::new();
cached.insert("slf4j-api".into(), PackageVersions::latest_only("2.1.0"));
let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
assert_eq!(candidates.len(), 1);
assert!(matches!(
candidates[0],
UpdateCandidate::Unplannable {
reason: UnplannableReason::NonLiteralSpan,
..
}
));
assert!(
collect_update_edits(&pr, content, versions, &MOCK_FORMATTER).is_empty(),
"an unplannable candidate must never reach collect_update_edits's writable subset"
);
}
#[test]
fn test_collect_update_candidates_osv_flagged_latest_is_unplannable() {
let content = "feed-widget-helper = \"1.0.4\"\n";
let pr = parse_result(vec![dep(
"feed-widget-helper",
"1.0.4",
range(0, 22, 0, 29),
)]);
let mut cached = HashMap::new();
cached.insert(
"feed-widget-helper".into(),
PackageVersions::latest_only("1.0.8"),
);
let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
let mut latest_status = crate::osv::LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("feed-widget-helper"),
crate::osv::UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("1.0.8"),
advisory_ids: crate::osv::Capped::new(vec!["MAL-2026-16332".to_string()], 1),
worst_severity: Some(crate::osv::VulnSeverity::Malicious),
},
);
let versions = VersionData::new(&cached, &resolved).with_latest_status(&latest_status);
let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
assert_eq!(candidates.len(), 1, "{candidates:?}");
assert!(matches!(
candidates[0],
UpdateCandidate::Unplannable {
reason: UnplannableReason::LatestFlaggedByOsv,
..
}
));
assert!(
collect_update_edits(&pr, content, versions, &MOCK_FORMATTER).is_empty(),
"a flagged latest must never reach collect_update_edits's writable subset"
);
}
#[test]
fn test_collect_update_candidates_unverified_latest_is_unplannable() {
let content = "pkg = \"1.0.0\"\n";
let pr = parse_result(vec![dep("pkg", "1.0.0", range(0, 6, 0, 13))]);
let mut cached = HashMap::new();
cached.insert("pkg".into(), PackageVersions::latest_only("2.0.0"));
let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
let latest_status = crate::osv::LatestStatusMap::new();
let versions = VersionData::new(&cached, &resolved).with_latest_status(&latest_status);
let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
assert_eq!(candidates.len(), 1, "{candidates:?}");
assert!(matches!(
candidates[0],
UpdateCandidate::Unplannable {
reason: UnplannableReason::LatestUnverified,
..
}
));
}
#[test]
fn test_collect_update_candidates_no_op_rewrite_is_unplannable() {
struct NoOpFormatter;
impl crate::lsp_helpers::PackageNaming for NoOpFormatter {}
impl crate::lsp_helpers::PackageRendering for NoOpFormatter {
fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.as_str().to_string()
}
fn format_version_replacing(
&self,
_version: &ConcreteVersion,
current: &str,
) -> String {
current.to_string()
}
}
impl crate::lsp_helpers::RequirementResolution for NoOpFormatter {
fn is_bounded_requirement_up_to_date(
&self,
_requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
_latest: &ConcreteVersion,
) -> bool {
false
}
}
impl crate::lsp_helpers::DiagnosticMessages for NoOpFormatter {}
impl crate::lsp_helpers::DiagnosticPolicy for NoOpFormatter {}
impl crate::lsp_helpers::SourcePolicy for NoOpFormatter {}
impl crate::lsp_helpers::OsvNaming for NoOpFormatter {}
let content = "pkg = \"1.0.0\"\n";
let pr = parse_result(vec![dep("pkg", "1.0.0", range(0, 7, 0, 12))]);
let mut cached = HashMap::new();
cached.insert("pkg".into(), PackageVersions::latest_only("1.2.0"));
let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let candidates = collect_update_candidates(&pr, content, versions, &NoOpFormatter);
assert_eq!(candidates.len(), 1);
assert!(matches!(
candidates[0],
UpdateCandidate::Unplannable {
reason: UnplannableReason::NoOpRewrite,
..
}
));
}
#[test]
fn test_collect_update_candidates_unsafe_latest_is_unplannable() {
let content = "serde = \"1.0.0\"\n";
let pr = parse_result(vec![dep("serde", "1.0.0", range(0, 8, 0, 15))]);
let mut cached = HashMap::new();
cached.insert(
"serde".into(),
PackageVersions::latest_only("1.2.0\", \"evil\": \"true"),
);
let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
assert_eq!(candidates.len(), 1);
assert!(matches!(
candidates[0],
UpdateCandidate::Unplannable {
reason: UnplannableReason::UnsafeLatestVersion,
..
}
));
}
#[test]
fn test_collect_update_candidates_up_to_date_dependency_produces_no_candidate() {
let content = "serde = \"^1.0\"\n";
let pr = parse_result(vec![dep("serde", "^1.0", range(0, 8, 0, 14))]);
let mut cached = HashMap::new();
cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
assert!(candidates.is_empty());
}
#[test]
fn test_collect_update_candidates_github_actions_sha_pin_current_resolves_via_hook() {
struct ShaPinResolvedFormatter;
impl crate::lsp_helpers::PackageNaming for ShaPinResolvedFormatter {}
impl crate::lsp_helpers::PackageRendering for ShaPinResolvedFormatter {
fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.as_str().to_string()
}
}
impl crate::lsp_helpers::RequirementResolution for ShaPinResolvedFormatter {
fn is_bounded_requirement_up_to_date(
&self,
_requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
_latest: &ConcreteVersion,
) -> bool {
false
}
fn resolved_pin_version(
&self,
_dep: &dyn Dependency,
) -> Option<crate::lsp_helpers::ResolvedPin> {
Some(crate::lsp_helpers::ResolvedPin::most_specific(
ConcreteVersion::new("v1.3.0"),
))
}
}
impl crate::lsp_helpers::DiagnosticMessages for ShaPinResolvedFormatter {}
impl crate::lsp_helpers::DiagnosticPolicy for ShaPinResolvedFormatter {}
impl crate::lsp_helpers::SourcePolicy for ShaPinResolvedFormatter {}
impl crate::lsp_helpers::OsvNaming for ShaPinResolvedFormatter {}
let sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
let content = format!("actions/checkout = \"{sha}\"\n");
let sha_start = u32::try_from(content.find(sha).unwrap()).unwrap();
let sha_end = sha_start + u32::try_from(sha.len()).unwrap();
let pr = parse_result(vec![dep(
"actions/checkout",
sha,
range(0, sha_start, 0, sha_end),
)]);
let mut cached = HashMap::new();
cached.insert(
"actions/checkout".into(),
PackageVersions::latest_only("v1.4.0"),
);
let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
let versions =
VersionData::new(&cached, &resolved).with_ecosystem(EcosystemId::GithubActions);
let candidates =
collect_update_candidates(&pr, &content, versions, &ShaPinResolvedFormatter);
assert_eq!(candidates.len(), 1, "{candidates:?}");
let UpdateCandidate::Planned(planned) = &candidates[0] else {
panic!("expected a planned update, got {:?}", candidates[0]);
};
assert_eq!(planned.current, Some(ConcreteVersion::from("v1.3.0")));
assert_eq!(
classify_update(
planned.current.as_ref().unwrap().as_str(),
planned.target.as_str()
),
UpdateKind::Minor
);
}
}
mod plan_vulnerability_fix_tests {
use super::*;
use crate::PackageName;
use crate::VersionReq;
use crate::lsp_helpers::test_support::{
MOCK_FORMATTER, MockDep, StrictSemverFormatter, pkg,
};
use crate::lsp_helpers::{
DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
RequirementMatcher, RequirementResolution, SourcePolicy,
};
use crate::osv::{
Advisory, Capped, DependencyVulnerabilities, OsvVersion, UpgradeStatus, VulnSeverity,
};
fn dep(name: &str, req: &str, version_range: crate::position::Range) -> MockDep {
MockDep {
name: PackageName::new(name),
version_req: crate::VersionReq::new(req),
version_range,
name_range: crate::position::Range::default(),
}
}
fn verified_dv(fixed_version: &str) -> DependencyVulnerabilities {
let advisory = std::sync::Arc::new(
Advisory::new(
"RUSTSEC-2024-0001".to_string(),
"2024-01-01T00:00:00Z".to_string(),
VulnSeverity::High,
)
.expect("valid osv id")
.with_fixed_versions(vec![OsvVersion::new(fixed_version)]),
);
DependencyVulnerabilities::new(Capped::new(vec![advisory], 1)).with_fix_target_status(
UpgradeStatus::CandidateClean {
version: ConcreteVersion::new(fixed_version),
},
)
}
struct ShaPinFormatter;
impl PackageNaming for ShaPinFormatter {}
impl PackageRendering for ShaPinFormatter {
fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
version.to_string()
}
fn package_url(&self, name: &crate::PackageName) -> String {
name.as_str().to_string()
}
fn format_version_replacing(
&self,
version: &ConcreteVersion,
_current: &str,
) -> String {
format!("{version} # v-resolved")
}
}
impl RequirementResolution for ShaPinFormatter {
fn bounded_requirement_is_unresolved(
&self,
_requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
) -> bool {
true
}
}
impl DiagnosticMessages for ShaPinFormatter {}
impl DiagnosticPolicy for ShaPinFormatter {}
impl SourcePolicy for ShaPinFormatter {}
impl OsvNaming for ShaPinFormatter {}
#[test]
fn test_requirement_already_admitting_fix_returns_none() {
let d = dep("serde", "1", range(0, 8, 0, 9));
let dv = verified_dv("1.0.2");
let planned =
plan_vulnerability_fix(&d, d.version_range, "1", &dv, None, &StrictSemverFormatter);
assert_eq!(planned, Err(VulnFixSkip::RequirementAlreadyResolves));
}
#[test]
fn test_oversized_requirement_does_not_report_already_resolves() {
use crate::lsp_helpers::MAX_REQUIREMENT_LEN;
use crate::lsp_helpers::test_support::ExactMatchFormatter;
let at_cap = "1".repeat(MAX_REQUIREMENT_LEN);
let d = dep(
"serde",
&at_cap,
range(0, 8, 0, 8 + u32::try_from(at_cap.len()).unwrap()),
);
assert_eq!(
plan_verified_fix(&d, d.version_range, &at_cap, &at_cap, &ExactMatchFormatter),
Err(VulnFixSkip::RequirementAlreadyResolves)
);
let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
let d = dep(
"serde",
&oversized,
range(0, 8, 0, 8 + u32::try_from(oversized.len()).unwrap()),
);
let planned = plan_verified_fix(
&d,
d.version_range,
&oversized,
&oversized,
&ExactMatchFormatter,
);
assert_eq!(planned, Err(VulnFixSkip::OversizedRequirement));
}
#[test]
fn test_oversized_dependency_requirement_or_literal_is_oversized_skip() {
use crate::lsp_helpers::MAX_REQUIREMENT_LEN;
use crate::lsp_helpers::test_support::ExactMatchFormatter;
struct LiteralDep {
inner: MockDep,
literal: String,
}
impl Dependency for LiteralDep {
fn name(&self) -> &PackageName {
self.inner.name()
}
fn name_range(&self) -> crate::position::Range {
self.inner.name_range()
}
fn version_requirement(&self) -> Option<&VersionReq> {
self.inner.version_requirement()
}
fn version_range(&self) -> Option<crate::position::Range> {
self.inner.version_range()
}
fn version_literal(&self) -> Option<&str> {
Some(&self.literal)
}
fn source(&self) -> crate::parser::DependencySource {
self.inner.source()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
let oversized_requirement = dep("serde", &oversized, range(0, 8, 0, 9));
assert_eq!(
plan_verified_fix(
&oversized_requirement,
oversized_requirement.version_range,
"1",
"2",
&ExactMatchFormatter,
),
Err(VulnFixSkip::OversizedRequirement)
);
let oversized_literal = LiteralDep {
inner: dep("serde", "1", range(0, 8, 0, 9)),
literal: oversized,
};
assert_eq!(
plan_verified_fix(
&oversized_literal,
range(0, 8, 0, 9),
"1",
"2",
&ExactMatchFormatter,
),
Err(VulnFixSkip::OversizedRequirement)
);
}
#[test]
fn test_requirement_not_admitting_fix_returns_planned_edit() {
let d = dep("serde", "0.9", range(0, 8, 0, 11));
let dv = verified_dv("1.0.2");
let planned = plan_vulnerability_fix(
&d,
d.version_range,
"0.9",
&dv,
None,
&StrictSemverFormatter,
)
.expect("0.9 does not admit 1.0.2, so an edit must be planned");
assert_eq!(planned.edit.new_text, "1.0.2");
assert_eq!(planned.target.as_str(), "1.0.2");
}
#[test]
fn test_no_compile_requirement_override_falls_back_to_no_op_guard() {
let d = dep("serde", "1", range(0, 8, 0, 9));
let dv = verified_dv("1.0.2");
let planned =
plan_vulnerability_fix(&d, d.version_range, "1", &dv, None, &MOCK_FORMATTER)
.unwrap();
assert_eq!(planned.edit.new_text, "\"1.0.2\"");
}
#[test]
fn test_unsafe_fix_version_is_rejected() {
let d = dep("serde", "0.9", range(0, 8, 0, 11));
let dv = verified_dv("1.2.0 evil");
let planned =
plan_vulnerability_fix(&d, d.version_range, "0.9", &dv, None, &MOCK_FORMATTER);
assert_eq!(planned, Err(VulnFixSkip::UnsafeVersion));
}
#[test]
fn test_true_no_op_rewrite_is_rejected() {
let d = dep("serde", "1", range(0, 8, 0, 9));
let dv = verified_dv("1.0.2");
let planned = plan_vulnerability_fix(
&d,
d.version_range,
"\"1.0.2\"",
&dv,
None,
&MOCK_FORMATTER,
);
assert_eq!(planned, Err(VulnFixSkip::NoOpRewrite));
}
#[test]
fn test_plan_vulnerability_fix_uses_advisory_beyond_display_cap() {
use crate::osv::ADVISORY_DISPLAY_CAP;
let mut advisories: Vec<std::sync::Arc<Advisory>> = (0..ADVISORY_DISPLAY_CAP + 3)
.map(|i| {
std::sync::Arc::new(
Advisory::new(
format!("ADV-{i}"),
"2024-01-01T00:00:00Z".to_string(),
VulnSeverity::High,
)
.expect("valid osv id")
.with_fixed_versions(vec![OsvVersion::new("1.0.0")]),
)
})
.collect();
let beyond_cap_index = ADVISORY_DISPLAY_CAP + 1;
advisories[beyond_cap_index] = std::sync::Arc::new(
Advisory::new(
format!("ADV-{beyond_cap_index}"),
"2024-01-01T00:00:00Z".to_string(),
VulnSeverity::High,
)
.expect("valid osv id")
.with_fixed_versions(vec![OsvVersion::new("2.0.0")]),
);
let total = advisories.len();
let dv = DependencyVulnerabilities::new(Capped::new(advisories, total))
.with_fix_target_status(UpgradeStatus::CandidateClean {
version: ConcreteVersion::new("2.0.0"),
});
let d = dep("serde", "0.9", range(0, 8, 0, 11));
let planned =
plan_vulnerability_fix(&d, d.version_range, "0.9", &dv, None, &MOCK_FORMATTER)
.expect("fix beyond the display cap must still be recommended and verified");
assert_eq!(planned.edit.new_text, "\"2.0.0\"");
assert_eq!(planned.target.as_str(), "2.0.0");
}
struct FloorFormatter;
struct FloorMatcher(String);
impl RequirementMatcher for FloorMatcher {
fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
Some(version.as_str() >= self.0.as_str())
}
fn strict_prerelease_exclusion(&self) -> bool {
false
}
}
impl PackageNaming for FloorFormatter {}
impl PackageRendering for FloorFormatter {
fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.as_str().to_string()
}
}
impl RequirementResolution for FloorFormatter {
fn compile_bounded_requirement(
&self,
requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
Some(Box::new(FloorMatcher(requirement.as_str().to_string())))
}
fn bounded_requirement_already_resolves_to(
&self,
_requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
_target: &ConcreteVersion,
) -> bool {
false
}
}
impl DiagnosticMessages for FloorFormatter {}
impl DiagnosticPolicy for FloorFormatter {}
impl SourcePolicy for FloorFormatter {}
impl OsvNaming for FloorFormatter {}
#[test]
fn test_floor_shaped_requirement_still_returns_planned_edit() {
let d = dep("nuget.pkg", "1.0.0", range(0, 8, 0, 15));
let dv = verified_dv("1.0.2");
assert_eq!(
FloorFormatter
.compile_requirement(&crate::VersionReq::new("1.0.0"))
.unwrap()
.matches(&ConcreteVersion::new("1.0.2")),
Some(true)
);
let planned =
plan_vulnerability_fix(&d, d.version_range, "1.0.0", &dv, None, &FloorFormatter)
.expect("a floor-shaped requirement must not suppress the fix");
assert_eq!(planned.edit.new_text, "1.0.2");
}
#[test]
fn test_plan_vulnerability_fix_ignores_requirement_is_unresolved() {
let action_dep = MockDep {
name: pkg("some-action"),
version_req: VersionReq::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
version_range: range(0, 9, 0, 49),
name_range: crate::position::Range::default(),
};
let dv = verified_dv("1.2.0");
let planned = plan_vulnerability_fix(
&action_dep,
action_dep.version_range,
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
&dv,
None,
&ShaPinFormatter,
);
assert_eq!(
planned.expect("fix must still be planned").edit.new_text,
"1.2.0 # v-resolved"
);
}
}
}