diff --git a/crates/pfs-tftp-proto/src/packet.rs b/crates/pfs-tftp-proto/src/packet.rs index 9615305..380fb2a 100644 --- a/crates/pfs-tftp-proto/src/packet.rs +++ b/crates/pfs-tftp-proto/src/packet.rs @@ -363,8 +363,31 @@ mod tests { #[test] fn rejects_data_larger_than_512() { let mut bytes = vec![0, 3, 0, 1]; - bytes.extend(std::iter::repeat(0x61).take(BLOCK_SIZE + 1)); + bytes.extend(std::iter::repeat_n(0x61, BLOCK_SIZE + 1)); let err = Packet::decode(&bytes).unwrap_err(); assert!(matches!(err, DecodeError::OversizeData(513))); } + + #[allow(clippy::unwrap_used)] + #[test] + fn mode_is_case_insensitive() { + let req = Packet::decode(b"\0\x01file\0NeTaScIi\0").unwrap(); + assert!(matches!( + req, + Packet::Rrq(Request { + mode: Mode::NetAscii, + .. + }) + )); + } + + #[allow(clippy::unwrap_used)] + #[test] + fn error_roundtrip() { + let pkt = Packet::Error { + code: ErrorCode::AccessViolation, + message: "nope".to_string(), + }; + assert_eq!(Packet::decode(&pkt.encode()).unwrap(), pkt); + } } diff --git a/crates/pfs-tftp-sync/tests/end_to_end.rs b/crates/pfs-tftp-sync/tests/end_to_end.rs new file mode 100644 index 0000000..b4a50a9 --- /dev/null +++ b/crates/pfs-tftp-sync/tests/end_to_end.rs @@ -0,0 +1,190 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::{ + fs, + net::{SocketAddr, UdpSocket}, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + thread::JoinHandle, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use pfs_tftp_sync::{Client, ClientConfig, Mode, Server, ServerConfig}; + +#[test] +fn octet_put_and_get_roundtrip() { + let server_root = TempDir::new("pfs_tftp_server_root"); + let local_root = TempDir::new("pfs_tftp_local_root"); + + let (addr, shutdown, handle) = start_server(server_root.path(), true); + + let client = Client::new(addr, test_client_config()); + + let upload_local = local_root.path().join("upload.bin"); + let upload_remote = "upload.bin"; + let upload_data = vec![0xA5; 1500]; + fs::write(&upload_local, &upload_data).unwrap(); + client + .put(&upload_local, upload_remote, Mode::Octet) + .unwrap(); + assert_eq!( + fs::read(server_root.path().join(upload_remote)).unwrap(), + upload_data + ); + + let download_remote = "download.bin"; + let download_data = (0u8..=200).cycle().take(2048).collect::>(); + fs::write(server_root.path().join(download_remote), &download_data).unwrap(); + + let download_local = local_root.path().join("download.bin"); + client + .get(download_remote, &download_local, Mode::Octet) + .unwrap(); + assert_eq!(fs::read(download_local).unwrap(), download_data); + + stop_server(&shutdown, handle); +} + +#[test] +fn octet_exact_512_bytes_requires_final_empty_block() { + let server_root = TempDir::new("pfs_tftp_server_root_512"); + let local_root = TempDir::new("pfs_tftp_local_root_512"); + + let (addr, shutdown, handle) = start_server(server_root.path(), true); + let client = Client::new(addr, test_client_config()); + + let data = vec![0x11; 512]; + + fs::write(server_root.path().join("exact512.bin"), &data).unwrap(); + let local = local_root.path().join("exact512.bin"); + client.get("exact512.bin", &local, Mode::Octet).unwrap(); + assert_eq!(fs::read(local).unwrap(), data); + + let local_up = local_root.path().join("exact512_upload.bin"); + fs::write(&local_up, &data).unwrap(); + client + .put(&local_up, "exact512_upload.bin", Mode::Octet) + .unwrap(); + assert_eq!( + fs::read(server_root.path().join("exact512_upload.bin")).unwrap(), + data + ); + + stop_server(&shutdown, handle); +} + +#[test] +fn netascii_roundtrip_preserves_newlines_and_cr() { + let server_root = TempDir::new("pfs_tftp_server_root_netascii"); + let local_root = TempDir::new("pfs_tftp_local_root_netascii"); + + let (addr, shutdown, handle) = start_server(server_root.path(), true); + let client = Client::new(addr, test_client_config()); + + let data = b"line1\nline2\rline3\n".to_vec(); + + let local_up = local_root.path().join("text.txt"); + fs::write(&local_up, &data).unwrap(); + client.put(&local_up, "text.txt", Mode::NetAscii).unwrap(); + assert_eq!(fs::read(server_root.path().join("text.txt")).unwrap(), data); + + let local_down = local_root.path().join("text_downloaded.txt"); + client.get("text.txt", &local_down, Mode::NetAscii).unwrap(); + assert_eq!(fs::read(local_down).unwrap(), data); + + stop_server(&shutdown, handle); +} + +fn test_client_config() -> ClientConfig { + ClientConfig { + timeout: Duration::from_millis(200), + retries: 20, + dally_timeout: Duration::from_millis(200), + dally_retries: 2, + } +} + +fn start_server(root: &Path, allow_write: bool) -> (SocketAddr, Arc, JoinHandle<()>) { + let addr = reserve_local_addr(); + + let cfg = ServerConfig { + bind: addr, + root: root.to_path_buf(), + allow_write, + overwrite: true, + timeout: Duration::from_millis(200), + retries: 20, + dally_timeout: Duration::from_millis(200), + dally_retries: 2, + ..ServerConfig::default() + }; + + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_for_thread = Arc::clone(&shutdown); + + let handle = std::thread::spawn(move || { + Server::new(cfg) + .serve_until(shutdown_for_thread.as_ref()) + .unwrap(); + }); + + std::thread::sleep(Duration::from_millis(50)); + (addr, shutdown, handle) +} + +fn stop_server(shutdown: &Arc, handle: JoinHandle<()>) { + shutdown.store(true, Ordering::Relaxed); + handle.join().unwrap(); +} + +fn reserve_local_addr() -> SocketAddr { + let sock = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + sock.local_addr().unwrap() +} + +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new(prefix: &str) -> Self { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + + let mut path = test_tmp_root(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + path.push(format!("{prefix}_{now}_{}_{}", std::process::id(), id)); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ignored = fs::remove_dir_all(&self.path); + } +} + +fn test_tmp_root() -> PathBuf { + // Keep temporary test artifacts inside the workspace, since the Codex CLI + // sandbox does not allow writing to system temp directories. + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .and_then(Path::parent) + .unwrap_or(&manifest_dir); + workspace_root + .join("target") + .join("tmp") + .join("pfs-tftp-sync-tests") +}