feat: implement TFTP client and server binaries
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 <noreply@anthropic.com>
This commit is contained in:
285
crates/pfs-tftp/tests/integration.rs
Normal file
285
crates/pfs-tftp/tests/integration.rs
Normal file
@@ -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<AtomicBool>,
|
||||
}
|
||||
|
||||
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<u8> {
|
||||
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<u8> = (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<u8> = (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());
|
||||
}
|
||||
Reference in New Issue
Block a user