Files
pfs-tftp-codex/crates/pfs-tftp-proto/src/netascii.rs

150 lines
4.2 KiB
Rust

//! 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)));
}
}