feat: add sans-io TFTP protocol crate
This commit is contained in:
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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user