From 0c2cf0ac5ec8b1fa3e137b7b137506d17bc0a9a9 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Wed, 16 Sep 2026 21:51:33 +0200 Subject: [PATCH] put: refuse uploads superseded by published PPA versions --- src/launchpad.rs | 150 +++++++++++++++++++++++++++++++++ src/put/mod.rs | 211 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 361 insertions(+) diff --git a/src/launchpad.rs b/src/launchpad.rs index 21fd242..126583d 100644 --- a/src/launchpad.rs +++ b/src/launchpad.rs @@ -161,6 +161,88 @@ pub async fn ppa_info(ppa: &str) -> Result> { } } +/// 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, +} + +/// 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, Box> { + 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()); + } } diff --git a/src/put/mod.rs b/src/put/mod.rs index 994e419..d02289a 100644 --- a/src/put/mod.rs +++ b/src/put/mod.rs @@ -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 Option { + 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> { + 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.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 { + 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(); + } }