Files
pkh/src/put/ftp.rs
T
vhaudiquet 9e0b6a37a6
CI / build (push) Successful in 3m0s
CI / test (push) Skipped
CI / snap (push) Successful in 6m6s
put: degrade to the anonymous FTP queue when the SSH transport fails
pkh put only spoke SFTP to the PPA queue, so a failure of the SSH
transport itself (TCP, banner exchange) failed the upload even though
dput happily pushes the same files: its plain ppa: profile goes over
the anonymous FTP queue of ppa.launchpad.net, the same destination
over another port.

Classify the SSH connection failures: Transport (the connection never
came up: resolution, TCP, banner or key exchange) degrades to that FTP
queue — the upload order (payload first, .changes last), the
reverse-order DELE cleanup of a failed upload and the per-chunk
progress reporting all mirror the SFTP path, sharing cleanup_list.
Refused failures (host key not accepted, no matching authentication)
stay errors: silently switching transport would bypass the refusal.

The FTP client is suppaftp's blocking stream, with the time bounds it
does not carry by itself: the control channel's reads and writes, the
data channel's writes and connect (through a custom passive stream
builder), and the NAT workaround for PASV replies announcing an
unroutable address. The queue endpoints (host, port) join
data/launchpad.yml next to the SFTP ones, and the FTP transport is
covered by unit tests against an in-process fake queue plus a live
control-channel handshake with the real server (ignored, network).
2026-09-21 14:34:04 +02:00

457 lines
18 KiB
Rust

//! Anonymous FTP transport for the Launchpad PPA upload queue: the graceful
//! degradation of the SFTP transport when the SSH connection itself never
//! comes up (name resolution, TCP, banner or key exchange). dput-ng's plain
//! `ppa:user/ppa` profile pushes over this same queue — ppa.launchpad.net
//! over FTP, anonymous login, incoming `~user/ppa` — so it is the
//! interoperability-tested path.
//!
//! The client is [`suppaftp`]'s (plain-FTP, no TLS) blocking stream on the
//! same time-bounded sockets as the ssh2 transport: the TCP connect and
//! the control/data channel reads and writes all carry timeouts, so a
//! black-holed or stalled server fails the upload instead of hanging it
//! (suppaftp's defaults do not bound them). The upload order (payload
//! first, `.changes` last — the caller passes the files in that order) and
//! the best-effort cleanup of a failed upload (`DELE` of what was already
//! pushed, in reverse upload order) mirror the SFTP path exactly.
use std::fs::File;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::time::Duration;
use suppaftp::types::FileType;
use suppaftp::{FtpError, FtpStream};
use super::ssh;
/// Bounds one TCP connection attempt, to the control channel or a passive
/// data port: `connect(2)` would otherwise block for minutes (or forever,
/// behind a silent firewall). Generous enough for slow links to Launchpad,
/// short enough that a dead target fails in seconds — the same value and
/// rationale as the SSH path's bound.
const TCP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// Read/write timeout on the control channel: every reply is a few bytes,
/// so a stalled server has had its say by the time this expires.
const CONTROL_TIMEOUT: Duration = Duration::from_secs(60);
/// Write timeout of one data-transfer chunk: the clock restarts at every
/// write, so the wall-clock duration of a large upload is not bounded by
/// design — but a black-holed data connection fails one chunk after this
/// budget.
const DATA_TIMEOUT: Duration = Duration::from_secs(120);
/// Data transfers stream in chunks of this size.
const CHUNK_SIZE: usize = 32 * 1024;
/// Upload `files` to the `incoming` queue of `host:port` over anonymous FTP,
/// in the order given (payload first, `.changes` last — the queue processor
/// must never observe a `.changes` without its payload).
/// `on_progress(name, uploaded_bytes, total_bytes)` reports progress.
///
/// A failure mid-upload best-effort removes what was already pushed (`DELE`,
/// reverse upload order — a lingering payload in the write-only queue area
/// is only hygiene) before returning the original error, like the SFTP
/// path's [`super::cleanup_partial_upload`].
pub fn upload_queue(
host: &str,
port: u16,
incoming: &str,
files: &[(PathBuf, String)],
on_progress: &dyn Fn(&str, u64, u64),
) -> Result<(), Box<dyn std::error::Error>> {
let mut ftp = connect(host, port)?;
let who = std::env::var("USER").unwrap_or_else(|_| "anonymous".to_string());
ftp.login("anonymous".to_string(), format!("{who}@pkh.invalid"))
.map_err(|e| format!("the FTP queue rejected the anonymous login: {e}"))?;
ftp.cwd(incoming)
.map_err(|e| format!("cannot enter the upload queue '{incoming}': {e}"))?;
ftp.transfer_type(FileType::Binary)
.map_err(|e| format!("the FTP queue refused binary transfers: {e}"))?;
// Remote names pushed so far, in upload order, for the cleanup
let mut uploaded: Vec<String> = Vec::new();
for (path, name) in files {
if let Err(e) = store(&mut ftp, path, name, on_progress) {
// The failed file itself joins the cleanup: its `STOR` was
// accepted before the transfer failure, so a partial may be
// sitting in the queue
for leftover in super::cleanup_list(&uploaded, Some(name)) {
match ftp.rm(leftover.as_str()) {
Ok(()) => {
log::info!("Removed leftover {leftover} from the failed upload")
}
Err(e) => log::warn!(
"Could not remove the leftover {leftover} of the \
failed upload: {e}"
),
}
}
return Err(e);
}
uploaded.push(name.clone());
}
// Best effort: the queue keeps what it accepted, so a failure here must
// not fail a completed upload
if let Err(e) = ftp.quit() {
log::debug!("closing the FTP session: {e}");
}
Ok(())
}
/// Connect to the queue, read its banner and set every time bound and
/// workaround the plain suppaftp stream does not carry by itself.
fn connect(host: &str, port: u16) -> Result<FtpStream, Box<dyn std::error::Error>> {
let tcp = ssh::tcp_connect(host, port)?;
let mut ftp = FtpStream::connect_with_stream(tcp)?.passive_stream_builder(data_connect);
{
let control = ftp.get_ref();
control.set_read_timeout(Some(CONTROL_TIMEOUT))?;
control.set_write_timeout(Some(CONTROL_TIMEOUT))?;
}
// A `PASV` reply announcing an unroutable address (a server behind NAT
// that does not know its public IP) means the control connection's peer
ftp.set_passive_nat_workaround(true);
Ok(ftp)
}
/// The passive data-channel connect: bounded like every other network call,
/// where suppaftp's default builder is a plain, unbounded
/// `TcpStream::connect`.
fn data_connect(addr: SocketAddr) -> Result<TcpStream, FtpError> {
TcpStream::connect_timeout(&addr, TCP_CONNECT_TIMEOUT).map_err(FtpError::ConnectionError)
}
/// Upload `path` as `name` over a passive data connection, reporting
/// progress through `on_progress` per chunk. `finish` reads the transfer
/// completion reply — the only way to learn the server accepted the file.
fn store(
ftp: &mut FtpStream,
path: &Path,
name: &str,
on_progress: &dyn Fn(&str, u64, u64),
) -> Result<(), Box<dyn std::error::Error>> {
let mut upload = ftp
.put_with_stream(name)
.map_err(|e| format!("the FTP queue rejected '{name}': {e}"))?;
if let suppaftp::DataStream::Tcp(socket) = upload.get_mut() {
socket.set_write_timeout(Some(DATA_TIMEOUT))?;
}
let mut file =
File::open(path).map_err(|e| format!("cannot read '{}': {e}", path.display()))?;
let total = file.metadata().map(|m| m.len()).unwrap_or(0);
let mut buffer = vec![0u8; CHUNK_SIZE];
let mut uploaded: u64 = 0;
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
upload.write_all(&buffer[..read])?;
uploaded += read as u64;
on_progress(name, uploaded, total);
}
upload
.finish()
.map_err(|e| format!("upload of '{name}' failed: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use parking_lot::Mutex;
/// In-process fake of the Launchpad FTP upload queue: one control
/// session speaking the protocol subset the client uses (banner,
/// USER/PASS, CWD, TYPE, PASV, STOR, DELE, QUIT). Records every
/// command, stores received bytes under the `STOR` name, and can be
/// told to reject one `STOR` (by index) to exercise the cleanup. Its
/// `PASV` replies announce `0.0.0.0`, the NAT form, so the
/// happy-path test proves the control-peer fallback too.
struct FakeQueue {
addr: SocketAddr,
commands: Arc<Mutex<Vec<String>>>,
files: Arc<Mutex<HashMap<String, Vec<u8>>>>,
}
impl FakeQueue {
/// Serve one session on a background thread, greeting it with
/// `banner` and rejecting the `STOR` number `reject_stor`, when
/// set.
fn start(banner: &str, reject_stor: Option<usize>) -> FakeQueue {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let commands = Arc::new(Mutex::new(Vec::new()));
let files = Arc::new(Mutex::new(HashMap::new()));
let (c, f) = (Arc::clone(&commands), Arc::clone(&files));
let banner = banner.to_string();
std::thread::spawn(move || serve(listener, &banner, c, f, reject_stor));
FakeQueue {
addr,
commands,
files,
}
}
}
fn serve(
listener: TcpListener,
banner: &str,
commands: Arc<Mutex<Vec<String>>>,
files: Arc<Mutex<HashMap<String, Vec<u8>>>>,
reject_stor: Option<usize>,
) {
use std::io::BufRead;
let (stream, _) = listener.accept().unwrap();
let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
let mut writer = stream;
writer
.write_all(format!("{banner}\r\n").as_bytes())
.unwrap();
let mut pending_data: Option<TcpListener> = None;
let stor_index = AtomicUsize::new(0);
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
let cmd = line.trim_end().to_string();
commands.lock().push(cmd.clone());
let (verb, arg) = cmd.split_once(' ').unwrap_or((cmd.as_str(), ""));
match verb {
"USER" | "PASS" => {
writer
.write_all(b"230 Anonymous login ok, access restrictions apply.\r\n")
.unwrap();
}
"CWD" => {
writer.write_all(b"250 Command successful\r\n").unwrap();
}
"TYPE" => {
writer.write_all(b"200 Type set to I\r\n").unwrap();
}
"PASV" => {
let data = TcpListener::bind("127.0.0.1:0").unwrap();
let port = data.local_addr().unwrap().port();
writer
.write_all(
format!(
"227 Entering Passive Mode (0,0,0,0,{},{})\r\n",
port / 256,
port % 256
)
.as_bytes(),
)
.unwrap();
pending_data = Some(data);
}
"STOR" => {
let index = stor_index.fetch_add(1, Ordering::SeqCst);
if Some(index) == reject_stor {
writer.write_all(b"550 Permission denied\r\n").unwrap();
continue;
}
writer.write_all(b"150 Ok to send data\r\n").unwrap();
let (mut data, _) = pending_data.take().unwrap().accept().unwrap();
let mut bytes = Vec::new();
data.read_to_end(&mut bytes).unwrap();
files.lock().insert(arg.to_string(), bytes);
writer.write_all(b"226 Transfer complete\r\n").unwrap();
}
"DELE" => {
if files.lock().remove(arg).is_some() {
writer.write_all(b"250 File deleted\r\n").unwrap();
} else {
writer.write_all(b"550 No such file\r\n").unwrap();
}
}
"QUIT" => {
writer.write_all(b"221 Bye\r\n").unwrap();
break;
}
other => {
writer
.write_all(format!("502 Command '{other}' not implemented\r\n").as_bytes())
.unwrap();
}
}
}
}
/// A temp file with `content`, to upload.
fn upload_file(dir: &Path, name: &str, content: &[u8]) -> (PathBuf, String) {
let path = dir.join(name);
std::fs::write(&path, content).unwrap();
(path, name.to_string())
}
/// The full push: anonymous login, the queue directory entered,
/// payload files uploaded in order before the `.changes`, their bytes
/// intact, and progress reported up to each file's size.
#[test]
fn upload_queue_pushes_payload_before_changes() {
let queue = FakeQueue::start("220 Launchpad upload server", None);
let dir = tempfile::tempdir().unwrap();
let files = vec![
upload_file(dir.path(), "pkg_1.0.orig.tar.xz", b"orig bytes"),
upload_file(dir.path(), "pkg_1.0-1.dsc", b"dsc bytes"),
upload_file(dir.path(), "pkg_1.0-1_source.changes", b"changes bytes"),
];
let seen = Arc::new(Mutex::new(Vec::new()));
let progress_log = Arc::clone(&seen);
upload_queue(
"127.0.0.1",
queue.addr.port(),
"~vhaudiquet/lp2167827",
&files,
&|name, uploaded, total| {
progress_log
.lock()
.push((name.to_string(), uploaded, total))
},
)
.unwrap();
let commands = queue.commands.lock().clone();
assert_eq!(
commands[0], "USER anonymous",
"the anonymous login comes first"
);
assert_eq!(
commands
.iter()
.filter(|c| c == &"CWD ~vhaudiquet/lp2167827")
.count(),
1
);
let cwd = commands.iter().position(|c| c.starts_with("CWD")).unwrap();
let first_stor = commands.iter().position(|c| c.starts_with("STOR")).unwrap();
assert!(cwd < first_stor, "the queue directory is entered first");
assert!(
commands.iter().any(|c| c == "TYPE I"),
"binary transfers are requested"
);
// Upload order: the payload first, the .changes last
let stors: Vec<&String> = commands.iter().filter(|c| c.starts_with("STOR")).collect();
assert_eq!(
stors.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
vec![
"STOR pkg_1.0.orig.tar.xz",
"STOR pkg_1.0-1.dsc",
"STOR pkg_1.0-1_source.changes",
]
);
assert_eq!(*commands.last().unwrap(), "QUIT");
// The received bytes are intact
let stored = queue.files.lock();
assert_eq!(stored.get("pkg_1.0.orig.tar.xz").unwrap(), b"orig bytes");
assert_eq!(stored.get("pkg_1.0-1.dsc").unwrap(), b"dsc bytes");
assert_eq!(
stored.get("pkg_1.0-1_source.changes").unwrap(),
b"changes bytes"
);
// Progress reached each file's size
let progress = seen.lock();
for (path, name) in &files {
let size = path.metadata().unwrap().len();
let reached = progress
.iter()
.any(|(n, uploaded, total)| n == name && *uploaded == size && *total == size);
assert!(reached, "no progress report completed for '{name}'");
}
}
/// A `STOR` the queue rejects fails the upload, and the client removes
/// what it already pushed — the failed file first, then the earlier
/// payloads, in reverse upload order — before returning the error.
#[test]
fn upload_queue_cleans_up_after_a_rejected_stor() {
let queue = FakeQueue::start("220 Launchpad upload server", Some(1));
let dir = tempfile::tempdir().unwrap();
let files = vec![
upload_file(dir.path(), "one.dsc", b"one"),
upload_file(dir.path(), "two.tar.xz", b"two"),
upload_file(dir.path(), "three.changes", b"three"),
];
let err = upload_queue(
"127.0.0.1",
queue.addr.port(),
"~user/ppa",
&files,
&|_, _, _| {},
)
.unwrap_err();
assert!(
err.to_string().contains("rejected 'two.tar.xz'"),
"the error names the rejected file, got: {err}"
);
let commands = queue.commands.lock();
let deles: Vec<&String> = commands.iter().filter(|c| c.starts_with("DELE")).collect();
assert_eq!(
deles.iter().map(|d| d.as_str()).collect::<Vec<_>>(),
vec!["DELE two.tar.xz", "DELE one.dsc"],
"the failed file is removed first, then the earlier payloads"
);
let files = queue.files.lock();
assert!(
!files.contains_key("one.dsc"),
"the pushed payload is removed"
);
assert!(!files.contains_key("three.changes"), "never reached");
}
/// A multi-line banner (`220-first` closed by `220 last`) parses like
/// the single-line form.
#[test]
fn upload_queue_reads_multiline_replies() {
let queue = FakeQueue::start("220-Launchpad\r\n220 upload server", None);
let dir = tempfile::tempdir().unwrap();
let files = vec![upload_file(dir.path(), "pkg.dsc", b"bytes")];
upload_queue(
"127.0.0.1",
queue.addr.port(),
"~user/ppa",
&files,
&|_, _, _| {},
)
.unwrap();
let stored = queue.files.lock();
assert_eq!(stored.get("pkg.dsc").unwrap(), b"bytes");
}
/// Live control-channel handshake with the real Launchpad FTP queue:
/// banner, anonymous login and a `CWD` — nothing is uploaded, the
/// queue is left untouched. For deliberate ad-hoc runs
/// (`cargo test -- --ignored`), not the pre-commit pass: it hits the
/// network.
#[test]
#[ignore = "hits the network: the real Launchpad FTP queue"]
fn live_launchpad_control_channel() {
let (host, port) = crate::launchpad::ppa_ftp_queue();
assert_eq!((host.as_str(), port), ("ppa.launchpad.net", 21));
let mut ftp = connect(&host, port).unwrap();
ftp.login("anonymous", "pkh@invalid").unwrap();
ftp.cwd("/").unwrap();
ftp.quit().unwrap();
}
}