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());
}
}