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