put: refuse uploads superseded by published PPA versions
This commit is contained in:
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user