Skip to main content

sbom_model/
versions.rs

1//! version parsing and comparison utilities.
2//!
3//! provides lenient version parsing for SBOM component versions, supporting
4//! semver, dot-separated numeric strings, Debian/RPM-style epoch/revision
5//! versions, and opaque version strings.
6
7use std::cmp::Ordering;
8
9/// parsed version representation for lenient comparison.
10///
11/// covers the common version formats found in SBOMs:
12/// - standard semver (possibly with `v` prefix or fewer than three parts)
13/// - dot-separated numeric (e.g., date-based `2024.01.15` or four-part `1.2.3.4`)
14/// - Debian/RPM-style `epoch:upstream-revision` (dominant in OS/container SBOMs)
15/// - opaque strings that cannot be compared
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Version {
18    /// parseable as semver (with lenient parsing: `v`/`V` prefix stripped,
19    /// one- or two-part versions padded to three parts).
20    Semver(semver::Version),
21    /// dot-separated numeric segments that don't qualify as semver
22    /// (e.g., four-part versions or versions with leading zeros).
23    Numeric(Vec<u64>),
24    /// Debian/RPM-style version with an optional numeric epoch and a trailing
25    /// revision, compared with the Debian `dpkg` algorithm. covers
26    /// `epoch:upstream-revision` (Debian), `epoch:version-release` (RPM), and
27    /// PEP440 `epoch!version` forms that don't parse as clean semver but whose
28    /// ordering is still well-defined. an absent epoch is `0` and an absent
29    /// revision is the empty string.
30    Deb {
31        epoch: u64,
32        upstream: String,
33        revision: String,
34    },
35    /// non-parseable version string where ordering cannot be determined.
36    Opaque(String),
37}
38
39impl Version {
40    /// parses a version string leniently.
41    ///
42    /// tries semver first (stripping `v`/`V` prefix and padding one- or
43    /// two-part versions), then dot-separated numeric, then Debian/RPM-style
44    /// epoch/revision versions, then falls back to [`Opaque`](Version::Opaque).
45    ///
46    /// # Examples
47    ///
48    /// ```
49    /// use sbom_model::versions::Version;
50    ///
51    /// assert!(matches!(Version::parse_lenient("1.2.3"), Version::Semver(_)));
52    /// assert!(matches!(Version::parse_lenient("v1.2"), Version::Semver(_)));
53    /// assert!(matches!(Version::parse_lenient("2024.01.15"), Version::Numeric(_)));
54    /// assert!(matches!(Version::parse_lenient("2:1.0-3"), Version::Deb { .. }));
55    /// assert!(matches!(Version::parse_lenient("abc"), Version::Opaque(_)));
56    /// ```
57    pub fn parse_lenient(s: &str) -> Self {
58        let stripped = s
59            .strip_prefix('v')
60            .or_else(|| s.strip_prefix('V'))
61            .unwrap_or(s);
62
63        if let Ok(v) = semver::Version::parse(stripped) {
64            return Version::Semver(v);
65        }
66
67        // try padding: "1.0" -> "1.0.0", "1" -> "1.0.0"
68        let parts: Vec<&str> = stripped.splitn(3, '.').collect();
69        let padded = match parts.len() {
70            1 => Some(format!("{}.0.0", parts[0])),
71            2 => Some(format!("{}.{}.0", parts[0], parts[1])),
72            _ => None,
73        };
74        if let Some(ref padded) = padded {
75            if let Ok(v) = semver::Version::parse(padded) {
76                return Version::Semver(v);
77            }
78        }
79
80        if let Some(segments) = parse_numeric(stripped) {
81            return Version::Numeric(segments);
82        }
83
84        if let Some(deb) = parse_deb(stripped) {
85            return deb;
86        }
87
88        Version::Opaque(s.to_string())
89    }
90
91    /// orders two versions, returning `None` when the ordering is unknown.
92    ///
93    /// comparison strategy depends on the variant pair:
94    /// - **Semver vs Semver**: semver *precedence* ordering (including
95    ///   pre-release; build metadata is ignored per SemVer §10)
96    /// - **Numeric vs Numeric**: segment-by-segment with implicit zero padding
97    /// - **Semver vs Numeric** (either direction): extracts `[major, minor, patch]`
98    ///   from the semver side and compares as numeric segments
99    /// - **Deb vs Deb**: epoch (numeric), then upstream, then revision, via the
100    ///   Debian `dpkg` version-comparison algorithm
101    /// - **Any other pair** (including any Opaque, or a Deb against a
102    ///   semver/numeric version): `None`
103    ///
104    /// deliberately weaker than [`PartialOrd`]: even two identical
105    /// [`Opaque`](Version::Opaque) versions compare `None`.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use std::cmp::Ordering;
111    /// use sbom_model::versions::Version;
112    ///
113    /// let a = Version::parse_lenient("2.0.0");
114    /// let b = Version::parse_lenient("1.5.0");
115    /// assert_eq!(a.partial_cmp_lenient(&b), Some(Ordering::Greater));
116    ///
117    /// let opaque = Version::parse_lenient("deadbeef");
118    /// assert_eq!(a.partial_cmp_lenient(&opaque), None);
119    /// ```
120    pub fn partial_cmp_lenient(&self, other: &Self) -> Option<Ordering> {
121        match (self, other) {
122            (Version::Semver(a), Version::Semver(b)) => Some(a.cmp_precedence(b)),
123            (Version::Numeric(a), Version::Numeric(b)) => Some(numeric_cmp(a, b)),
124            (Version::Semver(a), Version::Numeric(b)) => {
125                Some(numeric_cmp(&[a.major, a.minor, a.patch], b))
126            }
127            (Version::Numeric(a), Version::Semver(b)) => {
128                Some(numeric_cmp(a, &[b.major, b.minor, b.patch]))
129            }
130            (
131                Version::Deb {
132                    epoch: ae,
133                    upstream: au,
134                    revision: arev,
135                },
136                Version::Deb {
137                    epoch: be,
138                    upstream: bu,
139                    revision: brev,
140                },
141            ) => Some(deb_cmp((*ae, au, arev), (*be, bu, brev))),
142            _ => None,
143        }
144    }
145
146    /// returns `true` if `new` is a downgrade from `self`.
147    ///
148    /// a pair whose ordering is unknown is not a downgrade; see
149    /// [`partial_cmp_lenient`](Self::partial_cmp_lenient) for the per-variant
150    /// comparison rules.
151    ///
152    /// # Examples
153    ///
154    /// ```
155    /// use sbom_model::versions::Version;
156    ///
157    /// let old = Version::parse_lenient("2.0.0");
158    /// let new = Version::parse_lenient("1.5.0");
159    /// assert!(old.is_downgrade(&new));
160    ///
161    /// let old = Version::parse_lenient("1.0.0");
162    /// let new = Version::parse_lenient("2.0.0");
163    /// assert!(!old.is_downgrade(&new));
164    /// ```
165    pub fn is_downgrade(&self, new: &Self) -> bool {
166        self.partial_cmp_lenient(new) == Some(Ordering::Greater)
167    }
168}
169
170/// segment-by-segment numeric comparison with implicit zero padding.
171fn numeric_cmp(a: &[u64], b: &[u64]) -> Ordering {
172    let max_len = a.len().max(b.len());
173    for i in 0..max_len {
174        let x = a.get(i).copied().unwrap_or(0);
175        let y = b.get(i).copied().unwrap_or(0);
176        if x != y {
177            return x.cmp(&y);
178        }
179    }
180    Ordering::Equal
181}
182
183/// parses dot-separated numeric segments (e.g. four-part or leading-zero
184/// versions). returns `None` when any segment is non-numeric or the string is
185/// empty, so the caller can fall through to the next parsing strategy.
186fn parse_numeric(stripped: &str) -> Option<Vec<u64>> {
187    let mut segments = Vec::new();
188    for part in stripped.split('.') {
189        segments.push(part.parse::<u64>().ok()?);
190    }
191    if segments.is_empty() {
192        None
193    } else {
194        Some(segments)
195    }
196}
197
198/// parses a Debian/RPM-style `epoch:upstream-revision` version.
199///
200/// returns `None` for strings that don't look like a comparable package
201/// version — the upstream part must start with a digit (the Debian convention)
202/// and every character must be in the Debian/RPM version alphabet — so that
203/// codenames, git hashes, and other genuinely opaque strings stay
204/// [`Opaque`](Version::Opaque) rather than being force-ordered.
205fn parse_deb(stripped: &str) -> Option<Version> {
206    let (epoch, rest) = split_epoch(stripped);
207
208    if !rest.starts_with(|c: char| c.is_ascii_digit()) {
209        return None;
210    }
211    if !rest.chars().all(is_deb_char) {
212        return None;
213    }
214
215    // the revision is everything after the last hyphen (dpkg splits there);
216    // an absent revision compares equal to "0".
217    let (upstream, revision) = match rest.rfind('-') {
218        Some(idx) => (rest[..idx].to_string(), rest[idx + 1..].to_string()),
219        None => (rest.to_string(), String::new()),
220    };
221
222    Some(Version::Deb {
223        epoch,
224        upstream,
225        revision,
226    })
227}
228
229/// splits a leading `N:` (Debian) or `N!` (PEP440) epoch off a version string.
230/// returns `(0, s)` when there is no numeric epoch prefix.
231fn split_epoch(s: &str) -> (u64, &str) {
232    if let Some(idx) = s.find([':', '!']) {
233        let (head, tail) = s.split_at(idx);
234        if !head.is_empty() && head.bytes().all(|b| b.is_ascii_digit()) {
235            if let Ok(epoch) = head.parse::<u64>() {
236                return (epoch, &tail[1..]);
237            }
238        }
239    }
240    (0, s)
241}
242
243/// characters permitted in a Debian/RPM upstream version or revision.
244fn is_deb_char(c: char) -> bool {
245    c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-' | '~' | ':')
246}
247
248/// orders two Debian/RPM-style versions given as `(epoch, upstream, revision)`:
249/// a higher epoch always wins; ties fall through to the upstream version and
250/// then the revision, both compared with [`verrevcmp`].
251fn deb_cmp(a: (u64, &str, &str), b: (u64, &str, &str)) -> Ordering {
252    a.0.cmp(&b.0)
253        .then_with(|| verrevcmp(a.1, b.1))
254        .then_with(|| verrevcmp(a.2, b.2))
255}
256
257/// the Debian `dpkg` version-component comparison (`verrevcmp`).
258///
259/// the two strings are scanned in lockstep, alternating between runs of
260/// non-digits and runs of digits. non-digit runs are compared lexically in the
261/// modified ordering of [`deb_order`]; digit runs are compared
262/// numerically (leading zeros stripped, longer run wins). this is the standard
263/// algorithm used for Debian upstream versions and revisions, and it also gives
264/// correct results for the overwhelming majority of RPM versions.
265fn verrevcmp(a: &str, b: &str) -> Ordering {
266    let a = a.as_bytes();
267    let b = b.as_bytes();
268    let mut i = 0;
269    let mut j = 0;
270
271    while i < a.len() || j < b.len() {
272        while (i < a.len() && !a[i].is_ascii_digit()) || (j < b.len() && !b[j].is_ascii_digit()) {
273            let ac = a.get(i).map_or(0, |&c| deb_order(c));
274            let bc = b.get(j).map_or(0, |&c| deb_order(c));
275            if ac != bc {
276                return ac.cmp(&bc);
277            }
278            i += 1;
279            j += 1;
280        }
281
282        while i < a.len() && a[i] == b'0' {
283            i += 1;
284        }
285        while j < b.len() && b[j] == b'0' {
286            j += 1;
287        }
288
289        let mut first_diff = 0i32;
290        while i < a.len() && a[i].is_ascii_digit() && j < b.len() && b[j].is_ascii_digit() {
291            if first_diff == 0 {
292                first_diff = i32::from(a[i]) - i32::from(b[j]);
293            }
294            i += 1;
295            j += 1;
296        }
297        // a longer remaining digit run means a larger number (no leading zeros
298        // remain), which takes precedence over any earlier per-digit difference.
299        if i < a.len() && a[i].is_ascii_digit() {
300            return Ordering::Greater;
301        }
302        if j < b.len() && b[j].is_ascii_digit() {
303            return Ordering::Less;
304        }
305        if first_diff != 0 {
306            return first_diff.cmp(&0);
307        }
308    }
309
310    Ordering::Equal
311}
312
313/// the per-character sort key used by [`verrevcmp`] for non-digit runs: a tilde
314/// sorts before everything (even the end of a string), letters keep their ASCII
315/// order, and all other characters sort after letters. digits and the end of a
316/// string both sort as `0`, so a digit encountered mid-scan behaves like a
317/// boundary (matching dpkg's `order()`).
318fn deb_order(c: u8) -> i32 {
319    if c.is_ascii_digit() {
320        0
321    } else if c.is_ascii_alphabetic() {
322        i32::from(c)
323    } else if c == b'~' {
324        -1
325    } else {
326        i32::from(c) + 256
327    }
328}
329
330/// convenience function: returns `true` if `new_ver` is a downgrade from `old_ver`.
331///
332/// parses both strings with [`Version::parse_lenient`] and delegates to
333/// [`Version::is_downgrade`].
334pub fn is_version_downgrade(old_ver: &str, new_ver: &str) -> bool {
335    Version::parse_lenient(old_ver).is_downgrade(&Version::parse_lenient(new_ver))
336}
337
338/// convenience function: orders two version strings, returning `None` when the
339/// ordering is unknown.
340///
341/// parses both strings with [`Version::parse_lenient`] and delegates to
342/// [`Version::partial_cmp_lenient`].
343pub fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
344    Version::parse_lenient(a).partial_cmp_lenient(&Version::parse_lenient(b))
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn parse_standard_semver() {
353        let v = Version::parse_lenient("1.2.3");
354        assert_eq!(v, Version::Semver(semver::Version::new(1, 2, 3)));
355    }
356
357    #[test]
358    fn parse_v_prefix() {
359        assert_eq!(
360            Version::parse_lenient("v1.2.3"),
361            Version::Semver(semver::Version::new(1, 2, 3))
362        );
363        assert_eq!(
364            Version::parse_lenient("V1.2.3"),
365            Version::Semver(semver::Version::new(1, 2, 3))
366        );
367    }
368
369    #[test]
370    fn parse_two_parts() {
371        assert_eq!(
372            Version::parse_lenient("1.2"),
373            Version::Semver(semver::Version::new(1, 2, 0))
374        );
375    }
376
377    #[test]
378    fn parse_single_part() {
379        assert_eq!(
380            Version::parse_lenient("42"),
381            Version::Semver(semver::Version::new(42, 0, 0))
382        );
383    }
384
385    #[test]
386    fn parse_prerelease() {
387        let v = Version::parse_lenient("1.2.3-beta.1");
388        match v {
389            Version::Semver(sv) => {
390                assert_eq!(sv.major, 1);
391                assert_eq!(sv.minor, 2);
392                assert_eq!(sv.patch, 3);
393                assert!(!sv.pre.is_empty());
394            }
395            other => panic!("expected Semver, got {:?}", other),
396        }
397    }
398
399    #[test]
400    fn parse_build_metadata() {
401        let v = Version::parse_lenient("1.2.3+build.456");
402        match v {
403            Version::Semver(sv) => {
404                assert_eq!((sv.major, sv.minor, sv.patch), (1, 2, 3));
405                assert!(!sv.build.is_empty());
406            }
407            other => panic!("expected Semver, got {:?}", other),
408        }
409    }
410
411    #[test]
412    fn parse_prerelease_and_build() {
413        let v = Version::parse_lenient("1.0.0-alpha.1+build.789");
414        match v {
415            Version::Semver(sv) => {
416                assert_eq!(sv.major, 1);
417                assert!(!sv.pre.is_empty());
418                assert!(!sv.build.is_empty());
419            }
420            other => panic!("expected Semver, got {:?}", other),
421        }
422    }
423
424    #[test]
425    fn parse_v_prefix_two_parts() {
426        assert_eq!(
427            Version::parse_lenient("v1.2"),
428            Version::Semver(semver::Version::new(1, 2, 0))
429        );
430    }
431
432    #[test]
433    fn parse_v_prefix_single_part() {
434        assert_eq!(
435            Version::parse_lenient("v5"),
436            Version::Semver(semver::Version::new(5, 0, 0))
437        );
438    }
439
440    #[test]
441    fn parse_v_prefix_prerelease() {
442        let v = Version::parse_lenient("v2.0.0-rc.1");
443        match v {
444            Version::Semver(sv) => {
445                assert_eq!(sv.major, 2);
446                assert!(!sv.pre.is_empty());
447            }
448            other => panic!("expected Semver, got {:?}", other),
449        }
450    }
451
452    #[test]
453    fn parse_zero_version() {
454        assert_eq!(
455            Version::parse_lenient("0.0.0"),
456            Version::Semver(semver::Version::new(0, 0, 0))
457        );
458    }
459
460    #[test]
461    fn parse_large_numbers() {
462        assert_eq!(
463            Version::parse_lenient("999.888.777"),
464            Version::Semver(semver::Version::new(999, 888, 777))
465        );
466    }
467
468    #[test]
469    fn parse_single_zero() {
470        assert_eq!(
471            Version::parse_lenient("0"),
472            Version::Semver(semver::Version::new(0, 0, 0))
473        );
474    }
475
476    #[test]
477    fn parse_four_part_is_numeric() {
478        assert_eq!(
479            Version::parse_lenient("1.2.3.4"),
480            Version::Numeric(vec![1, 2, 3, 4])
481        );
482    }
483
484    #[test]
485    fn parse_date_based_is_numeric() {
486        // leading zeros are rejected by semver but u64 parses them fine
487        assert_eq!(
488            Version::parse_lenient("2024.01.15"),
489            Version::Numeric(vec![2024, 1, 15])
490        );
491    }
492
493    #[test]
494    fn parse_v_prefix_four_part_is_numeric() {
495        // the v-prefix must be stripped before the numeric fallback splits
496        assert_eq!(
497            Version::parse_lenient("v1.2.3.4"),
498            Version::Numeric(vec![1, 2, 3, 4])
499        );
500        assert_eq!(
501            Version::parse_lenient("V1.2.3.4"),
502            Version::Numeric(vec![1, 2, 3, 4])
503        );
504    }
505
506    #[test]
507    fn parse_v_prefix_date_based_is_numeric() {
508        assert_eq!(
509            Version::parse_lenient("v2024.01.15"),
510            Version::Numeric(vec![2024, 1, 15])
511        );
512    }
513
514    #[test]
515    fn parse_leading_zeros_is_numeric() {
516        assert_eq!(
517            Version::parse_lenient("01.02.03"),
518            Version::Numeric(vec![1, 2, 3])
519        );
520    }
521
522    #[test]
523    fn parse_non_numeric_is_opaque() {
524        assert_eq!(Version::parse_lenient("abc"), Version::Opaque("abc".into()));
525        assert_eq!(
526            Version::parse_lenient("foo.bar.baz"),
527            Version::Opaque("foo.bar.baz".into())
528        );
529    }
530
531    #[test]
532    fn parse_whitespace_is_opaque() {
533        assert!(matches!(
534            Version::parse_lenient(" 1.2.3"),
535            Version::Opaque(_)
536        ));
537        assert!(matches!(
538            Version::parse_lenient("1.2.3 "),
539            Version::Opaque(_)
540        ));
541    }
542
543    #[test]
544    fn parse_empty_string_is_opaque() {
545        assert!(matches!(Version::parse_lenient(""), Version::Opaque(_)));
546    }
547
548    #[test]
549    fn downgrade_semver() {
550        assert!(is_version_downgrade("2.0.0", "1.5.0"));
551        assert!(is_version_downgrade("1.1.0", "1.0.0"));
552        assert!(is_version_downgrade("1.0.1", "1.0.0"));
553    }
554
555    #[test]
556    fn upgrade_semver_not_flagged() {
557        assert!(!is_version_downgrade("1.0.0", "1.1.0"));
558        assert!(!is_version_downgrade("1.0.0", "2.0.0"));
559        assert!(!is_version_downgrade("1.0.0", "1.0.1"));
560    }
561
562    #[test]
563    fn equal_semver_not_flagged() {
564        assert!(!is_version_downgrade("1.0.0", "1.0.0"));
565    }
566
567    #[test]
568    fn downgrade_v_prefix() {
569        assert!(is_version_downgrade("v2.0.0", "v1.0.0"));
570        assert!(!is_version_downgrade("v1.0.0", "v2.0.0"));
571    }
572
573    #[test]
574    fn downgrade_prerelease() {
575        assert!(is_version_downgrade("1.0.0", "1.0.0-rc1"));
576        assert!(!is_version_downgrade("1.0.0-rc1", "1.0.0"));
577    }
578
579    #[test]
580    fn downgrade_build_metadata() {
581        // SemVer §10: build metadata MUST be ignored when determining
582        // precedence, so a build-metadata-only change is never a downgrade in
583        // either direction.
584        assert!(!is_version_downgrade("1.0.0+build.1", "1.0.0+build.2"));
585        assert!(!is_version_downgrade("1.0.0+build.2", "1.0.0+build.1"));
586        assert!(!is_version_downgrade("1.0.0+build.1", "1.0.0+build.1"));
587        // commit-hash build metadata (common in generated SBOMs) must not trip
588        // the gate regardless of lexical ordering of the hashes.
589        assert!(!is_version_downgrade("1.0.0+c144a98", "1.0.0+bc17664"));
590        assert!(!is_version_downgrade("1.0.0+build.10", "1.0.0+build.9"));
591    }
592
593    #[test]
594    fn downgrade_mixed_v_prefix() {
595        assert!(is_version_downgrade("v2.0.0", "1.0.0"));
596        assert!(is_version_downgrade("2.0.0", "v1.0.0"));
597        assert!(!is_version_downgrade("v1.0.0", "2.0.0"));
598        assert!(!is_version_downgrade("1.0.0", "v2.0.0"));
599    }
600
601    #[test]
602    fn downgrade_prerelease_ordering() {
603        assert!(is_version_downgrade("1.0.0-beta.1", "1.0.0-alpha.1"));
604        assert!(is_version_downgrade("1.0.0-rc.1", "1.0.0-beta.1"));
605        assert!(!is_version_downgrade("1.0.0-alpha.1", "1.0.0-beta.1"));
606        assert!(!is_version_downgrade("1.0.0-beta.1", "1.0.0-rc.1"));
607    }
608
609    #[test]
610    fn downgrade_prerelease_numeric_ordering() {
611        assert!(is_version_downgrade("1.0.0-rc.2", "1.0.0-rc.1"));
612        assert!(!is_version_downgrade("1.0.0-rc.1", "1.0.0-rc.2"));
613    }
614
615    #[test]
616    fn downgrade_equal_with_v_prefix() {
617        assert!(!is_version_downgrade("v1.0.0", "v1.0.0"));
618    }
619
620    #[test]
621    fn downgrade_padded_two_part() {
622        assert!(is_version_downgrade("1.2", "1.1"));
623        assert!(!is_version_downgrade("1.1", "1.2"));
624        assert!(!is_version_downgrade("1.2", "1.2"));
625    }
626
627    #[test]
628    fn downgrade_padded_single_part() {
629        assert!(is_version_downgrade("2", "1"));
630        assert!(!is_version_downgrade("1", "2"));
631        assert!(!is_version_downgrade("5", "5"));
632    }
633
634    #[test]
635    fn downgrade_mixed_part_counts_semver() {
636        assert!(is_version_downgrade("2.0", "1.9.9"));
637        assert!(!is_version_downgrade("1.9.9", "2.0"));
638    }
639
640    #[test]
641    fn downgrade_v_prefix_two_part() {
642        assert!(is_version_downgrade("v2.0", "v1.0"));
643        assert!(!is_version_downgrade("v1.0", "v2.0"));
644    }
645
646    #[test]
647    fn downgrade_four_part() {
648        assert!(is_version_downgrade("1.2.3.4", "1.2.3.3"));
649        assert!(!is_version_downgrade("1.2.3.3", "1.2.3.4"));
650        assert!(!is_version_downgrade("1.2.3.4", "1.2.3.4"));
651    }
652
653    #[test]
654    fn downgrade_date_based() {
655        assert!(is_version_downgrade("2024.01.15", "2023.12.01"));
656        assert!(!is_version_downgrade("2023.12.01", "2024.01.15"));
657    }
658
659    #[test]
660    fn downgrade_v_prefix_four_part() {
661        // v-prefixed four-part versions parse to Numeric, so the downgrade
662        // gate sees them instead of silently treating them as Opaque
663        assert!(is_version_downgrade("v1.2.3.4", "v1.2.3.3"));
664        assert!(!is_version_downgrade("v1.2.3.3", "v1.2.3.4"));
665        assert!(!is_version_downgrade("v1.2.3.4", "v1.2.3.4"));
666    }
667
668    #[test]
669    fn downgrade_v_prefix_date_based() {
670        assert!(is_version_downgrade("v2024.01.15", "v2023.12.01"));
671        assert!(!is_version_downgrade("v2023.12.01", "v2024.01.15"));
672    }
673
674    #[test]
675    fn downgrade_non_numeric_not_flagged() {
676        assert!(!is_version_downgrade("abc", "def"));
677        assert!(!is_version_downgrade("foo.bar", "foo.baz"));
678    }
679
680    #[test]
681    fn downgrade_numeric_unequal_length() {
682        assert!(is_version_downgrade("1.2.3.4", "1.2.3"));
683        assert!(!is_version_downgrade("1.2.3", "1.2.3.4"));
684    }
685
686    #[test]
687    fn downgrade_large_major_numeric_equal() {
688        // "2024.1.15" has no leading zeros, so it parses as valid semver
689        assert!(!is_version_downgrade("2024.1.15", "2024.1.15"));
690    }
691
692    #[test]
693    fn downgrade_semver_vs_four_part() {
694        // "1.2.3" → Semver, "1.2.3.4" → Numeric; cross-comparison extracts
695        // [major,minor,patch] from the semver side
696        assert!(!is_version_downgrade("1.2.3", "1.2.3.4"));
697        assert!(is_version_downgrade("1.2.3.4", "1.2.3"));
698    }
699
700    #[test]
701    fn downgrade_v_prefix_vs_four_part() {
702        // cross-variant comparison works after stripping the v-prefix during parse.
703        assert!(!is_version_downgrade("v1.2.3", "1.2.3.4"));
704        assert!(is_version_downgrade("1.2.3.4", "v1.2.3"));
705    }
706
707    #[test]
708    fn downgrade_empty_strings() {
709        assert!(!is_version_downgrade("", "1.0.0"));
710        assert!(!is_version_downgrade("1.0.0", ""));
711        assert!(!is_version_downgrade("", ""));
712    }
713
714    // --- Debian/RPM epoch/upstream/revision parsing ---
715
716    #[test]
717    fn parse_epoch_is_deb() {
718        // versions with an epoch aren't semver
719        assert!(matches!(
720            Version::parse_lenient("2:1.0"),
721            Version::Deb { .. }
722        ));
723        assert!(matches!(
724            Version::parse_lenient("1:9.0"),
725            Version::Deb { .. }
726        ));
727    }
728
729    #[test]
730    fn parse_revision_is_deb() {
731        // "5.1-3" is not valid semver (two-part base)
732        assert!(matches!(
733            Version::parse_lenient("5.1-3"),
734            Version::Deb { .. }
735        ));
736    }
737
738    #[test]
739    fn parse_deb_fields() {
740        match Version::parse_lenient("2:1.2.3-4") {
741            Version::Deb {
742                epoch,
743                upstream,
744                revision,
745            } => {
746                assert_eq!(epoch, 2);
747                assert_eq!(upstream, "1.2.3");
748                assert_eq!(revision, "4");
749            }
750            other => panic!("expected Deb, got {:?}", other),
751        }
752    }
753
754    #[test]
755    fn parse_deb_revision_splits_at_last_hyphen() {
756        // "1.2.3-2-1" is valid semver (pre-release "2-1"), so use a two-part
757        // base that semver rejects to exercise the last-hyphen revision split
758        match Version::parse_lenient("1.2-2-1") {
759            Version::Deb {
760                epoch,
761                upstream,
762                revision,
763            } => {
764                assert_eq!(epoch, 0);
765                assert_eq!(upstream, "1.2-2");
766                assert_eq!(revision, "1");
767            }
768            other => panic!("expected Deb, got {:?}", other),
769        }
770    }
771
772    #[test]
773    fn parse_pep440_epoch_is_deb() {
774        match Version::parse_lenient("1!2.0") {
775            Version::Deb {
776                epoch,
777                upstream,
778                revision,
779            } => {
780                assert_eq!(epoch, 1);
781                assert_eq!(upstream, "2.0");
782                assert_eq!(revision, "");
783            }
784            other => panic!("expected Deb, got {:?}", other),
785        }
786    }
787
788    #[test]
789    fn parse_tilde_prerelease_is_deb() {
790        // tilde pre-release strings aren't semver but are comparable Debian versions
791        assert!(matches!(
792            Version::parse_lenient("1.0.0~rc1"),
793            Version::Deb { .. }
794        ));
795    }
796
797    #[test]
798    fn parse_codename_stays_opaque() {
799        // a leading non-digit means it isn't a comparable package version
800        assert!(matches!(
801            Version::parse_lenient("focal-1"),
802            Version::Opaque(_)
803        ));
804        assert!(matches!(
805            Version::parse_lenient("stable"),
806            Version::Opaque(_)
807        ));
808        // a bare numeric epoch with a non-version tail is not comparable either
809        assert!(matches!(
810            Version::parse_lenient("1:stable"),
811            Version::Opaque(_)
812        ));
813    }
814
815    // --- Debian/RPM downgrade detection ---
816
817    #[test]
818    fn downgrade_epoch() {
819        // a higher epoch always wins, regardless of the upstream version
820        assert!(is_version_downgrade("2:1.0", "1:9.0"));
821        assert!(!is_version_downgrade("1:9.0", "2:1.0"));
822        // epoch dominates: epoch up beats a lower upstream, epoch down beats a higher one
823        assert!(!is_version_downgrade("1:1.0", "2:0.1"));
824        assert!(is_version_downgrade("2:0.1", "1:1.0"));
825    }
826
827    #[test]
828    fn downgrade_epoch_equal_upstream() {
829        assert!(is_version_downgrade("1:2.0", "1:1.0"));
830        assert!(!is_version_downgrade("1:1.0", "1:2.0"));
831        assert!(!is_version_downgrade("1:1.0", "1:1.0"));
832    }
833
834    #[test]
835    fn downgrade_implicit_epoch_zero() {
836        // an absent epoch is 0, so adding an epoch is an upgrade, dropping to
837        // an explicit 0 is neutral
838        assert!(!is_version_downgrade("5.1-1", "1:0.1-1"));
839        assert!(is_version_downgrade("1:0.1-1", "0:0.1-1"));
840    }
841
842    #[test]
843    fn downgrade_revision() {
844        assert!(is_version_downgrade("5.1-3", "5.1-2"));
845        assert!(!is_version_downgrade("5.1-2", "5.1-3"));
846        assert!(!is_version_downgrade("5.1-2", "5.1-2"));
847    }
848
849    #[test]
850    fn downgrade_upstream_trumps_revision() {
851        // equal revision, upstream down -> downgrade
852        assert!(is_version_downgrade("1:5.2-1", "1:5.1-1"));
853        // upstream up, revision down -> upgrade (upstream is compared first)
854        assert!(!is_version_downgrade("1:5.1-9", "1:5.2-1"));
855    }
856
857    #[test]
858    fn downgrade_absent_revision_equals_zero() {
859        // an absent revision compares as "0"; "1.0" is semver so pin the epoch
860        // to force Debian parsing on both sides
861        assert!(is_version_downgrade("1:2.0-1", "1:2.0"));
862        assert!(!is_version_downgrade("1:2.0", "1:2.0-1"));
863    }
864
865    #[test]
866    fn downgrade_rpm_release_with_epoch() {
867        // an epoch forces Debian parsing even though the tail resembles a
868        // semver pre-release; RPM `.elN` release tails order numerically
869        assert!(is_version_downgrade("1:1.2.3-2.el8", "1:1.2.3-1.el8"));
870        assert!(!is_version_downgrade("1:1.2.3-1.el8", "1:1.2.3-2.el8"));
871        // el8 is newer than el7
872        assert!(is_version_downgrade("1:1.2.3-1.el8", "1:1.2.3-1.el7"));
873        assert!(!is_version_downgrade("1:1.2.3-1.el7", "1:1.2.3-1.el8"));
874    }
875
876    #[test]
877    fn downgrade_deb_numeric_not_lexical() {
878        // 10 > 9 numerically even though "9" > "1" lexically
879        assert!(is_version_downgrade("1.10-1", "1.9-1"));
880        assert!(!is_version_downgrade("1.9-1", "1.10-1"));
881    }
882
883    #[test]
884    fn downgrade_deb_tilde_prerelease() {
885        // a tilde sorts before everything, so ~rc2 > ~rc1 and ~rc1 < the release
886        assert!(is_version_downgrade("1.0.0~rc2", "1.0.0~rc1"));
887        assert!(!is_version_downgrade("1.0.0~rc1", "1.0.0~rc2"));
888        assert!(is_version_downgrade("1:1.0~rc1", "1:1.0~beta1"));
889    }
890
891    #[test]
892    fn downgrade_real_world_deb() {
893        // openssl with epoch and an Ubuntu security revision
894        assert!(is_version_downgrade(
895            "1:1.1.1f-1ubuntu2.16",
896            "1:1.1.1f-1ubuntu2.15"
897        ));
898        assert!(!is_version_downgrade(
899            "1:1.1.1f-1ubuntu2.15",
900            "1:1.1.1f-1ubuntu2.16"
901        ));
902    }
903
904    #[test]
905    fn downgrade_deb_opaque_not_flagged() {
906        // codenames and other non-version strings remain uncomparable
907        assert!(!is_version_downgrade("focal", "bionic"));
908        assert!(!is_version_downgrade("1:stable", "1:oldstable"));
909    }
910
911    #[test]
912    fn downgrade_deb_vs_semver_not_flagged() {
913        // cross-format comparison stays conservative (returns false)
914        assert!(!is_version_downgrade("2:1.0", "1.0.0"));
915        assert!(!is_version_downgrade("1.0.0", "2:1.0"));
916    }
917
918    #[test]
919    fn deb_canonical_ordering_vectors() {
920        use Ordering::{Equal, Greater, Less};
921
922        // canonical dpkg (`verrevcmp`) orderings for the edge cases the other
923        // tests don't fully pin, each `expected` derived by hand from the
924        // `deb_order`/`verrevcmp` rules documented above. every string pins an
925        // epoch so it forces `Deb` parsing — a bare `1.0`/`1.0~rc1` would parse
926        // as Semver/Numeric and exercise the wrong comparator (see
927        // `downgrade_absent_revision_equals_zero`). `expected` is how `a` orders
928        // relative to `b`; the harness drives each vector through the public
929        // `is_version_downgrade` in both directions.
930        let cases = [
931            // tilde chain: `~` < end-of-string < letters < other punctuation,
932            // so 1.0~~ < 1.0~~a < 1.0~ < 1.0 < 1.0a
933            ("1:1.0~~", "1:1.0~~a", Less),
934            ("1:1.0~~a", "1:1.0~", Less),
935            ("1:1.0~", "1:1.0", Less),
936            ("1:1.0", "1:1.0a", Less),
937            // tilde marks a pre-release: it sorts before the release, and
938            // pre-releases order among themselves
939            ("1:1.0~rc1", "1:1.0", Less),
940            ("1:1.0~rc1", "1:1.0~rc2", Less),
941            // digit runs compare numerically, not lexically: 10 > 9
942            ("1:1.10", "1:1.9", Greater),
943            // leading zeros don't change a digit run's value
944            ("1:1.0", "1:1.00", Equal),
945            ("1:1.01", "1:1.1", Equal),
946            // a letter outranks a digit at a component boundary...
947            ("1:1.a", "1:1.1", Greater),
948            // ...but a continuing digit run still outranks a letter suffix
949            ("1:1.0a", "1:1.01", Less),
950            // epoch dominates the upstream comparison
951            ("2:0.1", "1:9.9", Greater),
952            // upstream is compared before the revision
953            ("1:5.2-1", "1:5.1-9", Greater),
954            // an absent revision compares equal to an explicit "0"
955            ("1:2.0", "1:2.0-0", Equal),
956            // revision digit runs are numeric too: 10 > 9
957            ("1:2.0-10", "1:2.0-9", Greater),
958        ];
959
960        for (a, b, expected) in cases {
961            // guard the vector: if either side stops parsing as Deb, it would
962            // silently test a different comparator and prove nothing.
963            assert!(
964                matches!(Version::parse_lenient(a), Version::Deb { .. }),
965                "{a} no longer parses as Deb"
966            );
967            assert!(
968                matches!(Version::parse_lenient(b), Version::Deb { .. }),
969                "{b} no longer parses as Deb"
970            );
971            match expected {
972                // a < b: going b -> a is a downgrade, a -> b is not
973                Less => {
974                    assert!(is_version_downgrade(b, a), "expected {a} < {b}");
975                    assert!(!is_version_downgrade(a, b), "expected {a} < {b}");
976                }
977                // a > b: going a -> b is a downgrade, b -> a is not
978                Greater => {
979                    assert!(is_version_downgrade(a, b), "expected {a} > {b}");
980                    assert!(!is_version_downgrade(b, a), "expected {a} > {b}");
981                }
982                // a == b: neither direction is a downgrade
983                Equal => {
984                    assert!(!is_version_downgrade(a, b), "expected {a} == {b}");
985                    assert!(!is_version_downgrade(b, a), "expected {a} == {b}");
986                }
987            }
988        }
989    }
990
991    #[test]
992    fn compare_orders_comparable_variant_pairs() {
993        use Ordering::{Equal, Greater, Less};
994
995        for (a, b, expected) in [
996            ("2.0.0", "1.5.0", Greater),
997            ("1.0.0", "1.0.0", Equal),
998            ("1.2.3.4", "1.2.3.3", Greater),
999            ("1.2.3", "1.2.3.4", Less),
1000            ("2:1.0-3", "1:9.0-1", Greater),
1001            ("5.1-3", "5.1-3", Equal),
1002        ] {
1003            assert_eq!(compare_versions(a, b), Some(expected), "{a} vs {b}");
1004            assert_eq!(
1005                compare_versions(b, a),
1006                Some(expected.reverse()),
1007                "{b} vs {a}"
1008            );
1009        }
1010    }
1011
1012    #[test]
1013    fn compare_leaves_opaque_and_mixed_variants_unordered() {
1014        for (a, b) in [
1015            ("deadbeef", "1.0.0"),
1016            ("deadbeef", "cafebabe"),
1017            ("deadbeef", "deadbeef"),
1018            ("2:1.0-3", "1.0.0"),
1019            ("5.1-3", "5.1.0.0"),
1020        ] {
1021            assert_eq!(compare_versions(a, b), None, "{a} vs {b}");
1022            assert_eq!(compare_versions(b, a), None, "{b} vs {a}");
1023        }
1024    }
1025
1026    #[test]
1027    fn downgrade_agrees_with_compare() {
1028        use Ordering::Greater;
1029
1030        for (a, b) in [
1031            ("2.0.0", "1.5.0"),
1032            ("1.0.0", "2.0.0"),
1033            ("1.0.0", "1.0.0"),
1034            ("2024.01.15", "2024.01.14"),
1035            ("2:1.0-3", "1:9.0-1"),
1036            ("1.0.0+build.10", "1.0.0+build.9"),
1037            ("deadbeef", "1.0.0"),
1038        ] {
1039            assert_eq!(
1040                is_version_downgrade(a, b),
1041                compare_versions(a, b) == Some(Greater),
1042                "{a} -> {b}"
1043            );
1044        }
1045    }
1046}