feat: implement TFTP protocol crate (pfs-tftp-proto)
This commit introduces a sans-IO TFTP protocol implementation following RFC 1350. The protocol crate provides: - Packet types: RRQ, WRQ, DATA, ACK, ERROR with full serialization/parsing - Error codes as defined in RFC 1350 Appendix - Transfer modes: octet (binary) and netascii - Client and server state machines for managing protocol flow - Comprehensive tests for all packet types and state transitions The sans-IO design separates protocol logic from I/O operations, making the code testable and reusable across different I/O implementations. Key design decisions: - Mail mode is explicitly rejected as obsolete per RFC 1350 - Block numbers wrap around at 65535 for large file support - State machines emit events that tell the I/O layer what to do next - All protocol-specific values are documented with RFC citations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
3
crates/pfs-tftp/src/bin/tftp.rs
Normal file
3
crates/pfs-tftp/src/bin/tftp.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("tftp client placeholder");
|
||||
}
|
||||
3
crates/pfs-tftp/src/bin/tftpd.rs
Normal file
3
crates/pfs-tftp/src/bin/tftpd.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("tftpd server placeholder");
|
||||
}
|
||||
479
crates/pfs-tftp/src/client.rs
Normal file
479
crates/pfs-tftp/src/client.rs
Normal file
@@ -0,0 +1,479 @@
|
||||
//! TFTP Client implementation.
|
||||
//!
|
||||
//! Provides synchronous TFTP client operations for reading and writing files
|
||||
//! to/from a TFTP server.
|
||||
|
||||
// The expect() calls in this module cannot panic in practice because we always
|
||||
// set the server_tid before using it, but clippy doesn't know this.
|
||||
#![allow(clippy::missing_panics_doc)]
|
||||
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::{SocketAddr, ToSocketAddrs, UdpSocket},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use pfs_tftp_proto::{ClientState, Event, Mode, Packet, MAX_DATA_SIZE, TFTP_PORT};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Default timeout for TFTP operations (in seconds).
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
/// Default number of retries before giving up.
|
||||
const DEFAULT_RETRIES: u32 = 3;
|
||||
|
||||
/// Maximum packet size (opcode + block + max data).
|
||||
const MAX_PACKET_SIZE: usize = 4 + MAX_DATA_SIZE;
|
||||
|
||||
/// TFTP client for transferring files to/from a server.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pfs_tftp::{Client, Mode};
|
||||
///
|
||||
/// let client = Client::new("192.168.1.1:69").expect("connect");
|
||||
/// let data = client.get("config.txt", Mode::Octet).expect("get");
|
||||
/// println!("Received {} bytes", data.len());
|
||||
/// ```
|
||||
pub struct Client {
|
||||
/// Server address
|
||||
server_addr: SocketAddr,
|
||||
/// Local UDP socket
|
||||
socket: UdpSocket,
|
||||
/// Timeout duration
|
||||
timeout: Duration,
|
||||
/// Number of retries
|
||||
retries: u32,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Create a new TFTP client connected to the specified server.
|
||||
///
|
||||
/// The address can be in any format accepted by `ToSocketAddrs`, such as
|
||||
/// "192.168.1.1:69" or "tftp.example.com:69".
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 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")
|
||||
})?;
|
||||
|
||||
// Bind to any available local port
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")?;
|
||||
socket.set_read_timeout(Some(Duration::from_secs(DEFAULT_TIMEOUT_SECS)))?;
|
||||
|
||||
Ok(Self {
|
||||
server_addr,
|
||||
socket,
|
||||
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
retries: DEFAULT_RETRIES,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the timeout for operations.
|
||||
#[must_use]
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
let _ = self.socket.set_read_timeout(Some(timeout));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the number of retries.
|
||||
#[must_use]
|
||||
pub const fn with_retries(mut self, retries: u32) -> Self {
|
||||
self.retries = retries;
|
||||
self
|
||||
}
|
||||
|
||||
/// Download a file from the server.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `filename` - The name of the file to download
|
||||
/// * `mode` - The transfer mode (`Octet` or `NetAscii`)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The file contents as a byte vector.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the transfer fails.
|
||||
pub fn get(&self, filename: &str, mode: Mode) -> Result<Vec<u8>> {
|
||||
let mut state = ClientState::new_read(mode);
|
||||
let request = state.start(filename);
|
||||
|
||||
// Send initial request to well-known port
|
||||
let request_bytes = request.serialize()?;
|
||||
self.socket.send_to(&request_bytes, self.server_addr)?;
|
||||
|
||||
let mut recv_buf = [0u8; MAX_PACKET_SIZE];
|
||||
let mut data = Vec::new();
|
||||
let mut server_tid: Option<SocketAddr> = None;
|
||||
let mut retries_left = self.retries;
|
||||
|
||||
loop {
|
||||
// Receive packet
|
||||
let (len, from_addr) = match self.socket.recv_from(&mut recv_buf) {
|
||||
Ok(result) => result,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
// Timeout - retransmit last packet
|
||||
if retries_left == 0 {
|
||||
return Err(Error::Timeout {
|
||||
retries: self.retries,
|
||||
});
|
||||
}
|
||||
retries_left -= 1;
|
||||
self.socket.send_to(&request_bytes, self.server_addr)?;
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// RFC 1350 Section 4: First response establishes the TID
|
||||
// Subsequent packets must come from the same TID
|
||||
match server_tid {
|
||||
None => server_tid = Some(from_addr),
|
||||
Some(expected) if from_addr != expected => {
|
||||
// RFC 1350: "If a source TID does not match, the packet should
|
||||
// be discarded as erroneously sent from somewhere else. An error
|
||||
// packet should be sent to the source of the incorrect packet."
|
||||
let error =
|
||||
Packet::error(pfs_tftp_proto::ErrorCode::UnknownTransferId, "Unknown TID");
|
||||
let _ = self.socket.send_to(&error.serialize()?, from_addr);
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let packet = Packet::parse(&recv_buf[..len])?;
|
||||
|
||||
// Handle error packets
|
||||
if let Packet::Error { code, message } = packet {
|
||||
return Err(Error::Remote { code, message });
|
||||
}
|
||||
|
||||
let events = state.receive(&packet)?;
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
Event::ReceivedData { data: block_data, .. } => {
|
||||
data.extend_from_slice(&block_data);
|
||||
}
|
||||
Event::Send(packet) => {
|
||||
let bytes = packet.serialize()?;
|
||||
self.socket.send_to(&bytes, server_tid.expect("TID set"))?;
|
||||
}
|
||||
Event::Complete => {
|
||||
return Ok(data);
|
||||
}
|
||||
Event::NeedData { .. } => {
|
||||
// Not used in read transfers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
retries_left = self.retries;
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a file to the server.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `filename` - The name to give the file on the server
|
||||
/// * `mode` - The transfer mode (`Octet` or `NetAscii`)
|
||||
/// * `data` - The file contents to upload
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the transfer fails.
|
||||
pub fn put(&self, filename: &str, mode: Mode, data: &[u8]) -> Result<()> {
|
||||
let mut state = ClientState::new_write(mode);
|
||||
let request = state.start(filename);
|
||||
|
||||
// Send initial request
|
||||
let request_bytes = request.serialize()?;
|
||||
self.socket.send_to(&request_bytes, self.server_addr)?;
|
||||
|
||||
let mut recv_buf = [0u8; MAX_PACKET_SIZE];
|
||||
let mut server_tid: Option<SocketAddr> = None;
|
||||
let mut retries_left = self.retries;
|
||||
let mut last_sent: Option<Vec<u8>> = None;
|
||||
let mut data_offset: usize = 0;
|
||||
|
||||
loop {
|
||||
// Receive packet
|
||||
let (len, from_addr) = match self.socket.recv_from(&mut recv_buf) {
|
||||
Ok(result) => result,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
// Timeout - retransmit
|
||||
if retries_left == 0 {
|
||||
return Err(Error::Timeout {
|
||||
retries: self.retries,
|
||||
});
|
||||
}
|
||||
retries_left -= 1;
|
||||
if let Some(ref last) = last_sent {
|
||||
self.socket
|
||||
.send_to(last, server_tid.unwrap_or(self.server_addr))?;
|
||||
} else {
|
||||
self.socket.send_to(&request_bytes, self.server_addr)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// TID validation
|
||||
match server_tid {
|
||||
None => server_tid = Some(from_addr),
|
||||
Some(expected) if from_addr != expected => {
|
||||
let error =
|
||||
Packet::error(pfs_tftp_proto::ErrorCode::UnknownTransferId, "Unknown TID");
|
||||
let _ = self.socket.send_to(&error.serialize()?, from_addr);
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let packet = Packet::parse(&recv_buf[..len])?;
|
||||
|
||||
if let Packet::Error { code, message } = packet {
|
||||
return Err(Error::Remote { code, message });
|
||||
}
|
||||
|
||||
let events = state.receive(&packet)?;
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
Event::NeedData { block } => {
|
||||
// Calculate data for this block
|
||||
let start = data_offset;
|
||||
let end = (start + MAX_DATA_SIZE).min(data.len());
|
||||
let block_data = data[start..end].to_vec();
|
||||
data_offset = end;
|
||||
|
||||
let send_event = state.provide_data(block, block_data)?;
|
||||
if let Event::Send(packet) = send_event {
|
||||
let bytes = packet.serialize()?;
|
||||
self.socket.send_to(&bytes, server_tid.expect("TID set"))?;
|
||||
last_sent = Some(bytes);
|
||||
}
|
||||
}
|
||||
Event::Send(packet) => {
|
||||
let bytes = packet.serialize()?;
|
||||
self.socket.send_to(&bytes, server_tid.expect("TID set"))?;
|
||||
last_sent = Some(bytes);
|
||||
}
|
||||
Event::Complete => {
|
||||
return Ok(());
|
||||
}
|
||||
Event::ReceivedData { .. } => {
|
||||
// Not used in write transfers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if transfer is complete after sending final data
|
||||
if state.is_complete() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
retries_left = self.retries;
|
||||
}
|
||||
}
|
||||
|
||||
/// Download a file from the server to a writer.
|
||||
///
|
||||
/// This is more memory-efficient for large files as it streams data
|
||||
/// directly to the writer.
|
||||
///
|
||||
/// # 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> {
|
||||
let mut state = ClientState::new_read(mode);
|
||||
let request = state.start(filename);
|
||||
|
||||
let request_bytes = request.serialize()?;
|
||||
self.socket.send_to(&request_bytes, self.server_addr)?;
|
||||
|
||||
let mut recv_buf = [0u8; MAX_PACKET_SIZE];
|
||||
let mut total_bytes: u64 = 0;
|
||||
let mut server_tid: Option<SocketAddr> = None;
|
||||
let mut retries_left = self.retries;
|
||||
|
||||
loop {
|
||||
let (len, from_addr) = match self.socket.recv_from(&mut recv_buf) {
|
||||
Ok(result) => result,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
if retries_left == 0 {
|
||||
return Err(Error::Timeout {
|
||||
retries: self.retries,
|
||||
});
|
||||
}
|
||||
retries_left -= 1;
|
||||
self.socket.send_to(&request_bytes, self.server_addr)?;
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
match server_tid {
|
||||
None => server_tid = Some(from_addr),
|
||||
Some(expected) if from_addr != expected => {
|
||||
let error =
|
||||
Packet::error(pfs_tftp_proto::ErrorCode::UnknownTransferId, "Unknown TID");
|
||||
let _ = self.socket.send_to(&error.serialize()?, from_addr);
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let packet = Packet::parse(&recv_buf[..len])?;
|
||||
|
||||
if let Packet::Error { code, message } = packet {
|
||||
return Err(Error::Remote { code, message });
|
||||
}
|
||||
|
||||
let events = state.receive(&packet)?;
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
Event::ReceivedData { data, .. } => {
|
||||
writer.write_all(&data)?;
|
||||
total_bytes += data.len() as u64;
|
||||
}
|
||||
Event::Send(packet) => {
|
||||
let bytes = packet.serialize()?;
|
||||
self.socket.send_to(&bytes, server_tid.expect("TID set"))?;
|
||||
}
|
||||
Event::Complete => {
|
||||
writer.flush()?;
|
||||
return Ok(total_bytes);
|
||||
}
|
||||
Event::NeedData { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
retries_left = self.retries;
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload data from a reader to the server.
|
||||
///
|
||||
/// This is more memory-efficient for large files as it streams data
|
||||
/// from the reader.
|
||||
///
|
||||
/// # 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> {
|
||||
let mut state = ClientState::new_write(mode);
|
||||
let request = state.start(filename);
|
||||
|
||||
let request_bytes = request.serialize()?;
|
||||
self.socket.send_to(&request_bytes, self.server_addr)?;
|
||||
|
||||
let mut recv_buf = [0u8; MAX_PACKET_SIZE];
|
||||
let mut read_buf = [0u8; MAX_DATA_SIZE];
|
||||
let mut total_bytes: u64 = 0;
|
||||
let mut server_tid: Option<SocketAddr> = None;
|
||||
let mut retries_left = self.retries;
|
||||
let mut last_sent: Option<Vec<u8>> = None;
|
||||
|
||||
loop {
|
||||
let (len, from_addr) = match self.socket.recv_from(&mut recv_buf) {
|
||||
Ok(result) => result,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
if retries_left == 0 {
|
||||
return Err(Error::Timeout {
|
||||
retries: self.retries,
|
||||
});
|
||||
}
|
||||
retries_left -= 1;
|
||||
if let Some(ref last) = last_sent {
|
||||
self.socket
|
||||
.send_to(last, server_tid.unwrap_or(self.server_addr))?;
|
||||
} else {
|
||||
self.socket.send_to(&request_bytes, self.server_addr)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
match server_tid {
|
||||
None => server_tid = Some(from_addr),
|
||||
Some(expected) if from_addr != expected => {
|
||||
let error =
|
||||
Packet::error(pfs_tftp_proto::ErrorCode::UnknownTransferId, "Unknown TID");
|
||||
let _ = self.socket.send_to(&error.serialize()?, from_addr);
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let packet = Packet::parse(&recv_buf[..len])?;
|
||||
|
||||
if let Packet::Error { code, message } = packet {
|
||||
return Err(Error::Remote { code, message });
|
||||
}
|
||||
|
||||
let events = state.receive(&packet)?;
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
Event::NeedData { block } => {
|
||||
let n = reader.read(&mut read_buf)?;
|
||||
total_bytes += n as u64;
|
||||
|
||||
let send_event = state.provide_data(block, read_buf[..n].to_vec())?;
|
||||
if let Event::Send(packet) = send_event {
|
||||
let bytes = packet.serialize()?;
|
||||
self.socket.send_to(&bytes, server_tid.expect("TID set"))?;
|
||||
last_sent = Some(bytes);
|
||||
}
|
||||
}
|
||||
Event::Send(packet) => {
|
||||
let bytes = packet.serialize()?;
|
||||
self.socket.send_to(&bytes, server_tid.expect("TID set"))?;
|
||||
last_sent = Some(bytes);
|
||||
}
|
||||
Event::Complete => {
|
||||
return Ok(total_bytes);
|
||||
}
|
||||
Event::ReceivedData { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
if state.is_complete() {
|
||||
return Ok(total_bytes);
|
||||
}
|
||||
|
||||
retries_left = self.retries;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a client connected to "host:port" or "host" (default port 69).
|
||||
impl std::str::FromStr for Client {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
let addr = if s.contains(':') {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{s}:{TFTP_PORT}")
|
||||
};
|
||||
Self::new(addr)
|
||||
}
|
||||
}
|
||||
41
crates/pfs-tftp/src/error.rs
Normal file
41
crates/pfs-tftp/src/error.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Error types for TFTP I/O operations.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during TFTP operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// I/O error during socket or file operations.
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// Protocol-level error.
|
||||
#[error("protocol error: {0}")]
|
||||
Protocol(#[from] pfs_tftp_proto::Error),
|
||||
|
||||
/// Address parsing error.
|
||||
#[error("invalid address: {0}")]
|
||||
InvalidAddress(#[from] std::net::AddrParseError),
|
||||
|
||||
/// Remote peer sent an error.
|
||||
#[error("remote error ({code:?}): {message}")]
|
||||
Remote {
|
||||
code: pfs_tftp_proto::ErrorCode,
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Transfer timed out.
|
||||
#[error("transfer timed out after {retries} retries")]
|
||||
Timeout { retries: u32 },
|
||||
|
||||
/// File access error.
|
||||
#[error("file access error: {0}")]
|
||||
FileAccess(String),
|
||||
|
||||
/// Invalid filename (e.g., path traversal attempt).
|
||||
#[error("invalid filename: {0}")]
|
||||
InvalidFilename(String),
|
||||
}
|
||||
|
||||
/// Result type for TFTP operations.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
32
crates/pfs-tftp/src/lib.rs
Normal file
32
crates/pfs-tftp/src/lib.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! TFTP Client and Server Library (RFC 1350)
|
||||
//!
|
||||
//! This crate provides synchronous I/O implementations for TFTP client and
|
||||
//! server functionality, built on top of the `pfs-tftp-proto` protocol crate.
|
||||
//!
|
||||
//! # Example: Client
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use pfs_tftp::{Client, Mode};
|
||||
//!
|
||||
//! let mut client = Client::new("192.168.1.1:69").expect("connect");
|
||||
//! let data = client.get("config.txt", Mode::Octet).expect("get");
|
||||
//! ```
|
||||
//!
|
||||
//! # Example: Server
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use pfs_tftp::Server;
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! let server = Server::bind("0.0.0.0:69", Path::new("/tftpboot")).expect("bind");
|
||||
//! server.run().expect("run");
|
||||
//! ```
|
||||
|
||||
mod client;
|
||||
mod error;
|
||||
mod server;
|
||||
|
||||
pub use client::Client;
|
||||
pub use error::{Error, Result};
|
||||
pub use pfs_tftp_proto::Mode;
|
||||
pub use server::{Server, ServerBuilder, ServerConfig};
|
||||
569
crates/pfs-tftp/src/server.rs
Normal file
569
crates/pfs-tftp/src/server.rs
Normal file
@@ -0,0 +1,569 @@
|
||||
//! TFTP Server implementation.
|
||||
//!
|
||||
//! Provides a synchronous TFTP server that serves files from a root directory.
|
||||
|
||||
use std::{
|
||||
fs::{File, OpenOptions},
|
||||
io::{Read, Write},
|
||||
net::{SocketAddr, ToSocketAddrs, UdpSocket},
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use pfs_tftp_proto::{ErrorCode, Event, Mode, Packet, ServerState, MAX_DATA_SIZE, TFTP_PORT};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Default timeout for transfer operations (in seconds).
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
/// Default number of retries before giving up on a transfer.
|
||||
const DEFAULT_RETRIES: u32 = 3;
|
||||
|
||||
/// Maximum packet size.
|
||||
const MAX_PACKET_SIZE: usize = 4 + MAX_DATA_SIZE;
|
||||
|
||||
/// Configuration for the TFTP server.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerConfig {
|
||||
/// Root directory to serve files from/to.
|
||||
pub root_dir: PathBuf,
|
||||
/// Allow write operations (WRQ).
|
||||
pub allow_write: bool,
|
||||
/// Allow overwriting existing files.
|
||||
pub allow_overwrite: bool,
|
||||
/// Timeout for operations.
|
||||
pub timeout: Duration,
|
||||
/// Number of retries.
|
||||
pub retries: u32,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
root_dir: PathBuf::from("."),
|
||||
allow_write: false,
|
||||
allow_overwrite: false,
|
||||
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
retries: DEFAULT_RETRIES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TFTP server.
|
||||
///
|
||||
/// Serves files from a configured root directory.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pfs_tftp::Server;
|
||||
/// use std::path::Path;
|
||||
///
|
||||
/// let server = Server::bind("0.0.0.0:69", Path::new("/tftpboot")).expect("bind");
|
||||
/// server.run().expect("run");
|
||||
/// ```
|
||||
pub struct Server {
|
||||
/// Listening socket on the well-known TFTP port.
|
||||
socket: UdpSocket,
|
||||
/// Server configuration.
|
||||
config: ServerConfig,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Create a new TFTP server bound to the specified address.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `addr` - The address to bind to (e.g., "0.0.0.0:69")
|
||||
/// * `root_dir` - The root directory to serve files from
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the socket cannot be bound or the root directory
|
||||
/// is invalid.
|
||||
pub fn bind<A: ToSocketAddrs>(addr: A, root_dir: &Path) -> Result<Self> {
|
||||
let socket = UdpSocket::bind(addr)?;
|
||||
|
||||
let config = ServerConfig {
|
||||
root_dir: root_dir.to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(Self { socket, config })
|
||||
}
|
||||
|
||||
/// Create a server with custom configuration.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the socket cannot be bound.
|
||||
pub fn with_config<A: ToSocketAddrs>(addr: A, config: ServerConfig) -> Result<Self> {
|
||||
let socket = UdpSocket::bind(addr)?;
|
||||
Ok(Self { socket, config })
|
||||
}
|
||||
|
||||
/// Enable or disable write operations.
|
||||
#[must_use]
|
||||
pub fn allow_write(mut self, allow: bool) -> Self {
|
||||
self.config.allow_write = allow;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable or disable overwriting existing files.
|
||||
#[must_use]
|
||||
pub fn allow_overwrite(mut self, allow: bool) -> Self {
|
||||
self.config.allow_overwrite = allow;
|
||||
self
|
||||
}
|
||||
|
||||
/// Run the server, handling requests indefinitely.
|
||||
///
|
||||
/// This method blocks and handles one request at a time.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if a fatal socket error occurs.
|
||||
pub fn run(&self) -> Result<()> {
|
||||
let mut buf = [0u8; MAX_PACKET_SIZE];
|
||||
|
||||
loop {
|
||||
let (len, client_addr) = self.socket.recv_from(&mut buf)?;
|
||||
|
||||
// Try to parse the request
|
||||
let packet = match Packet::parse(&buf[..len]) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("Invalid packet from {client_addr}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle the request
|
||||
if let Err(e) = self.handle_request(client_addr, &packet) {
|
||||
eprintln!("Error handling request from {client_addr}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a single request, then return.
|
||||
///
|
||||
/// This is useful for testing or single-request operation.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if a fatal socket error occurs.
|
||||
pub fn handle_one(&self) -> Result<()> {
|
||||
let mut buf = [0u8; MAX_PACKET_SIZE];
|
||||
let (len, client_addr) = self.socket.recv_from(&mut buf)?;
|
||||
let packet = Packet::parse(&buf[..len])?;
|
||||
self.handle_request(client_addr, &packet)
|
||||
}
|
||||
|
||||
/// Handle a single request.
|
||||
fn handle_request(&self, client_addr: SocketAddr, packet: &Packet) -> Result<()> {
|
||||
// Create a new socket for this transfer with a random TID
|
||||
// RFC 1350 Section 4: "each end of the connection chooses a TID for
|
||||
// itself, to be used for the duration of that connection"
|
||||
let transfer_socket = UdpSocket::bind("0.0.0.0:0")?;
|
||||
transfer_socket.set_read_timeout(Some(self.config.timeout))?;
|
||||
transfer_socket.connect(client_addr)?;
|
||||
|
||||
match packet {
|
||||
Packet::ReadRequest { filename, mode } => {
|
||||
self.handle_rrq(&transfer_socket, filename, *mode)
|
||||
}
|
||||
Packet::WriteRequest { filename, mode } => {
|
||||
self.handle_wrq(&transfer_socket, filename, *mode)
|
||||
}
|
||||
_ => {
|
||||
// Unexpected packet type on the well-known port
|
||||
let error = Packet::error(ErrorCode::IllegalOperation, "Expected RRQ or WRQ");
|
||||
let _ = transfer_socket.send(&error.serialize()?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate and resolve a filename to a path within the root directory.
|
||||
fn resolve_path(&self, filename: &str) -> Result<PathBuf> {
|
||||
// Security: Prevent path traversal attacks
|
||||
// RFC 1350 Security Considerations: "care must be taken in the rights
|
||||
// granted to a TFTP server process so as not to violate the security
|
||||
// of the server hosts file system"
|
||||
|
||||
// Reject absolute paths
|
||||
if filename.starts_with('/') || filename.starts_with('\\') {
|
||||
return Err(Error::InvalidFilename(
|
||||
"absolute paths not allowed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Reject path traversal
|
||||
if filename.contains("..") {
|
||||
return Err(Error::InvalidFilename(
|
||||
"path traversal not allowed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Reject paths with null bytes
|
||||
if filename.contains('\0') {
|
||||
return Err(Error::InvalidFilename(
|
||||
"null bytes not allowed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let path = self.config.root_dir.join(filename);
|
||||
|
||||
// Verify the resolved path is still within root_dir
|
||||
let canonical_root = self
|
||||
.config
|
||||
.root_dir
|
||||
.canonicalize()
|
||||
.map_err(|e| Error::FileAccess(format!("cannot access root directory: {e}")))?;
|
||||
|
||||
// For non-existent files (WRQ), we check the parent
|
||||
let check_path = if path.exists() {
|
||||
path.canonicalize()?
|
||||
} else {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| Error::InvalidFilename("invalid path".to_string()))?;
|
||||
if !parent.exists() {
|
||||
return Err(Error::FileAccess(format!(
|
||||
"parent directory does not exist: {}",
|
||||
parent.display()
|
||||
)));
|
||||
}
|
||||
parent.canonicalize()?.join(path.file_name().ok_or_else(|| {
|
||||
Error::InvalidFilename("missing filename".to_string())
|
||||
})?)
|
||||
};
|
||||
|
||||
if !check_path.starts_with(&canonical_root) {
|
||||
return Err(Error::InvalidFilename(
|
||||
"path outside root directory".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Handle a read request (RRQ).
|
||||
fn handle_rrq(&self, socket: &UdpSocket, filename: &str, mode: Mode) -> Result<()> {
|
||||
let path = match self.resolve_path(filename) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let error = Packet::error(ErrorCode::AccessViolation, e.to_string());
|
||||
let _ = socket.send(&error.serialize()?);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Open the file
|
||||
let mut file = match File::open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let (code, msg) = match e.kind() {
|
||||
std::io::ErrorKind::NotFound => {
|
||||
(ErrorCode::FileNotFound, "File not found".to_string())
|
||||
}
|
||||
std::io::ErrorKind::PermissionDenied => {
|
||||
(ErrorCode::AccessViolation, "Permission denied".to_string())
|
||||
}
|
||||
_ => (ErrorCode::NotDefined, e.to_string()),
|
||||
};
|
||||
let error = Packet::error(code, msg);
|
||||
let _ = socket.send(&error.serialize()?);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Create server state for RRQ (server sends data)
|
||||
let request = Packet::rrq(filename, mode);
|
||||
let mut state = match ServerState::from_request(&request) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let error = Packet::error(ErrorCode::IllegalOperation, e.to_string());
|
||||
let _ = socket.send(&error.serialize()?);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Start transfer - get NeedData event
|
||||
let event = state.start();
|
||||
let mut read_buf = [0u8; MAX_DATA_SIZE];
|
||||
let mut recv_buf = [0u8; MAX_PACKET_SIZE];
|
||||
let mut last_sent: Option<Vec<u8>> = None;
|
||||
let mut retries_left = self.config.retries;
|
||||
|
||||
// Handle initial NeedData event
|
||||
if let Event::NeedData { block } = event {
|
||||
let n = file.read(&mut read_buf)?;
|
||||
let send_event = state.provide_data(block, read_buf[..n].to_vec())?;
|
||||
if let Event::Send(packet) = send_event {
|
||||
let bytes = packet.serialize()?;
|
||||
socket.send(&bytes)?;
|
||||
last_sent = Some(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
// Main transfer loop
|
||||
loop {
|
||||
let len = match socket.recv(&mut recv_buf) {
|
||||
Ok(len) => len,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
if retries_left == 0 {
|
||||
return Err(Error::Timeout {
|
||||
retries: self.config.retries,
|
||||
});
|
||||
}
|
||||
retries_left -= 1;
|
||||
if let Some(ref last) = last_sent {
|
||||
socket.send(last)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let packet = Packet::parse(&recv_buf[..len])?;
|
||||
|
||||
if let Packet::Error { code, message } = packet {
|
||||
return Err(Error::Remote { code, message });
|
||||
}
|
||||
|
||||
let events = state.receive(&packet)?;
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
Event::NeedData { block } => {
|
||||
let n = file.read(&mut read_buf)?;
|
||||
let send_event = state.provide_data(block, read_buf[..n].to_vec())?;
|
||||
if let Event::Send(packet) = send_event {
|
||||
let bytes = packet.serialize()?;
|
||||
socket.send(&bytes)?;
|
||||
last_sent = Some(bytes);
|
||||
}
|
||||
}
|
||||
Event::Complete => {
|
||||
return Ok(());
|
||||
}
|
||||
// Send and ReceivedData not expected in RRQ handling
|
||||
Event::Send(_) | Event::ReceivedData { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
if state.is_complete() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
retries_left = self.config.retries;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a write request (WRQ).
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn handle_wrq(&self, socket: &UdpSocket, filename: &str, mode: Mode) -> Result<()> {
|
||||
// Check if writes are allowed
|
||||
if !self.config.allow_write {
|
||||
let error = Packet::error(ErrorCode::AccessViolation, "Write not allowed");
|
||||
let _ = socket.send(&error.serialize()?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let path = match self.resolve_path(filename) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let error = Packet::error(ErrorCode::AccessViolation, e.to_string());
|
||||
let _ = socket.send(&error.serialize()?);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Check if file exists and overwrite is not allowed
|
||||
if path.exists() && !self.config.allow_overwrite {
|
||||
let error = Packet::error(ErrorCode::FileAlreadyExists, "File already exists");
|
||||
let _ = socket.send(&error.serialize()?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Open/create the file
|
||||
let mut file = match OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let (code, msg) = match e.kind() {
|
||||
std::io::ErrorKind::PermissionDenied => {
|
||||
(ErrorCode::AccessViolation, "Permission denied".to_string())
|
||||
}
|
||||
_ => (ErrorCode::NotDefined, e.to_string()),
|
||||
};
|
||||
let error = Packet::error(code, msg);
|
||||
let _ = socket.send(&error.serialize()?);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Create server state for WRQ (server receives data)
|
||||
let request = Packet::wrq(filename, mode);
|
||||
let mut state = match ServerState::from_request(&request) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let error = Packet::error(ErrorCode::IllegalOperation, e.to_string());
|
||||
let _ = socket.send(&error.serialize()?);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Start transfer - send ACK 0
|
||||
let event = state.start();
|
||||
let mut recv_buf = [0u8; MAX_PACKET_SIZE];
|
||||
let mut last_sent: Option<Vec<u8>> = None;
|
||||
let mut retries_left = self.config.retries;
|
||||
|
||||
if let Event::Send(packet) = event {
|
||||
let bytes = packet.serialize()?;
|
||||
socket.send(&bytes)?;
|
||||
last_sent = Some(bytes);
|
||||
}
|
||||
|
||||
// Main transfer loop
|
||||
loop {
|
||||
let len = match socket.recv(&mut recv_buf) {
|
||||
Ok(len) => len,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
if retries_left == 0 {
|
||||
// Clean up partial file on timeout
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(Error::Timeout {
|
||||
retries: self.config.retries,
|
||||
});
|
||||
}
|
||||
retries_left -= 1;
|
||||
if let Some(ref last) = last_sent {
|
||||
socket.send(last)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
let packet = Packet::parse(&recv_buf[..len])?;
|
||||
|
||||
if let Packet::Error { code, message } = packet {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(Error::Remote { code, message });
|
||||
}
|
||||
|
||||
let events = state.receive(&packet)?;
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
Event::ReceivedData { data, .. } => {
|
||||
if let Err(e) = file.write_all(&data) {
|
||||
let error = match e.kind() {
|
||||
std::io::ErrorKind::StorageFull => {
|
||||
Packet::error(ErrorCode::DiskFull, "Disk full")
|
||||
}
|
||||
_ => Packet::error(ErrorCode::NotDefined, e.to_string()),
|
||||
};
|
||||
let _ = socket.send(&error.serialize()?);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Event::Send(packet) => {
|
||||
let bytes = packet.serialize()?;
|
||||
socket.send(&bytes)?;
|
||||
last_sent = Some(bytes);
|
||||
}
|
||||
Event::Complete => {
|
||||
file.flush()?;
|
||||
return Ok(());
|
||||
}
|
||||
// NeedData not expected in WRQ handling
|
||||
Event::NeedData { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
if state.is_complete() {
|
||||
file.flush()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
retries_left = self.config.retries;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for creating a server with custom options.
|
||||
pub struct ServerBuilder {
|
||||
config: ServerConfig,
|
||||
}
|
||||
|
||||
impl ServerBuilder {
|
||||
/// Create a new server builder with the specified root directory.
|
||||
#[must_use]
|
||||
pub fn new(root_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
config: ServerConfig {
|
||||
root_dir: root_dir.into(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow write operations.
|
||||
#[must_use]
|
||||
pub const fn allow_write(mut self, allow: bool) -> Self {
|
||||
self.config.allow_write = allow;
|
||||
self
|
||||
}
|
||||
|
||||
/// Allow overwriting existing files.
|
||||
#[must_use]
|
||||
pub const fn allow_overwrite(mut self, allow: bool) -> Self {
|
||||
self.config.allow_overwrite = allow;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the timeout for operations.
|
||||
#[must_use]
|
||||
pub const fn timeout(mut self, timeout: Duration) -> Self {
|
||||
self.config.timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the number of retries.
|
||||
#[must_use]
|
||||
pub const fn retries(mut self, retries: u32) -> Self {
|
||||
self.config.retries = retries;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the server bound to the specified address.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the socket cannot be bound.
|
||||
pub fn bind<A: ToSocketAddrs>(self, addr: A) -> Result<Server> {
|
||||
Server::with_config(addr, self.config)
|
||||
}
|
||||
|
||||
/// Build the server bound to the default TFTP port.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the socket cannot be bound.
|
||||
pub fn bind_default(self) -> Result<Server> {
|
||||
self.bind(("0.0.0.0", TFTP_PORT))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user