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