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
+150
View File
@@ -161,6 +161,88 @@ pub async fn ppa_info(ppa: &str) -> Result<PpaInfo, Box<dyn Error>> {
}
}
/// Percent-encode a query-string value (RFC 3986): unreserved characters
/// pass through, everything else becomes `%XX`. Debian source package names
/// may contain `+` (`g++`), which must not reach the API unencoded — query
/// values follow form-urlencoded rules, where a literal `+` decodes to a
/// space.
fn percent_encode(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
/// URL of the `getPublishedSources` API call listing the currently
/// `Published` source packages named `source_name` in the PPA `user/name`:
/// `exact_match` avoids Launchpad's default case-insensitive substring
/// matching, which would return unrelated sources (`data` matching
/// `datatables`).
fn published_sources_url(user: &str, ppa: &str, source_name: &str) -> String {
format!(
"{}?ws.op=getPublishedSources&source_name={}&exact_match=true&status=Published",
archive_url(user, ppa),
percent_encode(source_name)
)
}
/// One entry of a `getPublishedSources` answer: the subset of the source
/// package publishing history the superseded-upload check needs (the live
/// answer carries many more fields, ignored by serde)
#[derive(Debug, Deserialize)]
struct PublishedSource {
/// Version of the published source package
source_package_version: String,
}
/// The `getPublishedSources` collection answer
#[derive(Debug, Deserialize)]
struct PublishedSources {
/// The currently published source packages matching the query
#[serde(default)]
entries: Vec<PublishedSource>,
}
/// Every version of `source_name` currently `Published` in the PPA
/// `user/name` (same `user/ppa_name` format as `pkh put --ppa`), in API
/// order. Empty when the source was never published there — a 200 answer
/// with zero entries, the normal first-upload case.
pub async fn published_versions(
ppa: &str,
source_name: &str,
) -> Result<Vec<String>, Box<dyn Error>> {
let (user, name) = split_ppa(ppa)?;
let client = crate::distro_info::http_client();
let response = client
.get(published_sources_url(&user, &name, source_name))
.send()
.await
.map_err(|e| format!("cannot reach the Launchpad API: {e}"))?;
match response.status() {
reqwest::StatusCode::OK => {
let sources: PublishedSources = response
.json()
.await
.map_err(|e| format!("cannot parse the Launchpad API response for '{ppa}': {e}"))?;
Ok(sources
.entries
.into_iter()
.map(|entry| entry.source_package_version)
.collect())
}
reqwest::StatusCode::NOT_FOUND => {
Err(format!("PPA '{ppa}' does not exist: create it on launchpad.net first").into())
}
status => Err(format!("Launchpad API returned {status} for PPA '{ppa}'").into()),
}
}
/// PPA uploads only target Ubuntu series: fail before uploading when the
/// changes' distribution is not a known series (typo) or a non-Ubuntu one —
/// both are only rejected during queue processing otherwise
@@ -278,4 +360,72 @@ mod tests {
);
assert_eq!(info.enabled, None);
}
#[test]
fn percent_encode_keeps_unreserved_and_escapes_the_rest() {
// The characters of Debian source package names pass through
assert_eq!(percent_encode("noctalia"), "noctalia");
assert_eq!(percent_encode("libfoo-1.0"), "libfoo-1.0");
// `+` must be escaped: in query values it would decode to a space
assert_eq!(percent_encode("g++"), "g%2B%2B");
assert_eq!(percent_encode("a b/c?d&e"), "a%20b%2Fc%3Fd%26e");
}
/// The query matches the verified live `getPublishedSources` call, with
/// the source name percent-encoded
#[test]
fn published_sources_url_matches_launchpad_call() {
assert_eq!(
published_sources_url("vhaudiquet", "noctalia", "noctalia"),
"https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia?ws.op=getPublishedSources&source_name=noctalia&exact_match=true&status=Published"
);
assert_eq!(
published_sources_url("vhaudiquet", "noctalia", "g++"),
"https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia?ws.op=getPublishedSources&source_name=g%2B%2B&exact_match=true&status=Published"
);
}
/// The live answer carries many unrelated fields per entry; only
/// `source_package_version` is needed (shape verified against the API)
#[test]
fn published_sources_parses_api_response() {
let json = r#"{
"start": 0,
"total_size": 2,
"entries": [
{
"self_link": "https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia/+sourcepub/18737497",
"resource_type_link": "https://api.launchpad.net/1.0/#source_package_publishing_history",
"display_name": "noctalia 5.1.0-1ubuntu2 in stonking",
"component_name": "main",
"section_name": "x11",
"status": "Published",
"pocket": "Release",
"date_published": "2026-09-16T19:36:46.116930+00:00",
"scheduled_deletion_date": null,
"source_package_name": "noctalia",
"source_package_version": "5.1.0-1ubuntu2",
"http_etag": "\"98f12b47\""
},
{"unknown_extra": {"nested": [1, 2]}, "source_package_version": "2:1.0-1"}
]
}"#;
let sources: PublishedSources = serde_json::from_str(json).unwrap();
let versions: Vec<&str> = sources
.entries
.iter()
.map(|entry| entry.source_package_version.as_str())
.collect();
assert_eq!(versions, vec!["5.1.0-1ubuntu2", "2:1.0-1"]);
}
/// A 200 answer with zero entries is the normal "nothing published
/// there" case, and must deserialize to an empty list
#[test]
fn published_sources_parses_empty_collection() {
let sources: PublishedSources =
serde_json::from_str(r#"{"start": 0, "total_size": 0, "entries": []}"#).unwrap();
assert!(sources.entries.is_empty());
}
}
+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();
}
}