put: remove partial uploads when the transfer fails

A failed or interrupted upload left the already-uploaded payloads — or
a truncated .changes — in the PPA's incoming area. On failure the
already-uploaded files are now removed best-effort in reverse upload
order with the failed file first, so a .changes never outlives the
payloads it references; the original upload error keeps precedence over
cleanup failures, and record-after-success semantics are unchanged (a
failed upload must not count as uploaded).
This commit is contained in:
2026-09-18 10:36:01 +02:00
parent 231c478d0b
commit e640b153bd
2 changed files with 143 additions and 7 deletions
+131 -7
View File
@@ -6,7 +6,10 @@
//!
//! 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.
//! 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;
@@ -147,23 +150,50 @@ pub async fn put(
.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 = path
.metadata()
.map_err(|e| format!("cannot stat '{}': {}", path.display(), e))?
.len();
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!("{}/{}", target.incoming.trim_end_matches('/'), name);
let remote = format!("{incoming}/{name}");
let result = ssh::upload_file(&sftp, path, &remote, &host, &bar);
bar.finish_and_clear();
result?;
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
@@ -180,6 +210,48 @@ pub async fn put(
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);
@@ -864,4 +936,56 @@ mod tests {
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"]
);
}
}
+12
View File
@@ -632,6 +632,18 @@ pub fn upload_file(
Ok(())
}
/// Remove the remote file `remote` on `host` (SFTP remove). Used by the
/// best-effort cleanup of an interrupted upload: the queue is write-only,
/// so the only remote operation ever needed besides `create` is this one.
pub fn remove_file(
sftp: &ssh2::Sftp,
remote: &str,
host: &str,
) -> Result<(), Box<dyn std::error::Error>> {
sftp.unlink(Path::new(remote))
.map_err(|e| format!("cannot remove remote file {remote} on {host}: {e}").into())
}
/// Open the SFTP subsystem on `session`
pub fn sftp(session: &Session) -> Result<ssh2::Sftp, Box<dyn std::error::Error>> {
session