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
+487
View File
@@ -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<PathBuf>,
/// 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<dyn std::error::Error>> {
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 <your-launchpad-id>`, 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<dyn std::error::Error>> {
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>_<version>_source.changes` written by `pkh build` (in the source
/// directory's parent or the directory itself), or the only other
/// `<source>_*.changes` present. Errors listing the candidates when several
/// exist.
fn discover_changes(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
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<PathBuf> = 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<PathBuf> = 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::<Vec<_>>()
.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<UploadRecord, Box<dyn std::error::Error>> {
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 (`<data dir>/pkh/uploads.json`)
fn upload_log_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
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<UploadRecord> {
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<Option<UploadRecord>, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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 <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());
}
}