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:
2026-08-24 11:57:54 +02:00
parent 4c52336000
commit 489b2aa29b
3 changed files with 312 additions and 2 deletions
+271 -1
View File
@@ -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));
}
}