diff --git a/Cargo.lock b/Cargo.lock index d40631c..841d17d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,3 +5,17 @@ version = 4 [[package]] name = "pfs-tftp" version = "0.1.0" +dependencies = [ + "pfs-tftp-sync", +] + +[[package]] +name = "pfs-tftp-proto" +version = "0.1.0" + +[[package]] +name = "pfs-tftp-sync" +version = "0.1.0" +dependencies = [ + "pfs-tftp-proto", +] diff --git a/Cargo.toml b/Cargo.toml index da8351b..d0f6a56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,9 @@ name = "pfs-tftp" version = "0.1.0" edition = "2024" +[workspace] +members = ["crates/pfs-tftp-proto", "crates/pfs-tftp-sync"] + [lints.rust] unsafe_code = "forbid" @@ -12,3 +15,4 @@ todo = "warn" unwrap_used = "warn" [dependencies] +pfs-tftp-sync = { path = "crates/pfs-tftp-sync" } diff --git a/crates/pfs-tftp-proto/Cargo.toml b/crates/pfs-tftp-proto/Cargo.toml new file mode 100644 index 0000000..5ac1741 --- /dev/null +++ b/crates/pfs-tftp-proto/Cargo.toml @@ -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] diff --git a/crates/pfs-tftp-proto/src/lib.rs b/crates/pfs-tftp-proto/src/lib.rs new file mode 100644 index 0000000..67f3836 --- /dev/null +++ b/crates/pfs-tftp-proto/src/lib.rs @@ -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; diff --git a/crates/pfs-tftp-proto/src/netascii.rs b/crates/pfs-tftp-proto/src/netascii.rs new file mode 100644 index 0000000..00f0e7a --- /dev/null +++ b/crates/pfs-tftp-proto/src/netascii.rs @@ -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) { + 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) -> 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))); + } +} diff --git a/crates/pfs-tftp-proto/src/packet.rs b/crates/pfs-tftp-proto/src/packet.rs new file mode 100644 index 0000000..58f20fe --- /dev/null +++ b/crates/pfs-tftp-proto/src/packet.rs @@ -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 { + 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 { + 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 { + 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 }, + 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 { + 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) { + 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 { + 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, 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))); + } +} diff --git a/crates/pfs-tftp-sync/Cargo.toml b/crates/pfs-tftp-sync/Cargo.toml new file mode 100644 index 0000000..56e6908 --- /dev/null +++ b/crates/pfs-tftp-sync/Cargo.toml @@ -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" } diff --git a/crates/pfs-tftp-sync/src/client.rs b/crates/pfs-tftp-sync/src/client.rs new file mode 100644 index 0000000..0c84d59 --- /dev/null +++ b/crates/pfs-tftp-sync/src/client.rs @@ -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 + } +} diff --git a/crates/pfs-tftp-sync/src/lib.rs b/crates/pfs-tftp-sync/src/lib.rs new file mode 100644 index 0000000..1fc881b --- /dev/null +++ b/crates/pfs-tftp-sync/src/lib.rs @@ -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}; diff --git a/crates/pfs-tftp-sync/src/server.rs b/crates/pfs-tftp-sync/src/server.rs new file mode 100644 index 0000000..b2ab8e6 --- /dev/null +++ b/crates/pfs-tftp-sync/src/server.rs @@ -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 + } +} diff --git a/crates/pfs-tftp-sync/src/util.rs b/crates/pfs-tftp-sync/src/util.rs new file mode 100644 index 0000000..13152f4 --- /dev/null +++ b/crates/pfs-tftp-sync/src/util.rs @@ -0,0 +1,4 @@ +#![forbid(unsafe_code)] + +// Misc sync helpers live here. +