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 >>.
391 lines
13 KiB
Rust
391 lines
13 KiB
Rust
//! 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<u32>,
|
|
/// Upstream version (may itself contain `-` when there is no revision).
|
|
pub upstream: String,
|
|
/// Optional Debian revision (part after the last `-`).
|
|
pub debian_revision: Option<String>,
|
|
}
|
|
|
|
impl DebianVersion {
|
|
/// Parse and validate a Debian version string.
|
|
pub fn parse(raw: &str) -> Result<DebianVersion, String> {
|
|
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<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)]
|
|
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<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));
|
|
}
|
|
}
|