Files
pkh/src/launchpad.rs
T

651 lines
26 KiB
Rust

//! 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
//! (<https://launchpad.net/~/+editsshkeys>). 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<String> {
// 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<String> {
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<UploadTarget, String> {
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<String>,
/// Disabled archives accept no uploads; absent/null on many archives
/// (treated as enabled)
pub enabled: Option<bool>,
}
/// 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<PpaInfo, Box<dyn Error>> {
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<PublishedSource>,
/// 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<String>,
}
/// 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<String>, Option<String>), 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<String, Box<dyn Error>> {
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<Vec<String>, Box<dyn Error>> {
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<Vec<String>, Box<dyn Error>> {
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<dyn Error>> {
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<String>) {
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<String> = 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}"
);
}
}