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:
2025-12-21 13:19:21 +01:00
parent fd8dace0dc
commit f4aa70ca62
8 changed files with 824 additions and 34 deletions

View File

@@ -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] <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
}

View File

@@ -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] <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
}

View File

@@ -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<A: ToSocketAddrs>(server_addr: A) -> Result<Self> {
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<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 request = state.start(filename);
@@ -376,7 +380,12 @@ impl Client {
/// # Errors
///
/// 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 request = state.start(filename);

View File

@@ -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) {