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, and opaque version strings.
5
6/// parsed version representation for lenient comparison.
7///
8/// covers the three common version formats found in SBOMs:
9/// - Standard semver (possibly with `v` prefix or fewer than three parts)
10/// - Dot-separated numeric (e.g., date-based `2024.01.15` or four-part `1.2.3.4`)
11/// - Opaque strings that cannot be compared
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Version {
14    /// parseable as semver (with lenient parsing: `v`/`V` prefix stripped,
15    /// one- or two-part versions padded to three parts).
16    Semver(semver::Version),
17    /// dot-separated numeric segments that don't qualify as semver
18    /// (e.g., four-part versions or versions with leading zeros).
19    Numeric(Vec<u64>),
20    /// non-parseable version string where ordering cannot be determined.
21    Opaque(String),
22}
23
24impl Version {
25    /// parses a version string leniently.
26    ///
27    /// tries semver first (stripping `v`/`V` prefix and padding one- or
28    /// two-part versions), then dot-separated numeric, then falls back
29    /// to [`Opaque`](Version::Opaque).
30    ///
31    /// # Examples
32    ///
33    /// ```
34    /// use sbom_model::versions::Version;
35    ///
36    /// assert!(matches!(Version::parse_lenient("1.2.3"), Version::Semver(_)));
37    /// assert!(matches!(Version::parse_lenient("v1.2"), Version::Semver(_)));
38    /// assert!(matches!(Version::parse_lenient("2024.01.15"), Version::Numeric(_)));
39    /// assert!(matches!(Version::parse_lenient("abc"), Version::Opaque(_)));
40    /// ```
41    pub fn parse_lenient(s: &str) -> Self {
42        let stripped = s
43            .strip_prefix('v')
44            .or_else(|| s.strip_prefix('V'))
45            .unwrap_or(s);
46
47        if let Ok(v) = semver::Version::parse(stripped) {
48            return Version::Semver(v);
49        }
50
51        // try padding: "1.0" -> "1.0.0", "1" -> "1.0.0"
52        let parts: Vec<&str> = stripped.splitn(3, '.').collect();
53        let padded = match parts.len() {
54            1 => Some(format!("{}.0.0", parts[0])),
55            2 => Some(format!("{}.{}.0", parts[0], parts[1])),
56            _ => None,
57        };
58        if let Some(ref padded) = padded {
59            if let Ok(v) = semver::Version::parse(padded) {
60                return Version::Semver(v);
61            }
62        }
63
64        // try dot-separated numeric
65        let raw_parts: Vec<&str> = s.split('.').collect();
66        let mut segments = Vec::new();
67        for part in &raw_parts {
68            match part.parse::<u64>() {
69                Ok(n) => segments.push(n),
70                Err(_) => return Version::Opaque(s.to_string()),
71            }
72        }
73        if segments.is_empty() {
74            return Version::Opaque(s.to_string());
75        }
76        Version::Numeric(segments)
77    }
78
79    /// returns `true` if `new` is a downgrade from `self`.
80    ///
81    /// comparison strategy depends on the variant pair:
82    /// - **Semver vs Semver**: standard semver ordering (including pre-release)
83    /// - **Numeric vs Numeric**: segment-by-segment with implicit zero padding
84    /// - **Semver vs Numeric** (either direction): extracts `[major, minor, patch]`
85    ///   from the semver side and compares as numeric segments
86    /// - **Any Opaque**: returns `false` (ordering unknown)
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use sbom_model::versions::Version;
92    ///
93    /// let old = Version::parse_lenient("2.0.0");
94    /// let new = Version::parse_lenient("1.5.0");
95    /// assert!(old.is_downgrade(&new));
96    ///
97    /// let old = Version::parse_lenient("1.0.0");
98    /// let new = Version::parse_lenient("2.0.0");
99    /// assert!(!old.is_downgrade(&new));
100    /// ```
101    pub fn is_downgrade(&self, new: &Self) -> bool {
102        match (self, new) {
103            (Version::Semver(old), Version::Semver(new)) => new < old,
104            (Version::Numeric(old), Version::Numeric(new)) => numeric_downgrade(old, new),
105            (Version::Semver(old), Version::Numeric(new_segs)) => {
106                let old_segs = [old.major, old.minor, old.patch];
107                numeric_downgrade(&old_segs, new_segs)
108            }
109            (Version::Numeric(old_segs), Version::Semver(new)) => {
110                let new_segs = [new.major, new.minor, new.patch];
111                numeric_downgrade(old_segs, &new_segs)
112            }
113            _ => false,
114        }
115    }
116}
117
118/// segment-by-segment numeric comparison with implicit zero padding.
119fn numeric_downgrade(old: &[u64], new: &[u64]) -> bool {
120    let max_len = old.len().max(new.len());
121    for i in 0..max_len {
122        let o = old.get(i).copied().unwrap_or(0);
123        let n = new.get(i).copied().unwrap_or(0);
124        if n < o {
125            return true;
126        }
127        if n > o {
128            return false;
129        }
130    }
131    false
132}
133
134/// convenience function: returns `true` if `new_ver` is a downgrade from `old_ver`.
135///
136/// parses both strings with [`Version::parse_lenient`] and delegates to
137/// [`Version::is_downgrade`].
138pub fn is_version_downgrade(old_ver: &str, new_ver: &str) -> bool {
139    Version::parse_lenient(old_ver).is_downgrade(&Version::parse_lenient(new_ver))
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    // -------------------------------------------------------------------
147    // Version::parse_lenient
148    // -------------------------------------------------------------------
149
150    #[test]
151    fn parse_standard_semver() {
152        let v = Version::parse_lenient("1.2.3");
153        assert_eq!(v, Version::Semver(semver::Version::new(1, 2, 3)));
154    }
155
156    #[test]
157    fn parse_v_prefix() {
158        assert_eq!(
159            Version::parse_lenient("v1.2.3"),
160            Version::Semver(semver::Version::new(1, 2, 3))
161        );
162        assert_eq!(
163            Version::parse_lenient("V1.2.3"),
164            Version::Semver(semver::Version::new(1, 2, 3))
165        );
166    }
167
168    #[test]
169    fn parse_two_parts() {
170        assert_eq!(
171            Version::parse_lenient("1.2"),
172            Version::Semver(semver::Version::new(1, 2, 0))
173        );
174    }
175
176    #[test]
177    fn parse_single_part() {
178        assert_eq!(
179            Version::parse_lenient("42"),
180            Version::Semver(semver::Version::new(42, 0, 0))
181        );
182    }
183
184    #[test]
185    fn parse_prerelease() {
186        let v = Version::parse_lenient("1.2.3-beta.1");
187        match v {
188            Version::Semver(sv) => {
189                assert_eq!(sv.major, 1);
190                assert_eq!(sv.minor, 2);
191                assert_eq!(sv.patch, 3);
192                assert!(!sv.pre.is_empty());
193            }
194            other => panic!("expected Semver, got {:?}", other),
195        }
196    }
197
198    #[test]
199    fn parse_build_metadata() {
200        let v = Version::parse_lenient("1.2.3+build.456");
201        match v {
202            Version::Semver(sv) => {
203                assert_eq!((sv.major, sv.minor, sv.patch), (1, 2, 3));
204                assert!(!sv.build.is_empty());
205            }
206            other => panic!("expected Semver, got {:?}", other),
207        }
208    }
209
210    #[test]
211    fn parse_prerelease_and_build() {
212        let v = Version::parse_lenient("1.0.0-alpha.1+build.789");
213        match v {
214            Version::Semver(sv) => {
215                assert_eq!(sv.major, 1);
216                assert!(!sv.pre.is_empty());
217                assert!(!sv.build.is_empty());
218            }
219            other => panic!("expected Semver, got {:?}", other),
220        }
221    }
222
223    #[test]
224    fn parse_v_prefix_two_parts() {
225        assert_eq!(
226            Version::parse_lenient("v1.2"),
227            Version::Semver(semver::Version::new(1, 2, 0))
228        );
229    }
230
231    #[test]
232    fn parse_v_prefix_single_part() {
233        assert_eq!(
234            Version::parse_lenient("v5"),
235            Version::Semver(semver::Version::new(5, 0, 0))
236        );
237    }
238
239    #[test]
240    fn parse_v_prefix_prerelease() {
241        let v = Version::parse_lenient("v2.0.0-rc.1");
242        match v {
243            Version::Semver(sv) => {
244                assert_eq!(sv.major, 2);
245                assert!(!sv.pre.is_empty());
246            }
247            other => panic!("expected Semver, got {:?}", other),
248        }
249    }
250
251    #[test]
252    fn parse_zero_version() {
253        assert_eq!(
254            Version::parse_lenient("0.0.0"),
255            Version::Semver(semver::Version::new(0, 0, 0))
256        );
257    }
258
259    #[test]
260    fn parse_large_numbers() {
261        assert_eq!(
262            Version::parse_lenient("999.888.777"),
263            Version::Semver(semver::Version::new(999, 888, 777))
264        );
265    }
266
267    #[test]
268    fn parse_single_zero() {
269        assert_eq!(
270            Version::parse_lenient("0"),
271            Version::Semver(semver::Version::new(0, 0, 0))
272        );
273    }
274
275    #[test]
276    fn parse_four_part_is_numeric() {
277        assert_eq!(
278            Version::parse_lenient("1.2.3.4"),
279            Version::Numeric(vec![1, 2, 3, 4])
280        );
281    }
282
283    #[test]
284    fn parse_date_based_is_numeric() {
285        // leading zeros are rejected by semver but u64 parses them fine
286        assert_eq!(
287            Version::parse_lenient("2024.01.15"),
288            Version::Numeric(vec![2024, 1, 15])
289        );
290    }
291
292    #[test]
293    fn parse_leading_zeros_is_numeric() {
294        assert_eq!(
295            Version::parse_lenient("01.02.03"),
296            Version::Numeric(vec![1, 2, 3])
297        );
298    }
299
300    #[test]
301    fn parse_non_numeric_is_opaque() {
302        assert_eq!(Version::parse_lenient("abc"), Version::Opaque("abc".into()));
303        assert_eq!(
304            Version::parse_lenient("foo.bar.baz"),
305            Version::Opaque("foo.bar.baz".into())
306        );
307    }
308
309    #[test]
310    fn parse_whitespace_is_opaque() {
311        assert!(matches!(
312            Version::parse_lenient(" 1.2.3"),
313            Version::Opaque(_)
314        ));
315        assert!(matches!(
316            Version::parse_lenient("1.2.3 "),
317            Version::Opaque(_)
318        ));
319    }
320
321    #[test]
322    fn parse_empty_string_is_opaque() {
323        assert!(matches!(Version::parse_lenient(""), Version::Opaque(_)));
324    }
325
326    // -------------------------------------------------------------------
327    // Version::is_downgrade — semver path
328    // -------------------------------------------------------------------
329
330    #[test]
331    fn downgrade_semver() {
332        assert!(is_version_downgrade("2.0.0", "1.5.0"));
333        assert!(is_version_downgrade("1.1.0", "1.0.0"));
334        assert!(is_version_downgrade("1.0.1", "1.0.0"));
335    }
336
337    #[test]
338    fn upgrade_semver_not_flagged() {
339        assert!(!is_version_downgrade("1.0.0", "1.1.0"));
340        assert!(!is_version_downgrade("1.0.0", "2.0.0"));
341        assert!(!is_version_downgrade("1.0.0", "1.0.1"));
342    }
343
344    #[test]
345    fn equal_semver_not_flagged() {
346        assert!(!is_version_downgrade("1.0.0", "1.0.0"));
347    }
348
349    #[test]
350    fn downgrade_v_prefix() {
351        assert!(is_version_downgrade("v2.0.0", "v1.0.0"));
352        assert!(!is_version_downgrade("v1.0.0", "v2.0.0"));
353    }
354
355    #[test]
356    fn downgrade_prerelease() {
357        assert!(is_version_downgrade("1.0.0", "1.0.0-rc1"));
358        assert!(!is_version_downgrade("1.0.0-rc1", "1.0.0"));
359    }
360
361    #[test]
362    fn downgrade_build_metadata() {
363        assert!(!is_version_downgrade("1.0.0+build.1", "1.0.0+build.2"));
364        assert!(is_version_downgrade("1.0.0+build.2", "1.0.0+build.1"));
365        assert!(!is_version_downgrade("1.0.0+build.1", "1.0.0+build.1"));
366    }
367
368    #[test]
369    fn downgrade_mixed_v_prefix() {
370        assert!(is_version_downgrade("v2.0.0", "1.0.0"));
371        assert!(is_version_downgrade("2.0.0", "v1.0.0"));
372        assert!(!is_version_downgrade("v1.0.0", "2.0.0"));
373        assert!(!is_version_downgrade("1.0.0", "v2.0.0"));
374    }
375
376    #[test]
377    fn downgrade_prerelease_ordering() {
378        assert!(is_version_downgrade("1.0.0-beta.1", "1.0.0-alpha.1"));
379        assert!(is_version_downgrade("1.0.0-rc.1", "1.0.0-beta.1"));
380        assert!(!is_version_downgrade("1.0.0-alpha.1", "1.0.0-beta.1"));
381        assert!(!is_version_downgrade("1.0.0-beta.1", "1.0.0-rc.1"));
382    }
383
384    #[test]
385    fn downgrade_prerelease_numeric_ordering() {
386        assert!(is_version_downgrade("1.0.0-rc.2", "1.0.0-rc.1"));
387        assert!(!is_version_downgrade("1.0.0-rc.1", "1.0.0-rc.2"));
388    }
389
390    #[test]
391    fn downgrade_equal_with_v_prefix() {
392        assert!(!is_version_downgrade("v1.0.0", "v1.0.0"));
393    }
394
395    #[test]
396    fn downgrade_padded_two_part() {
397        assert!(is_version_downgrade("1.2", "1.1"));
398        assert!(!is_version_downgrade("1.1", "1.2"));
399        assert!(!is_version_downgrade("1.2", "1.2"));
400    }
401
402    #[test]
403    fn downgrade_padded_single_part() {
404        assert!(is_version_downgrade("2", "1"));
405        assert!(!is_version_downgrade("1", "2"));
406        assert!(!is_version_downgrade("5", "5"));
407    }
408
409    #[test]
410    fn downgrade_mixed_part_counts_semver() {
411        assert!(is_version_downgrade("2.0", "1.9.9"));
412        assert!(!is_version_downgrade("1.9.9", "2.0"));
413    }
414
415    #[test]
416    fn downgrade_v_prefix_two_part() {
417        assert!(is_version_downgrade("v2.0", "v1.0"));
418        assert!(!is_version_downgrade("v1.0", "v2.0"));
419    }
420
421    // -------------------------------------------------------------------
422    // Version::is_downgrade — numeric fallback path
423    // -------------------------------------------------------------------
424
425    #[test]
426    fn downgrade_four_part() {
427        assert!(is_version_downgrade("1.2.3.4", "1.2.3.3"));
428        assert!(!is_version_downgrade("1.2.3.3", "1.2.3.4"));
429        assert!(!is_version_downgrade("1.2.3.4", "1.2.3.4"));
430    }
431
432    #[test]
433    fn downgrade_date_based() {
434        assert!(is_version_downgrade("2024.01.15", "2023.12.01"));
435        assert!(!is_version_downgrade("2023.12.01", "2024.01.15"));
436    }
437
438    #[test]
439    fn downgrade_non_numeric_not_flagged() {
440        assert!(!is_version_downgrade("abc", "def"));
441        assert!(!is_version_downgrade("foo.bar", "foo.baz"));
442    }
443
444    #[test]
445    fn downgrade_numeric_unequal_length() {
446        assert!(is_version_downgrade("1.2.3.4", "1.2.3"));
447        assert!(!is_version_downgrade("1.2.3", "1.2.3.4"));
448    }
449
450    #[test]
451    fn downgrade_large_major_numeric_equal() {
452        // "2024.1.15" has no leading zeros, so it parses as valid semver
453        assert!(!is_version_downgrade("2024.1.15", "2024.1.15"));
454    }
455
456    // -------------------------------------------------------------------
457    // Version::is_downgrade — cross-variant (Semver vs Numeric)
458    // -------------------------------------------------------------------
459
460    #[test]
461    fn downgrade_semver_vs_four_part() {
462        // "1.2.3" → Semver, "1.2.3.4" → Numeric; cross-comparison extracts
463        // [major,minor,patch] from the semver side
464        assert!(!is_version_downgrade("1.2.3", "1.2.3.4"));
465        assert!(is_version_downgrade("1.2.3.4", "1.2.3"));
466    }
467
468    #[test]
469    fn downgrade_v_prefix_vs_four_part() {
470        // unlike the old implementation (which bailed because the numeric
471        // fallback couldn't parse "v1"), the Version enum correctly handles
472        // cross-variant comparison after stripping the v-prefix during parse.
473        assert!(!is_version_downgrade("v1.2.3", "1.2.3.4"));
474        assert!(is_version_downgrade("1.2.3.4", "v1.2.3"));
475    }
476
477    // -------------------------------------------------------------------
478    // edge cases
479    // -------------------------------------------------------------------
480
481    #[test]
482    fn downgrade_empty_strings() {
483        assert!(!is_version_downgrade("", "1.0.0"));
484        assert!(!is_version_downgrade("1.0.0", ""));
485        assert!(!is_version_downgrade("", ""));
486    }
487}