put: refuse uploads superseded by published PPA versions

This commit is contained in:
2026-09-16 21:51:33 +02:00
parent f27d27ea99
commit 0c2cf0ac5e
2 changed files with 361 additions and 0 deletions
+211
View File
@@ -12,6 +12,7 @@ pub mod changes;
pub mod ssh;
pub mod target;
use std::cmp::Ordering;
use std::path::{Path, PathBuf};
use indicatif::{MultiProgress, ProgressBar};
@@ -21,6 +22,7 @@ use serde::{Deserialize, Serialize};
use crate::debian::changelog::parse_changelog_entry;
use crate::debian::checksums::FileChecksums;
use crate::debian::control::ControlInfo;
use crate::debian::version::DebianVersion;
use crate::launchpad;
use crate::ui;
@@ -90,6 +92,11 @@ pub async fn put(
launchpad::ppa_info(&opts.ppa).await?;
launchpad::check_ppa_series(&changes.distribution).await?;
// Last pre-flight check: a version the PPA already publishes at or
// above the changes' one would supersede (or reject) this upload
let published = launchpad::published_versions(&opts.ppa, &changes.source).await?;
check_not_superseded(&target, &changes, &published, opts.force)?;
let record = upload_record(&target, &changes_path)?;
if !opts.force
&& let Some(previous) = find_previous_upload(&upload_log_path()?, &record)?
@@ -183,6 +190,85 @@ fn check_control_section(cwd: &Path, dist: &str) -> Result<(), Box<dyn std::erro
.into())
}
/// The highest version among `published` that parses as a Debian version,
/// `None` when the list is empty or nothing in it parses (entries that
/// cannot be parsed are skipped rather than failing the check: the API
/// data is trusted to carry valid Debian versions)
fn max_published(published: &[String]) -> Option<DebianVersion> {
published
.iter()
.filter_map(|version| DebianVersion::parse(version).ok())
.max()
}
/// Refuse an upload the target PPA already supersedes: when a version equal
/// to or higher than the changes' one is published there for the same
/// source, the queue would reject the upload (equal version) or silently
/// supersede it (older one) — Launchpad never replaces a published version
/// with a lower one. `force` downgrades the refusal to a warning.
fn check_not_superseded(
target: &target::UploadTarget,
changes: &changes::ChangesFile,
published: &[String],
force: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let local = match DebianVersion::parse(&changes.version) {
Ok(version) => version,
Err(e) => {
// Only the pre-flight comparison is skipped: the upload itself
// is valid as far as this check is concerned
log::warn!(
"cannot compare {} against the published versions of {} ({e}): \
skipping the published-version check",
changes.version,
changes.source
);
return Ok(());
}
};
let Some(latest) = max_published(published) else {
return Ok(());
};
if local.cmp(&latest) == Ordering::Greater {
return Ok(());
}
let bump = "bump the version with `pkh chlog` and rebuild, or pass --force to upload anyway";
if force {
log::warn!(
"{} {} is superseded by {} {} already published in {}: \
uploading anyway (--force)",
changes.source,
local.full(),
changes.source,
latest.full(),
target.label
);
return Ok(());
}
match local.cmp(&latest) {
Ordering::Equal => Err(format!(
"{} {} is already published in {} (published version {}): {bump}",
changes.source,
local.full(),
target.label,
latest.full()
)
.into()),
_ => Err(format!(
"{} {} is older than the {} {} already published in {}: {bump}",
changes.source,
local.full(),
changes.source,
latest.full(),
target.label
)
.into()),
}
}
/// Find the `.changes` file of the package in `cwd`: the exact
/// `<source>_<version>_source.changes` written by `pkh build` (in the source
/// directory's parent or the directory itself), or the only other
@@ -484,4 +570,129 @@ mod tests {
rebuilt.sha256 = "def".to_string();
assert!(find_previous_upload(&log, &rebuilt).unwrap().is_none());
}
/// A minimal in-memory changes file for the superseded-version check
fn changes_fixture(version: &str) -> changes::ChangesFile {
changes::ChangesFile {
path: PathBuf::from("hello_1.0-1_source.changes"),
source: "hello".to_string(),
version: version.to_string(),
distribution: "resolute".to_string(),
files: Vec::new(),
}
}
fn versions(list: &[&str]) -> Vec<String> {
list.iter().map(|v| v.to_string()).collect()
}
#[test]
fn superseded_check_rejects_equal_published_version() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let changes = changes_fixture("1.2.3-1");
let err = check_not_superseded(&target, &changes, &versions(&["1.2.3-1"]), false)
.unwrap_err()
.to_string();
assert!(
err.contains("hello 1.2.3-1 is already published in ppa:user/ppa"),
"unexpected: {err}"
);
assert!(
err.contains("published version 1.2.3-1"),
"unexpected: {err}"
);
assert!(err.contains("--force"), "unexpected: {err}");
}
#[test]
fn superseded_check_rejects_older_upload() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let changes = changes_fixture("1.0-1");
let err = check_not_superseded(&target, &changes, &versions(&["2.0-1"]), false)
.unwrap_err()
.to_string();
assert!(
err.contains("hello 1.0-1 is older than the hello 2.0-1 already published"),
"unexpected: {err}"
);
}
#[test]
fn superseded_check_accepts_newer_version() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let changes = changes_fixture("2.0-1");
check_not_superseded(&target, &changes, &versions(&["1.0-1"]), false).unwrap();
}
#[test]
fn superseded_check_accepts_never_published_source() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let changes = changes_fixture("1.0-1");
check_not_superseded(&target, &changes, &[], false).unwrap();
}
/// The comparison follows dpkg ordering: `~` pre-releases, epochs and
/// Ubuntu revision suffixes all sort correctly
#[test]
fn superseded_check_uses_dpkg_ordering() {
let target = launchpad::ppa_target("user/ppa").unwrap();
// A tilde pre-release is older than the real release
let changes = changes_fixture("1.0~rc1-1");
assert!(check_not_superseded(&target, &changes, &versions(&["1.0-1"]), false).is_err());
// The real release supersedes the pre-release
let changes = changes_fixture("1.0-1");
check_not_superseded(&target, &changes, &versions(&["1.0~rc1-1"]), false).unwrap();
// An epoch outranks a higher upstream version
let changes = changes_fixture("2.0-1");
assert!(check_not_superseded(&target, &changes, &versions(&["1:1.0-1"]), false).is_err());
// ubuntu1.1 point updates supersede the base upload
let changes = changes_fixture("1.0-1ubuntu1");
assert!(
check_not_superseded(&target, &changes, &versions(&["1.0-1ubuntu1.1"]), false).is_err()
);
}
#[test]
fn superseded_check_force_warns_instead_of_erroring() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let changes = changes_fixture("1.0-1");
check_not_superseded(&target, &changes, &versions(&["1.0-1", "0.9-1"]), true).unwrap();
}
/// Versions the parser rejects never fail the upload: an unparseable
/// local version skips the check, unparseable published entries are
/// ignored
#[test]
fn superseded_check_tolerates_unparseable_versions() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let changes = changes_fixture("not_a_debian_version");
check_not_superseded(&target, &changes, &versions(&["1.0-1"]), false).unwrap();
let changes = changes_fixture("1.0-1");
check_not_superseded(&target, &changes, &versions(&["1.0_beta", "0.9-1"]), false).unwrap();
}
/// Several published versions (multiple series/pockets): the highest
/// one decides
#[test]
fn superseded_check_compares_against_highest_published() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let published = versions(&["1.0-1", "1.6-1", "1.2-3"]);
// 1.5-1 supersedes everything but the 1.6-1 entry, which decides
let changes = changes_fixture("1.5-1");
let err = check_not_superseded(&target, &changes, &published, false)
.unwrap_err()
.to_string();
assert!(err.contains("1.6-1"), "unexpected: {err}");
// ...and going one higher than the highest entry passes
let changes = changes_fixture("1.7-1");
check_not_superseded(&target, &changes, &published, false).unwrap();
}
}