pub const INFINITE: &str = "//INFINITE//";
const PLATFORM_SPECIFIERS: &[&str] = &["macos", "darwin", "linux", "win", "mingw"];
fn simplify_pre_release(pre: &str) -> Option<String> {
if PLATFORM_SPECIFIERS.iter().any(|p| pre.contains(p)) {
None
} else {
Some(pre.to_owned())
}
}
pub fn normalize_version_operators(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let is_op = |c: char| "<>=!~^".contains(c);
let mut in_op_seq = false;
for c in s.chars() {
if is_op(c) {
in_op_seq = true;
out.push(c);
} else if c == ' ' && in_op_seq {
} else {
in_op_seq = false;
out.push(c);
}
}
out
}
pub fn normalize_ver(version: &str) -> (Vec<String>, Option<String>) {
let (ver_part, pre_release) = version
.split_once('-')
.map_or((version, None), |(a, b)| (a, Some(b)));
let ver_owned;
let ver_part = if ver_part.contains(':') && !ver_part.contains(['<', '>', '=']) {
if let Some((epoch, rest)) = ver_part.split_once(':') {
ver_owned = format!("{epoch}.{rest}");
ver_owned.as_str()
} else {
ver_part
}
} else {
ver_part
};
let parts: Vec<String> = ver_part.split(['.', '-']).map(String::from).collect();
let pre = pre_release
.map(str::to_lowercase)
.and_then(|p| simplify_pre_release(&p));
(parts, pre)
}
fn is_numeric(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
}
fn cmp_numeric_strs(a: &str, b: &str) -> std::cmp::Ordering {
let a = a.trim_start_matches('0');
let b = b.trim_start_matches('0');
match a.len().cmp(&b.len()) {
std::cmp::Ordering::Equal => a.cmp(b),
other => other,
}
}
fn aux_compare_tokens(t1: &str, t2: &str) -> Option<bool> {
if is_numeric(t1) && is_numeric(t2) {
return Some(cmp_numeric_strs(t1, t2) == std::cmp::Ordering::Greater);
}
if is_numeric(t1) || t1 == INFINITE {
return Some(true); }
if is_numeric(t2) || t2 == INFINITE {
return Some(false); }
if t1 != t2 {
return Some(t1 > t2);
}
None }
fn aux_compare_suffix(s1: &str, s2: &str) -> Option<bool> {
match (s1.is_empty(), s2.is_empty()) {
(false, true) => Some(true),
(true, false) => Some(false),
_ if s1 != s2 => Some(s1 > s2),
_ => None,
}
}
fn compare_tokens(t1: &str, t2: &str) -> Option<bool> {
if t1.contains('+') || t2.contains('+') {
let (num1, suf1) = t1.split_once('+').unwrap_or((t1, ""));
let (num2, suf2) = t2.split_once('+').unwrap_or((t2, ""));
if is_numeric(num1) && is_numeric(num2) {
let ord = cmp_numeric_strs(num1, num2);
if ord != std::cmp::Ordering::Equal {
return Some(ord == std::cmp::Ordering::Greater);
}
} else if num1 != num2 {
return Some(num1 > num2);
}
if let Some(r) = aux_compare_suffix(suf1, suf2) {
return Some(r);
}
}
aux_compare_tokens(t1, t2)
}
fn compare_pre_releases(p1: Option<&str>, p2: Option<&str>) -> bool {
match (p1, p2) {
(None, Some(_)) => true, (Some(a), Some(b)) => a > b,
_ => false,
}
}
fn resolve_equal_parts(
v1_pre: Option<&str>,
v2_pre: Option<&str>,
v1: &[String],
v2: &[String],
include_same: bool,
) -> bool {
if v2_pre.is_none() && v2.iter().all(|s| s == "0") && v1.iter().all(|s| s == "0") {
return true;
}
if let (Some(a), Some(b)) = (v1_pre, v2_pre) {
if a != b {
return compare_pre_releases(Some(a), Some(b));
}
}
if include_same {
return v1_pre.is_none() && v2_pre.is_none() || compare_pre_releases(v1_pre, v2_pre);
}
false
}
pub fn compare_versions(version1: &str, version2: &str, include_same: bool) -> bool {
let (v1, v1_pre) = normalize_ver(version1);
let (v2, v2_pre) = normalize_ver(version2);
if include_same && v1 == v2 && v1_pre.as_deref() == v2_pre.as_deref() {
return true;
}
let max_len = v1.len().max(v2.len());
for i in 0..max_len {
let p1 = v1.get(i).map_or("0", String::as_str);
let p2 = v2.get(i).map_or("0", String::as_str);
if p1 != p2 {
if let Some(result) = compare_tokens(p1, p2) {
return result;
}
}
}
resolve_equal_parts(v1_pre.as_deref(), v2_pre.as_deref(), &v1, &v2, include_same)
}
fn parse_range_pairs(s: &str) -> Vec<(String, String)> {
let mut pairs = Vec::new();
let bytes = s.as_bytes();
let mut i = 0;
while let Some(&b) = bytes.get(i) {
if b != b'<' && b != b'>' {
i = i.saturating_add(1);
continue;
}
let op_start = i;
i = i.saturating_add(1);
if bytes.get(i) == Some(&b'=') {
i = i.saturating_add(1);
}
let op = s[op_start..i].to_string();
while bytes.get(i) == Some(&b' ') {
i = i.saturating_add(1);
}
let val_start = i;
while matches!(bytes.get(i), Some(&c) if c != b'<' && c != b'>' && c != b'=' && c != b' ') {
i = i.saturating_add(1);
}
if val_start < i {
pairs.push((op, s[val_start..i].to_string()));
}
}
pairs
}
pub fn parse_version_range(version_range: &str) -> Option<(String, String, String, String)> {
let mut range = version_range.to_owned();
if range.starts_with('<') {
range = format!(">0 {range}");
}
if range.starts_with('>') && !range.contains('<') {
range = format!("{range} <{INFINITE}");
}
let pairs = parse_range_pairs(&range);
if let [(op1, val1), (op2, val2)] = pairs.as_slice() {
Some((op1.clone(), val1.clone(), op2.clone(), val2.clone()))
} else {
tracing::error!("Invalid version range: {version_range}. Values cannot be parsed.");
None
}
}
pub fn is_single_version(s: &str) -> bool {
!s.contains('<') && !s.contains('>')
}
pub fn is_single_version_in_range(version: &str, range: &str) -> bool {
let v = version.trim_start_matches('=');
let Some((op1, min2, op2, max2)) = parse_version_range(range) else {
return false;
};
compare_versions(v, &min2, op1.contains('=')) && compare_versions(&max2, v, op2.contains('='))
}
pub fn do_ranges_intersect(r1: &str, r2: &str) -> bool {
let sv1 = is_single_version(r1);
let sv2 = is_single_version(r2);
if sv1 && sv2 {
return r1 == r2;
}
if sv1 {
return is_single_version_in_range(r1, r2);
}
if sv2 {
return is_single_version_in_range(r2, r1);
}
let Some((op1_r1, lower1, op2_r1, upper1)) = parse_version_range(r1) else {
return false;
};
let Some((op1_r2, lower2, op2_r2, upper2)) = parse_version_range(r2) else {
return false;
};
let inc1 = op1_r1.contains('=') && op2_r2.contains('=');
let inc2 = op1_r2.contains('=') && op2_r1.contains('=');
compare_versions(&upper2, &lower1, inc1) && compare_versions(&upper1, &lower2, inc2)
}
pub fn increment_version(version: &str, position: usize) -> String {
let mut parts: Vec<String> = version.split('.').map(String::from).collect();
let position = if parts.len() < 2 {
while parts.len() < 2 {
parts.push("0".to_owned());
}
if position > 1 {
position.saturating_sub(1)
} else {
position
}
} else {
position
};
if let Some(p) = parts.get_mut(position) {
INFINITE.clone_into(p);
for part in parts.iter_mut().skip(position.saturating_add(1)) {
"0".clone_into(part);
}
}
parts.join(".")
}
fn simplify_final_version(version: &str) -> String {
let v = if let Some((base, _)) = version.rsplit_once('+') {
base
} else {
version
};
let lower = v.to_lowercase();
let parts: Vec<&str> = lower.split('.').collect();
if parts.last().is_some_and(|p| p.contains("final")) {
parts
.split_last()
.map_or_else(String::new, |(_, init)| init.join("."))
} else {
v.to_owned()
}
}
fn convert_asterisk_to_range(version: &str) -> String {
let parts: Vec<&str> = version.split('.').collect();
match parts.as_slice() {
[major, rest @ ..] if rest.last() == Some(&"*") => match rest.len() {
1 => format!(">={major}.0.0 <{major}.{INFINITE}.0"),
2.. => {
if let [minor, ..] = rest {
format!(">={major}.{minor}.0 <{major}.{minor}.{INFINITE}")
} else {
version.to_owned()
}
}
_ => version.to_owned(),
},
_ => version.to_owned(),
}
}
pub fn convert_semver_to_range(version: &str) -> String {
let version = version.replace("==", "=").replace(' ', "");
if let Some(rest) = version.strip_prefix('~') {
let (inner, position) = rest.strip_prefix(['=', '>']).map_or_else(
|| {
let inner = if rest.split('.').count() == 2 {
format!("{rest}.0")
} else {
rest.to_owned()
};
(inner, 2usize)
},
|inner| {
let dot_count = inner.split('.').count();
let pos = if dot_count == 2 { 1 } else { 2 };
(inner.to_owned(), pos)
},
);
return format!(">={inner} <{}", increment_version(&inner, position));
}
if let Some(rest) = version.strip_prefix('^') {
return format!(">={rest} <{}", increment_version(rest, 1));
}
if version.contains(".*") {
return convert_asterisk_to_range(&version);
}
if is_single_version(&version) && !version.starts_with('=') {
return format!("={}", simplify_final_version(&version));
}
simplify_final_version(&version)
}
pub fn convert_to_range(version: &str) -> Vec<String> {
let normalized = normalize_version_operators(version);
normalized
.split(|c: char| c == ',' || c.is_whitespace())
.filter(|s| !s.is_empty())
.map(|s| convert_semver_to_range(s.trim()))
.collect()
}
fn match_version_ranges(dep_version: &str, vulnerable_version: &str) -> bool {
let and_ranges = convert_to_range(dep_version);
if and_ranges.is_empty() {
return false;
}
vulnerable_version.split("||").any(|vuln| {
let normalized = vuln.trim().replace(',', " ");
let vuln_range = convert_semver_to_range(&normalized);
and_ranges
.iter()
.all(|ar| do_ranges_intersect(ar, &vuln_range))
})
}
pub fn match_vulnerable_versions(dep_version: &str, advisory_range: Option<&str>) -> bool {
let Some(advisory_range) = advisory_range else {
return false;
};
if dep_version.is_empty() {
return false;
}
let dep_version = normalize_version_operators(dep_version);
let normalized = dep_version.replace("||", "|");
normalized
.split('|')
.any(|dv| match_version_ranges(dv.trim(), advisory_range))
}
#[cfg(test)]
mod tests {
use super::*;
macro_rules! mvv {
($dep:expr, $adv:expr, $expected:expr) => {
assert_eq!(
match_vulnerable_versions($dep, $adv),
$expected,
"match_vulnerable_versions({:?}, {:?})",
$dep,
$adv,
);
};
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_match_vulnerable_versions() {
mvv!("^1.0.0", Some("<0.0"), false);
mvv!("^7.0.0", Some("=6.12.2"), false);
mvv!("^7.0.0", Some("=6.12.2 || =6.9.1"), false);
mvv!("~2.2.3", Some(">=3.0.0 <=4.0.0"), false);
mvv!("=2.2", Some(">=2.3.0 <=2.4.0"), false);
mvv!("~0.8.0", Some(">=0 <=1.8.6"), true);
mvv!("1.8.0", Some(">=0 <=0.3.0 || >=1.0.1 <=1.8.6"), true);
mvv!("^2.1.0", Some(">=0 <11.0.5 || >=11.1.0 <11.1.0"), true);
mvv!("2.1.0", Some("~2"), true);
mvv!("=2.3.0-pre", Some(">=2.1.1 <2.3.0"), false);
mvv!("=2.3.0-pre", Some(">=2.3.0 <2.7.0"), false);
mvv!("=2.2.0-rc1", Some(">=2.1.1 <2.3.0"), true);
mvv!("=2.1.0-pre", Some("=2.1.0-pre"), true);
mvv!("=2.1.0-pre", Some("=2.1.0"), false);
mvv!(
">2.1.1 <=2.3.0",
Some("<2.1.0||=2.3.0-pre||>=2.4.0 <2.5.0"),
true
);
mvv!(">2.1.1 <2.3.0", Some("<2.1.0||=2.3.1-pre"), false);
mvv!("1.0.0-beta.8", Some("<=1.0.0-beta.6"), false);
mvv!("1.0.0-beta.4", Some("<=1.0.0-beta.6"), true);
mvv!("^1.0.0-rc.10", Some(">2.0.0 <=4.0.0"), false);
mvv!("^1.0.0-rc.10", Some(">=1.0.0 <=2.0.0"), true);
mvv!(
"^7.23.2",
Some(">=0 <7.23.2 || >=8.0.0-alpha.0 <8.0.0-alpha.4"),
false
);
mvv!("7.23.2", Some(">=0 <=7.23.2"), true);
mvv!("7.23.2", Some(">=6.5.1"), true);
mvv!("=7.23.2", Some(">=6.5.1"), true);
mvv!(">=11.1", Some(">=0 <12.3.3"), true);
mvv!("^1.2.0", Some(">=0 <1.0.3"), false);
mvv!("2.0.0||^3.0.0", Some(">=3.0.0"), true);
mvv!("3.*", Some(">=3.2.0 <4.0.0"), true);
mvv!("4.0", Some("=3.5.1 || =4.0 || =5.0"), true);
mvv!("4.2.2.RELEASE", Some(">0 <4.2.16"), true);
mvv!("2.13.14", Some(">0 <2.13.14-1"), false);
mvv!("8.4", Some(">=0 <7.6.3 || >=8.0.0 <8.4.0"), false);
mvv!("6.1.5.Final", Some(">=6.1.2 <6.1.5"), false);
mvv!("6.1.5.Final", Some(">=6.1.2 <=6.1.5"), true);
mvv!("==3.0.0 || >=4.0.1 <4.0.2 || ==4.0.1", Some("=3.0.0"), true);
mvv!("1.16.5-x86_64-darwin", Some("<1.16.5"), false);
mvv!("1.16.5-x86_64-mingw-10", Some("<1.16.5"), false);
mvv!("1.16.5-aarch64-linux", Some("<=1.16.5"), true);
mvv!("0.0.0-20221012-56ae", Some(">=0.0.0 <0.17.0"), true);
mvv!("0.0.0-20221012-56ae", Some("<0.17.0"), true);
mvv!("=0.10.0-20221012-e7cb96979f69", Some("<0.10.0"), false);
mvv!("=0.10.0-20221012-e7cb96979f69", Some("<=0.10.0"), true);
mvv!("${lombokVersion}", Some(">0"), false);
mvv!("", Some(">0"), false);
mvv!("0.0.0", None::<&str>, false);
mvv!("2.*,<2.3", Some(">=2.0.1"), true);
mvv!("2.*,<2.3", Some(">=1.3.0 <2.0.0"), false);
mvv!("1.2.0", Some(">=1.0.0,<=2.0.0"), true);
mvv!("1.2.0", Some(">=1.0.0, <=2.0.0"), true);
mvv!("3.2.0+incompatible", Some(">1.0.0 <=3.2.0"), true);
mvv!(">= 3.1.44 , < 3.2.0", Some(">=3.1.0"), true);
mvv!(">= 3.1.44 < 3.2.0", Some(">=3.1.0"), true);
mvv!("1.2.3 <=2.0.0", Some(">=1.0.0"), true);
mvv!("4.0.0", Some(">=3,<5"), true);
mvv!("3.0.0", Some(">=3,<5"), true);
mvv!("2.9.9", Some(">=3,<5"), false);
mvv!("5.0.0", Some(">=3,<5"), false);
mvv!("3.0.0", Some(">3,<5"), false);
mvv!("4.0.0", Some(">=3,<=4"), true);
mvv!("4.0.0", Some(">=3,<4"), false);
}
#[test]
fn test_match_vulnerable_versions_rpm_epoch() {
mvv!("0:2.35.2-42.el9", Some("<=0:2.35.2-42.el9"), true);
mvv!("0:2.35.2-63.el9", Some("<=0:2.35.2-42.el9"), false);
mvv!("0:2.35.2-30.el9", Some("<=0:2.35.2-42.el9"), true);
mvv!("0:3.8.3-6.el9", Some("<0:3.8.3-6.el9_6.2"), true);
mvv!("0:1.2.3", Some("<=0:1.2.3"), true);
mvv!("0:1.2.3", Some("<0:1.2.3"), false);
mvv!("0:1.2.3", Some(">=0:1.2.3"), true);
mvv!("0:1.2.3", Some(">0:1.2.3"), false);
mvv!("1:2.3.4", Some("==0:2.3.4"), false);
mvv!("0:2.3.4", Some("==1:2.3.4"), false);
mvv!("0:2.35.2-42.el9", Some("==0:2.35.2-42.el9"), true);
mvv!("0:2.35.2-42.el9", Some("==0:2.35.2-63.el9"), false);
mvv!("0:2.35.2-63.el9", Some("==0:2.35.2-42.el9"), false);
}
#[test]
fn test_match_vulnerable_versions_rhel_major_minor() {
mvv!("0:3.8.3-6.el9", Some("<0:3.8.3-6.el9_6.2"), true);
mvv!("0:3.8.3-6.el9_3", Some("<0:3.8.3-6.el9_6"), true);
mvv!("0:3.8.3-6.el9_6", Some("<0:3.8.3-6.el9_6.2"), true);
mvv!("0:2.35.2-42.el8_5", Some("<0:2.35.2-42.el8_8"), true);
mvv!("0:1.2.3-4.el7_6", Some("<0:1.2.3-4.el7_9"), true);
mvv!("0:3.8.3-6.el9_6", Some("<0:3.8.3-6.el9"), false);
mvv!("0:3.8.3-6.el9_6.2", Some("<0:3.8.3-6.el9_6"), false);
mvv!("0:3.8.3-6.el9_6", Some("<=0:3.8.3-6.el9_6"), true);
}
macro_rules! csr {
($input:expr, $expected:expr) => {
assert_eq!(
convert_semver_to_range($input),
$expected,
"convert_semver_to_range({:?})",
$input,
);
};
}
#[test]
fn test_convert_semver_to_range() {
csr!("~1.4.2", ">=1.4.2 <1.4.//INFINITE//");
csr!("~1.4", ">=1.4.0 <1.4.//INFINITE//");
csr!("~=1.4.2", ">=1.4.2 <1.4.//INFINITE//");
csr!("~=1.4", ">=1.4 <1.//INFINITE//");
csr!("~>1.4.2", ">=1.4.2 <1.4.//INFINITE//");
csr!("~>1.4", ">=1.4 <1.//INFINITE//");
csr!("~=1", ">=1 <1.//INFINITE//");
csr!("~>1", ">=1 <1.//INFINITE//");
csr!("~1", ">=1 <1.//INFINITE//");
csr!("^1", ">=1 <1.//INFINITE//");
csr!("^2", ">=2 <2.//INFINITE//");
}
#[test]
fn test_do_ranges_intersect_unparseable_range() {
assert!(!do_ranges_intersect(">=", ">= 3.4.0"));
}
#[test]
fn test_is_single_version_in_range_unparseable_range() {
assert!(!is_single_version_in_range("1.0.0", ">"));
}
}