Skip to main content

fluidattacks_core/semver/
match_versions.rs

1// Port of fluidattacks_core/semver/match_versions.py
2//
3// Intended to be promoted to a standalone `fa-core` crate.
4// No regex; all patterns are simple enough for manual parsing.
5
6pub const INFINITE: &str = "//INFINITE//";
7const PLATFORM_SPECIFIERS: &[&str] = &["macos", "darwin", "linux", "win", "mingw"];
8
9// ─── Normalisation ────────────────────────────────────────────────────────────
10
11fn simplify_pre_release(pre: &str) -> Option<String> {
12    if PLATFORM_SPECIFIERS.iter().any(|p| pre.contains(p)) {
13        None
14    } else {
15        Some(pre.to_owned())
16    }
17}
18
19/// Removes whitespace that immediately follows an operator sequence.
20/// `">= 1.0"` → `">=1.0"`
21pub fn normalize_version_operators(s: &str) -> String {
22    let mut out = String::with_capacity(s.len());
23    let is_op = |c: char| "<>=!~^".contains(c);
24    let mut in_op_seq = false;
25    for c in s.chars() {
26        if is_op(c) {
27            in_op_seq = true;
28            out.push(c);
29        } else if c == ' ' && in_op_seq {
30            // drop space that follows an operator sequence
31        } else {
32            in_op_seq = false;
33            out.push(c);
34        }
35    }
36    out
37}
38
39/// Splits a version string into (numeric/string parts, optional pre-release).
40/// RPM epoch `"0:1.2.3"` → parts `["0","1","2","3"]`.
41pub fn normalize_ver(version: &str) -> (Vec<String>, Option<String>) {
42    let (ver_part, pre_release) = version
43        .split_once('-')
44        .map_or((version, None), |(a, b)| (a, Some(b)));
45
46    // Convert RPM epoch "N:…" → "N.…" so it participates in normal comparison
47    let ver_owned;
48    let ver_part = if ver_part.contains(':') && !ver_part.contains(['<', '>', '=']) {
49        if let Some((epoch, rest)) = ver_part.split_once(':') {
50            ver_owned = format!("{epoch}.{rest}");
51            ver_owned.as_str()
52        } else {
53            ver_part
54        }
55    } else {
56        ver_part
57    };
58
59    let parts: Vec<String> = ver_part.split(['.', '-']).map(String::from).collect();
60    let pre = pre_release
61        .map(str::to_lowercase)
62        .and_then(|p| simplify_pre_release(&p));
63
64    (parts, pre)
65}
66
67// ─── Token / version comparison ───────────────────────────────────────────────
68
69fn is_numeric(s: &str) -> bool {
70    !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
71}
72
73fn cmp_numeric_strs(a: &str, b: &str) -> std::cmp::Ordering {
74    let a = a.trim_start_matches('0');
75    let b = b.trim_start_matches('0');
76    match a.len().cmp(&b.len()) {
77        std::cmp::Ordering::Equal => a.cmp(b),
78        other => other,
79    }
80}
81
82fn aux_compare_tokens(t1: &str, t2: &str) -> Option<bool> {
83    if is_numeric(t1) && is_numeric(t2) {
84        return Some(cmp_numeric_strs(t1, t2) == std::cmp::Ordering::Greater);
85    }
86    if is_numeric(t1) || t1 == INFINITE {
87        return Some(true); // numeric/infinite beats non-numeric
88    }
89    if is_numeric(t2) || t2 == INFINITE {
90        return Some(false); // non-numeric loses to numeric/infinite
91    }
92    if t1 != t2 {
93        return Some(t1 > t2);
94    }
95    None // equal
96}
97
98fn aux_compare_suffix(s1: &str, s2: &str) -> Option<bool> {
99    match (s1.is_empty(), s2.is_empty()) {
100        (false, true) => Some(true),
101        (true, false) => Some(false),
102        _ if s1 != s2 => Some(s1 > s2),
103        _ => None,
104    }
105}
106
107fn compare_tokens(t1: &str, t2: &str) -> Option<bool> {
108    if t1.contains('+') || t2.contains('+') {
109        let (num1, suf1) = t1.split_once('+').unwrap_or((t1, ""));
110        let (num2, suf2) = t2.split_once('+').unwrap_or((t2, ""));
111        if is_numeric(num1) && is_numeric(num2) {
112            let ord = cmp_numeric_strs(num1, num2);
113            if ord != std::cmp::Ordering::Equal {
114                return Some(ord == std::cmp::Ordering::Greater);
115            }
116        } else if num1 != num2 {
117            return Some(num1 > num2);
118        }
119        if let Some(r) = aux_compare_suffix(suf1, suf2) {
120            return Some(r);
121        }
122    }
123    aux_compare_tokens(t1, t2)
124}
125
126fn compare_pre_releases(p1: Option<&str>, p2: Option<&str>) -> bool {
127    match (p1, p2) {
128        (None, Some(_)) => true, // release > pre-release
129        // pre-release < release
130        (Some(a), Some(b)) => a > b,
131        _ => false,
132    }
133}
134
135fn resolve_equal_parts(
136    v1_pre: Option<&str>,
137    v2_pre: Option<&str>,
138    v1: &[String],
139    v2: &[String],
140    include_same: bool,
141) -> bool {
142    if v2_pre.is_none() && v2.iter().all(|s| s == "0") && v1.iter().all(|s| s == "0") {
143        return true;
144    }
145    if let (Some(a), Some(b)) = (v1_pre, v2_pre) {
146        if a != b {
147            return compare_pre_releases(Some(a), Some(b));
148        }
149    }
150    if include_same {
151        return v1_pre.is_none() && v2_pre.is_none() || compare_pre_releases(v1_pre, v2_pre);
152    }
153    false
154}
155
156/// Returns `true` if `version1 > version2`, or `version1 >= version2` when
157/// `include_same` is set.
158pub fn compare_versions(version1: &str, version2: &str, include_same: bool) -> bool {
159    let (v1, v1_pre) = normalize_ver(version1);
160    let (v2, v2_pre) = normalize_ver(version2);
161
162    if include_same && v1 == v2 && v1_pre.as_deref() == v2_pre.as_deref() {
163        return true;
164    }
165
166    let max_len = v1.len().max(v2.len());
167    for i in 0..max_len {
168        let p1 = v1.get(i).map_or("0", String::as_str);
169        let p2 = v2.get(i).map_or("0", String::as_str);
170        if p1 != p2 {
171            if let Some(result) = compare_tokens(p1, p2) {
172                return result;
173            }
174        }
175    }
176
177    resolve_equal_parts(v1_pre.as_deref(), v2_pre.as_deref(), &v1, &v2, include_same)
178}
179
180// ─── Range parsing ────────────────────────────────────────────────────────────
181
182/// Extracts all `(operator, value)` pairs like `">="` + `"1.0"` from a range
183/// string. Recognises `<`, `<=`, `>`, `>=`.
184fn parse_range_pairs(s: &str) -> Vec<(String, String)> {
185    let mut pairs = Vec::new();
186    let bytes = s.as_bytes();
187    let mut i = 0;
188    while let Some(&b) = bytes.get(i) {
189        if b != b'<' && b != b'>' {
190            i = i.saturating_add(1);
191            continue;
192        }
193        let op_start = i;
194        i = i.saturating_add(1);
195        if bytes.get(i) == Some(&b'=') {
196            i = i.saturating_add(1);
197        }
198        let op = s[op_start..i].to_string();
199        while bytes.get(i) == Some(&b' ') {
200            i = i.saturating_add(1);
201        }
202        let val_start = i;
203        while matches!(bytes.get(i), Some(&c) if c != b'<' && c != b'>' && c != b'=' && c != b' ') {
204            i = i.saturating_add(1);
205        }
206        if val_start < i {
207            pairs.push((op, s[val_start..i].to_string()));
208        }
209    }
210    pairs
211}
212
213/// Returns `(op1, val1, op2, val2)`, prepending/appending bounds as needed.
214/// Returns `None` when the string cannot be parsed into exactly four elements.
215pub fn parse_version_range(version_range: &str) -> Option<(String, String, String, String)> {
216    let mut range = version_range.to_owned();
217    if range.starts_with('<') {
218        range = format!(">0 {range}");
219    }
220    if range.starts_with('>') && !range.contains('<') {
221        range = format!("{range} <{INFINITE}");
222    }
223    let pairs = parse_range_pairs(&range);
224    if let [(op1, val1), (op2, val2)] = pairs.as_slice() {
225        Some((op1.clone(), val1.clone(), op2.clone(), val2.clone()))
226    } else {
227        tracing::error!("Invalid version range: {version_range}. Values cannot be parsed.");
228        None
229    }
230}
231
232pub fn is_single_version(s: &str) -> bool {
233    !s.contains('<') && !s.contains('>')
234}
235
236pub fn is_single_version_in_range(version: &str, range: &str) -> bool {
237    let v = version.trim_start_matches('=');
238    let Some((op1, min2, op2, max2)) = parse_version_range(range) else {
239        return false;
240    };
241    compare_versions(v, &min2, op1.contains('=')) && compare_versions(&max2, v, op2.contains('='))
242}
243
244pub fn do_ranges_intersect(r1: &str, r2: &str) -> bool {
245    let sv1 = is_single_version(r1);
246    let sv2 = is_single_version(r2);
247    if sv1 && sv2 {
248        return r1 == r2;
249    }
250    if sv1 {
251        return is_single_version_in_range(r1, r2);
252    }
253    if sv2 {
254        return is_single_version_in_range(r2, r1);
255    }
256    let Some((op1_r1, lower1, op2_r1, upper1)) = parse_version_range(r1) else {
257        return false;
258    };
259    let Some((op1_r2, lower2, op2_r2, upper2)) = parse_version_range(r2) else {
260        return false;
261    };
262    let inc1 = op1_r1.contains('=') && op2_r2.contains('=');
263    let inc2 = op1_r2.contains('=') && op2_r1.contains('=');
264    compare_versions(&upper2, &lower1, inc1) && compare_versions(&upper1, &lower2, inc2)
265}
266
267// ─── Semver operator conversion ───────────────────────────────────────────────
268
269pub fn increment_version(version: &str, position: usize) -> String {
270    let mut parts: Vec<String> = version.split('.').map(String::from).collect();
271    let position = if parts.len() < 2 {
272        while parts.len() < 2 {
273            parts.push("0".to_owned());
274        }
275        if position > 1 {
276            position.saturating_sub(1)
277        } else {
278            position
279        }
280    } else {
281        position
282    };
283    if let Some(p) = parts.get_mut(position) {
284        INFINITE.clone_into(p);
285        for part in parts.iter_mut().skip(position.saturating_add(1)) {
286            "0".clone_into(part);
287        }
288    }
289    parts.join(".")
290}
291
292fn simplify_final_version(version: &str) -> String {
293    // Strip build metadata
294    let v = if let Some((base, _)) = version.rsplit_once('+') {
295        base
296    } else {
297        version
298    };
299    let lower = v.to_lowercase();
300    let parts: Vec<&str> = lower.split('.').collect();
301    if parts.last().is_some_and(|p| p.contains("final")) {
302        // Return the lowercase joined parts (mirrors Python behaviour)
303        parts
304            .split_last()
305            .map_or_else(String::new, |(_, init)| init.join("."))
306    } else {
307        v.to_owned()
308    }
309}
310
311fn convert_asterisk_to_range(version: &str) -> String {
312    let parts: Vec<&str> = version.split('.').collect();
313    match parts.as_slice() {
314        [major, rest @ ..] if rest.last() == Some(&"*") => match rest.len() {
315            1 => format!(">={major}.0.0 <{major}.{INFINITE}.0"),
316            2.. => {
317                if let [minor, ..] = rest {
318                    format!(">={major}.{minor}.0 <{major}.{minor}.{INFINITE}")
319                } else {
320                    version.to_owned()
321                }
322            }
323            _ => version.to_owned(),
324        },
325        _ => version.to_owned(),
326    }
327}
328
329/// Mirrors `convert_semver_to_range`: normalises a single version specifier
330/// (which may use `~`, `^`, `*`, `==`, plain version, etc.) into a canonical
331/// range string such as `">=1.2.0 <1.3.//INFINITE//"`.
332pub fn convert_semver_to_range(version: &str) -> String {
333    let version = version.replace("==", "=").replace(' ', "");
334
335    if let Some(rest) = version.strip_prefix('~') {
336        // Strip one optional `=` or `>` (covers ~=, ~>, bare ~)
337        let (inner, position) = rest.strip_prefix(['=', '>']).map_or_else(
338            || {
339                let inner = if rest.split('.').count() == 2 {
340                    format!("{rest}.0")
341                } else {
342                    rest.to_owned()
343                };
344                (inner, 2usize)
345            },
346            |inner| {
347                let dot_count = inner.split('.').count();
348                let pos = if dot_count == 2 { 1 } else { 2 };
349                (inner.to_owned(), pos)
350            },
351        );
352        return format!(">={inner} <{}", increment_version(&inner, position));
353    }
354
355    if let Some(rest) = version.strip_prefix('^') {
356        return format!(">={rest} <{}", increment_version(rest, 1));
357    }
358
359    if version.contains(".*") {
360        return convert_asterisk_to_range(&version);
361    }
362
363    if is_single_version(&version) && !version.starts_with('=') {
364        return format!("={}", simplify_final_version(&version));
365    }
366
367    simplify_final_version(&version)
368}
369
370pub fn convert_to_range(version: &str) -> Vec<String> {
371    let normalized = normalize_version_operators(version);
372    normalized
373        .split(|c: char| c == ',' || c.is_whitespace())
374        .filter(|s| !s.is_empty())
375        .map(|s| convert_semver_to_range(s.trim()))
376        .collect()
377}
378
379fn match_version_ranges(dep_version: &str, vulnerable_version: &str) -> bool {
380    let and_ranges = convert_to_range(dep_version);
381    if and_ranges.is_empty() {
382        return false;
383    }
384    vulnerable_version.split("||").any(|vuln| {
385        let normalized = vuln.trim().replace(',', " ");
386        let vuln_range = convert_semver_to_range(&normalized);
387        and_ranges
388            .iter()
389            .all(|ar| do_ranges_intersect(ar, &vuln_range))
390    })
391}
392
393/// Returns `true` if `dep_version` falls within `advisory_range`.
394/// Returns `false` when `advisory_range` is `None`.
395///
396/// Mirrors `match_vulnerable_versions` from `fluidattacks_core.semver.match_versions`.
397pub fn match_vulnerable_versions(dep_version: &str, advisory_range: Option<&str>) -> bool {
398    let Some(advisory_range) = advisory_range else {
399        return false;
400    };
401    if dep_version.is_empty() {
402        return false;
403    }
404    let dep_version = normalize_version_operators(dep_version);
405    let normalized = dep_version.replace("||", "|");
406    normalized
407        .split('|')
408        .any(|dv| match_version_ranges(dv.trim(), advisory_range))
409}
410
411// ─── Tests ────────────────────────────────────────────────────────────────────
412// All cases ported from common/fluidattacks-core/test/test_match_versions.py
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    // ── match_vulnerable_versions ─────────────────────────────────────────────
419
420    macro_rules! mvv {
421        ($dep:expr, $adv:expr, $expected:expr) => {
422            assert_eq!(
423                match_vulnerable_versions($dep, $adv),
424                $expected,
425                "match_vulnerable_versions({:?}, {:?})",
426                $dep,
427                $adv,
428            );
429        };
430    }
431
432    #[test]
433    #[allow(clippy::too_many_lines)]
434    fn test_match_vulnerable_versions() {
435        mvv!("^1.0.0", Some("<0.0"), false);
436        mvv!("^7.0.0", Some("=6.12.2"), false);
437        mvv!("^7.0.0", Some("=6.12.2 || =6.9.1"), false);
438        mvv!("~2.2.3", Some(">=3.0.0 <=4.0.0"), false);
439        mvv!("=2.2", Some(">=2.3.0 <=2.4.0"), false);
440        mvv!("~0.8.0", Some(">=0 <=1.8.6"), true);
441        mvv!("1.8.0", Some(">=0 <=0.3.0 || >=1.0.1 <=1.8.6"), true);
442        mvv!("^2.1.0", Some(">=0 <11.0.5 || >=11.1.0 <11.1.0"), true);
443        mvv!("2.1.0", Some("~2"), true);
444        mvv!("=2.3.0-pre", Some(">=2.1.1 <2.3.0"), false);
445        mvv!("=2.3.0-pre", Some(">=2.3.0 <2.7.0"), false);
446        mvv!("=2.2.0-rc1", Some(">=2.1.1 <2.3.0"), true);
447        mvv!("=2.1.0-pre", Some("=2.1.0-pre"), true);
448        mvv!("=2.1.0-pre", Some("=2.1.0"), false);
449        mvv!(
450            ">2.1.1 <=2.3.0",
451            Some("<2.1.0||=2.3.0-pre||>=2.4.0 <2.5.0"),
452            true
453        );
454        mvv!(">2.1.1 <2.3.0", Some("<2.1.0||=2.3.1-pre"), false);
455        mvv!("1.0.0-beta.8", Some("<=1.0.0-beta.6"), false);
456        mvv!("1.0.0-beta.4", Some("<=1.0.0-beta.6"), true);
457        mvv!("^1.0.0-rc.10", Some(">2.0.0 <=4.0.0"), false);
458        mvv!("^1.0.0-rc.10", Some(">=1.0.0 <=2.0.0"), true);
459        mvv!(
460            "^7.23.2",
461            Some(">=0 <7.23.2 || >=8.0.0-alpha.0 <8.0.0-alpha.4"),
462            false
463        );
464        mvv!("7.23.2", Some(">=0 <=7.23.2"), true);
465        mvv!("7.23.2", Some(">=6.5.1"), true);
466        mvv!("=7.23.2", Some(">=6.5.1"), true);
467        mvv!(">=11.1", Some(">=0 <12.3.3"), true);
468        mvv!("^1.2.0", Some(">=0 <1.0.3"), false);
469        mvv!("2.0.0||^3.0.0", Some(">=3.0.0"), true);
470        mvv!("3.*", Some(">=3.2.0 <4.0.0"), true);
471        mvv!("4.0", Some("=3.5.1 || =4.0 || =5.0"), true);
472        mvv!("4.2.2.RELEASE", Some(">0 <4.2.16"), true);
473        mvv!("2.13.14", Some(">0 <2.13.14-1"), false);
474        mvv!("8.4", Some(">=0 <7.6.3 || >=8.0.0 <8.4.0"), false);
475        mvv!("6.1.5.Final", Some(">=6.1.2 <6.1.5"), false);
476        mvv!("6.1.5.Final", Some(">=6.1.2 <=6.1.5"), true);
477        mvv!("==3.0.0 || >=4.0.1 <4.0.2 || ==4.0.1", Some("=3.0.0"), true);
478        mvv!("1.16.5-x86_64-darwin", Some("<1.16.5"), false);
479        mvv!("1.16.5-x86_64-mingw-10", Some("<1.16.5"), false);
480        mvv!("1.16.5-aarch64-linux", Some("<=1.16.5"), true);
481        mvv!("0.0.0-20221012-56ae", Some(">=0.0.0 <0.17.0"), true);
482        mvv!("0.0.0-20221012-56ae", Some("<0.17.0"), true);
483        mvv!("=0.10.0-20221012-e7cb96979f69", Some("<0.10.0"), false);
484        mvv!("=0.10.0-20221012-e7cb96979f69", Some("<=0.10.0"), true);
485        mvv!("${lombokVersion}", Some(">0"), false);
486        mvv!("", Some(">0"), false);
487        mvv!("0.0.0", None::<&str>, false);
488        mvv!("2.*,<2.3", Some(">=2.0.1"), true);
489        mvv!("2.*,<2.3", Some(">=1.3.0 <2.0.0"), false);
490        mvv!("1.2.0", Some(">=1.0.0,<=2.0.0"), true);
491        mvv!("1.2.0", Some(">=1.0.0,   <=2.0.0"), true);
492        mvv!("3.2.0+incompatible", Some(">1.0.0 <=3.2.0"), true);
493        mvv!(">= 3.1.44 , < 3.2.0", Some(">=3.1.0"), true);
494        mvv!(">= 3.1.44  < 3.2.0", Some(">=3.1.0"), true);
495        mvv!("1.2.3 <=2.0.0", Some(">=1.0.0"), true);
496        mvv!("4.0.0", Some(">=3,<5"), true);
497        mvv!("3.0.0", Some(">=3,<5"), true);
498        mvv!("2.9.9", Some(">=3,<5"), false);
499        mvv!("5.0.0", Some(">=3,<5"), false);
500        mvv!("3.0.0", Some(">3,<5"), false);
501        mvv!("4.0.0", Some(">=3,<=4"), true);
502        mvv!("4.0.0", Some(">=3,<4"), false);
503    }
504
505    #[test]
506    fn test_match_vulnerable_versions_rpm_epoch() {
507        mvv!("0:2.35.2-42.el9", Some("<=0:2.35.2-42.el9"), true);
508        mvv!("0:2.35.2-63.el9", Some("<=0:2.35.2-42.el9"), false);
509        mvv!("0:2.35.2-30.el9", Some("<=0:2.35.2-42.el9"), true);
510        mvv!("0:3.8.3-6.el9", Some("<0:3.8.3-6.el9_6.2"), true);
511        mvv!("0:1.2.3", Some("<=0:1.2.3"), true);
512        mvv!("0:1.2.3", Some("<0:1.2.3"), false);
513        mvv!("0:1.2.3", Some(">=0:1.2.3"), true);
514        mvv!("0:1.2.3", Some(">0:1.2.3"), false);
515        mvv!("1:2.3.4", Some("==0:2.3.4"), false);
516        mvv!("0:2.3.4", Some("==1:2.3.4"), false);
517        mvv!("0:2.35.2-42.el9", Some("==0:2.35.2-42.el9"), true);
518        mvv!("0:2.35.2-42.el9", Some("==0:2.35.2-63.el9"), false);
519        mvv!("0:2.35.2-63.el9", Some("==0:2.35.2-42.el9"), false);
520    }
521
522    #[test]
523    fn test_match_vulnerable_versions_rhel_major_minor() {
524        mvv!("0:3.8.3-6.el9", Some("<0:3.8.3-6.el9_6.2"), true);
525        mvv!("0:3.8.3-6.el9_3", Some("<0:3.8.3-6.el9_6"), true);
526        mvv!("0:3.8.3-6.el9_6", Some("<0:3.8.3-6.el9_6.2"), true);
527        mvv!("0:2.35.2-42.el8_5", Some("<0:2.35.2-42.el8_8"), true);
528        mvv!("0:1.2.3-4.el7_6", Some("<0:1.2.3-4.el7_9"), true);
529        mvv!("0:3.8.3-6.el9_6", Some("<0:3.8.3-6.el9"), false);
530        mvv!("0:3.8.3-6.el9_6.2", Some("<0:3.8.3-6.el9_6"), false);
531        mvv!("0:3.8.3-6.el9_6", Some("<=0:3.8.3-6.el9_6"), true);
532    }
533
534    // ── convert_semver_to_range ───────────────────────────────────────────────
535
536    macro_rules! csr {
537        ($input:expr, $expected:expr) => {
538            assert_eq!(
539                convert_semver_to_range($input),
540                $expected,
541                "convert_semver_to_range({:?})",
542                $input,
543            );
544        };
545    }
546
547    #[test]
548    fn test_convert_semver_to_range() {
549        // bare ~
550        csr!("~1.4.2", ">=1.4.2 <1.4.//INFINITE//");
551        csr!("~1.4", ">=1.4.0 <1.4.//INFINITE//");
552        // ~= (Python compatible release, PEP 440)
553        csr!("~=1.4.2", ">=1.4.2 <1.4.//INFINITE//");
554        csr!("~=1.4", ">=1.4 <1.//INFINITE//");
555        // ~> (Ruby pessimistic constraint)
556        csr!("~>1.4.2", ">=1.4.2 <1.4.//INFINITE//");
557        csr!("~>1.4", ">=1.4 <1.//INFINITE//");
558        // single-part variants
559        csr!("~=1", ">=1 <1.//INFINITE//");
560        csr!("~>1", ">=1 <1.//INFINITE//");
561        csr!("~1", ">=1 <1.//INFINITE//");
562        // caret with single-part version: must not shift INFINITE to major slot
563        csr!("^1", ">=1 <1.//INFINITE//");
564        csr!("^2", ">=2 <2.//INFINITE//");
565    }
566
567    // ── edge cases for range parsing ──────────────────────────────────────────
568
569    #[test]
570    fn test_do_ranges_intersect_unparseable_range() {
571        // A bare ">=" has no value; parse_version_range returns None → false
572        assert!(!do_ranges_intersect(">=", ">= 3.4.0"));
573    }
574
575    #[test]
576    fn test_is_single_version_in_range_unparseable_range() {
577        // A bare ">" has no value; parse_version_range returns None → false
578        assert!(!is_single_version_in_range("1.0.0", ">"));
579    }
580}