use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use crate::error::{Error, Result};
use crate::tls::TlsStream;
use crate::url::Url;
enum Stream {
Plain(TcpStream),
Tls(Box<TlsStream<TcpStream>>),
}
impl Read for Stream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self {
Stream::Plain(s) => s.read(buf),
Stream::Tls(s) => s.read(buf),
}
}
}
impl Write for Stream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
Stream::Plain(s) => s.write(buf),
Stream::Tls(s) => s.write(buf),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
Stream::Plain(s) => s.flush(),
Stream::Tls(s) => s.flush(),
}
}
}
struct Control {
ctrl: BufReader<Stream>,
ctrl_peer_ip: std::net::IpAddr,
}
fn connect_login(url: &Url) -> Result<Control> {
if url.scheme != "ftp" && url.scheme != "ftps" {
return Err(Error::UnsupportedScheme(url.scheme.clone()));
}
let tcp = TcpStream::connect((url.host.as_str(), url.port))?;
let ctrl_peer_ip = tcp.peer_addr()?.ip();
let control = if url.scheme == "ftps" {
Stream::Tls(Box::new(crate::tls::connect_over(tcp, &url.host)?))
} else {
Stream::Plain(tcp)
};
let mut ctrl = BufReader::new(control);
let (code, _) = read_reply(&mut ctrl)?;
if !is_positive(code) {
return Err(Error::BadResponse(format!("ftp banner: {code}")));
}
let (user, pass) = split_userinfo(url.userinfo.as_deref());
reject_ctl(&user, "ftp user")?;
reject_ctl(&pass, "ftp password")?;
send(&mut ctrl, &format!("USER {user}"))?;
let (c, _) = read_reply(&mut ctrl)?;
match c {
230 => {} 331 => {
send(&mut ctrl, &format!("PASS {pass}"))?;
let (c2, m2) = read_reply(&mut ctrl)?;
if c2 != 230 && c2 != 202 {
return Err(Error::BadResponse(format!("ftp PASS: {c2} {m2}")));
}
}
332 => {
return Err(Error::BadResponse(
"ftp server requires ACCT, not supported".into(),
));
}
_ => return Err(Error::BadResponse(format!("ftp USER: {c}"))),
}
send(&mut ctrl, "TYPE I")?;
let (c, m) = read_reply(&mut ctrl)?;
if c != 200 {
return Err(Error::BadResponse(format!("ftp TYPE I: {c} {m}")));
}
Ok(Control { ctrl, ctrl_peer_ip })
}
fn open_data<R: Read + Write>(
ctrl: &mut BufReader<R>,
url: &Url,
ctrl_peer_ip: std::net::IpAddr,
) -> Result<Stream> {
let (data_host, data_port) = open_passive(ctrl, &url.host, ctrl_peer_ip)?;
let data_tcp = TcpStream::connect((data_host.as_str(), data_port))?;
Ok(if url.scheme == "ftps" {
Stream::Tls(Box::new(crate::tls::connect_over(data_tcp, &url.host)?))
} else {
Stream::Plain(data_tcp)
})
}
pub fn fetch(url: &Url) -> Result<Vec<u8>> {
let mut con = connect_login(url)?;
let mut data = open_data(&mut con.ctrl, url, con.ctrl_peer_ip)?;
let ctrl = &mut con.ctrl;
reject_ctl(&url.path, "ftp path")?;
let cmd = if url.path.is_empty() || url.path == "/" {
"LIST".to_string()
} else if url.path.ends_with('/') {
format!("LIST {}", url.path)
} else {
format!("RETR {}", url.path)
};
send(ctrl, &cmd)?;
let (c, m) = read_reply(ctrl)?;
if !(c == 125 || c == 150) {
if !is_positive(c) {
return Err(Error::BadResponse(format!("ftp {cmd}: {c} {m}")));
}
}
let mut bytes = Vec::new();
data.read_to_end(&mut bytes)?;
drop(data);
if c == 125 || c == 150 {
let (cf, mf) = read_reply(ctrl)?;
if !is_positive(cf) {
return Err(Error::BadResponse(format!("ftp transfer end: {cf} {mf}")));
}
}
let _ = send(ctrl, "QUIT");
let _ = read_reply(ctrl);
Ok(bytes)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum UploadMode {
Stor,
Appe,
}
impl UploadMode {
fn verb(self) -> &'static str {
match self {
UploadMode::Stor => "STOR",
UploadMode::Appe => "APPE",
}
}
}
fn upload_command(mode: UploadMode, path: &str) -> Option<String> {
let name = path.strip_prefix('/').unwrap_or(path);
if name.is_empty() || name.ends_with('/') {
return None;
}
Some(format!("{} {name}", mode.verb()))
}
#[cfg(test)]
fn stor_command(path: &str) -> Option<String> {
upload_command(UploadMode::Stor, path)
}
#[cfg(test)]
fn appe_command(path: &str) -> Option<String> {
upload_command(UploadMode::Appe, path)
}
fn rest_command(offset: u64) -> String {
format!("REST {offset}")
}
pub fn store(url: &Url, body: &[u8], resume_at: Option<u64>) -> Result<()> {
upload(url, body, UploadMode::Stor, resume_at)
}
pub fn append(url: &Url, body: &[u8]) -> Result<()> {
upload(url, body, UploadMode::Appe, None)
}
fn upload(url: &Url, body: &[u8], mode: UploadMode, resume_at: Option<u64>) -> Result<()> {
let mut con = connect_login(url)?;
reject_ctl(&url.path, "ftp path")?;
let cmd = upload_command(mode, &url.path).ok_or_else(|| {
Error::BadResponse(format!(
"ftp {}: URL path {:?} does not name a file to upload",
mode.verb(),
url.path
))
})?;
if mode == UploadMode::Stor {
if let Some(offset) = resume_at {
send(&mut con.ctrl, &rest_command(offset))?;
let (c, m) = read_reply(&mut con.ctrl)?;
if c != 350 {
return Err(Error::BadResponse(format!("ftp REST: {c} {m}")));
}
}
}
let mut data = open_data(&mut con.ctrl, url, con.ctrl_peer_ip)?;
let ctrl = &mut con.ctrl;
send(ctrl, &cmd)?;
let (c, m) = read_reply(ctrl)?;
if !(c == 125 || c == 150) {
return Err(Error::BadResponse(format!("ftp {cmd}: {c} {m}")));
}
data.write_all(body)?;
data.flush()?;
drop(data);
let (cf, mf) = read_reply(ctrl)?;
if !is_positive(cf) {
return Err(Error::BadResponse(format!(
"ftp {} end: {cf} {mf}",
mode.verb()
)));
}
let _ = send(ctrl, "QUIT");
let _ = read_reply(ctrl);
Ok(())
}
fn open_passive<R: Read + Write>(
ctrl: &mut BufReader<R>,
fallback_host: &str,
ctrl_peer_ip: std::net::IpAddr,
) -> Result<(String, u16)> {
send(ctrl, "EPSV")?;
let (c, m) = read_reply(ctrl)?;
if c == 229 {
let port = parse_epsv(&m)
.ok_or_else(|| Error::BadResponse(format!("ftp EPSV: cannot parse reply: {m}")))?;
return Ok((fallback_host.to_string(), port));
}
if !(400..600).contains(&c) {
return Err(Error::BadResponse(format!("ftp EPSV: {c} {m}")));
}
send(ctrl, "PASV")?;
let (c2, m2) = read_reply(ctrl)?;
if c2 != 227 {
return Err(Error::BadResponse(format!("ftp PASV: {c2} {m2}")));
}
let (_ignored_host, port) = parse_pasv(&m2)
.ok_or_else(|| Error::BadResponse(format!("ftp PASV: cannot parse: {m2}")))?;
Ok((ctrl_peer_ip.to_string(), port))
}
fn send<R: Read + Write>(r: &mut BufReader<R>, line: &str) -> Result<()> {
if line.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
return Err(Error::BadResponse(
"ftp: refusing to send command line with embedded CR/LF/NUL".into(),
));
}
let w = r.get_mut();
w.write_all(line.as_bytes())?;
w.write_all(b"\r\n")?;
w.flush()?;
Ok(())
}
fn reject_ctl(s: &str, what: &str) -> Result<()> {
if let Some(b) = s.bytes().find(|b| *b < 0x20 || *b == 0x7f) {
return Err(Error::BadResponse(format!(
"ftp: {what} contains illegal control byte {b:#04x}"
)));
}
Ok(())
}
fn read_reply<R: BufRead>(r: &mut R) -> Result<(u16, String)> {
let first = read_line(r)?;
let (code, sep, rest) = split_code(&first)?;
let mut text = rest.to_string();
if sep == ' ' {
return Ok((code, text));
}
loop {
let line = read_line(r)?;
if let Ok((c, s, rest)) = split_code(&line) {
text.push('\n');
text.push_str(rest);
if c == code && s == ' ' {
return Ok((code, text));
}
} else {
text.push('\n');
text.push_str(line.trim_end_matches(['\r', '\n']));
}
}
}
fn read_line<R: BufRead>(r: &mut R) -> Result<String> {
let mut buf = String::new();
let n = r.read_line(&mut buf)?;
if n == 0 {
return Err(Error::UnexpectedEof);
}
Ok(buf)
}
fn split_code(line: &str) -> Result<(u16, char, &str)> {
let bytes = line.as_bytes();
if bytes.len() < 4
|| !bytes[0].is_ascii_digit()
|| !bytes[1].is_ascii_digit()
|| !bytes[2].is_ascii_digit()
{
return Err(Error::BadResponse(format!(
"ftp reply: no 3-digit code: {}",
line.trim_end()
)));
}
let sep = bytes[3] as char;
if sep != ' ' && sep != '-' {
return Err(Error::BadResponse(format!(
"ftp reply: bad separator: {}",
line.trim_end()
)));
}
let code: u16 = line[..3].parse().unwrap(); let rest = line[4..].trim_end_matches(['\r', '\n']);
Ok((code, sep, rest))
}
fn parse_pasv(text: &str) -> Option<(String, u16)> {
let open = text.find('(')?;
let close = text[open..].find(')')? + open;
let inner = &text[open + 1..close];
let parts: Vec<&str> = inner.split(',').map(str::trim).collect();
if parts.len() != 6 {
return None;
}
let nums: Vec<u16> = parts.iter().filter_map(|p| p.parse::<u16>().ok()).collect();
if nums.len() != 6 || nums.iter().any(|&n| n > 255) {
return None;
}
let host = format!("{}.{}.{}.{}", nums[0], nums[1], nums[2], nums[3]);
let port = ((nums[4] as u8 as u16) << 8) | nums[5] as u8 as u16;
Some((host, port))
}
fn parse_epsv(text: &str) -> Option<u16> {
let open = text.find('(')?;
let close = text[open..].rfind(')')? + open;
let inner = text.get(open + 1..close)?;
let mut chars = inner.chars();
let delim = chars.next()?;
let bytes: Vec<char> = inner.chars().collect();
let mut count = 0usize;
let mut start = None;
let mut end = None;
for (i, ch) in bytes.iter().enumerate() {
if *ch == delim {
count += 1;
if count == 3 {
start = Some(i + 1);
} else if count == 4 {
end = Some(i);
break;
}
}
}
let s = start?;
let e = end?;
let port_str: String = bytes[s..e].iter().collect();
port_str.parse().ok()
}
fn split_userinfo(ui: Option<&str>) -> (String, String) {
match ui {
None => ("anonymous".to_string(), "rsurl@".to_string()),
Some(s) => match s.split_once(':') {
Some((u, p)) => (u.to_string(), p.to_string()),
None => (s.to_string(), "rsurl@".to_string()),
},
}
}
fn is_positive(code: u16) -> bool {
(200..400).contains(&code)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn cur(s: &str) -> BufReader<Cursor<Vec<u8>>> {
BufReader::new(Cursor::new(s.as_bytes().to_vec()))
}
#[test]
fn read_reply_single_line() {
let mut r = cur("220 ProFTPD ready\r\n");
let (code, text) = read_reply(&mut r).unwrap();
assert_eq!(code, 220);
assert_eq!(text, "ProFTPD ready");
}
#[test]
fn read_reply_multi_line() {
let raw = "220-Welcome to the FTP server\r\n\
220-We have rules\r\n\
220 End of banner\r\n";
let mut r = cur(raw);
let (code, text) = read_reply(&mut r).unwrap();
assert_eq!(code, 220);
assert!(text.contains("Welcome"));
assert!(text.contains("End of banner"));
}
#[test]
fn read_reply_multi_line_continuation_without_code() {
let raw = "230-User logged in\r\n please read MOTD\r\n230 ok\r\n";
let mut r = cur(raw);
let (code, text) = read_reply(&mut r).unwrap();
assert_eq!(code, 230);
assert!(text.contains("User logged in"));
assert!(text.contains("please read MOTD"));
assert!(text.contains("ok"));
}
#[test]
fn read_reply_eof_is_error() {
let mut r = cur("");
assert!(matches!(read_reply(&mut r), Err(Error::UnexpectedEof)));
}
#[test]
fn read_reply_rejects_garbage() {
let mut r = cur("hello world\r\n");
assert!(matches!(read_reply(&mut r), Err(Error::BadResponse(_))));
}
#[test]
fn pasv_parses_canonical() {
let (host, port) = parse_pasv("Entering Passive Mode (10,0,0,1,4,5)").unwrap();
assert_eq!(host, "10.0.0.1");
assert_eq!(port, 4 * 256 + 5); }
#[test]
fn pasv_parses_with_prefix_code_text() {
let (host, port) = parse_pasv("Entering Passive Mode (192,168,1,2,200,100).").unwrap();
assert_eq!(host, "192.168.1.2");
assert_eq!(port, 200 * 256 + 100);
}
#[test]
fn pasv_rejects_short() {
assert!(parse_pasv("nope").is_none());
assert!(parse_pasv("(1,2,3)").is_none());
assert!(parse_pasv("(256,0,0,1,1,1)").is_none()); }
#[test]
fn pasv_rejects_out_of_range_port_bytes() {
assert!(parse_pasv("(10,0,0,1,256,5)").is_none());
assert!(parse_pasv("(10,0,0,1,5,256)").is_none());
let (_, port) = parse_pasv("(10,0,0,1,255,255)").unwrap();
assert_eq!(port, 65535);
}
struct MockIo {
to_read: std::io::Cursor<Vec<u8>>,
written: Vec<u8>,
}
impl Read for MockIo {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.to_read.read(buf)
}
}
impl Write for MockIo {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.written.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn open_passive_pasv_dials_control_peer_not_reply_ip() {
use std::net::{IpAddr, Ipv4Addr};
let script = "500 EPSV not understood\r\n227 Entering Passive Mode (10,0,0,1,4,5)\r\n";
let mut io = BufReader::new(MockIo {
to_read: std::io::Cursor::new(script.as_bytes().to_vec()),
written: Vec::new(),
});
let ctrl_peer = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7));
let (host, port) = open_passive(&mut io, "ftp.example.com", ctrl_peer).unwrap();
assert_eq!(host, "203.0.113.7");
assert_eq!(port, 4 * 256 + 5);
}
#[test]
fn send_rejects_embedded_crlf() {
let mut io = BufReader::new(MockIo {
to_read: std::io::Cursor::new(Vec::new()),
written: Vec::new(),
});
assert!(matches!(
send(&mut io, "USER alice\r\nDELE secret"),
Err(Error::BadResponse(_))
));
assert!(matches!(
send(&mut io, "USER alice\nNOOP"),
Err(Error::BadResponse(_))
));
assert!(matches!(
send(&mut io, "USER alice\0bob"),
Err(Error::BadResponse(_))
));
send(&mut io, "USER alice").unwrap();
assert_eq!(io.get_ref().written, b"USER alice\r\n");
}
#[test]
fn reject_ctl_flags_control_bytes() {
assert!(reject_ctl("alice", "ftp user").is_ok());
assert!(reject_ctl("a/b/c.txt", "ftp path").is_ok());
assert!(reject_ctl("alice\r\nPASS x", "ftp user").is_err());
assert!(reject_ctl("a\nb", "ftp path").is_err());
assert!(reject_ctl("a\0b", "ftp user").is_err());
assert!(reject_ctl("a\x7fb", "ftp user").is_err()); }
#[test]
fn epsv_parses_canonical() {
let port = parse_epsv("Entering Extended Passive Mode (|||45678|)").unwrap();
assert_eq!(port, 45678);
}
#[test]
fn epsv_parses_alternative_delimiter() {
let port = parse_epsv("(!!!2121!)").unwrap();
assert_eq!(port, 2121);
}
#[test]
fn epsv_rejects_garbage() {
assert!(parse_epsv("nope").is_none());
assert!(parse_epsv("(|||abc|)").is_none());
}
#[test]
fn split_userinfo_defaults_to_anonymous() {
let (u, p) = split_userinfo(None);
assert_eq!(u, "anonymous");
assert_eq!(p, "rsurl@");
}
#[test]
fn split_userinfo_user_only() {
let (u, p) = split_userinfo(Some("alice"));
assert_eq!(u, "alice");
assert_eq!(p, "rsurl@");
}
#[test]
fn split_userinfo_user_pass() {
let (u, p) = split_userinfo(Some("alice:secret"));
assert_eq!(u, "alice");
assert_eq!(p, "secret");
}
#[test]
fn split_userinfo_pass_with_colon() {
let (u, p) = split_userinfo(Some("alice:s:e:c"));
assert_eq!(u, "alice");
assert_eq!(p, "s:e:c");
}
#[test]
fn split_code_parses_space_and_dash() {
let (c, s, r) = split_code("200 OK\r\n").unwrap();
assert_eq!(c, 200);
assert_eq!(s, ' ');
assert_eq!(r, "OK");
let (c, s, r) = split_code("220-banner\r\n").unwrap();
assert_eq!(c, 220);
assert_eq!(s, '-');
assert_eq!(r, "banner");
}
#[test]
fn fetch_rejects_non_ftp_scheme() {
let u = Url::parse("http://example.com/").unwrap();
assert!(matches!(fetch(&u), Err(Error::UnsupportedScheme(_))));
}
#[test]
fn store_rejects_non_ftp_scheme() {
let u = Url::parse("http://example.com/x").unwrap();
assert!(matches!(
store(&u, b"data", None),
Err(Error::UnsupportedScheme(_))
));
}
#[test]
fn stor_command_strips_leading_slash() {
assert_eq!(stor_command("/pub/file.bin").unwrap(), "STOR pub/file.bin");
assert_eq!(stor_command("file.bin").unwrap(), "STOR file.bin");
assert_eq!(stor_command("/a.txt").unwrap(), "STOR a.txt");
}
#[test]
fn stor_command_rejects_directory_path() {
assert!(stor_command("").is_none());
assert!(stor_command("/").is_none());
assert!(stor_command("/pub/").is_none());
}
#[test]
fn appe_command_strips_leading_slash() {
assert_eq!(appe_command("/pub/file.bin").unwrap(), "APPE pub/file.bin");
assert_eq!(appe_command("file.bin").unwrap(), "APPE file.bin");
assert_eq!(appe_command("/a.txt").unwrap(), "APPE a.txt");
}
#[test]
fn appe_command_rejects_directory_path() {
assert!(appe_command("").is_none());
assert!(appe_command("/").is_none());
assert!(appe_command("/pub/").is_none());
}
#[test]
fn appe_command_rejects_control_bytes() {
let mut io = BufReader::new(MockIo {
to_read: std::io::Cursor::new(Vec::new()),
written: Vec::new(),
});
assert!(reject_ctl("a\r\nDELE secret", "ftp path").is_err());
assert!(reject_ctl("a\nb", "ftp path").is_err());
assert!(reject_ctl("a\0b", "ftp path").is_err());
assert!(matches!(
send(&mut io, "APPE a\r\nDELE secret"),
Err(Error::BadResponse(_))
));
}
#[test]
fn rest_command_formats_offset() {
assert_eq!(rest_command(0), "REST 0");
assert_eq!(rest_command(1048576), "REST 1048576");
assert_eq!(rest_command(u64::MAX), format!("REST {}", u64::MAX));
}
fn run_store_mock(
url: &str,
body: &[u8],
resume_at: Option<u64>,
) -> (Result<()>, Vec<u8>, Vec<u8>) {
run_upload_mock(url, body, UploadMode::Stor, resume_at)
}
fn run_upload_mock(
url: &str,
body: &[u8],
mode: UploadMode,
resume_at: Option<u64>,
) -> (Result<()>, Vec<u8>, Vec<u8>) {
use std::net::{Ipv4Addr, TcpListener};
use std::sync::mpsc;
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let data_port = listener.local_addr().unwrap().port();
let (p1, p2) = ((data_port >> 8) as u8, (data_port & 0xff) as u8);
let (tx, rx) = mpsc::channel();
let data_thread = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
let mut buf = Vec::new();
sock.read_to_end(&mut buf).unwrap();
tx.send(buf).unwrap();
});
let mut script = String::from(
"220 ready\r\n\
331 need pass\r\n\
230 logged in\r\n\
200 type ok\r\n",
);
if mode == UploadMode::Stor && resume_at.is_some() {
script.push_str("350 restart ok\r\n");
}
script.push_str(&format!(
"500 epsv?\r\n\
227 Entering Passive Mode (127,0,0,1,{p1},{p2})\r\n\
150 ok to send\r\n\
226 transfer complete\r\n\
221 bye\r\n"
));
let mut ctrl = BufReader::new(MockIo {
to_read: std::io::Cursor::new(script.into_bytes()),
written: Vec::new(),
});
let ctrl_peer = std::net::IpAddr::V4(Ipv4Addr::LOCALHOST);
let parsed = Url::parse(url).unwrap();
let result = (|| -> Result<()> {
for _ in 0..4 {
read_reply(&mut ctrl)?;
}
reject_ctl(&parsed.path, "ftp path")?;
let cmd = upload_command(mode, &parsed.path)
.ok_or_else(|| Error::BadResponse("no file".into()))?;
if mode == UploadMode::Stor {
if let Some(offset) = resume_at {
send(&mut ctrl, &rest_command(offset))?;
let (c, _) = read_reply(&mut ctrl)?;
if c != 350 {
return Err(Error::BadResponse(format!("REST: {c}")));
}
}
}
let mut data = open_data(&mut ctrl, &parsed, ctrl_peer)?;
send(&mut ctrl, &cmd)?;
let (c, m) = read_reply(&mut ctrl)?;
if !(c == 125 || c == 150) {
return Err(Error::BadResponse(format!("{cmd}: {c} {m}")));
}
data.write_all(body)?;
data.flush()?;
drop(data);
let (cf, mf) = read_reply(&mut ctrl)?;
if !is_positive(cf) {
return Err(Error::BadResponse(format!("end: {cf} {mf}")));
}
let _ = send(&mut ctrl, "QUIT");
let _ = read_reply(&mut ctrl);
Ok(())
})();
let received = rx.recv().unwrap();
data_thread.join().unwrap();
let written = ctrl.get_ref().written.clone();
(result, written, received)
}
#[test]
fn store_streams_body_and_sends_stor() {
let (res, written, received) =
run_store_mock("ftp://h.example/pub/up.bin", b"hello ftp", None);
res.unwrap();
let sent = String::from_utf8(written).unwrap();
assert!(sent.contains("STOR pub/up.bin\r\n"), "sent: {sent:?}");
assert!(!sent.contains("REST"), "no REST without offset: {sent:?}");
assert!(sent.contains("QUIT\r\n"));
assert_eq!(received, b"hello ftp");
}
#[test]
fn store_with_resume_sends_rest_before_stor() {
let (res, written, received) =
run_store_mock("ftp://h.example/up.bin", b"TAIL", Some(4096));
res.unwrap();
let sent = String::from_utf8(written).unwrap();
let rest_at = sent.find("REST 4096\r\n").expect("REST sent");
let stor_at = sent.find("STOR up.bin\r\n").expect("STOR sent");
assert!(rest_at < stor_at, "REST must precede STOR: {sent:?}");
assert_eq!(received, b"TAIL");
}
#[test]
fn append_streams_body_and_sends_appe() {
let (res, written, received) = run_upload_mock(
"ftp://h.example/pub/up.bin",
b"more data",
UploadMode::Appe,
None,
);
res.unwrap();
let sent = String::from_utf8(written).unwrap();
assert!(sent.contains("APPE pub/up.bin\r\n"), "sent: {sent:?}");
assert!(!sent.contains("STOR"), "must not send STOR: {sent:?}");
assert!(!sent.contains("REST"), "must not send REST: {sent:?}");
assert!(sent.contains("QUIT\r\n"));
assert_eq!(received, b"more data");
}
#[test]
fn append_ignores_resume_offset() {
let (res, written, received) = run_upload_mock(
"ftp://h.example/up.bin",
b"WHOLE",
UploadMode::Appe,
Some(4096),
);
res.unwrap();
let sent = String::from_utf8(written).unwrap();
assert!(!sent.contains("REST"), "APPE must not send REST: {sent:?}");
assert!(sent.contains("APPE up.bin\r\n"), "sent: {sent:?}");
assert_eq!(received, b"WHOLE");
}
#[test]
fn append_rejects_non_ftp_scheme() {
let u = Url::parse("http://example.com/x").unwrap();
assert!(matches!(
append(&u, b"data"),
Err(Error::UnsupportedScheme(_))
));
}
}