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:
@@ -111,7 +111,7 @@ Exit codes matter: e.g. unsatisfied build-deps ⇒ exit 3.
|
|||||||
| Tool | Used for | Complexity to replace natively | Strategy |
|
| Tool | Used for | Complexity to replace natively | Strategy |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `dpkg-parsechangelog` | source/version/maintainer/distribution/timestamp | **Low** — documented format; crates exist (`debian-changelog`, `deb822-parser` ecosystem) | Replaced (see §11, [`metadata.rs`](../src/build/metadata.rs)) |
|
| `dpkg-parsechangelog` | source/version/maintainer/distribution/timestamp | **Low** — documented format; crates exist (`debian-changelog`, `deb822-parser` ecosystem) | Replaced (see §11, [`metadata.rs`](../src/build/metadata.rs)) |
|
||||||
| `dpkg-version` compare | epoch/upstream/revision ordering | **Low** — small well-specified algorithm; crate `debversion` | Splitting/validation replaced; ordering still Phase 2 |
|
| `dpkg-version` compare | epoch/upstream/revision ordering | **Low** — small well-specified algorithm; crate `debversion` | **Replaced** (§11, [`debian/version.rs`](../src/debian/version.rs): `Ord`/`compare`/`later_than`) |
|
||||||
| `dpkg-architecture` | arch ↔ triplet tables, multiarch tuple, env dump | **Low-medium** — embed cputable/ostable/tupletable/abitable data (stable for years) | **Replaced** (§11, [`debian/arch.rs`](../src/debian/arch.rs)) |
|
| `dpkg-architecture` | arch ↔ triplet tables, multiarch tuple, env dump | **Low-medium** — embed cputable/ostable/tupletable/abitable data (stable for years) | **Replaced** (§11, [`debian/arch.rs`](../src/debian/arch.rs)) |
|
||||||
| `dpkg-checkbuilddeps` | deps vs installed status | **Medium** — `Dpkg::Deps` grammar (alternatives, arch qualifiers, `<profiles>` restrictions, versioned Provides subtleties, Multi-Arch facts) + status-file scan | Phase 2; keep `apt-get build-dep`/subprocess until then |
|
| `dpkg-checkbuilddeps` | deps vs installed status | **Medium** — `Dpkg::Deps` grammar (alternatives, arch qualifiers, `<profiles>` restrictions, versioned Provides subtleties, Multi-Arch facts) + status-file scan | Phase 2; keep `apt-get build-dep`/subprocess until then |
|
||||||
| `dpkg-genbuildinfo` | `.buildinfo` | **Medium** — deb822 emit + status snapshot + checksums | Native (see §11, [`buildinfo.rs`](../src/build/buildinfo.rs)) |
|
| `dpkg-genbuildinfo` | `.buildinfo` | **Medium** — deb822 emit + status snapshot + checksums | Native (see §11, [`buildinfo.rs`](../src/build/buildinfo.rs)) |
|
||||||
|
|||||||
@@ -909,6 +909,46 @@ mod differential_tests {
|
|||||||
diff_arch_env_one(None);
|
diff_arch_env_one(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Differential check of [`crate::debian::version`] against real
|
||||||
|
/// `dpkg --compare-versions` over every ported dpkg test vector and
|
||||||
|
/// every relation operator.
|
||||||
|
#[test]
|
||||||
|
fn diff_version_compare_against_dpkg() {
|
||||||
|
let vectors = crate::debian::version::test_vectors::COMPARE;
|
||||||
|
assert!(!vectors.is_empty());
|
||||||
|
for (a, b, expected) in vectors {
|
||||||
|
let va = crate::debian::DebianVersion::parse(a)
|
||||||
|
.unwrap_or_else(|e| panic!("parse {a}: {e}"));
|
||||||
|
let vb = crate::debian::DebianVersion::parse(b)
|
||||||
|
.unwrap_or_else(|e| panic!("parse {b}: {e}"));
|
||||||
|
let ours = match va.cmp(&vb) {
|
||||||
|
std::cmp::Ordering::Less => -1,
|
||||||
|
std::cmp::Ordering::Equal => 0,
|
||||||
|
std::cmp::Ordering::Greater => 1,
|
||||||
|
};
|
||||||
|
assert_eq!(ours, *expected, "native compare: {a} cmp {b}");
|
||||||
|
|
||||||
|
// Cross-check the relation operators against the real tool.
|
||||||
|
for (op, holds) in [
|
||||||
|
("<<", *expected < 0),
|
||||||
|
("<=", *expected <= 0),
|
||||||
|
("=", *expected == 0),
|
||||||
|
(">=", *expected >= 0),
|
||||||
|
(">>", *expected > 0),
|
||||||
|
] {
|
||||||
|
let output = Command::new("dpkg")
|
||||||
|
.args(["--compare-versions", "--", a, op, b])
|
||||||
|
.status()
|
||||||
|
.expect("run dpkg --compare-versions");
|
||||||
|
assert_eq!(
|
||||||
|
output.success(),
|
||||||
|
holds,
|
||||||
|
"dpkg --compare-versions -- {a} {op} {b}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn diff_native_minimal() {
|
fn diff_native_minimal() {
|
||||||
differential_case(&FixtureSpec::new("pkh-diff-a", "1.0-1", "unstable"));
|
differential_case(&FixtureSpec::new("pkh-diff-a", "1.0-1", "unstable"));
|
||||||
|
|||||||
+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.
|
//! `[epoch:]upstream[-revision]` version strings.
|
||||||
|
|
||||||
/// A Debian version, split into its `[epoch:]upstream[-revision]` parts.
|
/// A Debian version, split into its `[epoch:]upstream[-revision]` parts.
|
||||||
@@ -83,6 +83,210 @@ impl DebianVersion {
|
|||||||
None => self.upstream.clone(),
|
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)]
|
#[cfg(test)]
|
||||||
@@ -117,4 +321,70 @@ mod tests {
|
|||||||
assert!(DebianVersion::parse("1.0").is_ok());
|
assert!(DebianVersion::parse("1.0").is_ok());
|
||||||
assert!(DebianVersion::parse("1.0~rc1-2").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