new: add pkh put, a native dput replacement for PPA uploads

Upload built source packages over SFTP with host-key verification
(Launchpad fingerprints pinned in host_keys.yml, ask-to-accept
otherwise), Launchpad account discovery (git config lp.user), and
pre-flight checks the upload queue itself never does: changes file
discovery/validation, PPA existence via the Launchpad API, target
series validity, and debian/control Section validity (sections
bundled in distro_info.yml). Upload log prevents duplicate uploads
unless --force.
This commit is contained in:
2026-09-16 21:38:53 +02:00
parent 9228ff448b
commit f27d27ea99
13 changed files with 2023 additions and 97 deletions
+281
View File
@@ -0,0 +1,281 @@
//! 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";
/// 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)
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()),
}
}
/// 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);
}
}