//! Launchpad integration for `pkh put`: PPA upload targets, Launchpad //! account (username) discovery and pre-upload checks against the Launchpad //! API. //! //! Launchpad's SFTP upload server requires the SSH username to be a real //! Launchpad account name — anonymous logins are rejected ("Launchpad user //! 'anonymous' doesn't have a registered SSH key") — and authenticates it //! with the SSH keys registered on that account //! (). The username therefore has to //! be discovered on the machine rather than hardcoded: first from the git //! configuration ([`username`], the `lp.user` key), then through the generic //! fallbacks (SSH configuration `User`, local user name — see //! [`crate::put::ssh`]). //! //! The upload queue itself is a blind write: the SFTP server accepts any //! file an authenticated user puts into their incoming area, and invalid //! targets are only rejected later, during queue processing. The //! [`ppa_info`] check makes sure the target actually exists before anything //! is uploaded. use std::error::Error; use std::path::Path; use serde::Deserialize; use crate::put::target::UploadTarget; /// Git configuration key holding the Launchpad account name const LP_USER_KEY: &str = "lp.user"; /// Base URL of the Launchpad REST API const API_BASE: &str = "https://api.launchpad.net/1.0"; /// Page size (`ws.size`) asked from Launchpad collections. Launchpad /// truncates collection answers at 75 entries by default and rejects /// `ws.size` above 300 (both verified against the live API); 100 sits /// comfortably under the cap while keeping multi-page walks rare. const WS_PAGE_SIZE: u32 = 100; /// Hard cap on the pages followed while walking a `getPublishedSources` /// collection: 20 pages x 100 entries = 2000 currently published entries /// for one source name. The query only counts `Published` entries of live /// series/pockets, so real histories are a handful of entries; a walk /// reaching the cap means the API is misbehaving (an endless next-link /// chain), not that the history is genuinely huge. const MAX_COLLECTION_PAGES: u32 = 20; /// The Launchpad username configured in git: the repository-local /// configuration wins over the global one, like git's own precedence. /// `None` when no git repository is found or the key is unset. pub fn username(cwd: &Path) -> Option { // A repository's config covers the local file; the global/system levels // are consulted separately so the key is found in both setups if let Ok(repo) = git2::Repository::discover(cwd) && let Ok(config) = repo.config() && let Some(value) = config_value(&config) { return Some(value); } if let Ok(config) = git2::Config::open_default() { return config_value(&config); } None } /// Trimmed, non-empty `lp.user` value of a configuration, `None` when unset fn config_value(config: &git2::Config) -> Option { config .get_string(LP_USER_KEY) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } /// Split a `user/ppa_name` PPA argument, rejecting malformed ones fn split_ppa(ppa: &str) -> Result<(String, String), String> { let parts: Vec<&str> = ppa.split('/').collect(); if parts.len() != 2 || parts.iter().any(|p| p.is_empty()) { return Err(format!( "Invalid PPA format: '{ppa}'. Expected: user/ppa_name" )); } Ok((parts[0].to_string(), parts[1].to_string())) } /// URL of the Launchpad API resource of a Launchpad account fn person_url(user: &str) -> String { format!("{API_BASE}/~{user}") } /// URL of the Launchpad API resource of a PPA (`~user/+archive/ubuntu/name` /// covers the default `ppa` archive and named archives alike); shared by the /// put-side pre-flight checks and the apt keyring's fingerprint lookup pub(crate) fn archive_url(user: &str, ppa: &str) -> String { format!("{API_BASE}/~{user}/+archive/ubuntu/{ppa}") } /// Resolve a `user/ppa_name` PPA argument into its upload target /// (`ppa.launchpad.net`, incoming `~user/ppa_name`), like dput-ng's /// `ppa:user/ppa` profile expansion. pub fn ppa_target(ppa: &str) -> Result { let (user, name) = split_ppa(ppa)?; Ok(UploadTarget { fqdn: "ppa.launchpad.net".to_string(), port: 22, login: None, incoming: format!("~{user}/{name}"), label: format!("ppa:{ppa}"), }) } /// The subset of the Launchpad Archive API resource relevant for uploads #[derive(Debug, Deserialize)] pub struct PpaInfo { /// Display name of the archive (e.g. "Noctalia") pub displayname: String, /// The archive's self-description pub description: Option, /// Disabled archives accept no uploads; absent/null on many archives /// (treated as enabled) pub enabled: Option, } /// Look up the PPA `user/name` (same format as `pkh put --ppa`) in the /// Launchpad API, failing with a precise message when the account or the /// archive does not exist, or the archive is disabled. This is the /// pre-flight check the SFTP queue itself never does. pub async fn ppa_info(ppa: &str) -> Result> { let (user, name) = split_ppa(ppa)?; let client = crate::distro_info::http_client(); let response = client .get(person_url(&user)) .send() .await .map_err(|e| format!("cannot reach the Launchpad API: {e}"))?; if response.status() == reqwest::StatusCode::NOT_FOUND { return Err(format!( "Launchpad user '~{user}' does not exist: check the PPA argument '{ppa}'" ) .into()); } else if !response.status().is_success() { return Err(format!( "Launchpad API returned {} for user '~{user}'", response.status() ) .into()); } let response = client .get(archive_url(&user, &name)) .send() .await .map_err(|e| format!("cannot reach the Launchpad API: {e}"))?; match response.status() { reqwest::StatusCode::OK => { let info: PpaInfo = response .json() .await .map_err(|e| format!("cannot parse the Launchpad API response for '{ppa}': {e}"))?; if info.enabled == Some(false) { return Err( format!("PPA '{ppa}' is disabled: it exists but accepts no uploads").into(), ); } Ok(info) } 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()), } } /// 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 first page 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`). Further pages are reached through the answer's /// `next_collection_link`, not by hand-building URLs. fn published_sources_url(user: &str, ppa: &str, source_name: &str) -> String { format!( "{}?ws.op=getPublishedSources&source_name={}&exact_match=true&status=Published&ws.size={WS_PAGE_SIZE}", archive_url(user, ppa), percent_encode(source_name) ) } /// One page 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, } /// One page of the `getPublishedSources` collection answer #[derive(Debug, Deserialize)] struct PublishedSources { /// The currently published source packages matching the query, on this /// page only #[serde(default)] entries: Vec, /// URL of the next page, present only when the collection was /// truncated (Launchpad answers carry it as a plain JSON string) next_collection_link: Option, } /// Parse one page of a `getPublishedSources` collection into the versions /// it carries plus the link to the next page (`None` on the last one): the /// pagination decision, factored out of the HTTP walk so it can be tested /// without a server. fn parse_collection_page(body: &str) -> Result<(Vec, Option), serde_json::Error> { let sources: PublishedSources = serde_json::from_str(body)?; Ok(( sources .entries .into_iter() .map(|entry| entry.source_package_version) .collect(), sources.next_collection_link, )) } /// GET one page of a collection, mapping the API statuses to the same /// errors as the other Launchpad calls (404 means the PPA does not exist). /// Returns the response body for [`parse_collection_page`]. async fn fetch_collection_page( client: &reqwest::Client, url: &str, ppa: &str, ) -> Result> { let response = client .get(url) .send() .await .map_err(|e| format!("cannot reach the Launchpad API: {e}"))?; match response.status() { reqwest::StatusCode::OK => response .text() .await .map_err(|e| format!("cannot read the Launchpad API response for '{ppa}': {e}").into()), 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()), } } /// Walk a `getPublishedSources` collection page by page: fetch the first /// page, then follow `next_collection_link` (the canonical Launchpad /// pagination) until a page comes without one, accumulating the versions of /// every page in order. /// /// Exceeding [`MAX_COLLECTION_PAGES`] errors rather than returning the /// partial list: the result feeds `put`'s superseded-upload check, where a /// silently truncated list is exactly the bug pagination fixes — a /// superseded upload wrongly allowed through, to be rejected (or to /// silently supersede) in Launchpad's queue hours later. Every other /// failure mode of this check (network, HTTP status, parsing) aborts the /// upload too, and `put` fails before anything is written, so erring costs /// only a clear message. async fn walk_collection( client: &reqwest::Client, first_url: &str, ppa: &str, ) -> Result, Box> { let mut versions = Vec::new(); let mut url = first_url.to_string(); for _page in 1..=MAX_COLLECTION_PAGES { let body = fetch_collection_page(client, &url, ppa).await?; let (mut page_versions, next) = parse_collection_page(&body) .map_err(|e| format!("cannot parse the Launchpad API response for '{ppa}': {e}"))?; versions.append(&mut page_versions); match next { Some(next) => url = next, None => return Ok(versions), } } Err(format!( "the Launchpad API keeps paginating the published sources of '{ppa}' \ after {MAX_COLLECTION_PAGES} pages: cannot run the superseded check \ on a partial list" ) .into()) } /// 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. Launchpad truncates /// collections per page, so the walk follows the API's `next_collection_link` /// until the collection is exhausted: a single page would miss the highest /// version of a source published in many series/pockets over time, and the /// superseded check would wrongly pass. pub async fn published_versions( ppa: &str, source_name: &str, ) -> Result, Box> { let (user, name) = split_ppa(ppa)?; walk_collection( crate::distro_info::http_client(), &published_sources_url(&user, &name, source_name), ppa, ) .await } /// 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 pub async fn check_ppa_series(distribution: &str) -> Result<(), Box> { match crate::distro_info::get_dist_from_series(distribution).await { Ok(dist) if dist == "ubuntu" => Ok(()), Ok(dist) => Err(format!( "series '{distribution}' belongs to {dist}: PPA uploads target \ Ubuntu series only" ) .into()), Err(_) => Err(format!( "'{distribution}' is not a known distribution series: check the \ debian/changelog entry, the upload would be rejected" ) .into()), } } #[cfg(test)] mod tests { use super::*; #[test] fn ppa_target_expands_user_and_name() { let target = ppa_target("paultag/fluxbox").unwrap(); assert_eq!(target.fqdn, "ppa.launchpad.net"); assert_eq!(target.port, 22); assert_eq!(target.incoming, "~paultag/fluxbox"); assert_eq!(target.label, "ppa:paultag/fluxbox"); // No static login: the username is discovered per machine assert_eq!(target.login, None); } #[test] fn ppa_target_rejects_missing_separator() { assert!(ppa_target("just-a-name").is_err()); } #[test] fn ppa_target_rejects_extra_components() { assert!(ppa_target("user/ppa/extra").is_err()); } #[test] fn ppa_target_rejects_empty_components() { assert!(ppa_target("user/").is_err()); assert!(ppa_target("/ppa").is_err()); assert!(ppa_target("/").is_err()); } /// The `lp.user` key is read from the git configuration of the /// repository containing the working directory #[test] fn username_comes_from_repo_git_config() { let dir = tempfile::tempdir().unwrap(); let repo = git2::Repository::init(dir.path()).unwrap(); repo.config() .unwrap() .set_str(LP_USER_KEY, "vhaudiquet") .unwrap(); assert_eq!(username(dir.path()).as_deref(), Some("vhaudiquet")); } /// Values are trimmed, and an empty value counts as unset (it must not /// shadow a real lookup failure with a useless username) #[test] fn username_ignores_blank_values() { let dir = tempfile::tempdir().unwrap(); let repo = git2::Repository::init(dir.path()).unwrap(); repo.config().unwrap().set_str(LP_USER_KEY, " ").unwrap(); // Blank local value: the resolution keeps looking (and finds // nothing here unless a global lp.user exists — the assertion // accepts either "no value" or a real global value, never the // blank one) let found = username(dir.path()); assert_ne!(found.as_deref(), Some(" ")); let _ = found; } #[test] fn api_urls_match_launchpad_resources() { // Both URL shapes verified against the live API: 200 for an // existing account/archive, 404 for a missing one assert_eq!( person_url("vhaudiquet"), "https://api.launchpad.net/1.0/~vhaudiquet" ); assert_eq!( archive_url("vhaudiquet", "noctalia"), "https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia" ); } /// The API answer carries many unrelated fields; deserialization must /// pick the relevant ones and tolerate a null `enabled` #[test] fn ppa_info_parses_api_response() { let json = r#"{ "self_link": "https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia", "web_link": "https://launchpad.net/~vhaudiquet/+archive/ubuntu/noctalia", "displayname": "Noctalia", "description": "Noctalia PPA with experimental builds", "enabled": null, "official_bug_tags": ["a11y", "appstream"] }"#; let info: PpaInfo = serde_json::from_str(json).unwrap(); assert_eq!(info.displayname, "Noctalia"); assert_eq!( info.description.as_deref(), Some("Noctalia PPA with experimental builds") ); 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 and an explicit page size (the API /// default of 75 entries would hide part of long publishing histories) #[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&ws.size=100" ); 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&ws.size=100" ); } /// 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 (versions, next) = parse_collection_page(json).unwrap(); assert_eq!(versions, vec!["5.1.0-1ubuntu2", "2:1.0-1"]); // A page without a next link is the end of the collection assert_eq!(next, None); } /// 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 (versions, next) = parse_collection_page(r#"{"start": 0, "total_size": 0, "entries": []}"#).unwrap(); assert!(versions.is_empty()); assert_eq!(next, None); } /// A truncated page announces the next one through /// `next_collection_link`, carried as a plain JSON string (shape /// verified against the live API) #[test] fn parse_collection_page_reads_next_link() { let json = r#"{ "start": 0, "total_size": 150, "entries": [{"source_package_version": "1.0-1"}], "next_collection_link": "https://api.launchpad.net/1.0/~u/+archive/ubuntu/p?ws.op=getPublishedSources&ws.size=100&memo=100&ws.start=100" }"#; let (versions, next) = parse_collection_page(json).unwrap(); assert_eq!(versions, vec!["1.0-1"]); assert_eq!( next.as_deref(), Some( "https://api.launchpad.net/1.0/~u/+archive/ubuntu/p?ws.op=getPublishedSources&ws.size=100&memo=100&ws.start=100" ) ); } /// Serve canned byte responses on a local port, one per connection (the /// last response repeats), and return the listener for URL building /// /// The canned responses must use 'Connection: close' so the client opens /// a fresh connection (and receives a fresh response) per request. fn serve_responses(listener: std::net::TcpListener, responses: Vec) { use std::io::{Read, Write}; std::thread::spawn(move || { for (served, mut stream) in listener.incoming().flatten().enumerate() { let index = served.min(responses.len() - 1); // Drain the request first: closing with unread inbound data // would send a TCP RST and destroy the response in flight let mut buf = [0u8; 4096]; loop { match stream.read(&mut buf) { Ok(0) => break, Ok(n) if buf[..n].windows(4).any(|w| w == b"\r\n\r\n") => break, Ok(_) => continue, Err(_) => break, } } let body = &responses[index]; let _ = stream.write_all( format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ) .as_bytes(), ); let _ = stream.flush(); } }); } /// One `getPublishedSources` page carrying `versions`, with the /// `next_collection_link` of a truncated page when `next` is given fn collection_body(versions: &[&str], next: Option<&str>) -> String { let entries: Vec = versions .iter() .map(|v| format!(r#"{{"source_package_version": "{v}"}}"#)) .collect(); let next_field = next .map(|link| format!(r#", "next_collection_link": "{link}""#)) .unwrap_or_default(); format!( r#"{{"start": 0, "total_size": {}, "entries": [{}]{next_field}}}"#, versions.len(), entries.join(", ") ) } /// Bind a fresh mock server ready to serve `responses` (the caller /// needs the address to build self-referential `next_collection_link`s /// before serving starts) fn bound_collection_server() -> (std::net::TcpListener, String) { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); (listener, base) } /// The collection walk follows `next_collection_link`: the versions of /// every page are collected in order, and the walk stops on the page /// without a next link (the mock repeats its last response forever, so /// an extra fetch would still pass — but a missing next-link handling /// would drop page two's versions from the result) #[tokio::test] async fn walk_collection_collects_every_page() { let (listener, base) = bound_collection_server(); serve_responses( listener, vec![ collection_body(&["1.0-1", "1.6-1"], Some(&format!("{base}/next"))), collection_body(&["0.9-1"], None), ], ); let versions = walk_collection( crate::distro_info::http_client(), &format!("{base}/~u/+archive/ubuntu/p?ws.op=getPublishedSources"), "u/p", ) .await .unwrap(); assert_eq!(versions, vec!["1.0-1", "1.6-1", "0.9-1"]); } /// A next-link chain that never ends must error, not loop forever: the /// partial list would feed the superseded check a false "not superseded" #[tokio::test] async fn walk_collection_errors_when_pagination_never_ends() { // The last (only) response repeats forever, each page linking back // to the server: the walk must stop at the page cap by itself let (listener, base) = bound_collection_server(); serve_responses( listener, vec![collection_body(&["1.0-1"], Some(&format!("{base}/loop")))], ); let err = walk_collection( crate::distro_info::http_client(), &format!("{base}/~u/+archive/ubuntu/p?ws.op=getPublishedSources"), "u/p", ) .await .unwrap_err() .to_string(); assert!( err.contains("keeps paginating the published sources of 'u/p'"), "unexpected: {err}" ); } }