diff --git a/README.md b/README.md index 392fc8e..ff0d228 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Commands: chlog Auto-generate changelog entry, editing it, committing it afterwards build Build the source package (into a .dsc) deb Build the source package into binary package (.deb) + put Upload the built source package to a PPA help Print this message or the help of the given subcommand(s) ``` @@ -104,7 +105,7 @@ Missing features: - [ ] `pkh status` - [ ] Show build status - [ ] `pkh put` - - [ ] Upload the source package to a PPA + - [x] Upload the source package to a PPA (native SFTP, no `dput` dependency) - [ ] Upload the source package to the archive - [ ] `pkh commit` - [ ] Commit the changes to git diff --git a/distro_info.yml b/distro_info.yml index 50a044b..b448366 100644 --- a/distro_info.yml +++ b/distro_info.yml @@ -15,6 +15,70 @@ dist: - updates - security - proposed-updates + sections: + # Valid Section values for debian/control: the Debian policy section + # list unioned with the sections observed in the live Ubuntu archive. + # Archives reject uploads carrying an unknown section; only the part + # before a '/' (the subsection) is validated. + - admin + - cli-mono + - comm + - database + - debian-installer + - debug + - devel + - doc + - editors + - education + - electronics + - embedded + - fonts + - games + - gnome + - gnu-r + - golang + - graphics + - hamradio + - haskell + - httpd + - interpreters + - introspection + - java + - javascript + - kde + - kernel + - libdevel + - libs + - lisp + - localization + - mail + - math + - metapackages + - misc + - net + - news + - ocaml + - oldlibs + - otherosfs + - perl + - php + - python + - ruby + - rust + - science + - shells + - sound + - tasks + - tex + - text + - translations + - utils + - vcs + - video + - web + - x11 + - xfce + - zope series: local: /usr/share/distro-info/debian.csv network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/debian.csv @@ -25,6 +89,67 @@ dist: - updates - security - proposed + sections: + # Same list as debian (see the comment there) + - admin + - cli-mono + - comm + - database + - debian-installer + - debug + - devel + - doc + - editors + - education + - electronics + - embedded + - fonts + - games + - gnome + - gnu-r + - golang + - graphics + - hamradio + - haskell + - httpd + - interpreters + - introspection + - java + - javascript + - kde + - kernel + - libdevel + - libs + - lisp + - localization + - mail + - math + - metapackages + - misc + - net + - news + - ocaml + - oldlibs + - otherosfs + - perl + - php + - python + - ruby + - rust + - science + - shells + - sound + - tasks + - tex + - text + - translations + - utils + - vcs + - video + - web + - x11 + - xfce + - zope series: local: /usr/share/distro-info/ubuntu.csv network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/ubuntu.csv diff --git a/host_keys.yml b/host_keys.yml new file mode 100644 index 0000000..ee49039 --- /dev/null +++ b/host_keys.yml @@ -0,0 +1,19 @@ +## SSH host key fingerprints of known upload targets (`pkh put`). +## Like distro_info.yml, this file exists so that trust anchors are data, +## quickly updatable in one place, instead of hardcoded in the source. +## +## A server presenting a key whose fingerprint is listed for its host is +## verified without prompting. Fingerprints are the `SHA256:` values +## as displayed by ssh-keygen / pkh; an optional key type prefix (e.g. +## `ssh-rsa`) is tolerated as the first word of an entry. +## +## Source of the Launchpad fingerprints (published "as a stopgap measure +## until we have signed DNS records"): +## https://ubuntu.com/docs/launchpad/user/reference/ssh-fingerprints/ +## (formerly https://help.launchpad.net/SSHFingerprints) + +fingerprints: + ppa.launchpad.net: + - ssh-rsa SHA256:MGq+4hxD7RduVTcfwlwwboZnsgJC6SL/NltM8ye+gNg + upload.ubuntu.com: + - ssh-rsa SHA256:FN8sNU/MMmyvw/xtY5sAzkLGmkVQt2QpGZcwsHoBzjc diff --git a/src/distro_info.rs b/src/distro_info.rs index b0a6b20..3b73a4d 100644 --- a/src/distro_info.rs +++ b/src/distro_info.rs @@ -33,6 +33,8 @@ struct DistData { base_url: String, archive_keyring: String, pockets: Vec, + #[serde(default)] + sections: Vec, series: SeriesInfo, } @@ -316,6 +318,20 @@ pub fn get_dist_pockets(dist: &str) -> Result, Box> { Ok(pockets) } +/// Get the valid `Section` values of a distribution's packages, as accepted +/// by its archives (a `section/subsection` in debian/control validates on +/// the part before the '/') +pub fn get_sections(dist: &str) -> Result, Box> { + let dist_data = DATA.dist.get(dist).ok_or_else(|| { + format!( + "Unknown distribution '{}'. Supported distributions are: {}.", + dist, + supported_dists().join(", ") + ) + })?; + Ok(dist_data.sections.clone()) +} + /// Get the sources URL for a distribution, series, pocket, and component pub fn get_sources_url(base_url: &str, series: &str, pocket: &str, component: &str) -> String { let pocket_full = if pocket.is_empty() { @@ -487,6 +503,19 @@ pub async fn get_debian_series_number(series: &str) -> Result, Bo mod tests { use super::*; + #[test] + fn test_get_sections() { + // Both distributions bundle the policy section list + for dist in ["debian", "ubuntu"] { + let sections = get_sections(dist).unwrap(); + assert!(sections.contains(&"utils".to_string())); + assert!(sections.contains(&"devel".to_string())); + // 'unknown' is exactly what archives reject + assert!(!sections.contains(&"unknown".to_string())); + } + assert!(get_sections("not-a-distro").is_err()); + } + #[test] fn test_parse_series_csv_malformed_rows() { // A short row (missing 'codename') is skipped, a row with an invalid diff --git a/src/launchpad.rs b/src/launchpad.rs new file mode 100644 index 0000000..21fd242 --- /dev/null +++ b/src/launchpad.rs @@ -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 +//! (). 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 { + // 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) +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()), + } +} + +/// 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); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8c94ae6..a8326cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,8 @@ pub mod deb; pub mod debian; /// Obtain general information about distribution, series, etc pub mod distro_info; +/// Launchpad integration: PPA upload targets and account discovery +pub mod launchpad; /// Scaffold a new Debian source package (`pkh new`) pub mod new; /// Obtain information about one or multiple packages @@ -24,6 +26,8 @@ pub mod package_info; pub mod prune; /// Download a source package locally pub mod pull; +/// Upload a built source package to a PPA (or archive) +pub mod put; /// Handle package-specific quirks and workarounds pub mod quirks; diff --git a/src/main.rs b/src/main.rs index 990e08b..805682f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -164,6 +164,13 @@ fn main() { .about("Build the source package (into a .dsc)") .arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false)), ) + .subcommand( + Command::new("put") + .about("Upload the built source package to a PPA") + .arg(arg!(--ppa "Upload to a PPA (format: user/ppa_name)")) + .arg(arg!([changes] "Explicit .changes file to upload (default: the one built from this package, next to the source tree)").required(false)) + .arg(arg!(--force "Upload even if this exact .changes file was already uploaded to the target")), + ) .subcommand( Command::new("deb") .about("Build the source package into binary package (.deb)") @@ -470,6 +477,37 @@ fn main() { std::process::exit(1); } } + Some(("put", sub_matches)) => { + let cwd = current_dir_or_exit(); + let ppa = sub_matches.get_one::("ppa").map(|s| s.as_str()); + let changes = sub_matches + .get_one::("changes") + .map(std::path::PathBuf::from); + let force = sub_matches + .get_one::("force") + .copied() + .unwrap_or(false); + + // Only PPA targets are implemented for now + let Some(ppa) = ppa else { + error!( + "pkh put needs a target: pass --ppa user/ppa_name \ + (archive uploads are not supported yet)" + ); + std::process::exit(1); + }; + + let options = pkh::put::PutOptions { + ppa: ppa.to_string(), + changes, + force, + cwd, + }; + if let Err(e) = rt.block_on(async { pkh::put::put(&options, &multi).await }) { + error!("{}", e); + std::process::exit(1); + } + } Some(("deb", sub_matches)) => { let cwd = current_dir_or_exit(); let series = sub_matches.get_one::("series").map(|s| s.as_str()); diff --git a/src/put.rs b/src/put.rs deleted file mode 100644 index 38b78d9..0000000 --- a/src/put.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::path::Path; -use std::process::Command; -use crate::ProgressCallback; -use std::fs; -use pkh::package_info::parse_control_file; - -/// Execute the `put` subcommand to upload package to PPA or archive -/// -/// # Arguments -/// - series: Target distribution series (e.g. "focal") -/// - dist: Target distribution (e.g. "ubuntu") -/// - version: Package version override -/// - ppa: Target PPA in "user/ppa-name" format -/// - archive: Set to true for official archive uploads -/// - cwd: Current working directory containing source package -/// - progress: Progress callback for UI updates -pub async fn put( - series: Option<&str>, - dist: Option<&str>, - version: Option<&str>, - ppa: Option<&str>, - archive: bool, - cwd: Option<&Path>, - progress: ProgressCallback<'_>, -) -> Result<(), Box> { - let current_dir = cwd.unwrap_or_else(|| Path::new(".")); - let control_path = current_dir.join("debian/control"); - - let control_content = fs::read_to_string(&control_path).map_err(|e| { - format!("Failed to read debian/control: {}. Are you in a source package directory?", e) - })?; - - let package_info = parse_control_file(&control_content)?; - let package = package_info.source.ok_or("Could not determine package name from debian/control")?; - - if let Some(cb) = progress { - cb(&package, "Uploading package...", 0, 1); - } - - // Find .dsc file in current directory - let dsc_files: Vec<_> = current_dir.read_dir()? - .filter_map(|entry| { - let entry = entry.ok()?; - let path = entry.path(); - if path.extension()? == "dsc" { - Some(path) - } else { - None - } - }) - .collect(); - - let dsc_file = dsc_files.first().ok_or("No .dsc file found in current directory")?; - - if dsc_files.len() > 1 { - return Err("Multiple .dsc files found - please make sure only one exists".into()); - } - - if archive { - println!("Uploading {} to official archive", dsc_file.display()); - // Execute dput with official archive config - Command::new("dput") - .arg("ubuntu") - .arg(dsc_file) - .status()?; - } else if let Some(ppa) = ppa { - println!("Uploading {} to PPA: {}", dsc_file.display(), ppa); - // Execute dput with PPA target - Command::new("dput") - .arg(format!("ppa:{}", ppa)) - .arg(dsc_file) - .status()?; - } else { - return Err("Must specify either --ppa for PPA upload or --archive for official archive".into()); - } - - if let Some(cb) = progress { - cb(&package, "Upload complete", 1, 1); - } - - Ok(()) -} diff --git a/src/put/changes.rs b/src/put/changes.rs new file mode 100644 index 0000000..6a13fb1 --- /dev/null +++ b/src/put/changes.rs @@ -0,0 +1,380 @@ +//! `.changes` file parsing and pre-upload validation: signature presence, +//! field extraction, and verification that every file listed in the +//! checksums sections exists and matches on disk. +//! +//! Launchpad authenticates the upload through the GPG signature of the +//! `.changes` file, so an unsigned file is rejected before connecting, and +//! a file whose checksums do not match the artifacts next to it would only +//! fail server-side after a partial upload. + +use std::path::{Path, PathBuf}; + +use crate::debian::checksums::FileChecksums; +use crate::debian::control::{parse_paragraphs, strip_clearsigned_armour}; + +/// A file listed in a `.changes` checksums section. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileEntry { + /// File name, relative to the `.changes` file's directory. + pub name: String, + /// Expected size in bytes. + pub size: u64, + /// Expected SHA-256 digest (lowercase hex), from `Checksums-Sha256`. + pub sha256: Option, + /// Expected MD5 digest (lowercase hex), from `Files`. + pub md5: Option, +} + +impl FileEntry { + /// Whether `actual` matches every expected digest and the size. + fn matches(&self, actual: &crate::debian::checksums::Entry) -> bool { + if actual.size != self.size { + return false; + } + if let Some(sha256) = &self.sha256 + && actual.sha256 != *sha256 + { + return false; + } + if let Some(md5) = &self.md5 + && actual.md5 != *md5 + { + return false; + } + true + } +} + +/// Parsed `.changes` file, ready for validation and upload. +#[derive(Debug, Clone)] +pub struct ChangesFile { + /// Path of the `.changes` file itself. + pub path: PathBuf, + /// `Source` field. + pub source: String, + /// `Version` field. + pub version: String, + /// `Distribution` field. + pub distribution: String, + /// Files to upload, in listing order. The `.changes` file itself is not + /// part of this list: it is uploaded last, like dput does. + pub files: Vec, +} + +/// Parse a `.changes` file: extract the upload fields and the file list from +/// the checksums sections. Rejects unsigned files (Launchpad requires a +/// signature) and `UNRELEASED` entries (which are not uploadable). +pub fn parse(path: &Path) -> Result> { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("cannot read '{}': {}", path.display(), e))?; + + if !content.contains("-----BEGIN PGP SIGNATURE-----") { + return Err(format!( + "'{}' is not signed. Launchpad rejects unsigned uploads: \ + rebuild with `pkh build` (or sign with debsign) and retry", + path.display() + ) + .into()); + } + + // dpkg signs `.changes` either clearsigned (the whole document wrapped + // in a `-----BEGIN PGP SIGNED MESSAGE-----` armor, with the fields only + // starting after the armor headers) or with the signature block merely + // appended. Both need the armor out of the paragraph parse: + // [`strip_clearsigned_armour`] handles the wrapper and returns other + // input unchanged, the split drops an appended signature block. + let body = strip_clearsigned_armour( + content + .split("-----BEGIN PGP SIGNATURE-----") + .next() + .unwrap_or(&content), + ); + + let paragraphs = parse_paragraphs(body); + let fields = paragraphs + .first() + .ok_or_else(|| format!("'{}' contains no fields", path.display()))?; + + let missing = |field: &str| format!("'{}' has no {} field", path.display(), field); + + let source = fields.get("Source").ok_or_else(|| missing("Source"))?; + let version = fields.get("Version").ok_or_else(|| missing("Version"))?; + let distribution = fields + .get("Distribution") + .ok_or_else(|| missing("Distribution"))?; + + if distribution == "UNRELEASED" { + return Err(format!( + "'{}' targets UNRELEASED: pick a real series with `pkh chlog` and rebuild", + path.display() + ) + .into()); + } + + // `Checksums-Sha256` is the authoritative section; older files only + // carry the MD5 `Files` section + let mut files = parse_checksums(fields.get("Checksums-Sha256"), DigestKind::Sha256)?; + if files.is_empty() { + files = parse_checksums(fields.get("Files"), DigestKind::Md5)?; + } + if files.is_empty() { + return Err(format!( + "'{}' lists no files (no Checksums-Sha256 or Files section)", + path.display() + ) + .into()); + } + + Ok(ChangesFile { + path: path.to_path_buf(), + source: source.to_string(), + version: version.to_string(), + distribution: distribution.to_string(), + files, + }) +} + +/// Which digest the hash column of a `Checksums-*` / `Files` section holds +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DigestKind { + Sha256, + Md5, +} + +/// Parse a `Checksums-*` / `Files` field value: one line per file of the +/// form ` ` (leading whitespace is continuation-line +/// indentation). `digest` says which algorithm the hash column carries. +fn parse_checksums( + value: Option<&str>, + digest: DigestKind, +) -> Result, Box> { + let mut files = Vec::new(); + let Some(value) = value else { + return Ok(files); + }; + + for line in value.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() != 3 { + return Err(format!("malformed checksum line: '{line}'").into()); + } + + let hash = parts[0].to_lowercase(); + let size: u64 = parts[1] + .parse() + .map_err(|_| format!("malformed size '{}' in checksum line: '{line}'", parts[1]))?; + let name = parts[2].to_string(); + + let entry = FileEntry { + name, + size, + sha256: (digest == DigestKind::Sha256).then_some(hash.clone()), + md5: (digest == DigestKind::Md5).then_some(hash.clone()), + }; + files.push(entry); + } + + Ok(files) +} + +/// Verify every listed file: it must exist next to the `.changes` file and +/// match the expected size and checksums. +pub fn validate(changes: &ChangesFile) -> Result<(), Box> { + let dir = changes.path.parent().unwrap_or_else(|| Path::new(".")); + + for file in &changes.files { + let path = dir.join(&file.name); + + let mut checksums = FileChecksums::new(); + checksums + .add_file(&path) + .map_err(|e| format!("cannot upload '{}': {}", file.name, e))?; + let actual = checksums.get(&file.name).unwrap(); + + if !file.matches(actual) { + return Err(format!( + "'{}' does not match its checksums in '{}': \ + the file changed since the build (rebuild with `pkh build`)", + path.display(), + changes.path.display() + ) + .into()); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SIGNATURE: &str = "\ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEfakefingerprintXfakecommentlength +-----END PGP SIGNATURE-----"; + + /// Write a minimal valid changes file plus its two artifacts; returns + /// (changes path, dsc content, tarball content) + fn fixture(dir: &Path, distribution: &str) -> (PathBuf, Vec, Vec) { + let dsc = b"dsc content"; + let tarball = b"tarball content"; + + let mut cs = FileChecksums::new(); + let dsc_path = dir.join("hello_1.0-1.dsc"); + let tarball_path = dir.join("hello_1.0.orig.tar.gz"); + std::fs::write(&dsc_path, dsc).unwrap(); + std::fs::write(&tarball_path, tarball).unwrap(); + cs.add_file(&dsc_path).unwrap(); + cs.add_file(&tarball_path).unwrap(); + + let changes_path = dir.join("hello_1.0-1_source.changes"); + // dpkg's default signing layout: the whole document clearsigned, + // armor headers (`Hash:`) and a blank line before the fields + let mut content = format!( + "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\n\ + Format: 1.8\nSource: hello\nBinary: hello\nArchitecture: source\n\ + Version: 1.0-1\nDistribution: {distribution}\nUrgency: medium\n\ + Maintainer: A B \nChecksums-Sha256:{}\n", + // deb822 continuation lines are indented + cs.field_sha256().replace('\n', "\n ") + ); + content.push_str(SIGNATURE); + std::fs::write(&changes_path, content).unwrap(); + + (changes_path, dsc.to_vec(), tarball.to_vec()) + } + + #[test] + fn parse_and_validate_valid_changes() { + let dir = tempfile::tempdir().unwrap(); + let (path, _, _) = fixture(dir.path(), "resolute"); + + let changes = parse(&path).unwrap(); + assert_eq!(changes.source, "hello"); + assert_eq!(changes.version, "1.0-1"); + assert_eq!(changes.distribution, "resolute"); + assert_eq!(changes.files.len(), 2); + assert_eq!(changes.files[0].name, "hello_1.0-1.dsc"); + + validate(&changes).unwrap(); + } + + /// The other dpkg signing layout: plain deb822 content with the + /// signature block merely appended (no `PGP SIGNED MESSAGE` wrapper) + #[test] + fn parse_handles_appended_signature_layout() { + let dir = tempfile::tempdir().unwrap(); + let dsc = b"dsc content"; + std::fs::write(dir.path().join("hello_1.0-1.dsc"), dsc).unwrap(); + + let mut cs = FileChecksums::new(); + let dsc_path = dir.path().join("hello_1.0-1.dsc"); + cs.add_file(&dsc_path).unwrap(); + + let content = format!( + "Format: 1.8\nSource: hello\nVersion: 1.0-1\nDistribution: resolute\n\ + Checksums-Sha256:{}\n{}", + cs.field_sha256().replace('\n', "\n "), + SIGNATURE + ); + let path = dir.path().join("hello_1.0-1_source.changes"); + std::fs::write(&path, content).unwrap(); + + let changes = parse(&path).unwrap(); + assert_eq!(changes.source, "hello"); + validate(&changes).unwrap(); + } + + #[test] + fn parse_rejects_unsigned_changes() { + let dir = tempfile::tempdir().unwrap(); + let (path, _, _) = fixture(dir.path(), "resolute"); + let content = std::fs::read_to_string(&path).unwrap(); + let unsigned = content + .split("-----BEGIN PGP SIGNATURE-----") + .next() + .unwrap(); + std::fs::write(&path, unsigned).unwrap(); + + let err = parse(&path).unwrap_err().to_string(); + assert!(err.contains("not signed"), "unexpected error: {err}"); + } + + #[test] + fn parse_rejects_unreleased_distribution() { + let dir = tempfile::tempdir().unwrap(); + let (path, _, _) = fixture(dir.path(), "UNRELEASED"); + + let err = parse(&path).unwrap_err().to_string(); + assert!(err.contains("UNRELEASED"), "unexpected error: {err}"); + } + + #[test] + fn validate_rejects_corrupted_file() { + let dir = tempfile::tempdir().unwrap(); + let (path, _, _) = fixture(dir.path(), "resolute"); + std::fs::write(dir.path().join("hello_1.0-1.dsc"), b"tampered!").unwrap(); + + let changes = parse(&path).unwrap(); + let err = validate(&changes).unwrap_err().to_string(); + assert!( + err.contains("does not match its checksums"), + "unexpected: {err}" + ); + } + + #[test] + fn validate_rejects_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let (path, _, _) = fixture(dir.path(), "resolute"); + std::fs::remove_file(dir.path().join("hello_1.0-1.dsc")).unwrap(); + + let changes = parse(&path).unwrap(); + let err = validate(&changes).unwrap_err().to_string(); + assert!(err.contains("cannot upload"), "unexpected: {err}"); + } + + #[test] + fn parse_falls_back_to_md5_files_section() { + let dir = tempfile::tempdir().unwrap(); + let dsc = b"dsc content"; + std::fs::write(dir.path().join("hello_1.0-1.dsc"), dsc).unwrap(); + + let mut cs = FileChecksums::new(); + let dsc_path = dir.path().join("hello_1.0-1.dsc"); + cs.add_file(&dsc_path).unwrap(); + + let content = format!( + "Format: 1.8\nSource: hello\nVersion: 1.0-1\nDistribution: resolute\nFiles:{}\n{}", + cs.field_md5().replace('\n', "\n "), + SIGNATURE + ); + let path = dir.path().join("hello_1.0-1_source.changes"); + std::fs::write(&path, content).unwrap(); + + let changes = parse(&path).unwrap(); + assert_eq!(changes.files.len(), 1); + assert!(changes.files[0].sha256.is_none()); + assert!(changes.files[0].md5.is_some()); + + validate(&changes).unwrap(); + } + + #[test] + fn parse_checksums_rejects_malformed_lines() { + assert!(parse_checksums(Some("not-a-checksum-line"), DigestKind::Sha256).is_err()); + assert!(parse_checksums(Some("abc notanumber hello.dsc"), DigestKind::Sha256).is_err()); + assert!( + parse_checksums(None, DigestKind::Sha256) + .unwrap() + .is_empty() + ); + } +} diff --git a/src/put/mod.rs b/src/put/mod.rs new file mode 100644 index 0000000..994e419 --- /dev/null +++ b/src/put/mod.rs @@ -0,0 +1,487 @@ +//! Native upload of built source packages (`pkh put`): the dput +//! replacement. Resolves the upload target, discovers and validates the +//! `.changes` file and its artifacts, then pushes them over SFTP with +//! host-key verification and an upload record preventing accidental +//! duplicate uploads. +//! +//! Payload files are uploaded first and the `.changes` file last, like +//! dput does, so a partially uploaded set cannot be picked up by the +//! server-side queue processors. + +pub mod changes; +pub mod ssh; +pub mod target; + +use std::path::{Path, PathBuf}; + +use indicatif::{MultiProgress, ProgressBar}; +use log::info; +use serde::{Deserialize, Serialize}; + +use crate::debian::changelog::parse_changelog_entry; +use crate::debian::checksums::FileChecksums; +use crate::debian::control::ControlInfo; +use crate::launchpad; +use crate::ui; + +/// Everything `put` needs to run. +pub struct PutOptions { + /// PPA to upload to, `user/ppa_name` format. + pub ppa: String, + /// Explicit `.changes` file to upload; when `None`, the one matching the + /// current package (from `debian/changelog`) is discovered next to the + /// source tree. + pub changes: Option, + /// Upload even if this exact `.changes` file was already uploaded to the + /// target. + pub force: bool, + /// Source package directory (the one containing `debian/`). + pub cwd: PathBuf, +} + +/// Upload the package described by `opts` to its target through `multi`'s +/// progress bars. +pub async fn put( + opts: &PutOptions, + multi: &MultiProgress, +) -> Result<(), Box> { + let target = launchpad::ppa_target(&opts.ppa)?; + + let ssh_config = ssh::lookup_ssh_config(&target.fqdn); + let host = ssh_config + .host_name + .clone() + .unwrap_or_else(|| target.fqdn.clone()); + let port = ssh_config.port.unwrap_or(target.port); + // Launchpad's SFTP server requires a real Launchpad account name as the + // username: the git configuration (`lp.user`) first, then an SSH + // configuration `User`, then the local user name + let login = launchpad::username(&opts.cwd) + .or(ssh_config.user.clone()) + .or(target.login.clone()) + .or_else(|| std::env::var("USER").ok()) + .ok_or_else(|| { + format!( + "cannot determine the Launchpad username for {host}: set it \ + with `git config --global lp.user `, or \ + with a 'User' in the SSH configuration for {host}" + ) + })?; + + let changes_path = match &opts.changes { + Some(path) => path.clone(), + None => discover_changes(&opts.cwd)?, + }; + let changes = changes::parse(&changes_path)?; + info!( + "Uploading {} {} ({}) to {}", + changes.source, + changes.version, + ui::display_path(&changes_path), + target.label + ); + changes::validate(&changes)?; + + // Pre-flight checks for everything the upload queue only rejects after + // processing: a valid Section, a known target series, and the target + // PPA actually existing (the SFTP queue itself is a blind write) + check_control_section(&opts.cwd, "ubuntu")?; + info!("Checking {} on Launchpad...", target.label); + launchpad::ppa_info(&opts.ppa).await?; + launchpad::check_ppa_series(&changes.distribution).await?; + + let record = upload_record(&target, &changes_path)?; + if !opts.force + && let Some(previous) = find_previous_upload(&upload_log_path()?, &record)? + { + return Err(format!( + "'{}' was already uploaded to {} on {} (use --force to upload again)", + record.file, target.label, previous.date + ) + .into()); + } + + info!("Connecting to {login}@{host}:{port}..."); + let session = ssh::connect(&host, port, &login, &ssh_config)?; + let sftp = ssh::sftp(&session)?; + + // Payload first, the .changes file last (like dput), so the server-side + // queue processor can never pick up an incomplete upload + let dir = changes_path.parent().unwrap_or_else(|| Path::new(".")); + let mut uploads: Vec<(PathBuf, String)> = changes + .files + .iter() + .map(|f| (dir.join(&f.name), f.name.clone())) + .collect(); + let changes_name = changes_path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| format!("invalid .changes path: {}", changes_path.display()))? + .to_string(); + uploads.push((changes_path.clone(), changes_name.clone())); + + for (path, name) in &uploads { + let size = path + .metadata() + .map_err(|e| format!("cannot stat '{}': {}", path.display(), e))? + .len(); + // Same transfer view as pull: prefix line, bar on its own line + let bar = multi.add(ProgressBar::new(size)); + bar.enable_steady_tick(std::time::Duration::from_millis(50)); + bar.set_style(ui::transfer_style()); + bar.set_prefix(format!("Uploading {name}...")); + + let remote = format!("{}/{}", target.incoming.trim_end_matches('/'), name); + let result = ssh::upload_file(&sftp, path, &remote, &bar); + bar.finish_and_clear(); + result?; + } + + record_upload(&upload_log_path()?, &record)?; + + info!( + "Upload of {changes_name} to {} complete. Launchpad processes it asynchronously; \ + watch the PPA page or your inbox for acceptance/rejection", + target.label + ); + Ok(()) +} + +/// Validate the source package's `Section` (debian/control source stanza) +/// against the distribution's valid sections: a bare section or a +/// `section/subsection` is accepted. Archives reject uploads carrying an +/// unknown section during queue processing, so this fails before anything +/// is uploaded. +fn check_control_section(cwd: &Path, dist: &str) -> Result<(), Box> { + let control_path = cwd.join("debian/control"); + let control = ControlInfo::parse(&control_path)?; + let section = control.section(); + + if section == "-" { + return Err(format!( + "'{}' has no Section field: add one (e.g. utils, devel, net), \ + the upload would be rejected otherwise", + control_path.display() + ) + .into()); + } + + let base = section.split('/').next().unwrap_or(section); + if crate::distro_info::get_sections(dist)? + .iter() + .any(|valid| valid == base) + { + return Ok(()); + } + + Err(format!( + "Invalid section '{section}' in '{}': {dist} rejects uploads with \ + unknown sections. Pick a valid one in debian/control (e.g. utils, \ + devel, net, graphics, sound...)", + control_path.display() + ) + .into()) +} + +/// Find the `.changes` file of the package in `cwd`: the exact +/// `__source.changes` written by `pkh build` (in the source +/// directory's parent or the directory itself), or the only other +/// `_*.changes` present. Errors listing the candidates when several +/// exist. +fn discover_changes(cwd: &Path) -> Result> { + let entry = parse_changelog_entry(&cwd.join("debian/changelog"))?; + let sversion = entry.version.no_epoch(); + let expected_name = format!("{}_{}_source.changes", entry.source, sversion); + + let mut search_dirs: Vec = Vec::new(); + if let Some(parent) = cwd.parent() { + search_dirs.push(parent.to_path_buf()); + } + search_dirs.push(cwd.to_path_buf()); + + for dir in &search_dirs { + let candidate = dir.join(&expected_name); + if candidate.is_file() { + return Ok(candidate); + } + } + + // The exact build output is gone: accept any other single .changes of + // this package, preferring source uploads + let prefix = format!("{}_", entry.source); + let mut candidates: Vec = Vec::new(); + for dir in &search_dirs { + if let Ok(entries) = dir.read_dir() { + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if name.starts_with(&prefix) && name.ends_with(".changes") && path.is_file() { + candidates.push(path); + } + } + } + } + + let source_only: Vec<&PathBuf> = candidates + .iter() + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.ends_with("_source.changes")) + }) + .collect(); + + if let [only] = source_only.as_slice() { + log::warn!( + "'{expected_name}' not found, uploading the other build output {}", + only.display() + ); + return Ok((*only).clone()); + } + + match candidates.as_slice() { + [only] => { + log::warn!( + "'{expected_name}' not found, uploading the other build output {}", + only.display() + ); + Ok(only.clone()) + } + [] => Err(format!( + "no .changes file for package '{}' found (looked in the package \ + directory and its parent): build one with `pkh build` first", + entry.source + ) + .into()), + many => Err(format!( + "multiple .changes files found for package '{}': {}. \ + Pass the one to upload explicitly", + entry.source, + many.iter() + .map(|p| ui::display_path(p)) + .collect::>() + .join(", ") + ) + .into()), + } +} + +/// One recorded upload: target, `.changes` name, its SHA-256 digest and the +/// upload date. Matching on the digest makes `--force` the only way to +/// re-upload an identical file, while a rebuilt file (new digest) never +/// trips the guard. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct UploadRecord { + target: String, + file: String, + sha256: String, + date: String, +} + +/// Build the upload record for a `.changes` file, hashing its content +fn upload_record( + target: &target::UploadTarget, + changes: &Path, +) -> Result> { + let mut checksums = FileChecksums::new(); + checksums.add_file(changes)?; + let entry = checksums + .get(changes.file_name().and_then(|n| n.to_str()).unwrap_or("")) + .ok_or_else(|| format!("cannot hash '{}'", changes.display()))?; + + Ok(UploadRecord { + target: target.label.clone(), + file: changes + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_string(), + sha256: entry.sha256.clone(), + date: chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(), + }) +} + +/// Path of the upload log (`/pkh/uploads.json`) +fn upload_log_path() -> Result> { + let dirs = directories::ProjectDirs::from("com", "pkh", "pkh") + .ok_or("cannot determine the pkh data directory")?; + let path = dirs.data_dir().join("uploads.json"); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + Ok(path) +} + +/// Load the upload log, empty when the file does not exist yet +fn load_upload_log(path: &Path) -> Vec { + std::fs::read_to_string(path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + .unwrap_or_default() +} + +/// The previous upload of `record` from the log at `log`, if any (same +/// target, file and content) +fn find_previous_upload( + log: &Path, + record: &UploadRecord, +) -> Result, Box> { + Ok(load_upload_log(log) + .into_iter() + .find(|r| r.target == record.target && r.file == record.file && r.sha256 == record.sha256)) +} + +/// Append `record` to the upload log at `log` +fn record_upload(log: &Path, record: &UploadRecord) -> Result<(), Box> { + let mut entries = load_upload_log(log); + entries.push(record.clone()); + std::fs::write(log, serde_json::to_string_pretty(&entries)?)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Changelog of a package `hello` at version `1.0-1` + fn changelog_fixture(dir: &Path) { + std::fs::create_dir_all(dir.join("debian")).unwrap(); + std::fs::write( + dir.join("debian/changelog"), + "hello (1.0-1) resolute; urgency=medium\n\n * Something.\n\n \ + -- A B Mon, 01 Sep 2025 10:00:00 +0000\n", + ) + .unwrap(); + } + + #[test] + fn discover_finds_exact_build_output_in_parent() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("hello"); + changelog_fixture(&pkg); + std::fs::write(dir.path().join("hello_1.0-1_source.changes"), b"changes").unwrap(); + + let found = discover_changes(&pkg).unwrap(); + assert_eq!(found, dir.path().join("hello_1.0-1_source.changes")); + } + + #[test] + fn discover_finds_exact_build_output_in_cwd() { + let dir = tempfile::tempdir().unwrap(); + changelog_fixture(dir.path()); + std::fs::write(dir.path().join("hello_1.0-1_source.changes"), b"changes").unwrap(); + + let found = discover_changes(dir.path()).unwrap(); + assert_eq!(found, dir.path().join("hello_1.0-1_source.changes")); + } + + #[test] + fn discover_prefers_source_changes_among_several() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("hello"); + changelog_fixture(&pkg); + // A stale exact match does not exist; a source changes and a binary + // changes are present + std::fs::write(dir.path().join("hello_1.0-1_source.changes"), b"changes").unwrap(); + std::fs::write(dir.path().join("hello_1.0-1_amd64.changes"), b"binary").unwrap(); + + let found = discover_changes(&pkg).unwrap(); + assert_eq!(found, dir.path().join("hello_1.0-1_source.changes")); + } + + #[test] + fn discover_errors_when_nothing_matches() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("hello"); + changelog_fixture(&pkg); + + let err = discover_changes(&pkg).unwrap_err().to_string(); + assert!(err.contains("no .changes file"), "unexpected: {err}"); + } + + /// debian/control's Section must be a known distribution section; the + /// `section/subsection` form validates on the part before the '/' + #[test] + fn control_section_check() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("debian")).unwrap(); + let control = dir.path().join("debian/control"); + + std::fs::write(&control, "Source: hello\nSection: utils\n").unwrap(); + assert!(check_control_section(dir.path(), "ubuntu").is_ok()); + + std::fs::write(&control, "Source: hello\nSection: devel/i386\n").unwrap(); + assert!(check_control_section(dir.path(), "ubuntu").is_ok()); + + std::fs::write(&control, "Source: hello\nSection: unknown\n").unwrap(); + let err = check_control_section(dir.path(), "ubuntu") + .unwrap_err() + .to_string(); + assert!( + err.contains("Invalid section 'unknown'"), + "unexpected: {err}" + ); + + std::fs::write(&control, "Source: hello\n").unwrap(); + let err = check_control_section(dir.path(), "ubuntu") + .unwrap_err() + .to_string(); + assert!(err.contains("has no Section"), "unexpected: {err}"); + } + + #[test] + fn discover_errors_when_several_candidates() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("hello"); + changelog_fixture(&pkg); + std::fs::write(dir.path().join("hello_1.0-1_amd64.changes"), b"binary").unwrap(); + std::fs::write(dir.path().join("hello_1.0-2_amd64.changes"), b"binary2").unwrap(); + + let err = discover_changes(&pkg).unwrap_err().to_string(); + assert!(err.contains("multiple .changes"), "unexpected: {err}"); + } + + #[test] + fn upload_record_is_stable_and_content_hashed() { + let dir = tempfile::tempdir().unwrap(); + let changes = dir.path().join("hello_1.0-1_source.changes"); + std::fs::write(&changes, b"changes content").unwrap(); + + let target = launchpad::ppa_target("user/ppa").unwrap(); + let record = upload_record(&target, &changes).unwrap(); + assert_eq!(record.target, "ppa:user/ppa"); + assert_eq!(record.file, "hello_1.0-1_source.changes"); + // sha256("changes content") + assert_eq!( + record.sha256, + "104a1c78c7f8e6d28da700ac5eed27fd9cdace4a5d9b3caeeb08ab7558ece6b0" + ); + } + + #[test] + fn upload_log_round_trip_and_dedup() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("uploads.json"); + + let record = UploadRecord { + target: "ppa:user/ppa".to_string(), + file: "hello_1.0-1_source.changes".to_string(), + sha256: "abc".to_string(), + date: "2025-09-16 12:00".to_string(), + }; + assert!(find_previous_upload(&log, &record).unwrap().is_none()); + + record_upload(&log, &record).unwrap(); + record_upload(&log, &record).unwrap(); + + let previous = find_previous_upload(&log, &record).unwrap().unwrap(); + assert_eq!(previous.date, record.date); + + // A rebuilt file (different digest) is not a duplicate + let mut rebuilt = record.clone(); + rebuilt.sha256 = "def".to_string(); + assert!(find_previous_upload(&log, &rebuilt).unwrap().is_none()); + } +} diff --git a/src/put/ssh.rs b/src/put/ssh.rs new file mode 100644 index 0000000..625c812 --- /dev/null +++ b/src/put/ssh.rs @@ -0,0 +1,611 @@ +//! SSH/SFTP transport for uploads: SSH configuration lookup, connection +//! with host-key verification — Launchpad's published fingerprints are +//! pinned (no prompt on first use), other hosts fall back to +//! `~/.ssh/known_hosts` with an ask-to-accept for unknown keys —, +//! authentication (every ssh-agent identity first, then configured and +//! default key files) and chunked SFTP upload with progress reporting. +//! +//! This replaces dput-ng's paramiko transport with the `ssh2` (libssh2) +//! stack the rest of pkh already uses for build contexts. + +use std::collections::HashMap; +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; + +use indicatif::ProgressBar; +use lazy_static::lazy_static; +use log::debug; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, Session}; + +use crate::ui::prompt; + +const HOST_KEYS_YAML: &str = include_str!("../../host_keys.yml"); + +/// Pinned SSH host key fingerprints, loaded from the bundled +/// `host_keys.yml` data file (same pattern as `distro_info.yml`): data +/// rather than code, so trust anchors are updatable without touching the +/// source. +#[derive(Debug, Deserialize)] +struct PinnedHostKeys { + /// Host name → list of accepted `SHA256:` fingerprints; an + /// optional key type prefix (`ssh-rsa SHA256:...`) is tolerated. + fingerprints: HashMap>, +} + +lazy_static! { + // The YAML is include_str!'d at compile time and statically valid; if it + // ever failed to parse it would be a build-time bug that cannot be + // recovered from at runtime, so panicking here is acceptable. + static ref PINNED_HOST_KEYS: PinnedHostKeys = serde_yaml::from_str(HOST_KEYS_YAML) + .expect("built-in host_keys.yml data is statically valid and must parse"); +} + +/// Whether `fingerprint` is one of the pinned (published) fingerprints of +/// `host`, in which case the server is trusted without prompting +fn host_key_is_pinned(host: &str, fingerprint: &str) -> bool { + PINNED_HOST_KEYS.fingerprints.get(host).is_some_and(|pins| { + pins.iter() + .any(|pin| pin.split_whitespace().any(|token| token == fingerprint)) + }) +} + +/// Per-host settings extracted from the SSH configuration files +/// (`/etc/ssh/ssh_config`, `~/.ssh/config`). Every option keeps the first +/// value obtained from the matching `Host` blocks, like OpenSSH. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SshConfig { + /// `HostName`: real host behind a configured alias. + pub host_name: Option, + /// `User`: overrides the target's default login. + pub user: Option, + /// `Port`: overrides the target's default port. + pub port: Option, + /// `IdentityFile`s to try for authentication, in declaration order. + pub identity_files: Vec, +} + +/// Look up `host` in the system and user SSH configuration files. Missing +/// files yield an empty configuration. +pub fn lookup_ssh_config(host: &str) -> SshConfig { + let mut config = SshConfig::default(); + let mut files = vec![PathBuf::from("/etc/ssh/ssh_config")]; + if let Some(home) = std::env::var_os("HOME") { + files.push(Path::new(&home).join(".ssh/config")); + } + for file in files { + if let Ok(content) = fs::read_to_string(&file) { + apply_config_file(&mut config, &content, host); + } + } + config +} + +/// Fold one configuration file's matching `Host` blocks into `config`, +/// first obtained value wins per option. +fn apply_config_file(config: &mut SshConfig, content: &str, host: &str) { + let mut matching = false; + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let (keyword, rest) = match line.split_once(['=', ' ']) { + Some((k, r)) => (k.trim_end_matches('='), r.trim()), + None => (line, ""), + }; + + if keyword.eq_ignore_ascii_case("host") { + matching = rest + .split_whitespace() + .any(|pattern| match_pattern(host, pattern)); + continue; + } + + if !matching { + continue; + } + + if keyword.eq_ignore_ascii_case("hostname") && config.host_name.is_none() { + config.host_name = Some(rest.to_string()); + } else if keyword.eq_ignore_ascii_case("user") && config.user.is_none() { + config.user = Some(rest.to_string()); + } else if keyword.eq_ignore_ascii_case("port") && config.port.is_none() { + if let Ok(port) = rest.parse() { + config.port = Some(port); + } + } else if keyword.eq_ignore_ascii_case("identityfile") { + let path = PathBuf::from(rest); + if !config.identity_files.contains(&path) { + config.identity_files.push(path); + } + } + } +} + +/// One `Host` block pattern against a host name: exact match, `*` (any +/// run), `?` (one character) and `!pattern` negation (a matching negated +/// pattern excludes the host from the block). +fn match_pattern(host: &str, pattern: &str) -> bool { + if let Some(negated) = pattern.strip_prefix('!') { + !match_pattern(host, negated) + } else { + wildmatch(host, pattern) + } +} + +/// `*`/`?` glob matching without allocation, enough for `Host` patterns +fn wildmatch(text: &str, pattern: &str) -> bool { + let text: Vec = text.chars().collect(); + let pattern: Vec = pattern.chars().collect(); + + // Greedy backtracking over the pattern's last '*' position + let (mut t, mut p) = (0usize, 0usize); + let (mut star, mut mark) = (None::, 0usize); + + while t < text.len() { + if p < pattern.len() && (pattern[p] == '?' || pattern[p] == text[t]) { + t += 1; + p += 1; + } else if p < pattern.len() && pattern[p] == '*' { + star = Some(p); + mark = t; + p += 1; + } else if let Some(s) = star { + p = s + 1; + mark += 1; + t = mark; + } else { + return false; + } + } + + pattern[p..].iter().all(|&c| c == '*') +} + +/// Connect to `host:port`, verify the server host key and authenticate as +/// `login`: every ssh-agent identity first, then the configured and default +/// identity files. +pub fn connect( + host: &str, + port: u16, + login: &str, + config: &SshConfig, +) -> Result> { + let tcp = TcpStream::connect((host, port)) + .map_err(|e| format!("cannot connect to {host}:{port}: {e}"))?; + + let mut session = Session::new()?; + session.set_tcp_stream(tcp); + session + .handshake() + .map_err(|e| format!("SSH handshake with {host} failed: {e}"))?; + + let (key, key_type) = session + .host_key() + .ok_or_else(|| format!("{host} offered no host key"))?; + verify_host_key(host, port, key, key_type)?; + + authenticate(&session, host, login, config)?; + + Ok(session) +} + +/// Check the server's host key, in decreasing order of trust: +/// +/// 1. matching one of the host's pinned (published, `host_keys.yml`) +/// fingerprints — accepted silently; +/// 2. matching `~/.ssh/known_hosts` — accepted; +/// 3. known-and-different — refused loudly (possible man-in-the-middle); +/// 4. unknown — fingerprint shown, explicit confirmation required, and on +/// acceptance the key is appended to the user's known hosts file +/// (dput's "ask to accept" policy). +fn verify_host_key( + host: &str, + port: u16, + key: &[u8], + key_type: HostKeyType, +) -> Result<(), Box> { + let fingerprint = fingerprint(key); + + if host_key_is_pinned(host, &fingerprint) { + debug!("{host} host key matches the published fingerprint"); + return Ok(()); + } + + let mut known_hosts = Session::new()?.known_hosts()?; + for file in known_hosts_files() { + // Unreadable/unknown-format lines are skipped by libssh2; a missing + // file is fine + let _ = known_hosts.read_file(&file, KnownHostFileKind::OpenSSH); + } + + let check = if port == 22 { + known_hosts.check(host, key) + } else { + known_hosts.check_port(host, port, key) + }; + + match check { + CheckResult::Match => Ok(()), + CheckResult::Mismatch => Err(format!( + "Host key verification failed for {host}: the server now presents a \ + DIFFERENT key than the one recorded in your known_hosts files. \ + This could be a man-in-the-middle attack, or the server was \ + re-keyed. If you are sure it was re-keyed, remove the '{host}' \ + line(s) from ~/.ssh/known_hosts and retry" + ) + .into()), + CheckResult::NotFound | CheckResult::Failure => { + let key_type_desc = key_type_name(key_type).unwrap_or("Host"); + let display = if port == 22 { + host.to_string() + } else { + format!("[{host}]:{port}") + }; + + // The banner is plain output: the confirmation prompt itself + // must stay a single line for its redraw logic + println!("The authenticity of host '{display}' can't be established."); + println!("{key_type_desc} key fingerprint is {fingerprint}."); + let accepted = prompt::confirm("Accept and store this host key?", false)?; + if !accepted { + return Err(format!("Host key for {display} rejected, aborting upload").into()); + } + if let Some(name) = key_type_name(key_type) { + append_known_hosts_line(&format!("{display} {name} {}", base64_nopad(key)))?; + } else { + log::warn!("Unknown host key type: accepted for this session, not stored"); + } + Ok(()) + } + } +} + +/// Known hosts files to consult, user file first (so that new keys are +/// accepted because of the user file, mirroring ssh's own ordering) +fn known_hosts_files() -> Vec { + let mut files = Vec::new(); + if let Some(home) = std::env::var_os("HOME") { + files.push(Path::new(&home).join(".ssh/known_hosts")); + } + files.push(PathBuf::from("/etc/ssh/ssh_known_hosts")); + files +} + +/// Append one line to `~/.ssh/known_hosts` (creating the file), in the +/// plain OpenSSH `host keytype base64key` format +fn append_known_hosts_line(line: &str) -> Result<(), Box> { + let Some(home) = std::env::var_os("HOME") else { + return Err("cannot record the host key: HOME is not set".into()); + }; + let path = Path::new(&home).join(".ssh/known_hosts"); + fs::create_dir_all(path.parent().unwrap())?; + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path)? + .write_all(format!("{line}\n").as_bytes()) + .map_err(|e| format!("cannot write to {}: {e}", path.display()))?; + debug!("Recorded host key line in {}", path.display()); + Ok(()) +} + +/// Authenticate as `login`: every ssh-agent identity first (Launchpad +/// identifies uploaders by their registered key, which may be any entry of +/// the agent, not just the first), then identity files from the SSH +/// configuration and the usual `~/.ssh/id_*` defaults (passphrase-less +/// only — agent-loaded keys cover the protected ones). +fn authenticate( + session: &Session, + host: &str, + login: &str, + config: &SshConfig, +) -> Result<(), Box> { + // Try every identity the agent offers, not just the first: the + // Launchpad-registered key is not necessarily the agent's default + let mut agent_identities: Option = None; + if let Ok(mut agent) = session.agent() + && agent.connect().is_ok() + && agent.list_identities().is_ok() + { + match agent.identities() { + Ok(identities) => { + let count = identities.len(); + agent_identities = Some(count); + for identity in &identities { + if agent.userauth(login, identity).is_ok() && session.authenticated() { + debug!("authenticated through the ssh-agent"); + return Ok(()); + } + } + debug!("none of the {count} agent identity(ies) authenticated"); + } + Err(e) => debug!("cannot list agent identities: {e}"), + } + } + + let mut identity_files: Vec = config.identity_files.clone(); + if let Some(home) = std::env::var_os("HOME") { + for name in ["id_ed25519", "id_ecdsa", "id_rsa"] { + let key = Path::new(&home).join(".ssh").join(name); + if !identity_files.contains(&key) { + identity_files.push(key); + } + } + } + + let mut tried = Vec::new(); + for key in &identity_files { + if !key.is_file() { + continue; + } + match session.userauth_pubkey_file(login, None, key, None) { + Ok(()) => return Ok(()), + Err(e) => tried.push(format!("{} ({e})", key.display())), + } + } + + if session.authenticated() { + return Ok(()); + } + + let agent_desc = match agent_identities { + Some(count) => format!("{count} ssh-agent identity(ies)"), + None => "no ssh-agent".to_string(), + }; + Err(format!( + "SSH authentication failed for {login}@{host}: tried {agent_desc} \ + and the identity files [{}]. Launchpad authenticates the '{login}' \ + account with the SSH keys registered on it \ + (https://launchpad.net/~/+editsshkeys): make sure such a key is \ + available — load it with `ssh-add ` or point pkh at it with a \ + 'Host {host}' / 'IdentityFile' entry in ~/.ssh/config \ + (passphrase-protected key files are only usable through the agent) \ + — and check the account name with `git config lp.user`", + tried.join(", ") + ) + .into()) +} + +/// Upload `local` to the remote SFTP `path`, updating `bar` per chunk. The +/// remote file is created/truncated; Launchpad's upload queue is +/// write-only, so failures here mean the upload failed — there is nothing +/// to inspect server-side. +pub fn upload_file( + sftp: &ssh2::Sftp, + local: &Path, + remote: &str, + bar: &ProgressBar, +) -> Result<(), Box> { + let mut local_file = + fs::File::open(local).map_err(|e| format!("cannot open '{}': {}", local.display(), e))?; + + let mut remote_file = sftp + .create(Path::new(remote)) + .map_err(|e| format!("cannot create remote file {remote}: {e}"))?; + + let mut buf = [0u8; 32 * 1024]; + loop { + let n = local_file.read(&mut buf)?; + if n == 0 { + break; + } + remote_file + .write_all(&buf[..n]) + .map_err(|e| format!("failed uploading to {remote}: {e}"))?; + bar.inc(n as u64); + } + + Ok(()) +} + +/// Open the SFTP subsystem on `session` +pub fn sftp(session: &Session) -> Result> { + session + .sftp() + .map_err(|e| format!("cannot open the SFTP subsystem: {e}").into()) +} + +/// ssh-keygen-style `SHA256:` fingerprint of a raw host key +fn fingerprint(key: &[u8]) -> String { + let digest = Sha256::digest(key); + format!("SHA256:{}", base64_nopad(&digest)) +} + +/// OpenSSH name of a host key type, `None` when it cannot be named (and +/// thus not recorded in a known hosts file) +fn key_type_name(key_type: HostKeyType) -> Option<&'static str> { + match key_type { + HostKeyType::Rsa => Some("ssh-rsa"), + HostKeyType::Dss => Some("ssh-dss"), + HostKeyType::Ecdsa256 => Some("ecdsa-sha2-nistp256"), + HostKeyType::Ecdsa384 => Some("ecdsa-sha2-nistp384"), + HostKeyType::Ecdsa521 => Some("ecdsa-sha2-nistp521"), + HostKeyType::Ed25519 => Some("ssh-ed25519"), + HostKeyType::Unknown => None, + } +} + +/// Standard-alphabet base64 without padding (the encoding used by SSH +/// fingerprints and known_hosts key fields) +fn base64_nopad(data: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + + for chunk in data.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = chunk.get(1).copied().unwrap_or(0) as u32; + let b2 = chunk.get(2).copied().unwrap_or(0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + + out.push(TABLE[(n >> 18) as usize & 63] as char); + out.push(TABLE[(n >> 12) as usize & 63] as char); + if chunk.len() > 1 { + out.push(TABLE[(n >> 6) as usize & 63] as char); + } + if chunk.len() > 2 { + out.push(TABLE[n as usize & 63] as char); + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_nopad_rfc4648_vectors() { + assert_eq!(base64_nopad(b""), ""); + assert_eq!(base64_nopad(b"f"), "Zg"); + assert_eq!(base64_nopad(b"fo"), "Zm8"); + assert_eq!(base64_nopad(b"foo"), "Zm9v"); + assert_eq!(base64_nopad(b"foob"), "Zm9vYg"); + assert_eq!(base64_nopad(b"fooba"), "Zm9vYmE"); + assert_eq!(base64_nopad(b"foobar"), "Zm9vYmFy"); + } + + /// The known_hosts encoding of a key matches what ssh-keyscan writes: + /// unpadded base64 of the raw wire-format key + #[test] + fn base64_nopad_matches_known_hosts_encoding() { + // echo -n hello | sha256sum → + let digest = Sha256::digest(b"hello"); + assert_eq!( + fingerprint(b"hello"), + format!("SHA256:{}", base64_nopad(&digest)) + ); + // Verified with: printf 'hello' | sha256sum | xxd -r -p | base64 | tr -d '=' + assert_eq!( + base64_nopad(&digest), + "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ" + ); + } + + #[test] + fn wildmatch_handles_exact_and_wildcards() { + assert!(wildmatch("ppa.launchpad.net", "ppa.launchpad.net")); + assert!(wildmatch("ppa.launchpad.net", "*.launchpad.net")); + assert!(wildmatch("ppa.launchpad.net", "*")); + assert!(wildmatch("host1", "host?")); + assert!(!wildmatch("ppa.launchpad.net", "*.debian.org")); + assert!(!wildmatch("host12", "host?")); + } + + /// Launchpad's published fingerprints (from the bundled host_keys.yml) + /// are pinned for its hosts, so the common upload never prompts. The + /// published entries carry an `ssh-rsa` key type prefix, which must not + /// prevent matching. + #[test] + fn launchpad_hosts_are_pinned() { + assert!(host_key_is_pinned( + "ppa.launchpad.net", + "SHA256:MGq+4hxD7RduVTcfwlwwboZnsgJC6SL/NltM8ye+gNg" + )); + assert!(host_key_is_pinned( + "upload.ubuntu.com", + "SHA256:FN8sNU/MMmyvw/xtY5sAzkLGmkVQt2QpGZcwsHoBzjc" + )); + // A different key is not pinned, even for a pinned host... + assert!(!host_key_is_pinned( + "ppa.launchpad.net", + "SHA256:totally-different-key-fingerprint" + )); + // ...and other hosts have no pins: they fall back to known_hosts + // + prompt + assert!(!host_key_is_pinned( + "example.com", + "SHA256:MGq+4hxD7RduVTcfwlwwboZnsgJC6SL/NltM8ye+gNg" + )); + } + + #[test] + fn pattern_negation_excludes_host() { + assert!(!match_pattern("ppa.launchpad.net", "!*.launchpad.net")); + // A negated pattern only excludes; the other patterns of the block + // decide separately + assert!(match_pattern("example.com", "!*.launchpad.net example.com")); + assert!(!match_pattern( + "ppa.launchpad.net", + "example.com !*.launchpad.net" + )); + } + + #[test] + fn config_first_obtained_value_wins() { + // Like real configurations, the catch-all `Host *` block comes last: + // values from earlier matching blocks win, IdentityFile accumulates + let config = apply_config_file_all( + "\ +Host ppa.launchpad.net + User myuser + IdentityFile ~/.ssh/lp_key + +Host * + User fallback + ServerAliveInterval 30 + IdentityFile ~/.ssh/other_key +", + "ppa.launchpad.net", + ); + + assert_eq!(config.user.as_deref(), Some("myuser")); + assert_eq!( + config.identity_files, + vec![ + PathBuf::from("~/.ssh/lp_key"), + PathBuf::from("~/.ssh/other_key") + ] + ); + assert_eq!(config.host_name, None); + assert_eq!(config.port, None); + } + + #[test] + fn config_wildcard_and_question_marks_match() { + let config = apply_config_file_all( + "\ +Host *.launchpad.net + HostName launchpad-real.example.com + Port 2222 +", + "ppa.launchpad.net", + ); + + assert_eq!( + config.host_name.as_deref(), + Some("launchpad-real.example.com") + ); + assert_eq!(config.port, Some(2222)); + } + + #[test] + fn config_ignores_unrelated_blocks_and_comments() { + let config = apply_config_file_all( + "\ +# a comment +Host github.com + User git +Host other + User nope +", + "ppa.launchpad.net", + ); + + assert_eq!(config, SshConfig::default()); + } + + /// Parse helper for tests: applies `content` for `host` to a fresh config + fn apply_config_file_all(content: &str, host: &str) -> SshConfig { + let mut config = SshConfig::default(); + apply_config_file(&mut config, content, host); + config + } +} diff --git a/src/put/target.rs b/src/put/target.rs new file mode 100644 index 0000000..ac5bf4e --- /dev/null +++ b/src/put/target.rs @@ -0,0 +1,26 @@ +//! Generic upload target vocabulary for `pkh put`. Concrete targets are +//! built by their service modules (Launchpad — [`crate::launchpad`] — +//! today, archive targets later); this module only defines what any upload +//! target looks like and how the SSH username resolves when the service +//! does not pin one. + +/// A concrete SFTP upload destination. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UploadTarget { + /// Hostname to connect to. + pub fqdn: String, + /// TCP port (always 22 for now; SFTP only). + pub port: u16, + /// Default SSH username, when the service has one; `None` means the + /// username resolves per machine (service-specific discovery — git + /// configuration for Launchpad — then SSH configuration `User`, then + /// the local user name). + pub login: Option, + /// Incoming directory on the server, relative to the SFTP root. For + /// Launchpad targets this is a literal `~user/...` path: the upload + /// queue is write-only, so the path names the destination rather than a + /// browsable directory. + pub incoming: String, + /// Human-readable target name for logs and the upload record. + pub label: String, +} diff --git a/src/ui.rs b/src/ui.rs index 73d7a54..63a6de3 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -31,6 +31,24 @@ pub fn display_path(path: &Path) -> String { path.display().to_string() } +/// Style of an unsized operation: spinner and prefix on one line +pub(crate) fn spinner_style() -> ProgressStyle { + ProgressStyle::default_bar() + .template("> {spinner:.blue} {prefix}") + .unwrap() +} + +/// Style of a sized transfer: prefix on the first line, the bar on its own +/// indented line below so long prefixes cannot push it out of the terminal +pub(crate) fn transfer_style() -> ProgressStyle { + ProgressStyle::default_bar() + .template( + "> {spinner:.blue} {prefix}\n {msg} [{bar:40.cyan/blue}] {pos}/{len} ({eta})", + ) + .unwrap() + .progress_chars("=> ") +} + /// Create a spinner-style progress bar attached to `multi`, returning the bar /// and a callback compatible with [`crate::ProgressCallback`] pub fn create_progress_bar( @@ -38,26 +56,15 @@ pub fn create_progress_bar( ) -> (ProgressBar, impl Fn(&str, &str, usize, usize) + '_) { let pb = multi.add(ProgressBar::new(0)); pb.enable_steady_tick(Duration::from_millis(50)); - pb.set_style( - ProgressStyle::default_bar() - .template("> {spinner:.blue} {prefix}") - .unwrap(), - ); + pb.set_style(spinner_style()); let pb_clone = pb.clone(); let callback = move |prefix: &str, msg: &str, progress: usize, total: usize| { let pb = &pb_clone; if progress != 0 && total != 0 { - pb.set_style(ProgressStyle::default_bar() - .template("> {spinner:.blue} {prefix}\n {msg} [{bar:40.cyan/blue}] {pos}/{len} ({eta})") - .unwrap() - .progress_chars("=> ")); + pb.set_style(transfer_style()); } else { - pb.set_style( - ProgressStyle::default_bar() - .template("> {spinner:.blue} {prefix}") - .unwrap(), - ); + pb.set_style(spinner_style()); } if !prefix.is_empty() {