debian/version: dpkg-compatible version comparison
Implement the documented dpkg ordering algorithm (Debian Policy 5.6.1): numeric epoch, then upstream/revision compared as alternating non-digit and digit chunks, with '~' ordering before anything including the empty chunk and letters before non-letters in non-digit chunks. Adds Ord/PartialOrd for DebianVersion, a free compare() and a later_than() convenience. Unit tests port all vectors from dpkg's scripts/t/Dpkg_Version.t plus Ubuntu-flavored cases (security uploads, ~ppa1 backports). Differential gate: every vector cross-checked against real 'dpkg --compare-versions' for <<, <=, =, >= and >>.
This commit is contained in:
+271
-1
@@ -1,4 +1,4 @@
|
||||
//! Debian version handling: splitting and validation of
|
||||
//! Debian version handling: splitting, validation and ordering of
|
||||
//! `[epoch:]upstream[-revision]` version strings.
|
||||
|
||||
/// A Debian version, split into its `[epoch:]upstream[-revision]` parts.
|
||||
@@ -83,6 +83,210 @@ impl DebianVersion {
|
||||
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<std::cmp::Ordering> {
|
||||
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)]
|
||||
@@ -117,4 +321,70 @@ mod tests {
|
||||
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<DebianVersion> = unsorted
|
||||
.iter()
|
||||
.map(|v| DebianVersion::parse(v).unwrap())
|
||||
.collect();
|
||||
versions.sort();
|
||||
let rendered: Vec<String> = versions.iter().map(DebianVersion::full).collect();
|
||||
let expected: Vec<String> = 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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user