feat: add sans-io TFTP protocol crate
This commit is contained in:
14
Cargo.lock
generated
14
Cargo.lock
generated
@@ -5,3 +5,17 @@ version = 4
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "pfs-tftp"
|
name = "pfs-tftp"
|
||||||
version = "0.1.0"
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ name = "pfs-tftp"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = ["crates/pfs-tftp-proto", "crates/pfs-tftp-sync"]
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
@@ -12,3 +15,4 @@ todo = "warn"
|
|||||||
unwrap_used = "warn"
|
unwrap_used = "warn"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
pfs-tftp-sync = { path = "crates/pfs-tftp-sync" }
|
||||||
|
|||||||
14
crates/pfs-tftp-proto/Cargo.toml
Normal file
14
crates/pfs-tftp-proto/Cargo.toml
Normal 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]
|
||||||
13
crates/pfs-tftp-proto/src/lib.rs
Normal file
13
crates/pfs-tftp-proto/src/lib.rs
Normal 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;
|
||||||
149
crates/pfs-tftp-proto/src/netascii.rs
Normal file
149
crates/pfs-tftp-proto/src/netascii.rs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
360
crates/pfs-tftp-proto/src/packet.rs
Normal file
360
crates/pfs-tftp-proto/src/packet.rs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/pfs-tftp-sync/Cargo.toml
Normal file
15
crates/pfs-tftp-sync/Cargo.toml
Normal 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" }
|
||||||
44
crates/pfs-tftp-sync/src/client.rs
Normal file
44
crates/pfs-tftp-sync/src/client.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/pfs-tftp-sync/src/lib.rs
Normal file
13
crates/pfs-tftp-sync/src/lib.rs
Normal 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};
|
||||||
47
crates/pfs-tftp-sync/src/server.rs
Normal file
47
crates/pfs-tftp-sync/src/server.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crates/pfs-tftp-sync/src/util.rs
Normal file
4
crates/pfs-tftp-sync/src/util.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
// Misc sync helpers live here.
|
||||||
|
|
||||||
Reference in New Issue
Block a user