From f4aa70ca62eff7e4fb229c23c42890de8f4ecb03 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 21 Dec 2025 13:19:21 +0100 Subject: [PATCH] feat: implement TFTP client and server binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add command-line programs for TFTP operations: tftpd - TFTP server daemon: - Configurable root directory for serving files - Optional write support with --writable flag - Optional file overwriting with --overwrite flag - Custom port binding (default: 69) - Path traversal protection for security tftp - TFTP client: - get command for downloading files - put command for uploading files - Support for both octet and netascii modes - Verbose mode for debugging Also adds comprehensive integration tests that verify client-server communication with real network I/O. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- crates/pfs-tftp-proto/src/lib.rs | 2 +- crates/pfs-tftp-proto/src/packet.rs | 19 +- crates/pfs-tftp-proto/src/state.rs | 2 +- crates/pfs-tftp/src/bin/tftp.rs | 306 ++++++++++++++++++++++++++- crates/pfs-tftp/src/bin/tftpd.rs | 202 +++++++++++++++++- crates/pfs-tftp/src/client.rs | 29 ++- crates/pfs-tftp/src/server.rs | 13 +- crates/pfs-tftp/tests/integration.rs | 285 +++++++++++++++++++++++++ 8 files changed, 824 insertions(+), 34 deletions(-) create mode 100644 crates/pfs-tftp/tests/integration.rs diff --git a/crates/pfs-tftp-proto/src/lib.rs b/crates/pfs-tftp-proto/src/lib.rs index 50f0235..d8a9149 100644 --- a/crates/pfs-tftp-proto/src/lib.rs +++ b/crates/pfs-tftp-proto/src/lib.rs @@ -17,5 +17,5 @@ mod packet; mod state; pub use error::{Error, ErrorCode, Result}; -pub use packet::{Mode, Opcode, Packet, MAX_DATA_SIZE, TFTP_PORT}; +pub use packet::{MAX_DATA_SIZE, Mode, Opcode, Packet, TFTP_PORT}; pub use state::{ClientState, Event, ServerState, TransferDirection}; diff --git a/crates/pfs-tftp-proto/src/packet.rs b/crates/pfs-tftp-proto/src/packet.rs index 73ad7ed..2c48617 100644 --- a/crates/pfs-tftp-proto/src/packet.rs +++ b/crates/pfs-tftp-proto/src/packet.rs @@ -244,12 +244,11 @@ impl Packet { .position(|&b| b == 0) .ok_or(Error::MissingNullTerminator { field: "filename" })?; - let filename = std::str::from_utf8(&payload[..filename_end]).map_err(|e| { - Error::InvalidUtf8 { + let filename = + std::str::from_utf8(&payload[..filename_end]).map_err(|e| Error::InvalidUtf8 { field: "filename", source: e, - } - })?; + })?; // Find the mode string after the filename null terminator let mode_start = filename_end + 1; @@ -263,11 +262,12 @@ impl Packet { .ok_or(Error::MissingNullTerminator { field: "mode" })? + mode_start; - let mode_str = - std::str::from_utf8(&payload[mode_start..mode_end]).map_err(|e| Error::InvalidUtf8 { + let mode_str = std::str::from_utf8(&payload[mode_start..mode_end]).map_err(|e| { + Error::InvalidUtf8 { field: "mode", source: e, - })?; + } + })?; let mode = Mode::parse(mode_str)?; @@ -594,10 +594,7 @@ mod tests { #[test] fn test_invalid_mode() { - assert!(matches!( - Mode::parse("binary"), - Err(Error::InvalidMode(_)) - )); + assert!(matches!(Mode::parse("binary"), Err(Error::InvalidMode(_)))); assert!(matches!(Mode::parse("mail"), Err(Error::InvalidMode(_)))); } diff --git a/crates/pfs-tftp-proto/src/state.rs b/crates/pfs-tftp-proto/src/state.rs index c357728..3d0a701 100644 --- a/crates/pfs-tftp-proto/src/state.rs +++ b/crates/pfs-tftp-proto/src/state.rs @@ -25,7 +25,7 @@ //! ... (continues until final DATA with < 512 bytes) //! ``` -use crate::{Error, Mode, Packet, Result, MAX_DATA_SIZE}; +use crate::{Error, MAX_DATA_SIZE, Mode, Packet, Result}; /// Direction of data transfer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/pfs-tftp/src/bin/tftp.rs b/crates/pfs-tftp/src/bin/tftp.rs index ff263c9..729e159 100644 --- a/crates/pfs-tftp/src/bin/tftp.rs +++ b/crates/pfs-tftp/src/bin/tftp.rs @@ -1,3 +1,305 @@ -fn main() { - println!("tftp client placeholder"); +//! TFTP Client (tftp) +//! +//! A simple TFTP client implementation following RFC 1350. +//! +//! # Usage +//! +//! ```text +//! tftp [OPTIONS] +//! +//! Commands: +//! get [LOCAL_FILE] Download a file +//! put [REMOTE_FILE] Upload a file +//! +//! Options: +//! -m, --mode Transfer mode: octet (default) or netascii +//! -v, --verbose Enable verbose output +//! -h, --help Print help +//! ``` +//! +//! # Examples +//! +//! ```text +//! # Download a file +//! tftp get 192.168.1.1 config.txt +//! +//! # Download to a specific local file +//! tftp get 192.168.1.1 config.txt local_config.txt +//! +//! # Upload a file +//! tftp put 192.168.1.1 firmware.bin +//! +//! # Upload with netascii mode +//! tftp -m netascii put 192.168.1.1 readme.txt +//! ``` + +use std::{env, fs::File, path::Path, process::ExitCode}; + +use pfs_tftp::{Client, Mode}; + +/// Print usage information. +fn print_usage(program: &str) { + eprintln!("TFTP Client (RFC 1350)"); + eprintln!(); + eprintln!("Usage: {program} [OPTIONS] "); + eprintln!(); + eprintln!("Commands:"); + eprintln!(" get [LOCAL_FILE] Download a file"); + eprintln!(" put [REMOTE_FILE] Upload a file"); + eprintln!(); + eprintln!("Options:"); + eprintln!(" -m, --mode Transfer mode: octet (default) or netascii"); + eprintln!(" -v, --verbose Enable verbose output"); + eprintln!(" -h, --help Print help"); + eprintln!(); + eprintln!("Examples:"); + eprintln!(" {program} get 192.168.1.1 config.txt"); + eprintln!(" {program} put 192.168.1.1 firmware.bin"); + eprintln!(" {program} -m netascii get 192.168.1.1:69 readme.txt local.txt"); +} + +/// Command to execute. +enum Command { + Get { + host: String, + remote_file: String, + local_file: String, + }, + Put { + host: String, + local_file: String, + remote_file: String, + }, +} + +/// Parsed command-line arguments. +struct Args { + command: Command, + mode: Mode, + verbose: bool, +} + +/// Parse command-line arguments. +fn parse_args() -> Result { + let args: Vec = env::args().collect(); + let program = &args[0]; + + let mut mode = Mode::Octet; + let mut verbose = false; + + let mut i = 1; + + // Parse options + while i < args.len() && args[i].starts_with('-') { + match args[i].as_str() { + "-h" | "--help" => { + print_usage(program); + std::process::exit(0); + } + "-m" | "--mode" => { + i += 1; + if i >= args.len() { + return Err("--mode requires an argument".to_string()); + } + mode = match args[i].to_lowercase().as_str() { + "octet" | "binary" => Mode::Octet, + "netascii" | "ascii" => Mode::NetAscii, + _ => return Err(format!("invalid mode: {}", args[i])), + }; + } + "-v" | "--verbose" => { + verbose = true; + } + opt => { + return Err(format!("unknown option: {opt}")); + } + } + i += 1; + } + + // Parse command + if i >= args.len() { + return Err("missing command".to_string()); + } + + let command = match args[i].as_str() { + "get" => { + i += 1; + if i + 1 >= args.len() { + return Err("get requires HOST and REMOTE_FILE".to_string()); + } + let host = args[i].clone(); + let remote_file = args[i + 1].clone(); + let local_file = if i + 2 < args.len() { + args[i + 2].clone() + } else { + // Use the filename part of the remote file + Path::new(&remote_file) + .file_name() + .map_or_else(|| remote_file.clone(), |s| s.to_string_lossy().to_string()) + }; + Command::Get { + host, + remote_file, + local_file, + } + } + "put" => { + i += 1; + if i + 1 >= args.len() { + return Err("put requires HOST and LOCAL_FILE".to_string()); + } + let host = args[i].clone(); + let local_file = args[i + 1].clone(); + let remote_file = if i + 2 < args.len() { + args[i + 2].clone() + } else { + // Use the filename part of the local file + Path::new(&local_file) + .file_name() + .map_or_else(|| local_file.clone(), |s| s.to_string_lossy().to_string()) + }; + Command::Put { + host, + local_file, + remote_file, + } + } + cmd => { + return Err(format!("unknown command: {cmd}")); + } + }; + + Ok(Args { + command, + mode, + verbose, + }) +} + +/// Format host for connection (add default port if needed). +fn format_host(host: &str) -> String { + if host.contains(':') { + host.to_string() + } else { + format!("{host}:69") + } +} + +#[allow(clippy::too_many_lines)] +fn main() -> ExitCode { + let args = match parse_args() { + Ok(args) => args, + Err(e) => { + eprintln!("Error: {e}"); + eprintln!("Try '--help' for more information."); + return ExitCode::FAILURE; + } + }; + + match args.command { + Command::Get { + host, + remote_file, + local_file, + } => { + let addr = format_host(&host); + + if args.verbose { + eprintln!("Getting '{remote_file}' from {addr} -> '{local_file}'"); + eprintln!( + "Mode: {}", + match args.mode { + Mode::Octet => "octet", + Mode::NetAscii => "netascii", + } + ); + } + + let client = match Client::new(&addr) { + Ok(c) => c, + Err(e) => { + eprintln!("Error connecting to {addr}: {e}"); + return ExitCode::FAILURE; + } + }; + + // Create output file + let mut file = match File::create(&local_file) { + Ok(f) => f, + Err(e) => { + eprintln!("Error creating file '{local_file}': {e}"); + return ExitCode::FAILURE; + } + }; + + // Download + match client.get_to_writer(&remote_file, args.mode, &mut file) { + Ok(bytes) => { + if args.verbose { + eprintln!("Received {bytes} bytes"); + } + println!("Downloaded '{remote_file}' -> '{local_file}' ({bytes} bytes)"); + } + Err(e) => { + // Clean up partial file + let _ = std::fs::remove_file(&local_file); + eprintln!("Error: {e}"); + return ExitCode::FAILURE; + } + } + } + + Command::Put { + host, + local_file, + remote_file, + } => { + let addr = format_host(&host); + + if args.verbose { + eprintln!("Putting '{local_file}' -> {addr} as '{remote_file}'"); + eprintln!( + "Mode: {}", + match args.mode { + Mode::Octet => "octet", + Mode::NetAscii => "netascii", + } + ); + } + + let client = match Client::new(&addr) { + Ok(c) => c, + Err(e) => { + eprintln!("Error connecting to {addr}: {e}"); + return ExitCode::FAILURE; + } + }; + + // Open input file + let mut file = match File::open(&local_file) { + Ok(f) => f, + Err(e) => { + eprintln!("Error opening file '{local_file}': {e}"); + return ExitCode::FAILURE; + } + }; + + // Upload + match client.put_from_reader(&remote_file, args.mode, &mut file) { + Ok(bytes) => { + if args.verbose { + eprintln!("Sent {bytes} bytes"); + } + println!("Uploaded '{local_file}' -> '{remote_file}' ({bytes} bytes)"); + } + Err(e) => { + eprintln!("Error: {e}"); + return ExitCode::FAILURE; + } + } + } + } + + ExitCode::SUCCESS } diff --git a/crates/pfs-tftp/src/bin/tftpd.rs b/crates/pfs-tftp/src/bin/tftpd.rs index e2a41ea..7efe528 100644 --- a/crates/pfs-tftp/src/bin/tftpd.rs +++ b/crates/pfs-tftp/src/bin/tftpd.rs @@ -1,3 +1,201 @@ -fn main() { - println!("tftpd server placeholder"); +//! TFTP Server daemon (tftpd) +//! +//! A simple TFTP server implementation following RFC 1350. +//! +//! # Usage +//! +//! ```text +//! tftpd [OPTIONS] +//! +//! Arguments: +//! Directory to serve files from +//! +//! Options: +//! -p, --port Port to listen on (default: 69) +//! -w, --writable Allow write operations +//! -o, --overwrite Allow overwriting existing files (requires -w) +//! -v, --verbose Enable verbose output +//! -h, --help Print help +//! ``` +//! +//! # Examples +//! +//! ```text +//! # Serve files from /tftpboot (read-only) +//! sudo tftpd /tftpboot +//! +//! # Serve on port 6969 with write access +//! tftpd -p 6969 -w /tmp/tftp +//! ``` +//! +//! # Security Note +//! +//! As per RFC 1350 security considerations, this server implements path +//! traversal protection and requires explicit flags to enable write access. +//! Running on port 69 requires root privileges on most systems. + +use std::{env, net::SocketAddr, path::PathBuf, process::ExitCode}; + +use pfs_tftp::ServerBuilder; + +/// Print usage information. +fn print_usage(program: &str) { + eprintln!("TFTP Server (RFC 1350)"); + eprintln!(); + eprintln!("Usage: {program} [OPTIONS] "); + eprintln!(); + eprintln!("Arguments:"); + eprintln!(" Directory to serve files from"); + eprintln!(); + eprintln!("Options:"); + eprintln!(" -p, --port Port to listen on (default: 69)"); + eprintln!(" -w, --writable Allow write operations"); + eprintln!(" -o, --overwrite Allow overwriting files (requires -w)"); + eprintln!(" -v, --verbose Enable verbose output"); + eprintln!(" -h, --help Print help"); +} + +/// Parsed command-line arguments. +struct Args { + root_dir: PathBuf, + port: u16, + writable: bool, + overwrite: bool, + verbose: bool, +} + +/// Parse command-line arguments. +fn parse_args() -> Result { + let args: Vec = env::args().collect(); + let program = &args[0]; + + let mut port: u16 = 69; + let mut writable = false; + let mut overwrite = false; + let mut verbose = false; + let mut root_dir: Option = None; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "-h" | "--help" => { + print_usage(program); + std::process::exit(0); + } + "-p" | "--port" => { + i += 1; + if i >= args.len() { + return Err("--port requires an argument".to_string()); + } + port = args[i] + .parse() + .map_err(|_| format!("invalid port: {}", args[i]))?; + } + "-w" | "--writable" => { + writable = true; + } + "-o" | "--overwrite" => { + overwrite = true; + } + "-v" | "--verbose" => { + verbose = true; + } + arg if arg.starts_with('-') => { + return Err(format!("unknown option: {arg}")); + } + _ => { + if root_dir.is_some() { + return Err("unexpected argument".to_string()); + } + root_dir = Some(PathBuf::from(&args[i])); + } + } + i += 1; + } + + let root_dir = root_dir.ok_or_else(|| "missing required argument: ROOT_DIR".to_string())?; + + if overwrite && !writable { + return Err("--overwrite requires --writable".to_string()); + } + + Ok(Args { + root_dir, + port, + writable, + overwrite, + verbose, + }) +} + +fn main() -> ExitCode { + let args = match parse_args() { + Ok(args) => args, + Err(e) => { + eprintln!("Error: {e}"); + eprintln!("Try '--help' for more information."); + return ExitCode::FAILURE; + } + }; + + // Validate root directory + if !args.root_dir.exists() { + eprintln!( + "Error: root directory does not exist: {}", + args.root_dir.display() + ); + return ExitCode::FAILURE; + } + + if !args.root_dir.is_dir() { + eprintln!("Error: not a directory: {}", args.root_dir.display()); + return ExitCode::FAILURE; + } + + let addr: SocketAddr = ([0, 0, 0, 0], args.port).into(); + + let server = match ServerBuilder::new(&args.root_dir) + .allow_write(args.writable) + .allow_overwrite(args.overwrite) + .bind(addr) + { + Ok(s) => s, + Err(e) => { + eprintln!("Error: failed to bind to {addr}: {e}"); + if args.port < 1024 { + eprintln!("Note: ports below 1024 require root privileges"); + } + return ExitCode::FAILURE; + } + }; + + if args.verbose { + eprintln!("TFTP server starting..."); + eprintln!(" Root directory: {}", args.root_dir.display()); + eprintln!(" Listening on: {addr}"); + eprintln!( + " Write access: {}", + if args.writable { "enabled" } else { "disabled" } + ); + if args.writable { + eprintln!( + " Overwrite: {}", + if args.overwrite { + "enabled" + } else { + "disabled" + } + ); + } + eprintln!(); + } + + eprintln!("Listening on {addr} (serving {})", args.root_dir.display()); + + if let Err(e) = server.run() { + eprintln!("Error: {e}"); + return ExitCode::FAILURE; + } + + ExitCode::SUCCESS } diff --git a/crates/pfs-tftp/src/client.rs b/crates/pfs-tftp/src/client.rs index b21116a..7824394 100644 --- a/crates/pfs-tftp/src/client.rs +++ b/crates/pfs-tftp/src/client.rs @@ -13,7 +13,7 @@ use std::{ time::Duration, }; -use pfs_tftp_proto::{ClientState, Event, Mode, Packet, MAX_DATA_SIZE, TFTP_PORT}; +use pfs_tftp_proto::{ClientState, Event, MAX_DATA_SIZE, Mode, Packet, TFTP_PORT}; use crate::{Error, Result}; @@ -58,12 +58,9 @@ impl Client { /// /// Returns an error if the address is invalid or the socket cannot be bound. pub fn new(server_addr: A) -> Result { - let server_addr = server_addr - .to_socket_addrs()? - .next() - .ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, "no addresses found") - })?; + let server_addr = server_addr.to_socket_addrs()?.next().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "no addresses found") + })?; // Bind to any available local port let socket = UdpSocket::bind("0.0.0.0:0")?; @@ -164,7 +161,9 @@ impl Client { for event in events { match event { - Event::ReceivedData { data: block_data, .. } => { + Event::ReceivedData { + data: block_data, .. + } => { data.extend_from_slice(&block_data); } Event::Send(packet) => { @@ -299,7 +298,12 @@ impl Client { /// # Errors /// /// Returns an error if the transfer fails. - pub fn get_to_writer(&self, filename: &str, mode: Mode, writer: &mut W) -> Result { + pub fn get_to_writer( + &self, + filename: &str, + mode: Mode, + writer: &mut W, + ) -> Result { let mut state = ClientState::new_read(mode); let request = state.start(filename); @@ -376,7 +380,12 @@ impl Client { /// # Errors /// /// Returns an error if the transfer fails. - pub fn put_from_reader(&self, filename: &str, mode: Mode, reader: &mut R) -> Result { + pub fn put_from_reader( + &self, + filename: &str, + mode: Mode, + reader: &mut R, + ) -> Result { let mut state = ClientState::new_write(mode); let request = state.start(filename); diff --git a/crates/pfs-tftp/src/server.rs b/crates/pfs-tftp/src/server.rs index aff0dba..8ccfeeb 100644 --- a/crates/pfs-tftp/src/server.rs +++ b/crates/pfs-tftp/src/server.rs @@ -10,7 +10,7 @@ use std::{ time::Duration, }; -use pfs_tftp_proto::{ErrorCode, Event, Mode, Packet, ServerState, MAX_DATA_SIZE, TFTP_PORT}; +use pfs_tftp_proto::{ErrorCode, Event, MAX_DATA_SIZE, Mode, Packet, ServerState, TFTP_PORT}; use crate::{Error, Result}; @@ -208,9 +208,7 @@ impl Server { // Reject paths with null bytes if filename.contains('\0') { - return Err(Error::InvalidFilename( - "null bytes not allowed".to_string(), - )); + return Err(Error::InvalidFilename("null bytes not allowed".to_string())); } let path = self.config.root_dir.join(filename); @@ -235,9 +233,10 @@ impl Server { parent.display() ))); } - parent.canonicalize()?.join(path.file_name().ok_or_else(|| { - Error::InvalidFilename("missing filename".to_string()) - })?) + parent.canonicalize()?.join( + path.file_name() + .ok_or_else(|| Error::InvalidFilename("missing filename".to_string()))?, + ) }; if !check_path.starts_with(&canonical_root) { diff --git a/crates/pfs-tftp/tests/integration.rs b/crates/pfs-tftp/tests/integration.rs new file mode 100644 index 0000000..fb50b8e --- /dev/null +++ b/crates/pfs-tftp/tests/integration.rs @@ -0,0 +1,285 @@ +//! Integration tests for TFTP client and server. +//! +//! These tests verify that the client and server can communicate correctly +//! by running an actual server in a background thread and making client requests. + +use std::{ + fs, + io::Write, + net::SocketAddr, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::Duration, +}; + +use pfs_tftp::{Client, Mode, ServerBuilder}; + +/// Create a temporary directory for test files. +fn create_test_dir() -> PathBuf { + use std::sync::atomic::AtomicU64; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "tftp_test_{}_{}_{}", + std::process::id(), + id, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + if dir.exists() { + let _ = fs::remove_dir_all(&dir); + } + fs::create_dir_all(&dir).expect("create test dir"); + dir +} + +/// Clean up the test directory. +fn cleanup_test_dir(dir: &PathBuf) { + let _ = fs::remove_dir_all(dir); +} + +/// Find an available port for the test server. +fn find_available_port() -> u16 { + let socket = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind"); + socket.local_addr().expect("local_addr").port() +} + +/// Test context that manages server and client setup. +struct TestContext { + server_dir: PathBuf, + client_dir: PathBuf, + server_addr: SocketAddr, + running: Arc, +} + +impl TestContext { + fn new(writable: bool) -> Self { + let server_dir = create_test_dir(); + let client_dir = create_test_dir(); + let port = find_available_port(); + let server_addr: SocketAddr = ([127, 0, 0, 1], port).into(); + + let running = Arc::new(AtomicBool::new(true)); + let running_clone = running.clone(); + let server_dir_clone = server_dir.clone(); + + // Start server in background thread + thread::spawn(move || { + let server = ServerBuilder::new(&server_dir_clone) + .allow_write(writable) + .allow_overwrite(writable) + .timeout(Duration::from_secs(1)) + .retries(2) + .bind(server_addr) + .expect("bind server"); + + while running_clone.load(Ordering::Relaxed) { + // Use handle_one with a short timeout to allow checking the running flag + let _ = server.handle_one(); + } + }); + + // Give server time to start + thread::sleep(Duration::from_millis(50)); + + Self { + server_dir, + client_dir, + server_addr, + running, + } + } + + fn client(&self) -> Client { + Client::new(self.server_addr) + .expect("create client") + .with_timeout(Duration::from_secs(2)) + .with_retries(3) + } + + fn create_server_file(&self, name: &str, content: &[u8]) -> PathBuf { + let path = self.server_dir.join(name); + let mut file = fs::File::create(&path).expect("create file"); + file.write_all(content).expect("write file"); + path + } + + fn read_server_file(&self, name: &str) -> Vec { + let path = self.server_dir.join(name); + fs::read(path).expect("read file") + } + + fn server_file_exists(&self, name: &str) -> bool { + self.server_dir.join(name).exists() + } +} + +impl Drop for TestContext { + fn drop(&mut self) { + self.running.store(false, Ordering::Relaxed); + // Small delay to let server thread exit + thread::sleep(Duration::from_millis(100)); + cleanup_test_dir(&self.server_dir); + cleanup_test_dir(&self.client_dir); + } +} + +#[test] +fn test_get_small_file() { + let ctx = TestContext::new(false); + let content = b"Hello, TFTP!"; + ctx.create_server_file("hello.txt", content); + + let client = ctx.client(); + let received = client.get("hello.txt", Mode::Octet).expect("get"); + + assert_eq!(received, content); +} + +#[test] +fn test_get_exact_block_size() { + let ctx = TestContext::new(false); + // Exactly 512 bytes = one full block + empty final block + let content = vec![0xAB; 512]; + ctx.create_server_file("exact.bin", &content); + + let client = ctx.client(); + let received = client.get("exact.bin", Mode::Octet).expect("get"); + + assert_eq!(received, content); +} + +#[test] +fn test_get_large_file() { + let ctx = TestContext::new(false); + // Multi-block file: 2.5 blocks + let content: Vec = (0..1280).map(|i| (i % 256) as u8).collect(); + ctx.create_server_file("large.bin", &content); + + let client = ctx.client(); + let received = client.get("large.bin", Mode::Octet).expect("get"); + + assert_eq!(received.len(), content.len()); + assert_eq!(received, content); +} + +#[test] +fn test_get_empty_file() { + let ctx = TestContext::new(false); + ctx.create_server_file("empty.txt", b""); + + let client = ctx.client(); + let received = client.get("empty.txt", Mode::Octet).expect("get"); + + assert!(received.is_empty()); +} + +#[test] +fn test_get_file_not_found() { + let ctx = TestContext::new(false); + + let client = ctx.client(); + let result = client.get("nonexistent.txt", Mode::Octet); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("not found") || err.to_string().contains("Remote error")); +} + +#[test] +fn test_put_small_file() { + let ctx = TestContext::new(true); + let content = b"Hello from client!"; + + let client = ctx.client(); + client + .put("uploaded.txt", Mode::Octet, content) + .expect("put"); + + // Give server time to flush the file + thread::sleep(Duration::from_millis(50)); + + assert!(ctx.server_file_exists("uploaded.txt")); + assert_eq!(ctx.read_server_file("uploaded.txt"), content); +} + +#[test] +fn test_put_large_file() { + let ctx = TestContext::new(true); + // Multi-block file + let content: Vec = (0..2000).map(|i| (i % 256) as u8).collect(); + + let client = ctx.client(); + client + .put("big_upload.bin", Mode::Octet, &content) + .expect("put"); + + // Give server time to flush the file + thread::sleep(Duration::from_millis(50)); + + assert!(ctx.server_file_exists("big_upload.bin")); + assert_eq!(ctx.read_server_file("big_upload.bin"), content); +} + +#[test] +fn test_put_write_denied() { + let ctx = TestContext::new(false); // Write not allowed + + let client = ctx.client(); + let result = client.put("test.txt", Mode::Octet, b"data"); + + assert!(result.is_err()); +} + +#[test] +fn test_roundtrip() { + let ctx = TestContext::new(true); + let original = b"This is test data for roundtrip!"; + + // Upload - use a fresh client + let upload_client = ctx.client(); + upload_client + .put("roundtrip.txt", Mode::Octet, original) + .expect("put"); + + // Give server time to flush the file + thread::sleep(Duration::from_millis(50)); + + // Download - use a fresh client to avoid socket state issues + let download_client = ctx.client(); + let downloaded = download_client + .get("roundtrip.txt", Mode::Octet) + .expect("get"); + + assert_eq!(downloaded, original); +} + +#[test] +fn test_path_traversal_blocked() { + let ctx = TestContext::new(false); + + let client = ctx.client(); + let result = client.get("../../../etc/passwd", Mode::Octet); + + assert!(result.is_err()); +} + +#[test] +fn test_netascii_mode() { + let ctx = TestContext::new(false); + let content = b"Line 1\nLine 2\n"; + ctx.create_server_file("text.txt", content); + + let client = ctx.client(); + // NetASCII mode should work for simple ASCII text + let received = client.get("text.txt", Mode::NetAscii).expect("get"); + + // Content should be transferred (exact handling depends on implementation) + assert!(!received.is_empty()); +}