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.
1103 lines
42 KiB
Rust
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"]
|
|
);
|
|
}
|
|
}
|