651 lines
26 KiB
Rust
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}"
|
|
);
|
|
}
|
|
}
|