Files
pkh/src/put/mod.rs
T
vhaudiquet c1f8893576
CI / build (push) Failing after 2m57s
CI / test (push) Skipped
CI / snap (push) Skipped
put: run the section pre-flight against the uploaded tree
The Section check always read debian/control from the current working
directory, so 'pkh put --changes ../other/pkg_changes' validated the
wrong tree. With an explicit --changes the check now runs against that
file's own directory when it holds debian/control, and is skipped with
a warning otherwise; tree uploads are unchanged.
2026-09-18 10:40:48 +02:00

1103 lines
42 KiB
Rust

//! 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. A run that fails mid-upload removes its
//! already-uploaded files from the incoming queue (best effort), so a
//! retried upload starts from a clean queue; a failed upload is never
//! recorded in the upload log, so a re-run replays every file.
pub mod changes;
pub mod ssh;
pub mod target;
use std::cmp::Ordering;
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::debian::version::DebianVersion;
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)?;
// The summary line stays up for the whole flow: the completion message
// replaces it on success, and the `PutBars` guard clears it on every
// early `?` bail so the error logged by main is not preceded by stale
// bars
let mut bars = PutBars::default();
let summary = multi.add(ProgressBar::new(0));
// Style and prefix go in before the steady tick: otherwise the first
// tick can render one frame with the default bar template
summary.set_style(ui::spinner_style());
summary.set_prefix(format!(
"Uploading {} {} to {}",
changes.source, changes.version, target.label
));
summary.enable_steady_tick(TICK);
bars.track(summary.clone());
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)
match section_check_target(opts.changes.as_deref(), &opts.cwd) {
SectionCheckTarget::Dir(dir) => check_control_section(&dir, "ubuntu")?,
SectionCheckTarget::Skip => log::warn!(
"cannot check the Section: '{}' has no debian/control next to \
it, skipping the pre-flight (the archive still rejects uploads \
with unknown sections)",
changes_path.display()
),
}
let checking = multi.add(ProgressBar::new(0));
checking.set_style(ui::spinner_style());
checking.set_prefix(format!("Checking {} on Launchpad...", target.label));
checking.enable_steady_tick(TICK);
bars.track(checking.clone());
launchpad::ppa_info(&opts.ppa).await?;
launchpad::check_ppa_series(&changes.distribution).await?;
clear_bar(&checking);
// Last pre-flight check: a version the PPA already publishes at or
// above the changes' one would supersede (or reject) this upload
let published = launchpad::published_versions(&opts.ppa, &changes.source).await?;
check_not_superseded(&target, &changes, &published, opts.force)?;
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());
}
let connecting = multi.add(ProgressBar::new(0));
connecting.set_style(ui::spinner_style());
connecting.set_prefix(format!("Connecting to {login}@{host}:{port}..."));
connecting.enable_steady_tick(TICK);
bars.track(connecting.clone());
let session = ssh::connect(&host, port, &login, &ssh_config)?;
let sftp = ssh::sftp(&session)?;
clear_bar(&connecting);
// 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()));
let incoming = target.incoming.trim_end_matches('/');
// Remote names of the files uploaded so far, in upload order (the
// .changes last). A run failing mid-upload removes these from the
// write-only incoming queue before returning: the uploaded payloads
// would otherwise linger in the queue area forever, and a .changes
// truncated by a failed close could even be picked up by the scanner.
let mut uploaded: Vec<String> = Vec::new();
for (path, name) in &uploads {
let size = match path.metadata() {
Ok(metadata) => metadata.len(),
Err(e) => {
// Nothing was attempted for this file: only what earlier
// iterations uploaded needs removing
let error: Box<dyn std::error::Error> =
format!("cannot stat '{}': {}", path.display(), e).into();
cleanup_partial_upload(&sftp, incoming, &uploaded, None, &host);
return Err(error);
}
};
// 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!("{incoming}/{name}");
let result = ssh::upload_file(&sftp, path, &remote, &host, &bar);
bar.finish_and_clear();
if let Err(e) = result {
// The failed file itself joins the cleanup: its remote `create`
// may have succeeded before the failure, leaving a partial — or,
// on a failed close, a truncated — file behind
cleanup_partial_upload(&sftp, incoming, &uploaded, Some(name), &host);
return Err(e);
}
uploaded.push(name.clone());
}
// Recorded only once the whole upload succeeded: the log backs the
// duplicate-upload guard, and a failed upload must not count as
// uploaded (a re-run replays every file — `sftp.create` truncates, so
// replaying is safe).
record_upload(&upload_log_path()?, &record)?;
// The completion lines replace the summary bar; the guard's own clear
// at scope exit is a no-op for the already-finished bars
clear_bar(&summary);
info!(
"Upload of {} {} to {} complete.",
changes.source, changes.version, target.label
);
info!(
"Launchpad processes it asynchronously; watch the PPA page or your \
inbox for acceptance/rejection"
);
Ok(())
}
/// The remote names to attempt removing after a failed upload: everything
/// already uploaded plus, when set, `failed` (the file whose upload just
/// failed — its remote `create` may have succeeded before the failure,
/// leaving a partial or truncated file behind) — in reverse upload order,
/// so a `.changes` is removed before the payloads it references and the
/// queue scanner never observes the payload set shrinking under a
/// still-present `.changes`. Pure so the ordering decision is testable
/// without a server; the network side is [`cleanup_partial_upload`].
fn cleanup_list(uploaded: &[String], failed: Option<&str>) -> Vec<String> {
let mut names: Vec<String> = uploaded.to_vec();
if let Some(failed) = failed {
names.push(failed.to_string());
}
names.reverse();
names
}
/// Best-effort removal of what a failed upload left in the target's
/// incoming queue: the already-uploaded payloads, and a `.changes`
/// truncated by a failed close, would otherwise linger in the write-only
/// area until the queue is manually cleaned. Launchpad re-validates every
/// upload, so this is pure hygiene: a removal failure is logged and
/// skipped, and the caller returns the original upload error — never a
/// cleanup one.
fn cleanup_partial_upload(
sftp: &ssh2::Sftp,
incoming: &str,
uploaded: &[String],
failed: Option<&str>,
host: &str,
) {
for name in cleanup_list(uploaded, failed) {
let remote = format!("{incoming}/{name}");
match ssh::remove_file(sftp, &remote, host) {
Ok(()) => info!("Removed leftover {remote} from the failed upload"),
Err(e) => {
log::warn!("Could not remove the leftover {remote} of the failed upload: {e}")
}
}
}
}
/// Steady tick interval of every bar rendered by a `put` run
const TICK: std::time::Duration = std::time::Duration::from_millis(50);
/// Stop `bar`'s steady tick and clear it from the terminal. The tick is
/// disabled first: a tick firing right after the clear would redraw a stale
/// frame, the race [`crate::ui::deb::DebUi::suspend`] guards against too.
/// Clearing twice is harmless: finished bars stay finished.
fn clear_bar(bar: &ProgressBar) {
bar.disable_steady_tick();
bar.finish_and_clear();
}
/// The bars rendered by one `put` run, cleared when the guard drops. The
/// error paths bail out early through `?` and `main` logs the error
/// afterwards, so a bar left unfinished would linger on screen above it.
#[derive(Default)]
struct PutBars {
bars: Vec<ProgressBar>,
}
impl PutBars {
/// Track `bar` so it is cleared with the rest when the guard drops
fn track(&mut self, bar: ProgressBar) {
self.bars.push(bar);
}
}
impl Drop for PutBars {
fn drop(&mut self) {
for bar in &self.bars {
clear_bar(bar);
}
}
}
/// 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())
}
/// What the Section pre-flight decided to validate: a directory holding a
/// `debian/control` ([`check_control_section`]'s expected layout), or that
/// it cannot run where the upload lives and must be skipped.
#[derive(Debug, PartialEq, Eq)]
enum SectionCheckTarget {
/// Validate `debian/control` in this directory
Dir(PathBuf),
/// No `debian/control` next to the `.changes`: skip the check
Skip,
}
/// Decide which tree the Section pre-flight validates. An explicit
/// `--changes` file must be followed by its own directory — it can name a
/// different package than the current tree — so the check runs against the
/// `.changes`' directory when it directly holds a `debian/control` (the
/// `.changes` inside the package root), and is skipped otherwise: pkh
/// writes the `.changes` next to the source tree (an artifacts-only
/// directory as far as `debian/control` goes), and checking `cwd` there
/// would validate whatever package the user happens to be in. Without
/// `--changes` the upload comes from the current tree, which keeps getting
/// checked exactly as before. Pure decision, factored out so the
/// wrong-tree rule is testable with plain tempdirs.
fn section_check_target(changes_path: Option<&Path>, cwd: &Path) -> SectionCheckTarget {
let Some(changes) = changes_path else {
return SectionCheckTarget::Dir(cwd.to_path_buf());
};
let dir = changes.parent().unwrap_or_else(|| Path::new("."));
if dir.join("debian/control").is_file() {
SectionCheckTarget::Dir(dir.to_path_buf())
} else {
SectionCheckTarget::Skip
}
}
/// The highest version among `published` that parses as a Debian version,
/// `None` when the list is empty or nothing in it parses (entries that
/// cannot be parsed are skipped rather than failing the check: the API
/// data is trusted to carry valid Debian versions)
fn max_published(published: &[String]) -> Option<DebianVersion> {
published
.iter()
.filter_map(|version| DebianVersion::parse(version).ok())
.max()
}
/// Refuse an upload the target PPA already supersedes: when a version equal
/// to or higher than the changes' one is published there for the same
/// source, the queue would reject the upload (equal version) or silently
/// supersede it (older one) — Launchpad never replaces a published version
/// with a lower one. `force` downgrades the refusal to a warning.
fn check_not_superseded(
target: &target::UploadTarget,
changes: &changes::ChangesFile,
published: &[String],
force: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let local = match DebianVersion::parse(&changes.version) {
Ok(version) => version,
Err(e) => {
// Only the pre-flight comparison is skipped: the upload itself
// is valid as far as this check is concerned
log::warn!(
"cannot compare {} against the published versions of {} ({e}): \
skipping the published-version check",
changes.version,
changes.source
);
return Ok(());
}
};
let Some(latest) = max_published(published) else {
return Ok(());
};
if local.cmp(&latest) == Ordering::Greater {
return Ok(());
}
let bump = "bump the version with `pkh chlog` and rebuild, or pass --force to upload anyway";
if force {
log::warn!(
"{} {} is superseded by {} {} already published in {}: \
uploading anyway (--force)",
changes.source,
local.full(),
changes.source,
latest.full(),
target.label
);
return Ok(());
}
match local.cmp(&latest) {
Ordering::Equal => Err(format!(
"{} {} is already published in {} (published version {}): {bump}",
changes.source,
local.full(),
target.label,
latest.full()
)
.into()),
_ => Err(format!(
"{} {} is older than the {} {} already published in {}: {bump}",
changes.source,
local.full(),
changes.source,
latest.full(),
target.label
)
.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, PartialEq, Eq, 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.
///
/// The log only backs the advisory duplicate-upload guard (Launchpad
/// re-verifies every upload), so a corrupt log must not abort the upload:
/// like the context configuration in [`crate::context::manager`], the corrupt
/// file is first backed up to `<path>.bak` (best effort) so a later
/// [`record_upload`] cannot destroy it, then an empty log is used.
fn load_upload_log(path: &Path) -> Vec<UploadRecord> {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) => {
if e.kind() != std::io::ErrorKind::NotFound {
log::error!(
"Cannot read upload log {}: {e}; continuing as if nothing had been uploaded",
path.display()
);
}
return Vec::new();
}
};
match serde_json::from_str(&content) {
Ok(entries) => entries,
Err(e) => {
log::error!(
"Upload log {} is corrupt ({e}); ignoring its content, earlier uploads may not be detected as duplicates anymore",
path.display()
);
backup_corrupt_log(path);
Vec::new()
}
}
}
/// Back up a corrupt upload log (best effort) so a later
/// [`record_upload`] cannot silently destroy its content.
fn backup_corrupt_log(path: &Path) {
let mut os = path.as_os_str().to_os_string();
os.push(".bak");
let backup_path = PathBuf::from(os);
match std::fs::copy(path, &backup_path) {
Ok(_) => log::warn!("Corrupt upload log backed up to {}", backup_path.display()),
Err(e) => log::warn!(
"Could not back up corrupt upload log to {}: {e}",
backup_path.display()
),
}
}
/// 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());
// Write to a temporary file next to the log, then rename it over the
// log: rename is atomic within a filesystem, so a crash mid-write leaves
// the previous log intact instead of a truncated (corrupt) file.
// The temporary file is created with default permissions; the log holds
// no secrets and lives in the user's data directory, so the previous
// mode is not carried over (same choice as `Files::save_atomic`).
let tmp = log.with_extension("new");
std::fs::write(&tmp, serde_json::to_string_pretty(&entries)?)
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
std::fs::rename(&tmp, log).map_err(|e| format!("cannot install '{}': {}", log.display(), e))?;
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}");
}
/// An explicit `--changes` file of a different package must be checked
/// in its own directory (A), not in the current tree (B): B's control
/// file decided the pre-flight before the fix
#[test]
fn section_check_follows_explicit_changes_directory() {
let dir = tempfile::tempdir().unwrap();
let artifacts = dir.path().join("artifacts");
let elsewhere = dir.path().join("elsewhere");
std::fs::create_dir_all(artifacts.join("debian")).unwrap();
std::fs::create_dir_all(elsewhere.join("debian")).unwrap();
let changes = artifacts.join("hello_1.0-1_source.changes");
std::fs::write(&changes, b"changes").unwrap();
std::fs::write(
artifacts.join("debian/control"),
"Source: hello\nSection: utils\n",
)
.unwrap();
std::fs::write(
elsewhere.join("debian/control"),
"Source: other\nSection: unknown\n",
)
.unwrap();
assert_eq!(
section_check_target(Some(&changes), &elsewhere),
SectionCheckTarget::Dir(artifacts.clone())
);
// The decided directory carries the valid section, while the tree
// the old code checked would have failed the upload
match section_check_target(Some(&changes), &elsewhere) {
SectionCheckTarget::Dir(d) => check_control_section(&d, "ubuntu").unwrap(),
SectionCheckTarget::Skip => panic!("the .changes directory has debian/control"),
}
assert!(check_control_section(&elsewhere, "ubuntu").is_err());
}
/// A `.changes` in a directory without `debian/control` — pkh's own
/// layout puts it next to the source tree, and artifacts can be
/// collected away from any source — skips the check instead of
/// validating whatever tree `cwd` points at
#[test]
fn section_check_skips_when_changes_dir_has_no_control() {
let dir = tempfile::tempdir().unwrap();
let artifacts = dir.path().join("artifacts");
std::fs::create_dir_all(&artifacts).unwrap();
let changes = artifacts.join("hello_1.0-1_source.changes");
std::fs::write(&changes, b"changes").unwrap();
assert_eq!(
section_check_target(Some(&changes), dir.path()),
SectionCheckTarget::Skip
);
}
/// Without `--changes` the upload comes from the current tree: the
/// decision stays the cwd, byte-identical to the pre-fix behavior
#[test]
fn section_check_without_changes_validates_cwd() {
let dir = tempfile::tempdir().unwrap();
let pkg = dir.path().join("hello");
std::fs::create_dir_all(pkg.join("debian")).unwrap();
assert_eq!(
section_check_target(None, &pkg),
SectionCheckTarget::Dir(pkg)
);
}
#[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());
}
/// A plain upload record for the log tests
fn upload_log_record(sha256: &str) -> UploadRecord {
UploadRecord {
target: "ppa:user/ppa".to_string(),
file: "hello_1.0-1_source.changes".to_string(),
sha256: sha256.to_string(),
date: "2025-09-16 12:00".to_string(),
}
}
/// A corrupt log must not be silently reset: it is backed up byte for
/// byte, and the guard restarts from an empty log.
#[test]
fn corrupt_upload_log_is_backed_up_and_reset() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("uploads.json");
let corrupt = b"{ \"not\": valid json";
std::fs::write(&log, corrupt).unwrap();
assert!(load_upload_log(&log).is_empty());
let backup = dir.path().join("uploads.json.bak");
assert_eq!(std::fs::read(&backup).unwrap(), corrupt);
// Recording after a corrupt log starts a fresh, parseable log
// without clobbering the backup
record_upload(&log, &upload_log_record("abc")).unwrap();
assert_eq!(load_upload_log(&log), vec![upload_log_record("abc")]);
assert_eq!(std::fs::read(&backup).unwrap(), corrupt);
}
/// `record_upload` leaves a log that parses back, with no temporary
/// file left behind
#[test]
fn recorded_upload_parses_back_atomically() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("uploads.json");
record_upload(&log, &upload_log_record("abc")).unwrap();
assert_eq!(load_upload_log(&log), vec![upload_log_record("abc")]);
assert!(!dir.path().join("uploads.new").exists());
}
/// Recording a second upload appends to the log instead of replacing it
#[test]
fn record_over_existing_log_preserves_prior_entries() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("uploads.json");
record_upload(&log, &upload_log_record("abc")).unwrap();
record_upload(&log, &upload_log_record("def")).unwrap();
assert_eq!(
load_upload_log(&log),
vec![upload_log_record("abc"), upload_log_record("def")]
);
}
/// A minimal in-memory changes file for the superseded-version check
fn changes_fixture(version: &str) -> changes::ChangesFile {
changes::ChangesFile {
path: PathBuf::from("hello_1.0-1_source.changes"),
source: "hello".to_string(),
version: version.to_string(),
distribution: "resolute".to_string(),
files: Vec::new(),
}
}
fn versions(list: &[&str]) -> Vec<String> {
list.iter().map(|v| v.to_string()).collect()
}
#[test]
fn superseded_check_rejects_equal_published_version() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let changes = changes_fixture("1.2.3-1");
let err = check_not_superseded(&target, &changes, &versions(&["1.2.3-1"]), false)
.unwrap_err()
.to_string();
assert!(
err.contains("hello 1.2.3-1 is already published in ppa:user/ppa"),
"unexpected: {err}"
);
assert!(
err.contains("published version 1.2.3-1"),
"unexpected: {err}"
);
assert!(err.contains("--force"), "unexpected: {err}");
}
#[test]
fn superseded_check_rejects_older_upload() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let changes = changes_fixture("1.0-1");
let err = check_not_superseded(&target, &changes, &versions(&["2.0-1"]), false)
.unwrap_err()
.to_string();
assert!(
err.contains("hello 1.0-1 is older than the hello 2.0-1 already published"),
"unexpected: {err}"
);
}
#[test]
fn superseded_check_accepts_newer_version() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let changes = changes_fixture("2.0-1");
check_not_superseded(&target, &changes, &versions(&["1.0-1"]), false).unwrap();
}
#[test]
fn superseded_check_accepts_never_published_source() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let changes = changes_fixture("1.0-1");
check_not_superseded(&target, &changes, &[], false).unwrap();
}
/// The comparison follows dpkg ordering: `~` pre-releases, epochs and
/// Ubuntu revision suffixes all sort correctly
#[test]
fn superseded_check_uses_dpkg_ordering() {
let target = launchpad::ppa_target("user/ppa").unwrap();
// A tilde pre-release is older than the real release
let changes = changes_fixture("1.0~rc1-1");
assert!(check_not_superseded(&target, &changes, &versions(&["1.0-1"]), false).is_err());
// The real release supersedes the pre-release
let changes = changes_fixture("1.0-1");
check_not_superseded(&target, &changes, &versions(&["1.0~rc1-1"]), false).unwrap();
// An epoch outranks a higher upstream version
let changes = changes_fixture("2.0-1");
assert!(check_not_superseded(&target, &changes, &versions(&["1:1.0-1"]), false).is_err());
// ubuntu1.1 point updates supersede the base upload
let changes = changes_fixture("1.0-1ubuntu1");
assert!(
check_not_superseded(&target, &changes, &versions(&["1.0-1ubuntu1.1"]), false).is_err()
);
}
#[test]
fn superseded_check_force_warns_instead_of_erroring() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let changes = changes_fixture("1.0-1");
check_not_superseded(&target, &changes, &versions(&["1.0-1", "0.9-1"]), true).unwrap();
}
/// Versions the parser rejects never fail the upload: an unparseable
/// local version skips the check, unparseable published entries are
/// ignored
#[test]
fn superseded_check_tolerates_unparseable_versions() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let changes = changes_fixture("not_a_debian_version");
check_not_superseded(&target, &changes, &versions(&["1.0-1"]), false).unwrap();
let changes = changes_fixture("1.0-1");
check_not_superseded(&target, &changes, &versions(&["1.0_beta", "0.9-1"]), false).unwrap();
}
/// Several published versions (multiple series/pockets): the highest
/// one decides
#[test]
fn superseded_check_compares_against_highest_published() {
let target = launchpad::ppa_target("user/ppa").unwrap();
let published = versions(&["1.0-1", "1.6-1", "1.2-3"]);
// 1.5-1 supersedes everything but the 1.6-1 entry, which decides
let changes = changes_fixture("1.5-1");
let err = check_not_superseded(&target, &changes, &published, false)
.unwrap_err()
.to_string();
assert!(err.contains("1.6-1"), "unexpected: {err}");
// ...and going one higher than the highest entry passes
let changes = changes_fixture("1.7-1");
check_not_superseded(&target, &changes, &published, false).unwrap();
}
/// The upload loop is network-bound and `ssh2::Sftp` cannot be built
/// without a live SSH session, so there is no stub seam for it without
/// heavy refactoring; the cleanup decision is factored into the pure
/// [`cleanup_list`] and tested directly instead.
///
/// Failure during the final `.changes` upload (the dangerous case: a
/// partial or close-truncated `.changes` is scanner-visible): the
/// `.changes` is removed first, then the payloads it references, so the
/// queue never sees a `.changes` over a shrinking payload set.
#[test]
fn cleanup_list_removes_changes_first_then_payloads_in_reverse() {
let uploaded = vec![
"hello_1.0.orig.tar.xz".to_string(),
"hello_1.0-1.debian.tar.xz".to_string(),
"hello_1.0-1.dsc".to_string(),
];
assert_eq!(
cleanup_list(&uploaded, Some("hello_1.0-1_source.changes")),
vec![
"hello_1.0-1_source.changes",
"hello_1.0-1.dsc",
"hello_1.0-1.debian.tar.xz",
"hello_1.0.orig.tar.xz",
]
);
}
/// A failure during a payload upload: the `.changes` was never
/// attempted (it is uploaded last), so it must not appear in the
/// cleanup list — only the failed payload (a partial may exist) and the
/// earlier payloads.
#[test]
fn cleanup_list_before_the_changes_covers_the_failed_payload() {
let uploaded = vec!["hello_1.0.orig.tar.xz".to_string()];
assert_eq!(
cleanup_list(&uploaded, Some("hello_1.0-1.debian.tar.xz")),
vec!["hello_1.0-1.debian.tar.xz", "hello_1.0.orig.tar.xz"]
);
}
/// A stat failure happens before anything was attempted for that file,
/// so only what earlier iterations uploaded is removed — and with
/// nothing uploaded yet there is nothing to remove at all.
#[test]
fn cleanup_list_without_failed_file_keeps_uploaded_only() {
assert!(cleanup_list(&[], None).is_empty());
assert_eq!(
cleanup_list(&["hello_1.0.orig.tar.xz".to_string()], None),
vec!["hello_1.0.orig.tar.xz"]
);
}
}