use crate::semver::{compare_versions, match_vulnerable_versions, normalize_ver};
use serde_json::{json, Value};
use std::collections::HashSet;
#[derive(Clone, Debug)]
pub enum UpgradeType {
Unknown,
Major,
Minor,
Patch,
}
impl UpgradeType {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::Major => "major",
Self::Minor => "minor",
Self::Patch => "patch",
}
}
const fn is_major(&self) -> bool {
matches!(self, Self::Major)
}
}
#[derive(Clone, Debug)]
pub struct SeverityCves {
pub critical: Option<Vec<String>>,
pub high: Option<Vec<String>>,
pub medium: Option<Vec<String>>,
pub low: Option<Vec<String>>,
pub total: usize,
}
#[derive(Clone, Debug)]
pub struct ResidualCves {
pub remedied: Option<SeverityCves>,
pub maintained: Option<SeverityCves>,
pub introduced: Option<SeverityCves>,
}
#[derive(Clone, Debug)]
pub struct FixMetadata {
pub fix_version: String,
pub upgrade_type: UpgradeType,
pub breaking_change: bool,
pub residual_cves: Option<ResidualCves>,
}
#[derive(Clone, Debug)]
pub struct FixMetadataNew {
pub closest_min_fix: FixMetadata,
pub top_parents: Option<Vec<String>>,
pub closest_safe_fix: Option<FixMetadata>,
pub closest_complete_fix: Option<FixMetadata>,
}
pub struct CveEntry {
pub id: String,
pub vulnerable_version: String,
pub severity_level: String,
}
fn severity_cves_to_json(sc: &SeverityCves) -> Value {
json!({
"critical": sc.critical,
"high": sc.high,
"medium": sc.medium,
"low": sc.low,
"total": sc.total,
})
}
fn residual_cves_to_json(rc: &ResidualCves) -> Value {
json!({
"remedied": rc.remedied.as_ref().map(severity_cves_to_json),
"maintained": rc.maintained.as_ref().map(severity_cves_to_json),
"introduced": rc.introduced.as_ref().map(severity_cves_to_json),
})
}
fn fix_metadata_to_json(fm: &FixMetadata) -> Value {
json!({
"fix_version": fm.fix_version,
"upgrade_type": fm.upgrade_type.as_str(),
"breaking_change": fm.breaking_change,
"residual_cves": fm.residual_cves.as_ref().map(residual_cves_to_json),
})
}
pub fn to_json(fmn: &FixMetadataNew) -> Value {
let mut obj = serde_json::Map::new();
obj.insert(
"closest_min_fix".to_owned(),
fix_metadata_to_json(&fmn.closest_min_fix),
);
obj.insert(
"top_parents".to_owned(),
fmn.top_parents.as_ref().map_or(Value::Null, |v| json!(v)),
);
if let Some(csf) = &fmn.closest_safe_fix {
obj.insert("closest_safe_fix".to_owned(), fix_metadata_to_json(csf));
}
if let Some(ccf) = &fmn.closest_complete_fix {
obj.insert("closest_complete_fix".to_owned(), fix_metadata_to_json(ccf));
}
Value::Object(obj)
}
const UNSTABLE_SEMVER_KEYWORDS: &[&str] = &[
"alpha",
"beta",
"canary",
"cr",
"dev",
"milestone",
"next",
"nightly",
"pre",
"preview",
"rc",
"snapshot",
];
const STABLE_QUALIFIERS_LOWER: &[&str] = &[
"final",
"ga",
"ga.final",
"ga.release",
"lts",
"release",
"release.final",
"stable",
];
const OS_ECOSYSTEMS: &[&str] = &["alpm", "alpine", "debian", "rpm"];
fn is_unstable_keyword(segment: &str) -> Option<bool> {
let lower = segment.to_lowercase();
if STABLE_QUALIFIERS_LOWER.contains(&lower.as_str()) {
return Some(false);
}
if UNSTABLE_SEMVER_KEYWORDS.contains(&lower.as_str()) {
return Some(true);
}
if lower.len() > 1 && lower.starts_with('m') && lower[1..].bytes().all(|b| b.is_ascii_digit()) {
return Some(true);
}
let trimmed = lower.trim_end_matches(|c: char| c.is_ascii_digit());
if !trimmed.is_empty()
&& trimmed.len() < lower.len()
&& UNSTABLE_SEMVER_KEYWORDS.contains(&trimmed)
{
return Some(true);
}
None
}
fn check_pre_release(pre: &str) -> bool {
let norm = pre.to_lowercase().replace(['-', '_'], ".");
norm.split('.')
.any(|seg| is_unstable_keyword(seg) == Some(true))
}
fn is_unstable_semver(version: &str) -> bool {
if let Some(dash) = version.find('-') {
let main = &version[..dash];
let all_numeric = !main.is_empty()
&& main
.split('.')
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
if all_numeric {
let rest = &version[dash + 1..];
let pre = rest.split_once('+').map_or(rest, |(p, _)| p);
return check_pre_release(pre);
}
}
let parts: Vec<&str> = version.split('.').collect();
if parts.len() < 2 {
return false;
}
let last = parts[parts.len() - 1];
if !last.starts_with(|c: char| c.is_ascii_alphabetic()) {
return false;
}
let prefix_ok = parts[..parts.len() - 1]
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
if !prefix_ok {
return false;
}
let lower = last.to_lowercase();
let base = lower.trim_end_matches(|c: char| c.is_ascii_digit());
if let Some(r) = is_unstable_keyword(base) {
return r;
}
is_unstable_keyword(&lower).unwrap_or(false)
}
fn numeric_prefix_end(s: &[u8]) -> usize {
let mut pos = 0;
loop {
if pos >= s.len() || !s[pos].is_ascii_digit() {
return pos;
}
while pos < s.len() && s[pos].is_ascii_digit() {
pos += 1;
}
if pos + 1 < s.len() && s[pos] == b'.' && s[pos + 1].is_ascii_digit() {
pos += 1; } else {
return pos;
}
}
}
fn is_unstable_python(version: &str) -> bool {
let base = version.split_once('+').map_or(version, |(b, _)| b);
let lower = base.to_lowercase();
let end = numeric_prefix_end(lower.as_bytes());
if end == 0 {
return false; }
let rest = lower[end..].strip_prefix('.').unwrap_or(&lower[end..]);
if let Some(after) = rest.strip_prefix("dev") {
if after.is_empty() || after.bytes().all(|b| b.is_ascii_digit()) {
return true;
}
}
for des in &["alpha", "beta", "rc", "a", "b", "c"] {
if let Some(after) = rest.strip_prefix(des) {
let after_d = after.trim_start_matches(|c: char| c.is_ascii_digit());
if after_d.is_empty() || after_d.starts_with('.') {
return true;
}
}
}
false
}
fn is_unstable_ruby(version: &str) -> bool {
const RUBY_KEYWORDS: &[&str] = &["alpha", "beta", "dev", "pre", "rc"];
let lower = version.to_lowercase();
let parts: Vec<&str> = lower.split('.').collect();
let mut had_numeric = false;
for (i, &part) in parts.iter().enumerate() {
if !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()) {
had_numeric = true;
} else {
if !had_numeric {
return false;
}
let base = part.trim_end_matches(|c: char| c.is_ascii_digit());
if !RUBY_KEYWORDS.contains(&base) {
return false;
}
return parts[i + 1..]
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
}
}
false
}
pub fn is_unstable_version(version: &str, ecosystem: Option<&str>) -> bool {
if version.is_empty() || version.contains("${") {
return false;
}
let cleaned = version.trim().trim_start_matches(['v', 'V']);
if cleaned.is_empty() {
return false;
}
match ecosystem {
Some(eco) if OS_ECOSYSTEMS.contains(&eco) => false,
Some("packagist" | "go" | "maven" | "npm" | "nuget" | "pub" | "swifturl") => {
is_unstable_semver(cleaned)
}
Some("rubygems") => is_unstable_ruby(cleaned),
Some("pypi") => is_unstable_python(cleaned),
Some(_) => false, None => {
is_unstable_semver(cleaned) || is_unstable_python(cleaned) || is_unstable_ruby(cleaned)
}
}
}
fn filter_stable<'a>(versions: &'a [String], ecosystem: Option<&str>) -> Vec<&'a String> {
versions
.iter()
.filter(|v| !is_unstable_version(v, ecosystem))
.collect()
}
const COMMON_QUALIFIERS: &[&str] = &[
"release",
"final",
"ga",
"ga.release",
"release.final",
"stable",
"lts",
"ga.final",
];
fn is_qual(part: &str) -> bool {
let lower = part.to_lowercase();
COMMON_QUALIFIERS.contains(&lower.as_str())
}
fn extract_build_metadata(mut parts: Vec<String>) -> (Vec<String>, Option<String>) {
let mut build = None;
for p in &mut parts {
if let Some(plus) = p.find('+') {
build = Some(p[plus + 1..].to_owned());
*p = p[..plus].to_owned();
break;
}
}
(parts, build)
}
fn extract_qualifier(parts: Vec<String>) -> (Vec<String>, Option<String>) {
for i in 1..parts.len() {
if is_qual(&parts[i]) {
let mut result = parts;
let qual = result.remove(i);
return (result, Some(qual));
}
}
(parts, None)
}
fn normalize_for_cmp(
version: &str,
) -> (Vec<String>, Option<String>, Option<String>, Option<String>) {
let (parts, pre) = normalize_ver(version);
let (clean, build) = extract_build_metadata(parts);
let (final_parts, qual) = extract_qualifier(clean);
(final_parts, pre, qual, build)
}
fn are_versions_equivalent(v1: &str, v2: &str) -> bool {
let (p1, pre1, _, _) = normalize_for_cmp(v1);
let (p2, pre2, _, _) = normalize_for_cmp(v2);
p1 == p2 && pre1 == pre2
}
fn build_cmp(v: Option<&str>, d: Option<&str>) -> Option<bool> {
match (v, d) {
(None, Some(_)) => Some(true),
(Some(_), None) => Some(false),
(Some(a), Some(b)) => Some(a > b),
(None, None) => None,
}
}
fn compare_equal_parts(
v_pre: Option<&str>,
d_pre: Option<&str>,
v_b: Option<&str>,
d_b: Option<&str>,
) -> Option<bool> {
match (v_pre, d_pre) {
(None, Some(_)) => Some(true), (Some(_), None) => Some(false), (Some(a), Some(b)) if a != b => Some(a > b),
_ => build_cmp(v_b, d_b),
}
}
fn compare_common_parts(v: &[String], d: &[String]) -> Option<bool> {
for (vp, dp) in v.iter().zip(d.iter()) {
match (vp.parse::<i64>(), dp.parse::<i64>()) {
(Ok(a), Ok(b)) if a != b => return Some(a > b),
(Err(_), Err(_)) if vp != dp => return Some(vp > dp),
_ => {}
}
}
None
}
fn is_version_greater_than_dep(version: &str, dep: &str) -> bool {
if version.is_empty() || dep.is_empty() || version.contains("${") || dep.contains("${") {
return false;
}
if are_versions_equivalent(version, dep) {
return false;
}
if compare_versions(version, dep, false) {
return true;
}
let (dp, d_pre, _, d_b) = normalize_for_cmp(dep);
let (vp, v_pre, _, v_b) = normalize_for_cmp(version);
if vp == dp {
if let Some(r) = compare_equal_parts(
v_pre.as_deref(),
d_pre.as_deref(),
v_b.as_deref(),
d_b.as_deref(),
) {
return r;
}
}
if let Some(r) = compare_common_parts(&vp, &dp) {
return r;
}
vp.len() > dp.len()
}
fn first_three_nums(parts: &[String]) -> [i64; 3] {
let mut nums = [0i64; 3];
for (slot, p) in nums.iter_mut().zip(parts.iter()) {
if let Ok(n) = p.parse::<i64>() {
*slot = n;
}
}
nums
}
fn upgrade_from_index(idx: usize) -> UpgradeType {
match idx {
0 => UpgradeType::Major,
1 => UpgradeType::Minor,
_ => UpgradeType::Patch,
}
}
fn upgrade_from_parts(cur: &[String], tgt: &[String]) -> Option<UpgradeType> {
let min_len = cur.len().min(tgt.len());
for i in 0..min_len {
if cur[i] == tgt[i] {
continue;
}
if let (Ok(cn), Ok(tn)) = (cur[i].parse::<i64>(), tgt[i].parse::<i64>()) {
if cn != tn {
return Some(upgrade_from_index(i));
}
}
return Some(UpgradeType::Patch);
}
if cur.len() != tgt.len() {
return Some(UpgradeType::Patch);
}
None
}
#[allow(clippy::too_many_arguments)]
fn upgrade_from_metadata(
c_pre: Option<&str>,
t_pre: Option<&str>,
c_qual: Option<&str>,
t_qual: Option<&str>,
c_build: Option<&str>,
t_build: Option<&str>,
) -> UpgradeType {
if let Some(tp) = t_pre {
if c_pre != Some(tp) {
return UpgradeType::Patch;
}
}
if t_pre.is_none() && c_pre.is_some() {
return UpgradeType::Patch;
}
if c_qual != t_qual || c_build != t_build {
return UpgradeType::Patch;
}
UpgradeType::Unknown
}
pub fn get_upgrade_type(current: &str, target: &str) -> UpgradeType {
let (cp, c_pre, c_qual, c_build) = normalize_for_cmp(current);
let (tp, t_pre, t_qual, t_build) = normalize_for_cmp(target);
let cn = first_three_nums(&cp);
let tn = first_three_nums(&tp);
if tn[0] > cn[0] {
return UpgradeType::Major;
}
if tn[1] > cn[1] {
return UpgradeType::Minor;
}
if tn[2] > cn[2] {
return UpgradeType::Patch;
}
if let Some(u) = upgrade_from_parts(&cp, &tp) {
return u;
}
upgrade_from_metadata(
c_pre.as_deref(),
t_pre.as_deref(),
c_qual.as_deref(),
t_qual.as_deref(),
c_build.as_deref(),
t_build.as_deref(),
)
}
fn create_severity_cves<'a>(items: impl Iterator<Item = &'a CveEntry>) -> Option<SeverityCves> {
let mut critical: Vec<String> = vec![];
let mut high: Vec<String> = vec![];
let mut medium: Vec<String> = vec![];
let mut low: Vec<String> = vec![];
for e in items {
match e.severity_level.to_lowercase().as_str() {
"critical" => critical.push(e.id.clone()),
"high" => high.push(e.id.clone()),
"medium" => medium.push(e.id.clone()),
_ => low.push(e.id.clone()),
}
}
let total = critical
.len()
.saturating_add(high.len())
.saturating_add(medium.len())
.saturating_add(low.len());
if total == 0 {
return None;
}
Some(SeverityCves {
critical: if critical.is_empty() {
None
} else {
Some(critical)
},
high: if high.is_empty() { None } else { Some(high) },
medium: if medium.is_empty() {
None
} else {
Some(medium)
},
low: if low.is_empty() { None } else { Some(low) },
total,
})
}
fn get_residual_cves(
candidate: &str,
all_advisories: &[CveEntry],
base_keys: &HashSet<(&str, &str)>,
) -> Option<ResidualCves> {
let candidate_keys: HashSet<(&str, &str)> = all_advisories
.iter()
.filter(|a| match_vulnerable_versions(candidate, Some(&a.vulnerable_version)))
.map(|a| (a.id.as_str(), a.vulnerable_version.as_str()))
.collect();
let remedied = create_severity_cves(all_advisories.iter().filter(|a| {
let k = (a.id.as_str(), a.vulnerable_version.as_str());
base_keys.contains(&k) && !candidate_keys.contains(&k)
}));
let maintained = create_severity_cves(all_advisories.iter().filter(|a| {
let k = (a.id.as_str(), a.vulnerable_version.as_str());
base_keys.contains(&k) && candidate_keys.contains(&k)
}));
let introduced = create_severity_cves(all_advisories.iter().filter(|a| {
let k = (a.id.as_str(), a.vulnerable_version.as_str());
!base_keys.contains(&k) && candidate_keys.contains(&k)
}));
if remedied.is_none() && maintained.is_none() && introduced.is_none() {
return None;
}
Some(ResidualCves {
remedied,
maintained,
introduced,
})
}
fn make_fix(
dep_version: &str,
fix_version: &str,
all_advisories: &[CveEntry],
base_keys: &HashSet<(&str, &str)>,
) -> FixMetadata {
let upgrade_type = get_upgrade_type(dep_version, fix_version);
let breaking_change = upgrade_type.is_major();
let residual_cves = get_residual_cves(fix_version, all_advisories, base_keys);
FixMetadata {
fix_version: fix_version.to_owned(),
upgrade_type,
breaking_change,
residual_cves,
}
}
fn min_version(candidates: Vec<&String>) -> Option<&String> {
candidates.into_iter().min_by(|a, b| {
if compare_versions(a, b, false) {
std::cmp::Ordering::Greater
} else if compare_versions(b, a, false) {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Equal
}
})
}
fn calculate_minimal_fix(
dep_version: &str,
cve_fix_versions: &[String],
all_advisories: &[CveEntry],
base_keys: &HashSet<(&str, &str)>,
ecosystem: Option<&str>,
) -> Option<FixMetadata> {
let stable = filter_stable(cve_fix_versions, ecosystem);
let candidates: Vec<&String> = stable
.into_iter()
.filter(|v| is_version_greater_than_dep(v, dep_version))
.collect();
let closest = min_version(candidates)?;
Some(make_fix(dep_version, closest, all_advisories, base_keys))
}
fn calculate_complete_fix(
dep_version: &str,
complete_fix_versions: &[String],
all_advisories: &[CveEntry],
base_keys: &HashSet<(&str, &str)>,
ecosystem: Option<&str>,
) -> Option<FixMetadata> {
let stable = filter_stable(complete_fix_versions, ecosystem);
let candidates: Vec<&String> = stable
.into_iter()
.filter(|v| is_version_greater_than_dep(v, dep_version))
.collect();
let closest = min_version(candidates)?;
Some(make_fix(dep_version, closest, all_advisories, base_keys))
}
#[allow(clippy::too_many_arguments)]
fn calculate_safe_fix(
dep_version: &str,
minimal_fix: &FixMetadata,
complete_fix: &FixMetadata,
all_package_versions: &[String],
cve_vulnerable_version: &str,
all_advisories: &[CveEntry],
base_keys: &HashSet<(&str, &str)>,
ecosystem: Option<&str>,
) -> Option<FixMetadata> {
let min_ver = &minimal_fix.fix_version;
let cmp_ver = &complete_fix.fix_version;
let stable = filter_stable(all_package_versions, ecosystem);
let candidates: Vec<&String> = stable
.into_iter()
.filter(|v| {
is_version_greater_than_dep(v, min_ver) && is_version_greater_than_dep(cmp_ver, v)
})
.collect();
if candidates.is_empty() {
return None;
}
let mut valid: Vec<(&String, Option<ResidualCves>)> = vec![];
for candidate in candidates {
if match_vulnerable_versions(candidate, Some(cve_vulnerable_version)) {
continue;
}
let residual = get_residual_cves(candidate, all_advisories, base_keys);
if residual
.as_ref()
.and_then(|r| r.introduced.as_ref())
.is_some_and(|i| i.total > 0)
{
continue;
}
valid.push((candidate, residual));
}
let (closest, residual_cves) = valid.into_iter().min_by(|(a, _), (b, _)| {
if compare_versions(a, b, false) {
std::cmp::Ordering::Greater
} else if compare_versions(b, a, false) {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Equal
}
})?;
let upgrade_type = get_upgrade_type(dep_version, closest);
let breaking_change = upgrade_type.is_major();
Some(FixMetadata {
fix_version: closest.clone(),
upgrade_type,
breaking_change,
residual_cves,
})
}
#[allow(clippy::too_many_arguments)]
pub fn compute_fix_metadata(
dep_version: &str,
cve_fix_versions: &[String],
cve_vulnerable_version: &str,
complete_fix_versions: Option<&[String]>,
all_package_versions: &[String],
all_advisories: &[CveEntry],
ecosystem: Option<&str>,
) -> Option<FixMetadataNew> {
let base_keys: HashSet<(&str, &str)> = all_advisories
.iter()
.filter(|a| match_vulnerable_versions(dep_version, Some(&a.vulnerable_version)))
.map(|a| (a.id.as_str(), a.vulnerable_version.as_str()))
.collect();
let minimal_fix = calculate_minimal_fix(
dep_version,
cve_fix_versions,
all_advisories,
&base_keys,
ecosystem,
);
let complete_fix = complete_fix_versions.and_then(|cfv| {
calculate_complete_fix(dep_version, cfv, all_advisories, &base_keys, ecosystem)
});
let minimal_fix = minimal_fix.or_else(|| complete_fix.clone());
let minimal_fix = minimal_fix?;
let has_no_introduced = minimal_fix
.residual_cves
.as_ref()
.and_then(|r| r.introduced.as_ref())
.is_none_or(|i| i.total == 0);
let safe_fix = if has_no_introduced {
Some(minimal_fix.clone())
} else if let Some(ref cf) = complete_fix {
calculate_safe_fix(
dep_version,
&minimal_fix,
cf,
all_package_versions,
cve_vulnerable_version,
all_advisories,
&base_keys,
ecosystem,
)
} else {
None
};
Some(FixMetadataNew {
closest_min_fix: minimal_fix,
top_parents: None,
closest_safe_fix: safe_fix,
closest_complete_fix: complete_fix,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn cve(id: &str, vuln: &str, sev: &str) -> CveEntry {
CveEntry {
id: id.to_owned(),
vulnerable_version: vuln.to_owned(),
severity_level: sev.to_owned(),
}
}
fn parts(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
fn base_keys_for<'a>(entries: &'a [CveEntry], dep: &str) -> HashSet<(&'a str, &'a str)> {
entries
.iter()
.filter(|a| match_vulnerable_versions(dep, Some(&a.vulnerable_version)))
.map(|a| (a.id.as_str(), a.vulnerable_version.as_str()))
.collect()
}
#[test]
fn test_is_version_greater_placeholder() {
assert!(!is_version_greater_than_dep("${someVersion}", "1.0.0"));
}
#[test]
fn test_is_version_greater_empty_version() {
assert!(!is_version_greater_than_dep("", "1.0.0"));
}
#[test]
fn test_is_version_greater_empty_dep_version() {
assert!(!is_version_greater_than_dep("1.0.0", ""));
}
#[test]
fn test_is_version_greater_no_pre_vs_pre() {
assert!(is_version_greater_than_dep("1.0.0", "1.0.0-beta"));
}
#[test]
fn test_is_version_greater_same_parts_both_pre() {
assert!(is_version_greater_than_dep("1.0.0-beta.2", "1.0.0-beta.1"));
}
#[test]
fn test_is_version_greater_build_metadata() {
assert!(is_version_greater_than_dep(
"1.0.0+build.2",
"1.0.0+build.1"
));
assert!(!is_version_greater_than_dep(
"1.0.0+build.1",
"1.0.0+build.2"
));
}
#[test]
fn test_build_cmp_none_vs_some() {
assert_eq!(build_cmp(None, Some("build.1")), Some(true));
}
#[test]
fn test_build_cmp_some_vs_none() {
assert_eq!(build_cmp(Some("build.1"), None), Some(false));
}
#[test]
fn test_build_cmp_both_none() {
assert_eq!(build_cmp(None, None), None);
}
#[test]
fn test_build_cmp_both_present() {
assert_eq!(build_cmp(Some("build.2"), Some("build.1")), Some(true));
assert_eq!(build_cmp(Some("build.1"), Some("build.2")), Some(false));
}
#[test]
fn test_compare_common_parts_non_digit_equal() {
assert_eq!(compare_common_parts(&parts(&["a"]), &parts(&["a"])), None);
}
#[test]
fn test_compare_common_parts_non_digit_different() {
assert_eq!(
compare_common_parts(&parts(&["b"]), &parts(&["a"])),
Some(true)
);
assert_eq!(
compare_common_parts(&parts(&["a"]), &parts(&["b"])),
Some(false)
);
}
#[test]
fn test_upgrade_from_metadata_qualifier_change() {
let r = upgrade_from_metadata(None, None, Some("RELEASE"), Some("Final"), None, None);
assert!(matches!(r, UpgradeType::Patch));
}
#[test]
fn test_upgrade_from_metadata_build_change() {
let r = upgrade_from_metadata(None, None, None, None, Some("build.1"), Some("build.2"));
assert!(matches!(r, UpgradeType::Patch));
}
#[test]
fn test_upgrade_from_metadata_returns_unknown() {
let r = upgrade_from_metadata(None, None, None, None, None, None);
assert!(matches!(r, UpgradeType::Unknown));
}
#[test]
fn test_upgrade_from_metadata_same_pre_release() {
let r = upgrade_from_metadata(Some("alpha"), Some("alpha"), None, None, None, None);
assert!(matches!(r, UpgradeType::Unknown));
}
#[test]
fn test_upgrade_from_metadata_pre_release_change() {
let r = upgrade_from_metadata(Some("beta"), None, None, None, None, None);
assert!(matches!(r, UpgradeType::Patch));
}
#[test]
fn test_upgrade_from_index() {
assert!(matches!(upgrade_from_index(0), UpgradeType::Major));
assert!(matches!(upgrade_from_index(1), UpgradeType::Minor));
assert!(matches!(upgrade_from_index(2), UpgradeType::Patch));
assert!(matches!(upgrade_from_index(3), UpgradeType::Patch));
}
#[test]
fn test_upgrade_from_parts_major_minor_patch() {
assert!(matches!(
upgrade_from_parts(&parts(&["1", "0", "0"]), &parts(&["2", "0", "0"])),
Some(UpgradeType::Major)
));
assert!(matches!(
upgrade_from_parts(&parts(&["1", "0", "0"]), &parts(&["1", "1", "0"])),
Some(UpgradeType::Minor)
));
assert!(matches!(
upgrade_from_parts(&parts(&["1", "0", "0"]), &parts(&["1", "0", "1"])),
Some(UpgradeType::Patch)
));
}
#[test]
fn test_upgrade_from_parts_equal_returns_none() {
assert!(upgrade_from_parts(&parts(&["1", "0", "0"]), &parts(&["1", "0", "0"])).is_none());
}
#[test]
fn test_upgrade_from_parts_empty_returns_none() {
assert!(upgrade_from_parts(&[], &[]).is_none());
}
#[test]
fn test_upgrade_from_parts_non_digit_difference() {
assert!(matches!(
upgrade_from_parts(&parts(&["1", "a"]), &parts(&["1", "b"])),
Some(UpgradeType::Patch)
));
}
#[test]
fn test_get_upgrade_type_qualifier_change() {
assert!(matches!(
get_upgrade_type("4.2.2.RELEASE", "4.2.3"),
UpgradeType::Patch
));
}
#[test]
fn test_get_upgrade_type_build_change() {
assert!(matches!(
get_upgrade_type("3.2.0+incompatible", "3.2.1"),
UpgradeType::Patch
));
}
#[test]
fn test_create_severity_cves_case_insensitive() {
let entries = vec![
cve("CVE-001", "1.0.0", "Critical"),
cve("CVE-002", "1.0.0", "CRITICAL"),
cve("CVE-003", "1.0.0", "critical"),
cve("CVE-004", "1.0.0", "High"),
cve("CVE-005", "1.0.0", "HIGH"),
cve("CVE-006", "1.0.0", "Medium"),
cve("CVE-007", "1.0.0", "low"),
cve("CVE-008", "1.0.0", "LOW"),
];
let r = create_severity_cves(entries.iter()).unwrap();
assert_eq!(r.critical.as_ref().unwrap().len(), 3);
assert_eq!(r.high.as_ref().unwrap().len(), 2);
assert_eq!(r.medium.as_ref().unwrap().len(), 1);
assert_eq!(r.low.as_ref().unwrap().len(), 2);
assert_eq!(r.total, 8);
assert!(r.critical.as_ref().unwrap().contains(&"CVE-001".to_owned()));
assert!(r.critical.as_ref().unwrap().contains(&"CVE-002".to_owned()));
assert!(r.critical.as_ref().unwrap().contains(&"CVE-003".to_owned()));
assert!(r.high.as_ref().unwrap().contains(&"CVE-004".to_owned()));
assert!(r.high.as_ref().unwrap().contains(&"CVE-005".to_owned()));
}
#[test]
fn test_create_severity_cves_empty_string_severity() {
let entries = vec![cve("CVE-001", "1.0.0", ""), cve("CVE-002", "1.0.0", "High")];
let r = create_severity_cves(entries.iter()).unwrap();
assert_eq!(r.low.as_ref().unwrap().len(), 1);
assert!(r.low.as_ref().unwrap().contains(&"CVE-001".to_owned()));
assert_eq!(r.high.as_ref().unwrap().len(), 1);
assert_eq!(r.total, 2);
}
#[test]
fn test_get_residual_cves_remedied() {
let entries = vec![cve("CVE-2023-001", ">=1.0.0 <1.5.0", "High")];
let base_keys = base_keys_for(&entries, "1.0.0");
let r = get_residual_cves("1.5.0", &entries, &base_keys).unwrap();
assert_eq!(r.remedied.unwrap().total, 1);
}
#[test]
fn test_get_residual_cves_empty_returns_none() {
let entries: Vec<CveEntry> = vec![];
let base_keys: HashSet<(&str, &str)> = HashSet::new();
assert!(get_residual_cves("2.0.0", &entries, &base_keys).is_none());
}
#[test]
fn test_get_residual_cves_with_introduced_cves() {
let entries = vec![
cve("CVE-2023-001", ">=1.0.0 <1.5.0", "High"),
cve("CVE-2023-002", ">=1.5.0 <2.0.0", "Medium"),
];
let base_keys: HashSet<(&str, &str)> =
[("CVE-2023-001", ">=1.0.0 <1.5.0")].into_iter().collect();
let r = get_residual_cves("1.5.0", &entries, &base_keys).unwrap();
assert_eq!(r.remedied.unwrap().total, 1);
assert_eq!(r.introduced.unwrap().total, 1);
}
#[test]
fn test_get_residual_cves_with_maintained_cves() {
let entries = vec![cve("CVE-2023-001", ">=1.0.0 <2.0.0", "Critical")];
let base_keys: HashSet<(&str, &str)> =
[("CVE-2023-001", ">=1.0.0 <2.0.0")].into_iter().collect();
let r = get_residual_cves("1.5.0", &entries, &base_keys).unwrap();
assert_eq!(r.maintained.unwrap().total, 1);
}
#[test]
fn test_compute_fix_metadata_no_fix_returns_none() {
let entries = vec![cve("adv1", "1.0.0", "Low")];
assert!(compute_fix_metadata("1.0.0", &[], "1.0.0", None, &[], &entries, None).is_none());
}
#[test]
fn test_compute_fix_metadata_complete_fix_only_no_cve_fix() {
let entries = vec![cve("CVE-2023-001", "1.0.0", "High")];
let complete = vec!["1.0.1".to_owned()];
let all_pkg = vec!["1.0.1".to_owned()];
let r = compute_fix_metadata(
"1.0.0",
&[],
"1.0.0",
Some(&complete),
&all_pkg,
&entries,
Some("npm"),
)
.unwrap();
assert_eq!(r.closest_min_fix.fix_version, "1.0.1");
assert_eq!(
r.closest_complete_fix.as_ref().unwrap().fix_version,
"1.0.1"
);
assert_eq!(r.closest_safe_fix.as_ref().unwrap().fix_version, "1.0.1");
let introduced = r
.closest_safe_fix
.as_ref()
.unwrap()
.residual_cves
.as_ref()
.and_then(|rc| rc.introduced.as_ref())
.map_or(0, |i| i.total);
assert_eq!(introduced, 0);
}
#[test]
fn test_compute_fix_metadata_safe_fix_no_valid_candidates() {
let entries = vec![
cve("CVE-2023-001", ">=1.0.0 <1.5.0", "High"),
cve("CVE-2023-002", ">=1.3.0 <1.6.0", "Medium"),
];
let fix_v = vec!["1.2.0".to_owned()];
let complete = vec!["1.6.0".to_owned()];
let all_pkg = vec![
"1.0.0".to_owned(),
"1.2.0".to_owned(),
"1.3.0".to_owned(),
"1.4.0".to_owned(),
"1.5.0".to_owned(),
"1.6.0".to_owned(),
];
let r = compute_fix_metadata(
"1.0.0",
&fix_v,
">=1.0.0 <1.5.0",
Some(&complete),
&all_pkg,
&entries,
Some("npm"),
)
.unwrap();
assert_eq!(r.closest_min_fix.fix_version, "1.2.0");
}
#[test]
fn test_compute_fix_metadata_introduced_cves_in_minimal() {
let entries = vec![
cve("CVE-2023-001", ">=1.0.0 <1.2.0", "High"),
cve("CVE-2023-002", ">=1.2.0 <1.5.0", "Medium"),
];
let fix_v = vec!["1.2.0".to_owned()];
let complete = vec!["1.5.0".to_owned()];
let all_pkg = vec![
"1.0.0".to_owned(),
"1.2.0".to_owned(),
"1.3.0".to_owned(),
"1.4.0".to_owned(),
"1.5.0".to_owned(),
];
let r = compute_fix_metadata(
"1.0.0",
&fix_v,
">=1.0.0 <1.2.0",
Some(&complete),
&all_pkg,
&entries,
Some("npm"),
)
.unwrap();
assert_eq!(r.closest_min_fix.fix_version, "1.2.0");
}
#[test]
fn test_compute_fix_metadata_complete_fix_only() {
let entries = vec![cve("CVE-2023-001", ">=1.0.0 <2.0.0", "High")];
let complete = vec!["2.0.0".to_owned()];
let all_pkg = vec!["1.0.0".to_owned(), "2.0.0".to_owned()];
let r = compute_fix_metadata(
"1.0.0",
&[],
">=1.0.0 <2.0.0",
Some(&complete),
&all_pkg,
&entries,
Some("npm"),
)
.unwrap();
assert_eq!(r.closest_min_fix.fix_version, "2.0.0");
assert_eq!(
r.closest_complete_fix.as_ref().unwrap().fix_version,
"2.0.0"
);
}
#[test]
fn test_compute_fix_metadata_skip_vulnerable_candidate() {
let entries = vec![
cve("CVE-2023-001", "1.0.0", "High"),
cve("CVE-2023-002", "1.0.2", "Medium"),
];
let fix_v = vec!["1.0.1".to_owned()];
let complete = vec!["1.0.3".to_owned()];
let all_pkg = vec!["1.0.1".to_owned(), "1.0.2".to_owned(), "1.0.3".to_owned()];
let r = compute_fix_metadata(
"1.0.0",
&fix_v,
"1.0.0",
Some(&complete),
&all_pkg,
&entries,
Some("npm"),
)
.unwrap();
assert_eq!(r.closest_min_fix.fix_version, "1.0.1");
}
#[test]
fn test_compute_fix_metadata_with_fix_versions() {
let entries = vec![cve("CVE-2023-001", ">=1.0.0 <2.0.0", "High")];
let fix_v = vec!["2.0.0".to_owned()];
let complete = vec!["2.0.0".to_owned()];
let all_pkg = vec!["1.0.0".to_owned(), "1.5.0".to_owned(), "2.0.0".to_owned()];
let r = compute_fix_metadata(
"1.0.0",
&fix_v,
">=1.0.0 <2.0.0",
Some(&complete),
&all_pkg,
&entries,
Some("npm"),
)
.unwrap();
assert_eq!(r.closest_min_fix.fix_version, "2.0.0");
}
#[test]
fn test_compute_fix_metadata_no_fixes_available() {
let entries = vec![cve("CVE-2023-001", ">=1.0.0", "High")];
let all_pkg = vec!["1.0.0".to_owned()];
let r = compute_fix_metadata(
"1.0.0",
&[],
">=1.0.0",
None,
&all_pkg,
&entries,
Some("npm"),
);
assert!(r.is_none());
}
#[test]
fn test_compute_fix_metadata_safe_versions_only() {
let entries = vec![cve("CVE-2023-001", ">=1.0.0 <2.0.0", "High")];
let complete = vec!["2.0.0".to_owned()];
let all_pkg = vec!["1.0.0".to_owned(), "2.0.0".to_owned()];
let r = compute_fix_metadata(
"1.0.0",
&[],
">=1.0.0 <2.0.0",
Some(&complete),
&all_pkg,
&entries,
Some("npm"),
)
.unwrap();
assert_eq!(r.closest_min_fix.fix_version, "2.0.0");
}
}