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:
@@ -17,5 +17,5 @@ mod packet;
|
|||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
pub use error::{Error, ErrorCode, Result};
|
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};
|
pub use state::{ClientState, Event, ServerState, TransferDirection};
|
||||||
|
|||||||
@@ -244,11 +244,10 @@ impl Packet {
|
|||||||
.position(|&b| b == 0)
|
.position(|&b| b == 0)
|
||||||
.ok_or(Error::MissingNullTerminator { field: "filename" })?;
|
.ok_or(Error::MissingNullTerminator { field: "filename" })?;
|
||||||
|
|
||||||
let filename = std::str::from_utf8(&payload[..filename_end]).map_err(|e| {
|
let filename =
|
||||||
Error::InvalidUtf8 {
|
std::str::from_utf8(&payload[..filename_end]).map_err(|e| Error::InvalidUtf8 {
|
||||||
field: "filename",
|
field: "filename",
|
||||||
source: e,
|
source: e,
|
||||||
}
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Find the mode string after the filename null terminator
|
// Find the mode string after the filename null terminator
|
||||||
@@ -263,10 +262,11 @@ impl Packet {
|
|||||||
.ok_or(Error::MissingNullTerminator { field: "mode" })?
|
.ok_or(Error::MissingNullTerminator { field: "mode" })?
|
||||||
+ mode_start;
|
+ mode_start;
|
||||||
|
|
||||||
let mode_str =
|
let mode_str = std::str::from_utf8(&payload[mode_start..mode_end]).map_err(|e| {
|
||||||
std::str::from_utf8(&payload[mode_start..mode_end]).map_err(|e| Error::InvalidUtf8 {
|
Error::InvalidUtf8 {
|
||||||
field: "mode",
|
field: "mode",
|
||||||
source: e,
|
source: e,
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mode = Mode::parse(mode_str)?;
|
let mode = Mode::parse(mode_str)?;
|
||||||
@@ -594,10 +594,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_invalid_mode() {
|
fn test_invalid_mode() {
|
||||||
assert!(matches!(
|
assert!(matches!(Mode::parse("binary"), Err(Error::InvalidMode(_))));
|
||||||
Mode::parse("binary"),
|
|
||||||
Err(Error::InvalidMode(_))
|
|
||||||
));
|
|
||||||
assert!(matches!(Mode::parse("mail"), Err(Error::InvalidMode(_))));
|
assert!(matches!(Mode::parse("mail"), Err(Error::InvalidMode(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
//! ... (continues until final DATA with < 512 bytes)
|
//! ... (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.
|
/// Direction of data transfer.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -1,3 +1,305 @@
|
|||||||
fn main() {
|
//! TFTP Client (tftp)
|
||||||
println!("tftp client placeholder");
|
//!
|
||||||
|
//! A simple TFTP client implementation following RFC 1350.
|
||||||
|
//!
|
||||||
|
//! # Usage
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! tftp [OPTIONS] <COMMAND>
|
||||||
|
//!
|
||||||
|
//! Commands:
|
||||||
|
//! get <HOST> <REMOTE_FILE> [LOCAL_FILE] Download a file
|
||||||
|
//! put <HOST> <LOCAL_FILE> [REMOTE_FILE] Upload a file
|
||||||
|
//!
|
||||||
|
//! Options:
|
||||||
|
//! -m, --mode <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] <COMMAND>");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Commands:");
|
||||||
|
eprintln!(" get <HOST> <REMOTE_FILE> [LOCAL_FILE] Download a file");
|
||||||
|
eprintln!(" put <HOST> <LOCAL_FILE> [REMOTE_FILE] Upload a file");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Options:");
|
||||||
|
eprintln!(" -m, --mode <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<Args, String> {
|
||||||
|
let args: Vec<String> = 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,201 @@
|
|||||||
fn main() {
|
//! TFTP Server daemon (tftpd)
|
||||||
println!("tftpd server placeholder");
|
//!
|
||||||
|
//! A simple TFTP server implementation following RFC 1350.
|
||||||
|
//!
|
||||||
|
//! # Usage
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! tftpd [OPTIONS] <ROOT_DIR>
|
||||||
|
//!
|
||||||
|
//! Arguments:
|
||||||
|
//! <ROOT_DIR> Directory to serve files from
|
||||||
|
//!
|
||||||
|
//! Options:
|
||||||
|
//! -p, --port <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] <ROOT_DIR>");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Arguments:");
|
||||||
|
eprintln!(" <ROOT_DIR> Directory to serve files from");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Options:");
|
||||||
|
eprintln!(" -p, --port <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<Args, String> {
|
||||||
|
let args: Vec<String> = 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<PathBuf> = 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use std::{
|
|||||||
time::Duration,
|
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};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
@@ -58,10 +58,7 @@ impl Client {
|
|||||||
///
|
///
|
||||||
/// Returns an error if the address is invalid or the socket cannot be bound.
|
/// Returns an error if the address is invalid or the socket cannot be bound.
|
||||||
pub fn new<A: ToSocketAddrs>(server_addr: A) -> Result<Self> {
|
pub fn new<A: ToSocketAddrs>(server_addr: A) -> Result<Self> {
|
||||||
let server_addr = server_addr
|
let server_addr = server_addr.to_socket_addrs()?.next().ok_or_else(|| {
|
||||||
.to_socket_addrs()?
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| {
|
|
||||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, "no addresses found")
|
std::io::Error::new(std::io::ErrorKind::InvalidInput, "no addresses found")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -164,7 +161,9 @@ impl Client {
|
|||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
match event {
|
match event {
|
||||||
Event::ReceivedData { data: block_data, .. } => {
|
Event::ReceivedData {
|
||||||
|
data: block_data, ..
|
||||||
|
} => {
|
||||||
data.extend_from_slice(&block_data);
|
data.extend_from_slice(&block_data);
|
||||||
}
|
}
|
||||||
Event::Send(packet) => {
|
Event::Send(packet) => {
|
||||||
@@ -299,7 +298,12 @@ impl Client {
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns an error if the transfer fails.
|
/// Returns an error if the transfer fails.
|
||||||
pub fn get_to_writer<W: Write>(&self, filename: &str, mode: Mode, writer: &mut W) -> Result<u64> {
|
pub fn get_to_writer<W: Write>(
|
||||||
|
&self,
|
||||||
|
filename: &str,
|
||||||
|
mode: Mode,
|
||||||
|
writer: &mut W,
|
||||||
|
) -> Result<u64> {
|
||||||
let mut state = ClientState::new_read(mode);
|
let mut state = ClientState::new_read(mode);
|
||||||
let request = state.start(filename);
|
let request = state.start(filename);
|
||||||
|
|
||||||
@@ -376,7 +380,12 @@ impl Client {
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns an error if the transfer fails.
|
/// Returns an error if the transfer fails.
|
||||||
pub fn put_from_reader<R: Read>(&self, filename: &str, mode: Mode, reader: &mut R) -> Result<u64> {
|
pub fn put_from_reader<R: Read>(
|
||||||
|
&self,
|
||||||
|
filename: &str,
|
||||||
|
mode: Mode,
|
||||||
|
reader: &mut R,
|
||||||
|
) -> Result<u64> {
|
||||||
let mut state = ClientState::new_write(mode);
|
let mut state = ClientState::new_write(mode);
|
||||||
let request = state.start(filename);
|
let request = state.start(filename);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use std::{
|
|||||||
time::Duration,
|
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};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
@@ -208,9 +208,7 @@ impl Server {
|
|||||||
|
|
||||||
// Reject paths with null bytes
|
// Reject paths with null bytes
|
||||||
if filename.contains('\0') {
|
if filename.contains('\0') {
|
||||||
return Err(Error::InvalidFilename(
|
return Err(Error::InvalidFilename("null bytes not allowed".to_string()));
|
||||||
"null bytes not allowed".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = self.config.root_dir.join(filename);
|
let path = self.config.root_dir.join(filename);
|
||||||
@@ -235,9 +233,10 @@ impl Server {
|
|||||||
parent.display()
|
parent.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
parent.canonicalize()?.join(path.file_name().ok_or_else(|| {
|
parent.canonicalize()?.join(
|
||||||
Error::InvalidFilename("missing filename".to_string())
|
path.file_name()
|
||||||
})?)
|
.ok_or_else(|| Error::InvalidFilename("missing filename".to_string()))?,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
if !check_path.starts_with(&canonical_root) {
|
if !check_path.starts_with(&canonical_root) {
|
||||||
|
|||||||
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