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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user