feat: add sans-io TFTP protocol crate

This commit is contained in:
2025-12-21 11:14:59 +01:00
parent d537ad4ee2
commit 890443fdc2
11 changed files with 677 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
[package]
name = "pfs-tftp-proto"
version = "0.1.0"
edition = "2024"
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[dependencies]

View File

@@ -0,0 +1,13 @@
//! TFTP protocol types and codecs (sans I/O).
//!
//! This crate intentionally performs no network or filesystem I/O.
//! It provides:
//! - Encoding/decoding of RFC 1350 packet formats.
//! - Netascii streaming translation helpers.
//!
//! Protocol reference: `rfc1350.txt` at the workspace root.
#![forbid(unsafe_code)]
pub mod netascii;
pub mod packet;

View File

@@ -0,0 +1,149 @@
//! Netascii translation helpers.
//!
//! RFC 1350, Section 1 defines *netascii* as ASCII with the newline / carriage-return
//! conventions from the Telnet specification (RFC 764). In practice, this means:
//! - Newline is transmitted as CR LF.
//! - A literal carriage return is transmitted as CR NUL.
//!
//! The translation must work as a streaming transform because CR and LF can be
//! split across TFTP DATA block boundaries (RFC 1350, Section 2: 512-byte blocks).
#![forbid(unsafe_code)]
/// Streaming encoder from local bytes into netascii bytes.
///
/// This implementation treats `\n` as the local newline and maps:
/// - `\n` -> `\r\n`
/// - `\r` -> `\r\0`
///
/// RFC reference: RFC 1350, Section 1 (netascii definition).
#[derive(Debug, Default, Clone)]
pub struct NetasciiEncoder;
impl NetasciiEncoder {
#[must_use]
pub fn new() -> Self {
Self
}
pub fn encode_chunk(&mut self, input: &[u8], output: &mut Vec<u8>) {
for &b in input {
match b {
b'\n' => output.extend_from_slice(b"\r\n"),
b'\r' => output.extend_from_slice(b"\r\0"),
_ => output.push(b),
}
}
}
}
/// Streaming decoder from netascii bytes into local bytes.
///
/// This decoder maintains state for a pending CR at a chunk boundary.
/// It maps:
/// - `\r\n` -> `\n`
/// - `\r\0` -> `\r`
///
/// RFC reference: RFC 1350, Section 1 (netascii definition).
#[derive(Debug, Clone, Default)]
pub struct NetasciiDecoder {
pending_cr: bool,
}
impl NetasciiDecoder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Decodes a chunk of netascii bytes, appending the translated bytes to `output`.
///
/// # Errors
/// Returns an error if an invalid CR sequence is encountered (anything other than
/// CR LF or CR NUL), per the netascii definition referenced by RFC 1350, Section 1.
pub fn decode_chunk(&mut self, input: &[u8], output: &mut Vec<u8>) -> Result<(), DecodeError> {
for &b in input {
if self.pending_cr {
self.pending_cr = false;
match b {
b'\n' => output.push(b'\n'),
0 => output.push(b'\r'),
_ => return Err(DecodeError::InvalidCrSequence(b)),
}
continue;
}
if b == b'\r' {
self.pending_cr = true;
} else {
output.push(b);
}
}
Ok(())
}
/// Flushes any pending state at end-of-stream.
///
/// A dangling CR is invalid netascii.
///
/// # Errors
/// Returns an error if the stream ends with a dangling CR byte.
pub fn finish(self) -> Result<(), DecodeError> {
if self.pending_cr {
Err(DecodeError::DanglingCr)
} else {
Ok(())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecodeError {
DanglingCr,
InvalidCrSequence(u8),
}
impl core::fmt::Display for DecodeError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::DanglingCr => write!(f, "dangling CR at end of stream"),
Self::InvalidCrSequence(b) => write!(f, "invalid netascii CR sequence: 0x{b:02x}"),
}
}
}
impl std::error::Error for DecodeError {}
#[cfg(test)]
mod tests {
use super::*;
#[allow(clippy::unwrap_used)]
#[test]
fn encode_newlines_and_cr() {
let mut enc = NetasciiEncoder::new();
let mut out = Vec::new();
enc.encode_chunk(b"a\nb\rc", &mut out);
assert_eq!(out, b"a\r\nb\r\0c");
}
#[allow(clippy::unwrap_used)]
#[test]
fn decode_across_chunk_boundary() {
let mut dec = NetasciiDecoder::new();
let mut out = Vec::new();
dec.decode_chunk(b"a\r", &mut out).unwrap();
dec.decode_chunk(b"\nb", &mut out).unwrap();
dec.finish().unwrap();
assert_eq!(out, b"a\nb");
}
#[allow(clippy::unwrap_used)]
#[test]
fn decode_rejects_dangling_cr() {
let mut dec = NetasciiDecoder::new();
let mut out = Vec::new();
dec.decode_chunk(b"a\r", &mut out).unwrap();
assert!(matches!(dec.finish(), Err(DecodeError::DanglingCr)));
}
}

View File

@@ -0,0 +1,360 @@
//! Packet encoding/decoding for TFTP (RFC 1350).
//!
//! RFC 1350, Section 5 ("TFTP Packets") defines the packet formats:
//! - RRQ/WRQ: Figure 5-1
//! - DATA: Figure 5-2
//! - ACK: Figure 5-3
//! - ERROR: Figure 5-4
//!
//! This module implements strict parsing/serialization of those formats.
#![forbid(unsafe_code)]
use core::fmt;
/// RFC 1350, Section 2: data blocks are 512 bytes.
pub const BLOCK_SIZE: usize = 512;
/// Maximum packet size in RFC 1350 (DATA with 512 bytes).
pub const MAX_PACKET_SIZE: usize = 4 + BLOCK_SIZE;
/// TFTP opcode values (RFC 1350, Section 5).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u16)]
pub enum Opcode {
/// Read request (RRQ), RFC 1350 Figure 5-1.
Rrq = 1,
/// Write request (WRQ), RFC 1350 Figure 5-1.
Wrq = 2,
/// Data (DATA), RFC 1350 Figure 5-2.
Data = 3,
/// Acknowledgment (ACK), RFC 1350 Figure 5-3.
Ack = 4,
/// Error (ERROR), RFC 1350 Figure 5-4.
Error = 5,
}
impl Opcode {
#[must_use]
pub fn from_u16(value: u16) -> Option<Self> {
match value {
1 => Some(Self::Rrq),
2 => Some(Self::Wrq),
3 => Some(Self::Data),
4 => Some(Self::Ack),
5 => Some(Self::Error),
_ => None,
}
}
}
/// Transfer mode for RRQ/WRQ (RFC 1350, Section 5, Figure 5-1).
///
/// RFC 1350 lists: "netascii", "octet", "mail" (mail is obsolete; Section 1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
NetAscii,
Octet,
Mail,
}
impl Mode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::NetAscii => "netascii",
Self::Octet => "octet",
Self::Mail => "mail",
}
}
#[must_use]
pub fn parse_case_insensitive(s: &str) -> Option<Self> {
if s.eq_ignore_ascii_case("netascii") {
Some(Self::NetAscii)
} else if s.eq_ignore_ascii_case("octet") {
Some(Self::Octet)
} else if s.eq_ignore_ascii_case("mail") {
Some(Self::Mail)
} else {
None
}
}
}
/// Error codes (RFC 1350 Appendix, "Error Codes").
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u16)]
pub enum ErrorCode {
NotDefined = 0,
FileNotFound = 1,
AccessViolation = 2,
DiskFull = 3,
IllegalOperation = 4,
UnknownTransferId = 5,
FileAlreadyExists = 6,
NoSuchUser = 7,
}
impl ErrorCode {
#[must_use]
pub fn from_u16(value: u16) -> Option<Self> {
match value {
0 => Some(Self::NotDefined),
1 => Some(Self::FileNotFound),
2 => Some(Self::AccessViolation),
3 => Some(Self::DiskFull),
4 => Some(Self::IllegalOperation),
5 => Some(Self::UnknownTransferId),
6 => Some(Self::FileAlreadyExists),
7 => Some(Self::NoSuchUser),
_ => None,
}
}
}
/// A RRQ/WRQ request (RFC 1350 Figure 5-1).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Request {
pub filename: String,
pub mode: Mode,
}
/// A decoded TFTP packet (RFC 1350 Section 5).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Packet {
Rrq(Request),
Wrq(Request),
Data { block: u16, data: Vec<u8> },
Ack { block: u16 },
Error { code: ErrorCode, message: String },
}
impl Packet {
/// Encode a packet into a newly allocated buffer.
#[must_use]
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(MAX_PACKET_SIZE);
self.encode_into(&mut out);
out
}
/// Encode a packet by appending to `out`.
pub fn encode_into(&self, out: &mut Vec<u8>) {
match self {
Self::Rrq(req) => encode_request(out, Opcode::Rrq, req),
Self::Wrq(req) => encode_request(out, Opcode::Wrq, req),
Self::Data { block, data } => {
out.extend_from_slice(&(Opcode::Data as u16).to_be_bytes());
out.extend_from_slice(&block.to_be_bytes());
out.extend_from_slice(data);
}
Self::Ack { block } => {
out.extend_from_slice(&(Opcode::Ack as u16).to_be_bytes());
out.extend_from_slice(&block.to_be_bytes());
}
Self::Error { code, message } => {
out.extend_from_slice(&(Opcode::Error as u16).to_be_bytes());
out.extend_from_slice(&(*code as u16).to_be_bytes());
out.extend_from_slice(message.as_bytes());
out.push(0);
}
}
}
/// Decode a packet from UDP payload bytes.
///
/// # Errors
/// Returns a [`DecodeError`] if the input does not match an RFC 1350 packet format.
pub fn decode(input: &[u8]) -> Result<Self, DecodeError> {
let (opcode, rest) = parse_opcode(input)?;
match opcode {
Opcode::Rrq | Opcode::Wrq => {
let (filename, rest) = parse_netascii_z(rest, "filename")?;
let (mode_str, rest) = parse_netascii_z(rest, "mode")?;
if !rest.is_empty() {
return Err(DecodeError::TrailingBytes);
}
let Some(mode) = Mode::parse_case_insensitive(&mode_str) else {
return Err(DecodeError::UnknownMode(mode_str));
};
let req = Request { filename, mode };
Ok(if opcode == Opcode::Rrq {
Self::Rrq(req)
} else {
Self::Wrq(req)
})
}
Opcode::Data => {
if rest.len() < 2 {
return Err(DecodeError::Truncated("block"));
}
if rest.len() > 2 + BLOCK_SIZE {
return Err(DecodeError::OversizeData(rest.len() - 2));
}
let block = u16::from_be_bytes([rest[0], rest[1]]);
let data = rest[2..].to_vec();
Ok(Self::Data { block, data })
}
Opcode::Ack => {
if rest.len() != 2 {
return Err(DecodeError::InvalidLength {
kind: "ACK",
expected: 4,
got: input.len(),
});
}
let block = u16::from_be_bytes([rest[0], rest[1]]);
Ok(Self::Ack { block })
}
Opcode::Error => {
if rest.len() < 2 {
return Err(DecodeError::Truncated("error code"));
}
let code_u16 = u16::from_be_bytes([rest[0], rest[1]]);
let Some(code) = ErrorCode::from_u16(code_u16) else {
return Err(DecodeError::UnknownErrorCode(code_u16));
};
let (message, trailing) = parse_netascii_z(&rest[2..], "error message")?;
if !trailing.is_empty() {
return Err(DecodeError::TrailingBytes);
}
Ok(Self::Error { code, message })
}
}
}
}
fn encode_request(out: &mut Vec<u8>, opcode: Opcode, req: &Request) {
out.extend_from_slice(&(opcode as u16).to_be_bytes());
out.extend_from_slice(req.filename.as_bytes());
out.push(0);
out.extend_from_slice(req.mode.as_str().as_bytes());
out.push(0);
}
fn parse_opcode(input: &[u8]) -> Result<(Opcode, &[u8]), DecodeError> {
if input.len() < 2 {
return Err(DecodeError::Truncated("opcode"));
}
let opcode_u16 = u16::from_be_bytes([input[0], input[1]]);
let Some(opcode) = Opcode::from_u16(opcode_u16) else {
return Err(DecodeError::UnknownOpcode(opcode_u16));
};
Ok((opcode, &input[2..]))
}
fn parse_netascii_z<'a>(
input: &'a [u8],
field: &'static str,
) -> Result<(String, &'a [u8]), DecodeError> {
let Some(pos) = input.iter().position(|&b| b == 0) else {
return Err(DecodeError::MissingTerminator(field));
};
if pos == 0 {
return Err(DecodeError::EmptyField(field));
}
let bytes = &input[..pos];
if bytes.iter().any(|&b| b > 0x7F) {
return Err(DecodeError::NonNetascii(field));
}
let s = core::str::from_utf8(bytes).map_err(|_| DecodeError::NonUtf8(field))?;
Ok((s.to_owned(), &input[pos + 1..]))
}
/// A parse error for RFC 1350 packet formats.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecodeError {
Truncated(&'static str),
UnknownOpcode(u16),
MissingTerminator(&'static str),
EmptyField(&'static str),
NonNetascii(&'static str),
NonUtf8(&'static str),
UnknownMode(String),
UnknownErrorCode(u16),
OversizeData(usize),
InvalidLength {
kind: &'static str,
expected: usize,
got: usize,
},
TrailingBytes,
}
impl fmt::Display for DecodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Truncated(what) => write!(f, "truncated packet ({what})"),
Self::UnknownOpcode(op) => write!(f, "unknown opcode {op}"),
Self::MissingTerminator(field) => write!(f, "missing NUL terminator for {field}"),
Self::EmptyField(field) => write!(f, "empty {field}"),
Self::NonNetascii(field) => write!(f, "{field} contains non-netascii bytes"),
Self::NonUtf8(field) => write!(f, "{field} is not valid UTF-8"),
Self::UnknownMode(mode) => write!(f, "unknown transfer mode {mode:?}"),
Self::UnknownErrorCode(code) => write!(f, "unknown error code {code}"),
Self::OversizeData(n) => write!(f, "DATA payload too large ({n} bytes)"),
Self::InvalidLength { kind, expected, got } => {
write!(f, "{kind} packet has invalid length (expected {expected}, got {got})")
}
Self::TrailingBytes => write!(f, "trailing bytes after packet"),
}
}
}
impl std::error::Error for DecodeError {}
#[cfg(test)]
mod tests {
use super::*;
#[allow(clippy::unwrap_used)]
#[test]
fn rrq_roundtrip() {
let pkt = Packet::Rrq(Request {
filename: "hello.txt".to_string(),
mode: Mode::Octet,
});
let encoded = pkt.encode();
let decoded = Packet::decode(&encoded).unwrap();
assert_eq!(decoded, pkt);
}
#[allow(clippy::unwrap_used)]
#[test]
fn data_roundtrip_small() {
let pkt = Packet::Data {
block: 42,
data: b"abc".to_vec(),
};
let encoded = pkt.encode();
let decoded = Packet::decode(&encoded).unwrap();
assert_eq!(decoded, pkt);
}
#[allow(clippy::unwrap_used)]
#[test]
fn ack_requires_exact_length() {
let mut bytes = Vec::new();
Packet::Ack { block: 1 }.encode_into(&mut bytes);
bytes.push(0);
let err = Packet::decode(&bytes).unwrap_err();
assert!(matches!(err, DecodeError::InvalidLength { kind: "ACK", .. }));
}
#[allow(clippy::unwrap_used)]
#[test]
fn rejects_unknown_opcode() {
let err = Packet::decode(&[0, 9]).unwrap_err();
assert!(matches!(err, DecodeError::UnknownOpcode(9)));
}
#[allow(clippy::unwrap_used)]
#[test]
fn rejects_data_larger_than_512() {
let mut bytes = vec![0, 3, 0, 1];
bytes.extend(std::iter::repeat(0x61).take(BLOCK_SIZE + 1));
let err = Packet::decode(&bytes).unwrap_err();
assert!(matches!(err, DecodeError::OversizeData(513)));
}
}

View File

@@ -0,0 +1,15 @@
[package]
name = "pfs-tftp-sync"
version = "0.1.0"
edition = "2024"
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[dependencies]
pfs-tftp-proto = { path = "../pfs-tftp-proto" }

View File

@@ -0,0 +1,44 @@
#![forbid(unsafe_code)]
use std::net::SocketAddr;
use std::time::Duration;
/// Configuration for a synchronous TFTP client.
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub timeout: Duration,
pub retries: u32,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
timeout: Duration::from_secs(5),
retries: 5,
}
}
}
/// A synchronous TFTP client.
#[derive(Debug)]
pub struct Client {
pub(crate) server: SocketAddr,
pub(crate) config: ClientConfig,
}
impl Client {
#[must_use]
pub fn new(server: SocketAddr, config: ClientConfig) -> Self {
Self { server, config }
}
#[must_use]
pub fn server(&self) -> SocketAddr {
self.server
}
#[must_use]
pub fn config(&self) -> &ClientConfig {
&self.config
}
}

View File

@@ -0,0 +1,13 @@
//! Synchronous TFTP client/server helpers built on `pfs-tftp-proto`.
//!
//! This crate provides small, blocking APIs built on `std::net::UdpSocket`.
//! It intentionally avoids async runtimes for simplicity.
#![forbid(unsafe_code)]
pub mod client;
pub mod server;
pub mod util;
pub use client::{Client, ClientConfig};
pub use server::{Server, ServerConfig};

View File

@@ -0,0 +1,47 @@
#![forbid(unsafe_code)]
use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;
/// Configuration for a synchronous TFTP server.
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub bind: SocketAddr,
pub root: PathBuf,
pub allow_write: bool,
pub overwrite: bool,
pub timeout: Duration,
pub retries: u32,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind: SocketAddr::from(([0, 0, 0, 0], 6969)),
root: PathBuf::from("."),
allow_write: false,
overwrite: false,
timeout: Duration::from_secs(5),
retries: 5,
}
}
}
/// A synchronous TFTP server.
#[derive(Debug)]
pub struct Server {
pub(crate) config: ServerConfig,
}
impl Server {
#[must_use]
pub fn new(config: ServerConfig) -> Self {
Self { config }
}
#[must_use]
pub fn config(&self) -> &ServerConfig {
&self.config
}
}

View File

@@ -0,0 +1,4 @@
#![forbid(unsafe_code)]
// Misc sync helpers live here.