//! Debian version handling: splitting, validation and ordering of //! `[epoch:]upstream[-revision]` version strings. /// A Debian version, split into its `[epoch:]upstream[-revision]` parts. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DebianVersion { /// Optional numeric epoch (part before the first `:`). pub epoch: Option, /// Upstream version (may itself contain `-` when there is no revision). pub upstream: String, /// Optional Debian revision (part after the last `-`). pub debian_revision: Option, } impl DebianVersion { /// Parse and validate a Debian version string. pub fn parse(raw: &str) -> Result { let raw = raw.trim(); if raw.is_empty() { return Err("empty version string".to_string()); } let (epoch, rest) = match raw.split_once(':') { Some((e, r)) => { let epoch: u32 = e .parse() .map_err(|_| format!("invalid epoch '{}' in version '{}'", e, raw))?; (Some(epoch), r) } None => (None, raw), }; // The revision is everything after the last hyphen. let (upstream, debian_revision) = match rest.rsplit_once('-') { Some((u, r)) => (u.to_string(), Some(r.to_string())), None => (rest.to_string(), None), }; if upstream.is_empty() { return Err(format!("missing upstream version in '{}'", raw)); } for c in upstream.chars() { if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-' | '~' | ':') || !c.is_ascii()) { return Err(format!("invalid character '{}' in version '{}'", c, raw)); } } if let Some(rev) = &debian_revision { for c in rev.chars() { if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '~') || !c.is_ascii()) { return Err(format!( "invalid character '{}' in revision of version '{}'", c, raw )); } } } Ok(DebianVersion { epoch, upstream, debian_revision, }) } /// Full version string, including the epoch (`[epoch:]upstream[-rev]`). pub fn full(&self) -> String { match (&self.epoch, &self.debian_revision) { (Some(e), Some(r)) => format!("{}:{}-{}", e, self.upstream, r), (Some(e), None) => format!("{}:{}", e, self.upstream), (None, Some(r)) => format!("{}-{}", self.upstream, r), (None, None) => self.upstream.clone(), } } /// Version string without the epoch (`upstream[-rev]`), used in artifact /// file names. pub fn no_epoch(&self) -> String { match &self.debian_revision { Some(r) => format!("{}-{}", self.upstream, r), None => self.upstream.clone(), } } /// Convenience predicate: whether this version orders strictly later /// than `other`. pub fn later_than(&self, other: &DebianVersion) -> bool { self > other } } /// Compare two versions according to dpkg's ordering algorithm /// (Debian Policy ยง5.6.1 / `dpkg(1)`): /// /// - the epoch compares numerically (a missing epoch counts as `0`), /// - then the upstream version and the Debian revision compare by /// alternating non-digit and digit chunks, from left to right, /// - in non-digit chunks letters sort earlier than non-letters, and `~` /// sorts before anything, including the end of the chunk, /// - digit chunks compare numerically (leading zeroes are irrelevant; an /// empty digit chunk counts as `0`, so a missing revision equals `0`). pub fn compare(a: &DebianVersion, b: &DebianVersion) -> std::cmp::Ordering { a.epoch.unwrap_or(0) .cmp(&b.epoch.unwrap_or(0)) .then_with(|| verrevcmp(a.upstream.as_bytes(), b.upstream.as_bytes())) .then_with(|| { verrevcmp( a.debian_revision.as_deref().unwrap_or("").as_bytes(), b.debian_revision.as_deref().unwrap_or("").as_bytes(), ) }) } /// Sort weight of a character inside a non-digit chunk: `~` sorts before the /// end of the chunk, letters before non-letters, everything else by ASCII /// order. fn char_order(c: u8) -> i32 { if c == b'~' { -1 } else if c.is_ascii_alphabetic() { i32::from(c) } else { i32::from(c) + 256 } } /// Compare the upstream/revision part of two versions by alternating /// non-digit and digit chunks. fn verrevcmp(mut a: &[u8], mut b: &[u8]) -> std::cmp::Ordering { use std::cmp::Ordering; while !a.is_empty() || !b.is_empty() { let mut first_diff: i32 = 0; // Non-digit chunks: compare by character weight. A chunk boundary // (end of string or start of a digit run) weighs 0, which sorts // after `~` (-1) and before every real character. while (!a.is_empty() && !a[0].is_ascii_digit()) || (!b.is_empty() && !b[0].is_ascii_digit()) { let ac = if !a.is_empty() && !a[0].is_ascii_digit() { char_order(a[0]) } else { 0 }; let bc = if !b.is_empty() && !b[0].is_ascii_digit() { char_order(b[0]) } else { 0 }; if ac != bc { return ac.cmp(&bc); } // Reaching here means both sides carried equal real characters. a = &a[1..]; b = &b[1..]; } // Digit chunks: strip leading zeroes, then the number whose // remaining digit run is longer is larger; otherwise the first // differing digit decides. while !a.is_empty() && a[0] == b'0' { a = &a[1..]; } while !b.is_empty() && b[0] == b'0' { b = &b[1..]; } while !a.is_empty() && !b.is_empty() && a[0].is_ascii_digit() && b[0].is_ascii_digit() { if first_diff == 0 { first_diff = i32::from(a[0]) - i32::from(b[0]); } a = &a[1..]; b = &b[1..]; } if !a.is_empty() && a[0].is_ascii_digit() { return Ordering::Greater; } if !b.is_empty() && b[0].is_ascii_digit() { return Ordering::Less; } if first_diff != 0 { return first_diff.cmp(&0); } } Ordering::Equal } impl PartialOrd for DebianVersion { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for DebianVersion { fn cmp(&self, other: &Self) -> std::cmp::Ordering { compare(self, other) } } /// Test vectors ported from dpkg's `scripts/t/Dpkg_Version.t` (`__DATA__` /// section): `(version_a, version_b, expected_cmp)` with `-1/0/1`. Shared /// with the differential tests against real `dpkg --compare-versions`. #[cfg(test)] pub(crate) mod test_vectors { /// `(a, b, cmp)` triples. pub(crate) const COMPARE: &[(&str, &str, i32)] = &[ ("1.0-1", "2.0-2", -1), ("2.2~rc-4", "2.2-1", -1), ("2.2-1", "2.2~rc-4", 1), ("1.0000-1", "1.0-1", 0), ("1", "0:1", 0), ("0", "0:0-0", 0), ("2:2.5", "1:7.5", 1), ("1:0foo", "0foo", 1), ("0:0foo", "0foo", 0), ("0foo", "0foo", 0), ("0foo-0", "0foo", 0), ("0foo", "0foo-0", 0), ("0foo", "0fo", 1), ("0foo-0", "0foo+", -1), ("0foo~1", "0foo", -1), ("0foo~foo+Bar", "0foo~foo+bar", -1), ("0foo~~", "0foo~", -1), ("1~", "1", -1), ( "12345+that-really-is-some-ver-0", "12345+that-really-is-some-ver-10", -1, ), ("0foo-0", "0foo-01", -1), ("0foo.bar", "0foobar", 1), ("0foo.bar", "0foo1bar", 1), ("0foo.bar", "0foo0bar", 1), ("0foo1bar-1", "0foobar-1", -1), ("0foo2.0", "0foo2", 1), ("0foo2.0.0", "0foo2.10.0", -1), ("0foo2.0", "0foo2.0.0", -1), ("0foo2.0", "0foo2.10", -1), ("0foo2.1", "0foo2.10", -1), ("1.09", "1.9", 0), ("1.0.8+nmu1", "1.0.8", 1), ("3.11", "3.10+nmu1", 1), ("0.9j-20080306-4", "0.9i-20070324-2", 1), ("1.2.0~b7-1", "1.2.0~b6-1", 1), ("1.011-1", "1.06-2", 1), ("0.0.9+dfsg1-1", "0.0.8+dfsg1-3", 1), ("4.6.99+svn6582-1", "4.6.99+svn6496-1", 1), ("53", "52", 1), ("0.9.9~pre122-1", "0.9.9~pre111-1", 1), ("2:2.3.2-2+lenny2", "2:2.3.2-2", 1), ("1:3.8.1-1", "3.8.GA-1", 1), ("1.0.1+gpl-1", "1.0.1-2", 1), ("1a", "1000a", -1), ]; /// Unsorted lists with their expected order under dpkg comparison. pub(crate) const SORTED: &[(&[&str], &[&str])] = &[ ( &[ "4:4-4", "5.0abc", "0.0-0.0alpha0", "10.100.1-1", "0~999.999zeta", "0:1.0-0", ], &[ "0~999.999zeta", "0.0-0.0alpha0", "0:1.0-0", "5.0abc", "10.100.1-1", "4:4-4", ], ), ( &["4", "5.0abc", "0.0alpha0", "10.100.1", "0~999.999zeta", "1.0"], &[ "0~999.999zeta", "0.0alpha0", "1.0", "4", "5.0abc", "10.100.1", ], ), ]; } #[cfg(test)] mod tests { use super::*; #[test] fn version_splitting() { let v = DebianVersion::parse("1.2.3-4ubuntu5").unwrap(); assert_eq!(v.epoch, None); assert_eq!(v.upstream, "1.2.3"); assert_eq!(v.debian_revision.as_deref(), Some("4ubuntu5")); assert_eq!(v.full(), "1.2.3-4ubuntu5"); assert_eq!(v.no_epoch(), "1.2.3-4ubuntu5"); let v = DebianVersion::parse("3:2.10-3").unwrap(); assert_eq!(v.epoch, Some(3)); assert_eq!(v.upstream, "2.10"); assert_eq!(v.no_epoch(), "2.10-3"); assert_eq!(v.full(), "3:2.10-3"); let v = DebianVersion::parse("1.0").unwrap(); assert_eq!(v.debian_revision, None); assert_eq!(v.no_epoch(), "1.0"); } #[test] fn version_validation() { assert!(DebianVersion::parse("").is_err()); assert!(DebianVersion::parse(":1.0").is_err()); assert!(DebianVersion::parse("a:_b").is_err()); assert!(DebianVersion::parse("1.0").is_ok()); assert!(DebianVersion::parse("1.0~rc1-2").is_ok()); } fn cmp_sign(a: &DebianVersion, b: &DebianVersion) -> i32 { match a.cmp(b) { std::cmp::Ordering::Less => -1, std::cmp::Ordering::Equal => 0, std::cmp::Ordering::Greater => 1, } } /// All vectors from dpkg's own `Dpkg_Version.t` must pass. #[test] fn comparison_dpkg_vectors() { for (a, b, expected) in test_vectors::COMPARE { let va = DebianVersion::parse(a).unwrap_or_else(|e| panic!("parse {a}: {e}")); let vb = DebianVersion::parse(b).unwrap_or_else(|e| panic!("parse {b}: {e}")); assert_eq!( cmp_sign(&va, &vb), *expected, "{a} cmp {b} must be {expected}" ); // Ordering is antisymmetric. assert_eq!(cmp_sign(&vb, &va), -*expected, "{b} cmp {a}"); } } #[test] fn sorting_dpkg_vectors() { for (unsorted, expected) in test_vectors::SORTED { let mut versions: Vec = unsorted .iter() .map(|v| DebianVersion::parse(v).unwrap()) .collect(); versions.sort(); let rendered: Vec = versions.iter().map(DebianVersion::full).collect(); let expected: Vec = expected.iter().map(|s| s.to_string()).collect(); assert_eq!(rendered, expected); } } /// Ubuntu-flavored cases: security updates, backports, PPA versions. #[test] fn comparison_ubuntu_flavored() { let cases: &[(&str, &str, i32)] = &[ // Security update on top of a release upload. ("1.0-0ubuntu1", "1.0-0ubuntu1.22.04.1", -1), // PPA/backports pre-releases sort before the real upload. ("1.0-0ubuntu1~ppa1", "1.0-0ubuntu1", -1), ("1.0~bpo22.04.1", "1.0", -1), // Series-specific uploads. ("2.3-1ubuntu3.22.04.2", "2.3-1ubuntu3", 1), ("1:2.0.4-0ubuntu1", "1:2.0.4-0ubuntu1.1", -1), ]; for (a, b, expected) in cases { let va = DebianVersion::parse(a).unwrap(); let vb = DebianVersion::parse(b).unwrap(); assert_eq!(cmp_sign(&va, &vb), *expected, "{a} cmp {b}"); } // later_than convenience. let old = DebianVersion::parse("1.0-0ubuntu1").unwrap(); let new = DebianVersion::parse("1.0-0ubuntu1.22.04.1").unwrap(); assert!(new.later_than(&old)); assert!(!old.later_than(&old)); } }