diff --git a/plans/native-build.md b/plans/native-build.md index 7f43671..511f476 100644 --- a/plans/native-build.md +++ b/plans/native-build.md @@ -111,7 +111,7 @@ Exit codes matter: e.g. unsatisfied build-deps ⇒ exit 3. | 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-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-checkbuilddeps` | deps vs installed status | **Medium** — `Dpkg::Deps` grammar (alternatives, arch qualifiers, `` 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)) | diff --git a/src/build/mod.rs b/src/build/mod.rs index f08003d..6b29814 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -909,6 +909,46 @@ mod differential_tests { 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] fn diff_native_minimal() { differential_case(&FixtureSpec::new("pkh-diff-a", "1.0-1", "unstable")); diff --git a/src/debian/version.rs b/src/debian/version.rs index 23a0f15..192a984 100644 --- a/src/debian/version.rs +++ b/src/debian/version.rs @@ -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 { + 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 = 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)); + } }