use std::time::Duration;
use deps_core::Ecosystem;
use deps_core::edit::{VulnFixSkip, plan_verified_fix, resolve_verified_fix};
use deps_core::lsp_helpers::{RequirementGate, resolve_in_use_version, resolve_scan_outcome};
use deps_core::osv::{OsvClient, ScanOutcome};
use crate::analyze::ManifestAnalysis;
use crate::update::ignore::IgnoreRules;
use crate::update::{
CurrentVersion, Outcome, PlannedUpdateItem, UnfixableReason, UpdatePlan, is_requested,
};
const OSV_CHECK_TIMEOUT_CEILING_SECS: u64 = 30;
pub async fn plan_security_updates(
analysis: &ManifestAnalysis,
ecosystem: &dyn Ecosystem,
osv: &OsvClient,
package_filter: &[String],
ignore_rules: &IgnoreRules,
fetch_timeout_secs: u64,
) -> UpdatePlan {
let formatter = ecosystem.formatter();
let ecosystem_id = analysis.ecosystem_id;
let Some(vulnerabilities) = analysis.vulnerabilities.as_ref() else {
return UpdatePlan::default();
};
let vulnerable_keys: Vec<deps_core::osv::VulnKey> = vulnerabilities
.iter()
.filter(|(_, outcome)| matches!(outcome, ScanOutcome::Vulnerable(_)))
.map(|(key, _)| key.clone())
.collect();
if vulnerable_keys.is_empty() {
return UpdatePlan::default();
}
let (targets, _skipped) = deps_engine::classify::osv::build_scan_targets(
analysis.parse_result.as_ref(),
&analysis.resolved_versions,
&analysis.resolved_version_candidates,
formatter,
ecosystem_id,
);
let osv_name_by_key = deps_engine::classify::osv::osv_name_by_key(&targets);
static EMPTY_LATEST_STATUS: std::sync::LazyLock<deps_core::osv::LatestStatusMap> =
std::sync::LazyLock::new(deps_core::osv::LatestStatusMap::new);
let latest_status = analysis
.latest_status
.as_ref()
.unwrap_or(&EMPTY_LATEST_STATUS);
let (resolved, live_check_candidates) =
deps_engine::classify::osv::collect_fix_target_resolutions(
vulnerabilities,
&vulnerable_keys,
&osv_name_by_key,
latest_status,
formatter,
);
let mut vulnerabilities = vulnerabilities.clone();
for (key, status) in resolved {
if let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get_mut(&key) {
dv.fix_target_status = status;
}
}
if !live_check_candidates.is_empty() {
let timeout = Duration::from_secs(fetch_timeout_secs.min(OSV_CHECK_TIMEOUT_CEILING_SECS));
let statuses = osv
.check_candidates(ecosystem_id, &live_check_candidates, timeout)
.await;
deps_engine::classify::osv::apply_live_fix_target_statuses(&mut vulnerabilities, statuses);
}
let vuln_key_by_range = deps_core::osv::vulnerability_keys(
analysis.parse_result.as_ref(),
&analysis.resolved_versions,
Some(&analysis.resolved_version_candidates),
formatter,
ecosystem_id,
);
let mut items = Vec::new();
for dep in analysis.parse_result.dependencies() {
let normalized_name = formatter.normalize_package_name(dep.name());
let Some(ScanOutcome::Vulnerable(dv)) = resolve_scan_outcome(
&vulnerabilities,
dep,
Some(&vuln_key_by_range),
&normalized_name,
) else {
continue;
};
if !is_requested(package_filter, &normalized_name, formatter) {
items.push(skipped_not_requested(
dep,
&normalized_name,
ecosystem_id,
analysis,
formatter,
));
continue;
}
let latest = analysis.latest_status.as_ref().and_then(|map| {
deps_core::lsp_helpers::resolve_latest_status(
map,
dep,
Some(&vuln_key_by_range),
&normalized_name,
)
});
items.push(classify_vulnerable_dependency(
dep,
dv,
latest,
&normalized_name,
analysis,
formatter,
ecosystem_id,
ignore_rules,
));
}
UpdatePlan { items }
}
#[expect(
clippy::too_many_arguments,
reason = "issue #1517 added `latest` alongside seven pre-existing parameters; mirrors \
`deps-lsp::document::osv_scan::run_osv_fix_target_verification`'s identical \
precedent for the same OSV-plumbing reason"
)]
fn classify_vulnerable_dependency(
dep: &dyn deps_core::Dependency,
dv: &deps_core::osv::DependencyVulnerabilities,
latest: Option<&deps_core::osv::UpgradeStatus>,
normalized_name: &str,
analysis: &ManifestAnalysis,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
ecosystem_id: deps_core::EcosystemId,
ignore_rules: &IgnoreRules,
) -> PlannedUpdateItem {
let current = security_current(dep, normalized_name, analysis, formatter, ecosystem_id);
let ignore_rule_overridden = ignore_override(ignore_rules, normalized_name);
let cached = analysis
.cached_versions
.get(normalized_name)
.or_else(|| analysis.cached_versions.get(dep.name()));
if analysis.fetch_failed.contains(dep.name()) || cached.is_none() {
return unfixable_item(
dep,
current,
UnfixableReason::FetchFailedOrAbsent,
ignore_rule_overridden,
);
}
let (fix, version_native) = match resolve_verified_fix(dv, latest, formatter) {
Ok(pair) => pair,
Err(_) => {
return unfixable_item(
dep,
current,
UnfixableReason::NoVerifiedFix,
ignore_rule_overridden,
);
}
};
let Some(version_req) = dep.version_requirement() else {
return requires_lockfile_update_item(
dep,
current,
deps_core::ConcreteVersion::new(version_native),
&fix.advisory_ids,
ignore_rule_overridden,
);
};
let Some(version_range) = dep.version_range() else {
return requires_lockfile_update_item(
dep,
current,
deps_core::ConcreteVersion::new(version_native),
&fix.advisory_ids,
ignore_rule_overridden,
);
};
let yanked = cached.is_some_and(|pv| {
pv.yanked
.iter()
.any(|(v, status)| v.as_str() == version_native && status.blocks_resolution())
});
if yanked {
return unfixable_item(
dep,
current,
UnfixableReason::Yanked {
target: deps_core::ConcreteVersion::new(version_native),
},
ignore_rule_overridden,
);
}
match plan_verified_fix(
dep,
version_range,
version_req.as_str(),
&version_native,
formatter,
) {
Ok(planned) => PlannedUpdateItem::new(
dep.name().as_str().to_string(),
current,
Outcome::Applied {
edit: planned.edit,
target: deps_core::ConcreteVersion::new(version_native),
},
fix.advisory_ids,
ignore_rule_overridden,
None,
None,
),
Err(VulnFixSkip::RequirementAlreadyResolves) => requires_lockfile_update_item(
dep,
current,
deps_core::ConcreteVersion::new(version_native),
&fix.advisory_ids,
ignore_rule_overridden,
),
Err(VulnFixSkip::NoOpRewrite) => {
let fix_concrete = deps_core::ConcreteVersion::new(version_native.as_str());
let confirmed_excluded = formatter
.compile_requirement(version_req)
.is_some_and(|matcher| matcher.matches(&fix_concrete) == Some(false));
if confirmed_excluded {
unfixable_item(
dep,
current,
UnfixableReason::UnsupportedRequirementShape {
target: fix_concrete,
},
ignore_rule_overridden,
)
} else {
requires_lockfile_update_item(
dep,
current,
fix_concrete,
&fix.advisory_ids,
ignore_rule_overridden,
)
}
}
Err(VulnFixSkip::OversizedRequirement) => unfixable_item(
dep,
current,
UnfixableReason::OversizedRequirement {
target: deps_core::ConcreteVersion::new(version_native.as_str()),
},
ignore_rule_overridden,
),
Err(
VulnFixSkip::UnverifiedTarget
| VulnFixSkip::NoRecommendedFix
| VulnFixSkip::UnsafeVersion
| VulnFixSkip::UnresolvedPlaceholder,
) => unfixable_item(
dep,
current,
UnfixableReason::NoVerifiedFix,
ignore_rule_overridden,
),
}
}
fn security_current(
dep: &dyn deps_core::Dependency,
normalized_name: &str,
analysis: &ManifestAnalysis,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
ecosystem_id: deps_core::EcosystemId,
) -> CurrentVersion {
resolve_in_use_version(
dep,
normalized_name,
&analysis.resolved_versions,
Some(&analysis.resolved_version_candidates),
formatter,
ecosystem_id,
)
.map(CurrentVersion::Resolved)
.or_else(|| {
dep.version_requirement()
.cloned()
.map(CurrentVersion::Declared)
})
.unwrap_or(CurrentVersion::Unknown)
}
fn ignore_override(ignore_rules: &IgnoreRules, normalized_name: &str) -> bool {
ignore_rules.matches_name(normalized_name)
}
fn skipped_not_requested(
dep: &dyn deps_core::Dependency,
normalized_name: &str,
ecosystem_id: deps_core::EcosystemId,
analysis: &ManifestAnalysis,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
) -> PlannedUpdateItem {
let current = security_current(dep, normalized_name, analysis, formatter, ecosystem_id);
PlannedUpdateItem::new(
dep.name().as_str().to_string(),
current,
Outcome::Skipped {
reason: crate::update::SkipReason::NotRequested,
target: None,
},
Vec::new(),
false,
None,
None,
)
}
fn unfixable_item(
dep: &dyn deps_core::Dependency,
current: CurrentVersion,
reason: UnfixableReason,
ignore_rule_overridden: bool,
) -> PlannedUpdateItem {
PlannedUpdateItem::new(
dep.name().as_str().to_string(),
current,
Outcome::Unfixable(reason),
Vec::new(),
ignore_rule_overridden,
None,
None,
)
}
fn requires_lockfile_update_item(
dep: &dyn deps_core::Dependency,
current: CurrentVersion,
target: deps_core::ConcreteVersion,
advisory_ids: &[String],
ignore_rule_overridden: bool,
) -> PlannedUpdateItem {
PlannedUpdateItem::new(
dep.name().as_str().to_string(),
current,
Outcome::RequiresLockfileUpdate { target },
advisory_ids.to_vec(),
ignore_rule_overridden,
None,
None,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::IgnoreRule;
use deps_core::licenses::LicensePolicy;
use deps_core::lsp_helpers::{
DiagnosticMessages, DiagnosticPolicy, MAX_REQUIREMENT_LEN, OsvNaming, PackageNaming,
PackageRendering, RequirementMatcher, RequirementResolution, SourcePolicy,
};
use deps_core::osv::{Advisory, Capped, OsvVersion, UpgradeStatus, VulnSeverity};
use deps_core::parser::DependencySource;
use deps_core::position::{Position, Range};
use deps_core::{
ConcreteVersion, Dependency, EcosystemId, PackageName, PackageVersions, RemovalStatus,
VersionReq,
};
use std::any::Any;
use std::collections::{HashMap, HashSet};
struct MockDep {
name: PackageName,
version_req: Option<VersionReq>,
version_range: Option<Range>,
}
impl Dependency for MockDep {
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) -> DependencySource {
DependencySource::Registry
}
fn as_any(&self) -> &dyn Any {
self
}
}
fn dep(name: &str, req: &str) -> MockDep {
MockDep {
name: PackageName::new(name),
version_req: Some(VersionReq::new(req)),
version_range: Some(Range::new(
Position::new(0, 9),
Position::new(0, 9 + req.len() as u32),
)),
}
}
struct FixedMatcher(bool);
impl RequirementMatcher for FixedMatcher {
fn matches(&self, _version: &deps_core::ConcreteVersion) -> Option<bool> {
Some(self.0)
}
fn strict_prerelease_exclusion(&self) -> bool {
false
}
}
struct TestFormatter {
requirement_already_admits_fix: bool,
osv_native_differs: bool,
}
impl PackageNaming for TestFormatter {}
impl PackageRendering for TestFormatter {
fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
v.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.as_str().to_string()
}
}
impl RequirementResolution for TestFormatter {
fn compile_bounded_requirement(
&self,
_requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
Some(Box::new(FixedMatcher(self.requirement_already_admits_fix)))
}
}
impl DiagnosticMessages for TestFormatter {}
impl DiagnosticPolicy for TestFormatter {}
impl SourcePolicy for TestFormatter {}
impl OsvNaming for TestFormatter {
fn osv_version_to_native(
&self,
version: &deps_core::osv::OsvVersion,
) -> deps_core::ConcreteVersion {
let version = version.as_str();
if self.osv_native_differs {
deps_core::ConcreteVersion::new(format!("v{version}"))
} else {
deps_core::ConcreteVersion::new(version)
}
}
}
struct UnsupportedShapeFormatter;
impl PackageNaming for UnsupportedShapeFormatter {}
impl PackageRendering for UnsupportedShapeFormatter {
fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
v.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.as_str().to_string()
}
fn format_version_replacing(
&self,
_version: &deps_core::ConcreteVersion,
current: &str,
) -> String {
current.to_string()
}
}
impl RequirementResolution for UnsupportedShapeFormatter {
fn compile_bounded_requirement(
&self,
_requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
Some(Box::new(FixedMatcher(false)))
}
}
impl DiagnosticMessages for UnsupportedShapeFormatter {}
impl DiagnosticPolicy for UnsupportedShapeFormatter {}
impl SourcePolicy for UnsupportedShapeFormatter {}
impl OsvNaming for UnsupportedShapeFormatter {}
struct FloorLikeFormatter;
impl PackageNaming for FloorLikeFormatter {}
impl PackageRendering for FloorLikeFormatter {
fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
v.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.as_str().to_string()
}
fn format_version_replacing(
&self,
_version: &deps_core::ConcreteVersion,
current: &str,
) -> String {
current.to_string()
}
}
impl RequirementResolution for FloorLikeFormatter {
fn compile_bounded_requirement(
&self,
_requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
Some(Box::new(FixedMatcher(true)))
}
fn bounded_requirement_already_resolves_to(
&self,
_requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
_target: &deps_core::ConcreteVersion,
) -> bool {
false
}
}
impl DiagnosticMessages for FloorLikeFormatter {}
impl DiagnosticPolicy for FloorLikeFormatter {}
impl SourcePolicy for FloorLikeFormatter {}
impl OsvNaming for FloorLikeFormatter {}
struct IndeterminateMatcher;
impl RequirementMatcher for IndeterminateMatcher {
fn matches(&self, _version: &deps_core::ConcreteVersion) -> Option<bool> {
None
}
fn strict_prerelease_exclusion(&self) -> bool {
false
}
}
struct IndeterminateFormatter;
impl PackageNaming for IndeterminateFormatter {}
impl PackageRendering for IndeterminateFormatter {
fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
v.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.as_str().to_string()
}
fn format_version_replacing(
&self,
_version: &deps_core::ConcreteVersion,
current: &str,
) -> String {
current.to_string()
}
}
impl RequirementResolution for IndeterminateFormatter {
fn compile_bounded_requirement(
&self,
_requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
Some(Box::new(IndeterminateMatcher))
}
}
impl DiagnosticMessages for IndeterminateFormatter {}
impl DiagnosticPolicy for IndeterminateFormatter {}
impl SourcePolicy for IndeterminateFormatter {}
impl OsvNaming for IndeterminateFormatter {}
struct PanicsIfCompiledFormatter;
impl PackageNaming for PanicsIfCompiledFormatter {}
impl PackageRendering for PanicsIfCompiledFormatter {
fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
v.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.as_str().to_string()
}
fn format_version_replacing(
&self,
_version: &deps_core::ConcreteVersion,
current: &str,
) -> String {
current.to_string()
}
}
impl RequirementResolution for PanicsIfCompiledFormatter {
fn compile_bounded_requirement(
&self,
_requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
panic!(
"compile_bounded_requirement must not be called for an oversized requirement (#1578)"
);
}
}
impl DiagnosticMessages for PanicsIfCompiledFormatter {}
impl DiagnosticPolicy for PanicsIfCompiledFormatter {}
impl SourcePolicy for PanicsIfCompiledFormatter {}
impl OsvNaming for PanicsIfCompiledFormatter {}
const NO_COMPILE_REQUIREMENT_FORMATTER: deps_core::test_util::StubFormatter =
deps_core::test_util::StubFormatter::new().with_package_url_prefix("");
fn advisory(id: &str, fixed_version: &str) -> std::sync::Arc<Advisory> {
std::sync::Arc::new(
Advisory::new(
id.to_string(),
"2024-01-01T00:00:00Z".to_string(),
VulnSeverity::High,
)
.expect("valid osv id")
.with_fixed_versions(vec![OsvVersion::new(fixed_version)]),
)
}
fn verified_dv(fixed_version: &str) -> deps_core::osv::DependencyVulnerabilities {
deps_core::osv::DependencyVulnerabilities::new(Capped::new(
vec![advisory("RUSTSEC-2024-0001", fixed_version)],
1,
))
.with_fix_target_status(UpgradeStatus::CandidateClean {
version: ConcreteVersion::new(fixed_version),
})
}
fn test_analysis(
cached_versions: HashMap<PackageName, PackageVersions>,
fetch_failed: HashSet<PackageName>,
) -> ManifestAnalysis {
ManifestAnalysis {
parse_result: deps_core::test_util::stub_parse_result_with_dependencies(0),
uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
now: deps_core::PublishTime::now(),
ecosystem_id: EcosystemId::Cargo,
cached_versions,
resolved_versions: HashMap::new(),
resolved_version_candidates: HashMap::new(),
outcomes: deps_core::lsp_helpers::DependencyOutcomes::new(),
vulnerabilities: None,
latest_status: None,
fallback_status: None,
cooldown_fallback_view: None,
gossip_findings: HashMap::new(),
licenses: HashMap::new(),
license_policy: LicensePolicy::default(),
license_source: deps_core::LicenseSource::default(),
network: deps_core::NetworkMode::Online,
fetch_failed,
registry_unreachable: false,
license_fetch_incomplete: false,
}
}
fn cached_with(name: &str, latest: &str) -> HashMap<PackageName, PackageVersions> {
let mut map = HashMap::new();
map.insert(PackageName::new(name), PackageVersions::latest_only(latest));
map
}
#[test]
fn test_classify_happy_path_is_applied() {
let dep = dep("serde", "0.9");
let dv = verified_dv("1.0.2");
let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(matches!(item.outcome, Outcome::Applied { .. }));
assert_eq!(
item.target(),
Some(&deps_core::ConcreteVersion::from("1.0.2"))
);
}
#[test]
fn test_classify_no_version_requirement_is_requires_lockfile_update() {
let dep = MockDep {
name: PackageName::new("serde"),
version_req: None,
version_range: None,
};
let dv = verified_dv("1.0.2");
let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(matches!(
item.outcome,
Outcome::RequiresLockfileUpdate { .. }
));
}
#[test]
fn test_classify_no_version_range_is_requires_lockfile_update() {
let dep = MockDep {
name: PackageName::new("serde"),
version_req: Some(VersionReq::new("1")),
version_range: None,
};
let dv = verified_dv("1.0.2");
let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(matches!(
item.outcome,
Outcome::RequiresLockfileUpdate { .. }
));
}
#[test]
fn test_classify_requirement_already_admits_fix_is_requires_lockfile_update() {
let dep = dep("serde", "1");
let dv = verified_dv("1.0.2");
let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: true,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(matches!(
item.outcome,
Outcome::RequiresLockfileUpdate { .. }
));
assert_eq!(
item.target(),
Some(&deps_core::ConcreteVersion::from("1.0.2"))
);
}
#[test]
fn test_classify_no_compile_requirement_falls_back_to_no_op_guard() {
let dep = dep("serde", "1.0.2");
let dv = verified_dv("1.0.2");
let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&NO_COMPILE_REQUIREMENT_FORMATTER,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(matches!(
item.outcome,
Outcome::RequiresLockfileUpdate { .. }
));
}
#[test]
fn test_classify_unsupported_requirement_shape_is_unfixable_not_requires_lockfile_update() {
let dep = dep("foo", ">=1.2, <1.5");
let dv = verified_dv("1.5.2");
let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"foo",
&analysis,
&UnsupportedShapeFormatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(
matches!(
item.outcome,
Outcome::Unfixable(UnfixableReason::UnsupportedRequirementShape { .. })
),
"expected Unfixable(UnsupportedRequirementShape), got {:?}",
item.outcome
);
assert_eq!(
item.target(),
Some(&deps_core::ConcreteVersion::from("1.5.2")),
"an Unfixable item with a rejected fix target must report it (#1614)"
);
assert!(
!item.reason().contains("regenerate the lock file"),
"message must not claim the fix is already admitted: {}",
item.reason()
);
}
#[test]
fn test_classify_floor_requirement_matching_matcher_is_requires_lockfile_update_not_unfixable()
{
let dep = dep("foo", "1.5.2");
let dv = verified_dv("1.5.2");
let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"foo",
&analysis,
&FloorLikeFormatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(
matches!(item.outcome, Outcome::RequiresLockfileUpdate { .. }),
"expected RequiresLockfileUpdate, got {:?}",
item.outcome
);
}
#[test]
fn test_classify_indeterminate_matcher_is_requires_lockfile_update_not_unfixable() {
let dep = dep("foo", "1.5.2");
let dv = verified_dv("1.5.2");
let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"foo",
&analysis,
&IndeterminateFormatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(
matches!(item.outcome, Outcome::RequiresLockfileUpdate { .. }),
"expected RequiresLockfileUpdate, got {:?}",
item.outcome
);
}
#[test]
fn test_classify_us003_mixed_outcomes_are_both_reachable() {
let rewrite_dep = dep("serde", "0.9");
let rewrite_dv = verified_dv("1.0.2");
let rewrite_analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
let rewrite_formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let rewrite_item = classify_vulnerable_dependency(
&rewrite_dep,
&rewrite_dv,
None,
"serde",
&rewrite_analysis,
&rewrite_formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
let lockfile_dep = dep("tokio", "1");
let lockfile_dv = verified_dv("1.0.2");
let lockfile_analysis = test_analysis(cached_with("tokio", "1.0.2"), HashSet::new());
let lockfile_formatter = TestFormatter {
requirement_already_admits_fix: true,
osv_native_differs: false,
};
let lockfile_item = classify_vulnerable_dependency(
&lockfile_dep,
&lockfile_dv,
None,
"tokio",
&lockfile_analysis,
&lockfile_formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(
matches!(rewrite_item.outcome, Outcome::Applied { .. }),
"serde's requirement (\"0.9\") does not yet admit 1.0.2, so it must be rewritten"
);
assert_eq!(
rewrite_item.target(),
Some(&deps_core::ConcreteVersion::from("1.0.2"))
);
assert!(
matches!(
lockfile_item.outcome,
Outcome::RequiresLockfileUpdate { .. }
),
"tokio's requirement (\"1\") already admits 1.0.2 per spec.md's US-003 fixture, so \
it must be reported, not rewritten"
);
assert_eq!(
lockfile_item.target(),
Some(&deps_core::ConcreteVersion::from("1.0.2"))
);
}
#[test]
fn test_classify_fetch_failed_is_unfixable() {
let dep = dep("serde", "0.9");
let dv = verified_dv("1.0.2");
let mut fetch_failed = HashSet::new();
fetch_failed.insert(PackageName::new("serde"));
let analysis = test_analysis(cached_with("serde", "1.0.2"), fetch_failed);
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(matches!(
item.outcome,
Outcome::Unfixable(UnfixableReason::FetchFailedOrAbsent)
));
}
#[test]
fn test_classify_absent_package_versions_is_unfixable() {
let dep = dep("serde", "0.9");
let dv = verified_dv("1.0.2");
let analysis = test_analysis(HashMap::new(), HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(matches!(
item.outcome,
Outcome::Unfixable(UnfixableReason::FetchFailedOrAbsent)
));
}
#[test]
fn test_classify_no_recommended_fix_is_unfixable() {
let dep = dep("serde", "0.9");
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"),
);
let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(vec![advisory], 1));
let analysis = test_analysis(cached_with("serde", "1.2.0"), HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(matches!(
item.outcome,
Outcome::Unfixable(UnfixableReason::NoVerifiedFix)
));
}
#[test]
fn test_classify_unverified_fix_target_is_unfixable() {
let dep = dep("serde", "0.9");
let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(
vec![advisory("RUSTSEC-2024-0001", "1.0.2")],
1,
));
let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(matches!(
item.outcome,
Outcome::Unfixable(UnfixableReason::NoVerifiedFix)
));
}
#[test]
fn test_classify_unverified_target_with_no_version_requirement_is_unfixable_not_lockfile() {
let dep = MockDep {
name: PackageName::new("serde"),
version_req: None,
version_range: None,
};
let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(
vec![advisory("RUSTSEC-2024-0001", "1.0.2")],
1,
));
let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(
matches!(
item.outcome,
Outcome::Unfixable(UnfixableReason::NoVerifiedFix)
),
"got {:?}",
item.outcome
);
}
#[test]
fn test_classify_unverified_target_and_yanked_is_unfixable_no_verified_fix_not_yanked() {
let dep = dep("serde", "0.9");
let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(
vec![advisory("RUSTSEC-2024-0001", "1.0.2")],
1,
));
let mut cached = HashMap::new();
cached.insert(
PackageName::new("serde"),
PackageVersions::new("1.0.2".into(), std::sync::Arc::from([])).with_yanked(
std::sync::Arc::from([("1.0.2".into(), RemovalStatus::from_yanked(true))]),
),
);
let analysis = test_analysis(cached, HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(
matches!(
item.outcome,
Outcome::Unfixable(UnfixableReason::NoVerifiedFix)
),
"got {:?}",
item.outcome
);
}
#[test]
fn test_classify_yanked_fix_target_is_unfixable() {
let dep = dep("serde", "0.9");
let dv = verified_dv("1.0.2");
let mut cached = HashMap::new();
cached.insert(
PackageName::new("serde"),
PackageVersions::new("1.0.2".into(), std::sync::Arc::from([])).with_yanked(
std::sync::Arc::from([("1.0.2".into(), RemovalStatus::from_yanked(true))]),
),
);
let analysis = test_analysis(cached, HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(matches!(
item.outcome,
Outcome::Unfixable(UnfixableReason::Yanked { .. })
));
assert_eq!(
item.target(),
Some(&deps_core::ConcreteVersion::from("1.0.2")),
"a yanked-unfixable item must report the rejected fix target (#1614)"
);
}
#[test]
fn test_classify_requirement_admits_fix_and_yanked_is_unfixable_yanked_not_lockfile() {
let dep = dep("serde", "1");
let dv = verified_dv("1.0.2");
let mut cached = HashMap::new();
cached.insert(
PackageName::new("serde"),
PackageVersions::new("1.0.2".into(), std::sync::Arc::from([])).with_yanked(
std::sync::Arc::from([("1.0.2".into(), RemovalStatus::from_yanked(true))]),
),
);
let analysis = test_analysis(cached, HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: true,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(matches!(
item.outcome,
Outcome::Unfixable(UnfixableReason::Yanked { .. })
));
}
#[test]
fn test_classify_yank_filter_uses_native_form_not_osv_wire_form() {
let dep = dep("serde", "0.9");
let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(
vec![advisory("RUSTSEC-2024-0001", "1.0.2")],
1,
))
.with_fix_target_status(UpgradeStatus::CandidateClean {
version: ConcreteVersion::new("v1.0.2"),
});
let mut cached = HashMap::new();
cached.insert(
PackageName::new("serde"),
PackageVersions::new("v1.0.2".into(), std::sync::Arc::from([])).with_yanked(
std::sync::Arc::from([("v1.0.2".into(), RemovalStatus::from_yanked(true))]),
),
);
let analysis = test_analysis(cached, HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: true,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(
matches!(
item.outcome,
Outcome::Unfixable(UnfixableReason::Yanked { .. })
),
"the yanked native-form entry (v1.0.2) must match the converted native-form fix \
target, not the raw OSV wire form (1.0.2), which was never in the yanked list"
);
}
#[test]
fn test_classify_empty_yanked_list_is_fail_open() {
let dep = dep("serde", "0.9");
let dv = verified_dv("1.0.2");
let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(matches!(item.outcome, Outcome::Applied { .. }));
}
#[test]
fn test_classify_ignore_rule_is_overridden_not_suppressed() {
let dep = dep("serde", "0.9");
let dv = verified_dv("1.0.2");
let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let ignore_rules = IgnoreRules::new(
vec![IgnoreRule {
name: "serde".to_string(),
update_types: None,
}],
&formatter,
);
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&ignore_rules,
);
assert!(matches!(item.outcome, Outcome::Applied { .. }));
assert!(
item.ignore_rule_overridden,
"a matching rule must be reported as overridden, not silently applied"
);
}
#[test]
fn test_classify_current_falls_back_to_declared_requirement_when_unresolvable() {
let dep = dep("serde", "0.9");
let dv = verified_dv("1.0.2");
let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
let formatter = TestFormatter {
requirement_already_admits_fix: false,
osv_native_differs: false,
};
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"serde",
&analysis,
&formatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert_eq!(
item.current,
CurrentVersion::Declared(VersionReq::new("0.9"))
);
}
#[test]
fn test_classify_real_cargo_formatter_compound_requirement_confirms_exclusion() {
let dep = dep("foo", ">=1.2, <1.5");
let dv = verified_dv("1.5.2");
let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"foo",
&analysis,
&deps_engine::setup::CargoFormatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(
matches!(
item.outcome,
Outcome::Unfixable(UnfixableReason::UnsupportedRequirementShape { .. })
),
"real Cargo semver matcher must confirm 1.5.2 is excluded by \">=1.2, <1.5\", got {:?}",
item.outcome
);
}
#[test]
fn test_classify_oversized_requirement_never_reaches_compile_requirement() {
let oversized_req = "1".repeat(MAX_REQUIREMENT_LEN + 1);
let dep = dep("foo", &oversized_req);
let dv = verified_dv("1.5.2");
let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"foo",
&analysis,
&PanicsIfCompiledFormatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(
matches!(
item.outcome,
Outcome::Unfixable(UnfixableReason::OversizedRequirement { .. })
),
"got {:?}",
item.outcome
);
assert_eq!(
item.target(),
Some(&deps_core::ConcreteVersion::from("1.5.2")),
"an oversized-requirement-unfixable item must report the rejected fix target (#1614)"
);
}
#[test]
fn test_classify_requirement_at_exact_cap_is_not_oversized() {
let head = ">=1.2,";
let tail = " <1.5";
let padding = " ".repeat(MAX_REQUIREMENT_LEN - head.len() - tail.len());
let requirement = format!("{head}{padding}{tail}");
assert_eq!(requirement.len(), MAX_REQUIREMENT_LEN);
let dep = dep("foo", &requirement);
let dv = verified_dv("1.5.2");
let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
let item = classify_vulnerable_dependency(
&dep,
&dv,
None,
"foo",
&analysis,
&deps_engine::setup::CargoFormatter,
EcosystemId::Cargo,
&IgnoreRules::empty(),
);
assert!(
matches!(
item.outcome,
Outcome::Unfixable(UnfixableReason::UnsupportedRequirementShape { .. })
),
"expected the matcher to run at the exact cap and confirm exclusion, got {:?}",
item.outcome
);
}
}