use semver::{BuildMetadata, Version};
use super::OsvVersion;
use super::types::{OsvAffected, OsvEvent, OsvRange, OsvRangeType};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct LocalVersion(Version);
impl LocalVersion {
pub(super) fn parse(version: &OsvVersion) -> Option<Self> {
Version::parse(version.as_str())
.ok()
.map(|v| Self(strip_build(v)))
}
}
fn strip_build(mut version: Version) -> Version {
version.build = BuildMetadata::EMPTY;
version
}
fn parse_bound(raw: &str) -> Option<Version> {
let raw = raw.strip_prefix('v').unwrap_or(raw);
let is_numeric = |part: &str| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit());
let components = raw.split('.').count();
let padded = if raw.split('.').all(is_numeric) {
match components {
1 => format!("{raw}.0.0"),
2 => format!("{raw}.0"),
_ => raw.to_owned(),
}
} else {
raw.to_owned()
};
Version::parse(&padded).ok().map(strip_build)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Verdict {
Affected,
NotAffected,
Undeterminable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RangeVerdict {
Affected,
NotAffected,
Undeterminable,
Irrelevant,
}
#[derive(Debug)]
enum LastKnownRange {
Below(Version),
AtMost(Version),
Unparsable,
}
impl LastKnownRange {
fn of(entry: &OsvAffected) -> Option<Self> {
let raw = entry
.database_specific
.as_ref()?
.get("last_known_affected_version_range")?
.as_str()?;
Some(Self::parse(raw))
}
fn parse(raw: &str) -> Self {
let raw = raw.trim();
let (bound, inclusive) = match raw.strip_prefix("<=") {
Some(rest) => (rest, true),
None => match raw.strip_prefix('<') {
Some(rest) => (rest, false),
None => return Self::Unparsable,
},
};
match parse_bound(bound.trim()) {
Some(v) if inclusive => Self::AtMost(v),
Some(v) => Self::Below(v),
None => Self::Unparsable,
}
}
fn verdict(&self, version: &LocalVersion) -> RangeVerdict {
let inside = match self {
Self::Below(bound) => version.0 < *bound,
Self::AtMost(bound) => version.0 <= *bound,
Self::Unparsable => return RangeVerdict::Undeterminable,
};
if inside {
RangeVerdict::Affected
} else {
RangeVerdict::NotAffected
}
}
}
#[derive(Debug, Clone, Copy)]
enum Edge {
Introduced,
Fixed,
LastAffected,
}
pub(super) fn match_affected(affected: &[&OsvAffected], version: &LocalVersion) -> Verdict {
let mut verdict = if affected.is_empty() {
Verdict::Undeterminable
} else {
Verdict::NotAffected
};
for entry in affected {
let last_known = LastKnownRange::of(entry);
if entry
.versions
.iter()
.filter_map(|v| parse_bound(v))
.any(|v| v == version.0)
&& !cap_excludes(entry, last_known.as_ref(), version)
{
return Verdict::Affected;
}
let mut evaluable = !entry.versions.is_empty();
for range in &entry.ranges {
match match_range(range, version, last_known.as_ref()) {
RangeVerdict::Affected => return Verdict::Affected,
RangeVerdict::Undeterminable => verdict = Verdict::Undeterminable,
RangeVerdict::NotAffected => evaluable = true,
RangeVerdict::Irrelevant => {}
}
}
if !evaluable {
verdict = Verdict::Undeterminable;
}
}
verdict
}
fn cap_excludes(
entry: &OsvAffected,
last_known: Option<&LastKnownRange>,
version: &LocalVersion,
) -> bool {
last_known.is_some_and(|cap| {
cap.verdict(version) == RangeVerdict::NotAffected
&& entry
.ranges
.iter()
.any(|range| range_edges(range).is_some_and(|edges| is_open_ended(&edges)))
})
}
fn range_edges(range: &OsvRange) -> Option<Vec<(Edge, Version)>> {
if !matches!(
range.range_type,
OsvRangeType::Semver | OsvRangeType::Ecosystem
) {
return None;
}
let mut edges = Vec::with_capacity(range.events.len());
for event in &range.events {
match event_edge(event) {
Ok(Some(edge)) => edges.push(edge),
Ok(None) => {}
Err(UnparsableBound) => return None,
}
}
edges.sort_by(|a, b| a.1.cmp(&b.1));
Some(edges)
}
fn is_open_ended(edges: &[(Edge, Version)]) -> bool {
!edges.is_empty()
&& edges
.iter()
.all(|(edge, _)| matches!(edge, Edge::Introduced))
}
fn match_range(
range: &OsvRange,
version: &LocalVersion,
last_known: Option<&LastKnownRange>,
) -> RangeVerdict {
match range.range_type {
OsvRangeType::Git => RangeVerdict::Irrelevant,
OsvRangeType::Unknown => RangeVerdict::Undeterminable,
OsvRangeType::Semver | OsvRangeType::Ecosystem => {
let Some(edges) = range_edges(range) else {
return RangeVerdict::Undeterminable;
};
let open_ended = is_open_ended(&edges);
let mut affected = false;
for (edge, bound) in edges {
match edge {
Edge::Introduced if version.0 >= bound => affected = true,
Edge::Fixed if version.0 >= bound => affected = false,
Edge::LastAffected if version.0 > bound => affected = false,
Edge::Introduced | Edge::Fixed | Edge::LastAffected => {}
}
}
if affected && open_ended {
last_known.map_or(RangeVerdict::Affected, |r| r.verdict(version))
} else if affected {
RangeVerdict::Affected
} else {
RangeVerdict::NotAffected
}
}
}
}
struct UnparsableBound;
fn event_edge(event: &OsvEvent) -> Result<Option<(Edge, Version)>, UnparsableBound> {
let (edge, raw) = if let Some(v) = &event.introduced {
(Edge::Introduced, v)
} else if let Some(v) = &event.fixed {
(Edge::Fixed, v)
} else if let Some(v) = &event.last_affected {
(Edge::LastAffected, v)
} else {
return Ok(None);
};
let bound = parse_bound(raw).ok_or(UnparsableBound)?;
Ok(Some((edge, bound)))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn verdict_for(entries: &[serde_json::Value], version: &str) -> Verdict {
let entries: Vec<OsvAffected> = entries
.iter()
.map(|e| serde_json::from_value(e.clone()).unwrap())
.collect();
let refs: Vec<&OsvAffected> = entries.iter().collect();
let version = LocalVersion::parse(&OsvVersion::new(version)).unwrap();
match_affected(&refs, &version)
}
fn verdict(entry: serde_json::Value, version: &str) -> Verdict {
verdict_for(&[entry], version)
}
fn semver_range(events: serde_json::Value) -> serde_json::Value {
json!({"ranges": [{"type": "SEMVER", "events": events}]})
}
#[test]
fn introduced_fixed_range_brackets_the_affected_versions() {
let r = || semver_range(json!([{"introduced": "4.0.0"}, {"fixed": "4.1.3"}]));
assert_eq!(verdict(r(), "4.1.2"), Verdict::Affected);
assert_eq!(verdict(r(), "4.0.0"), Verdict::Affected);
assert_eq!(verdict(r(), "4.1.3"), Verdict::NotAffected);
assert_eq!(verdict(r(), "3.9.9"), Verdict::NotAffected);
}
#[test]
fn introduced_zero_and_last_affected_is_inclusive() {
let r = || semver_range(json!([{"introduced": "0"}, {"last_affected": "2.0.0"}]));
assert_eq!(verdict(r(), "0.0.1"), Verdict::Affected);
assert_eq!(verdict(r(), "2.0.0"), Verdict::Affected);
assert_eq!(verdict(r(), "2.0.1"), Verdict::NotAffected);
}
#[test]
fn build_metadata_is_ignored_on_pin_and_bounds() {
let r = || semver_range(json!([{"introduced": "0"}, {"last_affected": "2.0.0"}]));
assert_eq!(verdict(r(), "2.0.0+b"), Verdict::Affected);
let fixed = || semver_range(json!([{"introduced": "0"}, {"fixed": "2.0.0+meta"}]));
assert_eq!(verdict(fixed(), "2.0.0"), Verdict::NotAffected);
assert_eq!(verdict(fixed(), "1.9.9+x"), Verdict::Affected);
assert_eq!(
verdict(json!({"versions": ["v1.2.3"]}), "1.2.3+build"),
Verdict::Affected
);
}
#[test]
fn events_are_sorted_before_folding() {
let r = semver_range(json!([{"fixed": "4.1.3"}, {"introduced": "4.0.0"}]));
assert_eq!(verdict(r, "4.1.2"), Verdict::Affected);
}
#[test]
fn multiple_introduced_fixed_pairs_in_one_range() {
let r = || {
semver_range(json!([
{"introduced": "1.0.0"}, {"fixed": "1.5.0"},
{"introduced": "2.0.0"}, {"fixed": "2.5.0"}
]))
};
assert_eq!(verdict(r(), "1.2.0"), Verdict::Affected);
assert_eq!(verdict(r(), "1.7.0"), Verdict::NotAffected);
assert_eq!(verdict(r(), "2.1.0"), Verdict::Affected);
assert_eq!(verdict(r(), "2.5.0"), Verdict::NotAffected);
}
#[test]
fn any_affecting_range_or_entry_wins() {
let two_ranges = json!({"ranges": [
{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "1.0.0"}]},
{"type": "SEMVER", "events": [{"introduced": "3.0.0"}, {"fixed": "3.5.0"}]}
]});
assert_eq!(verdict(two_ranges, "3.1.0"), Verdict::Affected);
let miss = semver_range(json!([{"introduced": "0"}, {"fixed": "1.0.0"}]));
let hit = semver_range(json!([{"introduced": "5.0.0"}]));
assert_eq!(verdict_for(&[miss, hit], "6.0.0"), Verdict::Affected);
}
#[test]
fn prerelease_orders_before_its_release() {
let r = || semver_range(json!([{"introduced": "0"}, {"fixed": "2.0.0"}]));
assert_eq!(verdict(r(), "2.0.0-rc.1"), Verdict::Affected);
let from = semver_range(json!([{"introduced": "2.0.0"}]));
assert_eq!(verdict(from, "2.0.0-rc.1"), Verdict::NotAffected);
}
#[test]
fn limit_event_is_ignored() {
let r = semver_range(json!([{"introduced": "1.0.0"}, {"limit": "2.0.0"}]));
assert_eq!(verdict(r, "9.0.0"), Verdict::Affected);
}
#[test]
fn same_bound_introduced_and_fixed_follow_input_order() {
let r = || semver_range(json!([{"introduced": "1.0.0"}, {"fixed": "1.0.0"}]));
assert_eq!(verdict(r(), "1.0.0"), Verdict::NotAffected);
assert_eq!(verdict(r(), "1.0.1"), Verdict::NotAffected);
}
#[test]
fn bare_major_and_minor_bounds_are_padded() {
let fixed = || semver_range(json!([{"introduced": "0"}, {"fixed": "41"}]));
assert_eq!(verdict(fixed(), "40.9.9"), Verdict::Affected);
assert_eq!(verdict(fixed(), "41.0.0"), Verdict::NotAffected);
let introduced = || semver_range(json!([{"introduced": "5"}, {"fixed": "6"}]));
assert_eq!(verdict(introduced(), "4.9.9"), Verdict::NotAffected);
assert_eq!(verdict(introduced(), "5.0.0"), Verdict::Affected);
assert_eq!(verdict(introduced(), "6.0.0"), Verdict::NotAffected);
let minor = semver_range(json!([{"introduced": "v2.1"}, {"fixed": "2.3"}]));
assert_eq!(verdict(minor, "2.2.9"), Verdict::Affected);
}
#[test]
fn non_numeric_bounds_stay_undeterminable() {
for bad in [
"v4-beta",
"latest",
"abcdef1234567890abcdef1234567890abcdef12",
"",
] {
let fixed = semver_range(json!([{"introduced": "0"}, {"fixed": bad}]));
assert_eq!(verdict(fixed, "1.0.0"), Verdict::Undeterminable, "{bad}");
}
let introduced = semver_range(json!([{"introduced": "main"}]));
assert_eq!(verdict(introduced, "1.0.0"), Verdict::Undeterminable);
let last = semver_range(json!([{"introduced": "0"}, {"last_affected": "x"}]));
assert_eq!(verdict(last, "1.0.0"), Verdict::Undeterminable);
}
#[test]
fn explicit_versions_list_is_v_normalised() {
assert_eq!(
verdict(json!({"versions": ["v0.1.21", "1.2.3"]}), "0.1.21"),
Verdict::Affected
);
assert_eq!(
verdict(json!({"versions": ["1.2.3"]}), "1.2.4"),
Verdict::NotAffected
);
}
#[test]
fn unknown_range_is_undeterminable_but_a_hit_still_wins() {
let unknown = json!({"ranges": [{"events": [{"introduced": "0"}]}]});
assert_eq!(verdict(unknown.clone(), "1.0.0"), Verdict::Undeterminable);
let hit = semver_range(json!([{"introduced": "0"}]));
assert_eq!(verdict_for(&[unknown, hit], "1.0.0"), Verdict::Affected);
}
#[test]
fn undeterminable_is_sticky_across_later_entries() {
let bad = semver_range(json!([{"introduced": "0"}, {"fixed": "zzz"}]));
let miss = semver_range(json!([{"introduced": "0"}, {"fixed": "1.0.0"}]));
assert_eq!(
verdict_for(&[bad.clone(), miss.clone()], "2.0.0"),
Verdict::Undeterminable
);
assert_eq!(verdict_for(&[miss, bad], "2.0.0"), Verdict::Undeterminable);
}
#[test]
fn git_only_entry_is_undeterminable_but_git_beside_a_semver_range_is_not() {
let git =
json!({"ranges": [{"type": "GIT", "events": [{"introduced": "0"}, {"fixed": "abc"}]}]});
assert_eq!(verdict(git, "1.0.0"), Verdict::Undeterminable);
let both = json!({"ranges": [
{"type": "GIT", "events": [{"introduced": "0"}]},
{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "1.0.0"}]}
]});
assert_eq!(verdict(both, "2.0.0"), Verdict::NotAffected);
}
#[test]
fn empty_entry_list_is_undeterminable() {
let version = LocalVersion::parse(&OsvVersion::new("1.0.0")).unwrap();
assert_eq!(match_affected(&[], &version), Verdict::Undeterminable);
}
fn codeql_open_ended_entries() -> Vec<serde_json::Value> {
vec![
json!({
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "3.26.11"}, {"fixed": "3.28.3"}]}],
"database_specific": {"last_known_affected_version_range": "<= 3.28.2"}
}),
json!({
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "2.26.11"}]}],
"database_specific": {"last_known_affected_version_range": "< 3.0.0"}
}),
]
}
#[test]
fn ghsa_vqf5_open_ended_range_is_capped_by_last_known_affected_range() {
let entries = codeql_open_ended_entries();
for affected in ["3.28.2", "3.26.11", "2.26.11", "2.30.0", "2.99.99"] {
assert_eq!(
verdict_for(&entries, affected),
Verdict::Affected,
"{affected}"
);
}
for clean in ["3.28.3", "4.38.2", "2.26.10", "3.0.0", "3.26.10"] {
assert_eq!(
verdict_for(&entries, clean),
Verdict::NotAffected,
"{clean}"
);
}
}
fn open_ended_with_versions(cap: &str, versions: serde_json::Value) -> serde_json::Value {
json!({
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "1.0.0"}]}],
"versions": versions,
"database_specific": {"last_known_affected_version_range": cap}
})
}
#[test]
fn versions_hit_above_last_known_cap_is_not_affected() {
let entry = || open_ended_with_versions("< 2.0.0", json!(["1.5.0", "2.0.0", "3.1.0"]));
assert_eq!(verdict(entry(), "3.1.0"), Verdict::NotAffected);
assert_eq!(verdict(entry(), "2.0.0"), Verdict::NotAffected);
}
#[test]
fn versions_hit_below_last_known_cap_is_affected() {
let entry = || open_ended_with_versions("<= 2.0.0", json!(["1.5.0", "2.0.0"]));
assert_eq!(verdict(entry(), "1.5.0"), Verdict::Affected);
assert_eq!(verdict(entry(), "2.0.0"), Verdict::Affected);
}
#[test]
fn versions_hit_below_introduced_is_affected_only_through_the_versions_list() {
let entry = open_ended_with_versions("< 2.0.0", json!(["0.9.0"]));
assert_eq!(verdict(entry, "0.9.0"), Verdict::Affected);
}
#[test]
fn versions_hit_excluded_by_cap_falls_through_to_a_closed_range() {
let entry = |closed: (&str, &str)| {
json!({
"ranges": [
{"type": "ECOSYSTEM", "events": [{"introduced": closed.0}, {"fixed": closed.1}]},
{"type": "ECOSYSTEM", "events": [{"introduced": "1.0.0"}]}
],
"versions": ["3.1.0"],
"database_specific": {"last_known_affected_version_range": "< 2.0.0"}
})
};
assert_eq!(
verdict(entry(("3.0.0", "3.5.0")), "3.1.0"),
Verdict::Affected
);
assert_eq!(
verdict(entry(("4.0.0", "4.5.0")), "3.1.0"),
Verdict::NotAffected
);
}
#[test]
fn versions_hit_excluded_in_one_entry_is_decided_by_the_next_entry() {
let capped = || open_ended_with_versions("< 2.0.0", json!(["3.1.0"]));
let covering = semver_range(json!([{"introduced": "3.0.0"}, {"fixed": "3.5.0"}]));
let elsewhere = semver_range(json!([{"introduced": "4.0.0"}, {"fixed": "5.0.0"}]));
assert_eq!(
verdict_for(&[capped(), covering], "3.1.0"),
Verdict::Affected
);
assert_eq!(
verdict_for(&[capped(), elsewhere], "3.1.0"),
Verdict::NotAffected
);
}
#[test]
fn versions_hit_with_git_range_is_not_capped() {
let entry = json!({
"ranges": [{"type": "GIT", "events": [{"introduced": "0"}]}],
"versions": ["3.1.0"],
"database_specific": {"last_known_affected_version_range": "< 2.0.0"}
});
assert_eq!(verdict(entry, "3.1.0"), Verdict::Affected);
}
#[test]
fn versions_hit_with_unparsable_range_bound_is_not_capped() {
let entry = json!({
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "abc"}]}],
"versions": ["3.1.0"],
"database_specific": {"last_known_affected_version_range": "< 2.0.0"}
});
assert_eq!(verdict(entry, "3.1.0"), Verdict::Affected);
}
#[test]
fn versions_hit_above_cap_written_in_other_forms_is_not_affected() {
for cap in ["<= 2.0.0", "< v2.0.0", "< 2.0"] {
let entry = open_ended_with_versions(cap, json!(["3.1.0"]));
assert_eq!(verdict(entry, "3.1.0"), Verdict::NotAffected, "{cap}");
}
}
#[test]
fn open_ended_range_above_cap_outside_versions_is_not_affected() {
let entry = open_ended_with_versions("< 2.0.0", json!(["1.5.0"]));
assert_eq!(verdict(entry, "3.1.0"), Verdict::NotAffected);
}
#[test]
fn versions_hit_without_cap_or_open_ended_range_is_unchanged() {
let no_cap = json!({
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "1.0.0"}]}],
"versions": ["3.1.0"]
});
assert_eq!(verdict(no_cap, "3.1.0"), Verdict::Affected);
let closed = json!({
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "1.0.0"}, {"fixed": "1.5.0"}]}],
"versions": ["3.1.0"],
"database_specific": {"last_known_affected_version_range": "< 2.0.0"}
});
assert_eq!(verdict(closed, "3.1.0"), Verdict::Affected);
let versions_only = json!({
"versions": ["3.1.0"],
"database_specific": {"last_known_affected_version_range": "< 2.0.0"}
});
assert_eq!(verdict(versions_only, "3.1.0"), Verdict::Affected);
}
#[test]
fn versions_hit_with_unparsable_cap_stays_affected() {
let entry = open_ended_with_versions("garbage", json!(["3.1.0"]));
assert_eq!(verdict(entry, "3.1.0"), Verdict::Affected);
}
#[test]
fn last_known_affected_range_at_most_is_inclusive() {
let e = json!({
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "1.0.0"}]}],
"database_specific": {"last_known_affected_version_range": "<= 2.0.0"}
});
assert_eq!(verdict(e.clone(), "2.0.0"), Verdict::Affected);
assert_eq!(verdict(e.clone(), "2.0.1"), Verdict::NotAffected);
assert_eq!(verdict(e, "0.9.0"), Verdict::NotAffected);
}
#[test]
fn unparsable_last_known_affected_range_is_undeterminable() {
for bad in [">= 1.0.0", "3.0.0", "< latest", "", "<"] {
let e = json!({
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "1.0.0"}]}],
"database_specific": {"last_known_affected_version_range": bad}
});
assert_eq!(verdict(e, "2.0.0"), Verdict::Undeterminable, "{bad:?}");
}
}
#[test]
fn last_known_affected_range_ignored_without_open_ended_range_or_key() {
let closed = json!({
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "1.0.0"}, {"fixed": "2.0.0"}]}],
"database_specific": {"last_known_affected_version_range": "< 1.5.0"}
});
assert_eq!(verdict(closed, "1.9.0"), Verdict::Affected);
let no_key = json!({
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "1.0.0"}]}],
"database_specific": {"informational": "unmaintained"}
});
assert_eq!(verdict(no_key, "9.0.0"), Verdict::Affected);
let non_string = json!({
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "1.0.0"}]}],
"database_specific": {"last_known_affected_version_range": 3}
});
assert_eq!(verdict(non_string, "9.0.0"), Verdict::Affected);
}
#[test]
fn last_known_cap_scope_is_per_entry_and_all_introduced_only() {
let capped = json!({
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "1.0.0"}]}],
"database_specific": {"last_known_affected_version_range": "< 2.0.0"}
});
let uncapped = semver_range(json!([{"introduced": "1.0.0"}]));
assert_eq!(verdict_for(&[capped, uncapped], "9.0.0"), Verdict::Affected);
let mixed = json!({
"ranges": [{"type": "ECOSYSTEM", "events": [
{"introduced": "1.0.0"}, {"fixed": "2.0.0"}, {"introduced": "3.0.0"}
]}],
"database_specific": {"last_known_affected_version_range": "< 4.0.0"}
});
assert_eq!(verdict(mixed, "9.0.0"), Verdict::Affected);
}
#[test]
fn introduced_zero_with_last_known_upper_bound() {
let e = || {
json!({
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}]}],
"database_specific": {"last_known_affected_version_range": "< 2.0.0"}
})
};
assert_eq!(verdict(e(), "1.9.9"), Verdict::Affected);
assert_eq!(verdict(e(), "2.0.0"), Verdict::NotAffected);
assert_eq!(verdict(e(), "3.0.0"), Verdict::NotAffected);
}
#[test]
fn only_full_semver_pins_parse() {
for bad in [
"4",
"4.1",
"v4.1.2",
"main",
"1234567890abcdef1234567890abcdef12345678",
] {
assert!(
LocalVersion::parse(&OsvVersion::new(bad)).is_none(),
"{bad}"
);
}
for good in ["4.1.2", "4.1.2+meta", "4.1.2-rc.1"] {
assert!(
LocalVersion::parse(&OsvVersion::new(good)).is_some(),
"{good}"
);
}
}
}