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