diff --git a/docs/architecture/bast.md b/docs/architecture/bast.md index 1cc6b0e..9886644 100644 --- a/docs/architecture/bast.md +++ b/docs/architecture/bast.md @@ -74,7 +74,7 @@ What this BAST deliberately does **not** cover: { "name": "addr", "kind": "bytes", "encoding": "length-prefixed", "maxLength": 259, "description": "RFC 1928 address encoding: ATYP + addr + port (TargetAddr::to_be_bytes); v4 = 1 + 4 + 2 = 7, v6 = 1 + 16 + 2 = 19, domain = 1 + 1 + 255 + 2 = 259 (the length byte of the domain form counts)" }, { "name": "data", "kind": "bytes", "encoding": "length-prefixed", - "maxLength": 65535, "description": "the datagram payload; len == 0 is a legal empty datagram, NOT EOF" } + "maxLength": 65535, "description": "the datagram payload; a zero-length payload is a legal empty datagram, NOT EOF (the F-2 invariant at payload level — len == 0, zero inner bytes, is unreachable from a compliant peer since addr is always >= 7 bytes)" } ] } } @@ -109,9 +109,11 @@ One frame per datagram, in both directions of the channel `BiStream`, for the whole datagram stage (association lifetime = channel lifetime). Frames are self-delimiting on the byte stream; a decoder is an incremental state machine across chunk boundaries -(boundary preservation is mandatory — an empty datagram is a frame -with `len == 0`, never EOF; alktunnels F-2's invariant, met -structurally). +(boundary preservation is mandatory — an empty datagram is a legal +zero-length payload, never EOF; alktunnels F-2's invariant, met +structurally and stated at payload level: `len == 0` — zero inner +bytes — is unreachable from a compliant peer, since `addr` is always +≥ 7 bytes). ### Layering note diff --git a/docs/architecture/wire.md b/docs/architecture/wire.md index eec7f32..be1eba7 100644 --- a/docs/architecture/wire.md +++ b/docs/architecture/wire.md @@ -123,9 +123,12 @@ the same channel: - **Frame:** `[len: u16 BE][addr + data]` where `addr + data` is the RFC address encoding (`TargetAddr::to_be_bytes()` — ATYP + addr + port; the RFC UDP header minus RSV/FRAG). One frame per datagram, - both directions. `len = 0` is a legal empty datagram (never EOF — - the F-2 invariant, shaped on alktunnels ADR-003's codec, - re-implemented in first-party `src/wire.rs`). **The length covers + both directions. **A zero-length payload is a legal empty datagram + (never EOF — the F-2 invariant, shaped on alktunnels ADR-003's + codec, re-implemented in first-party `src/wire.rs`); `len == 0` — + zero inner bytes — is unreachable from a compliant peer, since + `addr` is always ≥ 7 bytes, so the operative invariant lives at + payload level.** The length covers `addr + data`, not data alone — the joint bound is the constraint:** a frame is rejected (`Oversize`, at frame time) whenever `len(addr) + len(data) > 65535`. The UDP payload bound @@ -209,7 +212,9 @@ UDP ASSOCIATE (the datagram stage, one direction's view): ``` [len:u16 BE][ATYP + addr + port + data] [len:u16 BE][...] ... - len == 0: an empty datagram (legal payload, NOT EOF) + zero-length data: an empty datagram (legal payload, NOT EOF — + the F-2 invariant at payload level) + len == 0 (no addr bytes): unreachable from a compliant peer EOF: the channels-level sentinel, outside the codec ``` diff --git a/src/lib.rs b/src/lib.rs index 7eeba24..3636112 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,3 +28,5 @@ pub mod error; pub mod params; pub mod producer; pub mod wire; + +pub use wire::{addr_encoding_len, frame, DatagramReader, WireError, MAX_INNER_LEN}; diff --git a/src/wire.rs b/src/wire.rs index 8d59d29..34ae767 100644 --- a/src/wire.rs +++ b/src/wire.rs @@ -1,5 +1,574 @@ -//! The ASSOCIATE datagram frame codec: the first-party -//! `[len: u16 BE][addr + data]` framing (ADR-003), applied exactly -//! once, by this layer. +//! The ASSOCIATE datagram frame codec (ADR-003): the crate's one +//! framed stage — `[len: u16 BE][addr + data]` per datagram, both +//! directions of the channel `BiStream`. CONNECT/BIND are raw +//! pass-through (no framing). Normative WHAT: `wire.md` §The Data +//! Plane; the binary contract: `bast.md` (`AssociateFrame`). The +//! length codec is first-party code shaped on alktunnels' +//! `frame_datagram`/`DatagramReader` (same F-2 behavior; no +//! dependency on it). //! -//! Stub: filled by the `implementation/core-wire-codec` task. +//! The inner bytes are the RFC 1928 address encoding — exactly +//! `TargetAddr::to_be_bytes()` (ATYP + addr + port; the RFC UDP +//! header minus RSV/FRAG, which ride only vanilla front doors, +//! ADR-003) — followed by the datagram payload. +//! +//! **Framing is applied exactly once, by this layer.** The egress +//! seam speaks *inner* bytes (`addr + data`, no length prefix — +//! `Egress::next_reply` returns inner bytes); the producer's frame +//! writer adds the single length prefix, and the consumer's send path +//! frames via [`frame`] exactly once. The double-framing bug class +//! POC #2 caught (an egress framing its replies, the producer +//! prefixing again — the client parsed the length field as ATYP +//! `0x00`) is an API-design invariant: the wire layer owns the length +//! prefix; nothing else adds one. +//! +//! **The joint bound (the load-bearing constraint).** The u16 length +//! field covers `addr + data`, not the payload alone: a frame is +//! rejected ([`WireError::Oversize`], at frame time) whenever +//! `len(addr) + len(payload) > 65535`. The UDP payload bound alone +//! (65507) does NOT fit u16 unconditionally — a 65507-byte datagram +//! to a 255-character domain is 65507 + 259 = 65766 frame bytes. Max +//! payload per address form: 65276 (domain, 255 chars), 65516 (IPv6), +//! 65528 (IPv4). [`addr_encoding_len`] exposes the per-address +//! encoding size so callers can compute the joint bound; no unchecked +//! u16 arithmetic is used on the payload bound. +//! +//! **The F-2 invariant, at payload level.** A zero-length payload is +//! a legal empty datagram — a real datagram, never an EOF signal: +//! [`frame`] encodes it and [`DatagramReader`] decodes it as +//! `(addr, empty)`. (`len == 0` — zero inner bytes — is unreachable +//! from a compliant peer, since `addr` is always ≥ 7 bytes; a wire +//! `len` of 0 is protocol garbage and surfaces as +//! [`WireError::EmptyFrame`].) +//! +//! **EOF layering.** EOF is the channels-level sentinel on the +//! `BiStream`, outside this codec: the codec never emits zero-length +//! reads, so the sentinel and the datagram stream never collide (an +//! empty datagram and a zero-byte read are different shapes — the +//! reason boundary-preserving framing is mandatory for the datagram +//! stage at all). A stream end mid-frame is the session's fail-loud +//! truncated-datagram condition ([`DatagramReader::is_mid_frame`] is +//! the teardown diagnostic); the codec itself only buffers — it never +//! errors on truncation and never yields a partial frame. + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use thiserror::Error; + +use crate::rfc1928::consts; +use crate::rfc1928::util::target_addr::{AddrError, TargetAddr}; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; + +/// The u16 length-field bound on a frame's inner bytes (`addr + data`): +/// frames whose address encoding plus payload exceed this are rejected +/// at frame time (ADR-003 — never a wire overflow). The joint bound, +/// not a payload bound: see the module docs. +pub const MAX_INNER_LEN: usize = u16::MAX as usize; + +const FRAME_LEN_FIELD: usize = 2; + +/// Codec errors. Two families: +/// +/// - **Frame time** ([`frame`]): [`WireError::Oversize`] (the joint +/// bound — `len(addr) + len(payload) > 65535` would wrap the u16 +/// length field) and [`WireError::Address`] (the address encoding +/// refused — e.g. a domain longer than 255 bytes). +/// - **Decode time** ([`DatagramReader::feed`], per completed frame): +/// [`WireError::EmptyFrame`], [`WireError::BadAtyp`], +/// [`WireError::ShortAddress`], [`WireError::DomainNotUtf8`] — all +/// protocol-garbage shapes a compliant peer never produces. A bad +/// ATYP is its own variant so the producer stage can fail the +/// association loud (`udp_undecodable_frame_ends_association`, +/// POC #2) instead of panicking or guessing. +/// +/// The reassembly itself is infallible: with the oversize bound +/// enforced at frame time, a declared length can never exceed what +/// the frame carries, so the incremental decode only buffers (bounded +/// by `u16::MAX`) and completes. +#[derive(Debug, Error)] +pub enum WireError { + /// A frame's inner bytes (`addr + data`) exceed + /// [`MAX_INNER_LEN`] — the u16 length field would wrap. Rejected + /// at frame time; carries `(total, max)`. + #[error("datagram frame too large for u16 length prefix: {0} bytes of addr + data (max {1})")] + Oversize(usize, usize), + + /// The address could not be encoded (`TargetAddr::to_be_bytes` + /// refused — e.g. `AddrError::DomainLenTooLong`). + #[error("address encode: {0}")] + Address(#[from] AddrError), + + /// A completed frame declared `len == 0` — zero inner bytes, no + /// address at all. Unreachable from a compliant peer (`addr` is + /// always ≥ 7 bytes); protocol garbage. + #[error("empty frame: no address bytes (len == 0 is unreachable from a compliant peer)")] + EmptyFrame, + + /// The frame's first byte is not a known ATYP (`0x01`/`0x03`/ + /// `0x04`) — post-reply control bytes parse as exactly this (the + /// frame's first byte is ATYP). The loud-fail shape: + /// `udp_undecodable_frame_ends_association`, POC #2. + #[error("unknown ATYP {0:#x}: protocol garbage")] + BadAtyp(u8), + + /// A frame declared fewer bytes than its ATYP's address form + /// occupies (e.g. ATYP `0x04` with `len < 19`). + #[error("frame too short for its address form")] + ShortAddress, + + /// The domain form's bytes are not valid UTF-8 (the forked + /// address codec validates UTF-8; mirrored here). + #[error("domain form is not valid UTF-8")] + DomainNotUtf8(#[source] std::str::Utf8Error), +} + +/// The RFC 1928 address encoding's length for `addr` (ATYP + addr + +/// port — exactly `TargetAddr::to_be_bytes()`'s size): 7 bytes for +/// IPv4, 19 for IPv6, `4 + domain.len()` for domain form (259 max). +/// Callers compute the joint bound with it: a payload is framable for +/// `addr` iff `addr_encoding_len(addr) + payload.len() <= MAX_INNER_LEN`. +pub fn addr_encoding_len(addr: &TargetAddr) -> usize { + match addr { + TargetAddr::Ip(SocketAddr::V4(_)) => 1 + 4 + 2, + TargetAddr::Ip(SocketAddr::V6(_)) => 1 + 16 + 2, + TargetAddr::Domain(domain, _) => 1 + 1 + domain.len() + 2, + } +} + +/// Frame one datagram: `[len: u16 BE][addr + data]` where `addr + +/// data` is `TargetAddr::to_be_bytes()` (ATYP + addr + port) followed +/// by `payload`. One frame per datagram; the single length prefix on +/// this wire (framing-once — see the module docs). A zero-length +/// payload is a legal empty datagram (the F-2 invariant, at payload +/// level). Rejects [`WireError::Oversize`] at frame time when +/// `len(addr) + len(payload) > 65535` — the joint bound; the payload +/// bound alone (65507) does not fit u16 unconditionally. +pub fn frame(addr: &TargetAddr, payload: &[u8]) -> Result { + let addr_bytes = addr.to_be_bytes()?; + let inner = addr_bytes + .len() + .checked_add(payload.len()) + .unwrap_or(MAX_INNER_LEN + 1); + if inner > MAX_INNER_LEN { + return Err(WireError::Oversize(inner, MAX_INNER_LEN)); + } + let mut buf = BytesMut::with_capacity(FRAME_LEN_FIELD + inner); + buf.put_u16(inner as u16); + buf.put_slice(&addr_bytes); + buf.put_slice(payload); + Ok(buf.freeze()) +} + +/// Incremental datagram decoder over the framed stage's read +/// direction. Feed it whatever `read()` yields; it reassembles +/// `[len: u16 BE][addr + data]` frames across chunk boundaries — a +/// frame may be split across chunks, several frames may share one +/// chunk, and partial headers wait for more bytes. Each completed +/// frame is decoded into `(TargetAddr, Bytes)` — the addr sub-layout +/// (ATYP determines the address length) is split off, the remainder +/// is the datagram payload. +/// +/// Reassembly is infallible (see [`WireError`] docs — the oversize +/// bound is enforced at frame time); the only error per frame is the +/// addr decode (the garbage shapes). The reader keeps going after a +/// decode error (the frame's bytes are consumed either way); the +/// caller fails loud on the first one. +#[derive(Debug)] +pub struct DatagramReader { + buf: BytesMut, + current: Option<(usize, BytesMut)>, +} + +impl Default for DatagramReader { + fn default() -> Self { + Self::new() + } +} + +impl DatagramReader { + pub fn new() -> Self { + Self { + buf: BytesMut::new(), + current: None, + } + } + + /// Feed raw bytes from the read half. Returns every frame that + /// completed with this feed, in order — zero or more per chunk. + /// Buffers partial frames across chunk boundaries (bounded by the + /// u16 declared length). Infallible reassembly; per-frame addr + /// decode errors are the [`WireError`] decode variants. + pub fn feed(&mut self, chunk: &[u8]) -> Vec> { + self.buf.extend_from_slice(chunk); + let mut out = Vec::new(); + loop { + if self.current.is_none() { + if self.buf.len() < FRAME_LEN_FIELD { + break; + } + let declared = u16::from_be_bytes([self.buf[0], self.buf[1]]) as usize; + self.buf.advance(FRAME_LEN_FIELD); + self.current = Some((declared, BytesMut::new())); + } + let Some((declared, acc)) = self.current.as_mut() else { + break; + }; + if *declared == 0 { + self.current = None; + out.push(Err(WireError::EmptyFrame)); + continue; + } + let take = (*declared - acc.len()).min(self.buf.len()); + if take > 0 { + acc.extend_from_slice(&self.buf[..take]); + self.buf.advance(take); + } + if acc.len() == *declared { + let inner = std::mem::take(acc).freeze(); + self.current = None; + out.push(decode_inner(&inner)); + } else { + break; + } + } + out + } + + /// `true` while a partial frame is in flight (the teardown + /// diagnostic — a clean peer never ends the stream mid-frame; a + /// stream end while this is set is the session's fail-loud + /// truncated-datagram condition, never a silent partial + /// datagram). + pub fn is_mid_frame(&self) -> bool { + self.current.is_some() + } +} + +/// Decode one frame's inner bytes (`addr + data`, the length prefix +/// already consumed) into `(addr, payload)`. The first byte is ATYP; +/// the address encoding is parsed by hand over the borrowed slice — +/// the same sub-layout the forked `read_address` accepts in-stream, +/// mirrored synchronously (the stage's decode must not be async over +/// a borrowed frame). RSV/FRAG cannot appear (ADR-003: they do not +/// ride the tunnel wire). +fn decode_inner(inner: &[u8]) -> Result<(TargetAddr, Bytes), WireError> { + if inner.is_empty() { + return Err(WireError::EmptyFrame); + } + let atyp = inner[0]; + match atyp { + consts::SOCKS5_ADDR_TYPE_IPV4 => { + if inner.len() < 1 + 4 + 2 { + return Err(WireError::ShortAddress); + } + let ip = Ipv4Addr::new(inner[1], inner[2], inner[3], inner[4]); + let port = u16::from_be_bytes([inner[5], inner[6]]); + Ok(( + TargetAddr::Ip(SocketAddr::from((ip, port))), + Bytes::copy_from_slice(&inner[7..]), + )) + } + consts::SOCKS5_ADDR_TYPE_IPV6 => { + if inner.len() < 1 + 16 + 2 { + return Err(WireError::ShortAddress); + } + let mut octets = [0u8; 16]; + octets.copy_from_slice(&inner[1..17]); + let ip = Ipv6Addr::from(octets); + let port = u16::from_be_bytes([inner[17], inner[18]]); + Ok(( + TargetAddr::Ip(SocketAddr::from((ip, port))), + Bytes::copy_from_slice(&inner[19..]), + )) + } + consts::SOCKS5_ADDR_TYPE_DOMAIN_NAME => { + if inner.len() < 2 { + return Err(WireError::ShortAddress); + } + let dlen = inner[1] as usize; + if inner.len() < 2 + dlen + 2 { + return Err(WireError::ShortAddress); + } + let domain = + std::str::from_utf8(&inner[2..2 + dlen]).map_err(WireError::DomainNotUtf8)?; + let port_at = 2 + dlen; + let port = u16::from_be_bytes([inner[port_at], inner[port_at + 1]]); + Ok(( + TargetAddr::Domain(domain.to_owned(), port), + Bytes::copy_from_slice(&inner[port_at + 2..]), + )) + } + other => Err(WireError::BadAtyp(other)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rfc1928::util::target_addr::AddrError as AddrErr; + use std::net::{SocketAddrV4, SocketAddrV6}; + + fn v4(port: u16) -> TargetAddr { + TargetAddr::Ip(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::new(192, 168, 0, 1), + port, + ))) + } + + fn v6(port: u16) -> TargetAddr { + TargetAddr::Ip(SocketAddr::V6(SocketAddrV6::new( + Ipv6Addr::LOCALHOST, + port, + 0, + 0, + ))) + } + + fn domain(name: &str, port: u16) -> TargetAddr { + TargetAddr::Domain(name.to_owned(), port) + } + + fn feed_one( + reader: &mut DatagramReader, + chunk: &[u8], + ) -> Option> { + reader.feed(chunk).into_iter().next() + } + + #[test] + fn addr_encoding_len_matches_the_rfc_forms() { + assert_eq!(addr_encoding_len(&v4(80)), 7); + assert_eq!(addr_encoding_len(&v6(443)), 19); + assert_eq!(addr_encoding_len(&domain("example.com", 53)), 15); + assert_eq!(addr_encoding_len(&domain(&"a".repeat(255), 53)), 259); + } + + #[test] + fn frame_layout_is_len_then_addr_then_payload() { + let addr = v4(80); + let framed = frame(&addr, b"hi").unwrap(); + let addr_bytes = addr.to_be_bytes().unwrap(); + let inner = (addr_bytes.len() + 2) as u16; + assert_eq!(&framed[..2], &inner.to_be_bytes()); + assert_eq!(&framed[2..2 + addr_bytes.len()], &addr_bytes[..]); + assert_eq!(&framed[2 + addr_bytes.len()..], b"hi"); + assert_eq!(framed.len(), 2 + inner as usize); + } + + #[test] + fn round_trips_all_address_forms() { + for addr in [v4(80), v6(443), domain("example.com", 53)] { + let framed = frame(&addr, b"hello").unwrap(); + let mut r = DatagramReader::new(); + let dgs = r.feed(&framed); + assert_eq!(dgs.len(), 1); + let (src, payload) = dgs.into_iter().next().unwrap().unwrap(); + assert_eq!(src, addr); + assert_eq!(&payload[..], b"hello"); + } + } + + #[test] + fn empty_payload_is_a_legal_datagram_not_eof() { + for addr in [v4(53), v6(53), domain("example.com", 53)] { + let framed = frame(&addr, b"").unwrap(); + assert_eq!(framed.len(), 2 + addr_encoding_len(&addr)); + let mut r = DatagramReader::new(); + let dgs = r.feed(&framed); + assert_eq!(dgs.len(), 1, "an empty datagram is a real datagram"); + let (src, payload) = dgs.into_iter().next().unwrap().unwrap(); + assert_eq!(src, addr); + assert!(payload.is_empty()); + } + } + + #[test] + fn frame_split_across_awkward_chunks_reassembles() { + let framed = frame(&v4(80), b"0123456789").unwrap(); + let mut r = DatagramReader::new(); + let mut got = None; + for piece in framed.chunks(7) { + for res in r.feed(piece) { + got = Some(res); + } + } + let (src, payload) = got + .expect("datagram reassembled across 7-byte chunks") + .unwrap(); + assert_eq!(src, v4(80)); + assert_eq!(&payload[..], b"0123456789"); + } + + #[test] + fn byte_by_byte_feeding_reassembles_batched_frames_in_order() { + let mut buf = BytesMut::new(); + buf.extend_from_slice(&frame(&v4(1), b"abc").unwrap()); + buf.extend_from_slice(&frame(&domain("example.com", 2), b"de").unwrap()); + buf.extend_from_slice(&frame(&v6(3), b"").unwrap()); + let wire = buf.freeze(); + let mut r = DatagramReader::new(); + let mut got = Vec::new(); + for piece in wire.chunks(1) { + got.extend(r.feed(piece)); + } + assert_eq!(got.len(), 3); + let (src1, p1) = got.remove(0).unwrap(); + assert_eq!(src1, v4(1)); + assert_eq!(&p1[..], b"abc"); + let (src2, p2) = got.remove(0).unwrap(); + assert_eq!(src2, domain("example.com", 2)); + assert_eq!(&p2[..], b"de"); + let (src3, p3) = got.remove(0).unwrap(); + assert_eq!(src3, v6(3)); + assert!(p3.is_empty()); + } + + #[test] + fn two_frames_batched_in_one_chunk() { + let mut buf = BytesMut::new(); + buf.extend_from_slice(&frame(&v4(1), b"abc").unwrap()); + buf.extend_from_slice(&frame(&v6(2), b"de").unwrap()); + let mut r = DatagramReader::new(); + let mut dgs = r.feed(&buf.freeze()); + assert_eq!(dgs.len(), 2); + let (src1, p1) = dgs.remove(0).unwrap(); + assert_eq!(src1, v4(1)); + assert_eq!(&p1[..], b"abc"); + let (src2, p2) = dgs.remove(0).unwrap(); + assert_eq!(src2, v6(2)); + assert_eq!(&p2[..], b"de"); + } + + #[test] + fn partial_header_at_chunk_boundary_waits() { + let framed = frame(&v4(80), b"xyz").unwrap(); + let mut r = DatagramReader::new(); + assert!(feed_one(&mut r, &framed[..1]).is_none()); + assert!( + !r.is_mid_frame(), + "a partial header is not a frame in flight" + ); + let pair = feed_one(&mut r, &framed[1..]).expect("frame after header completes"); + let (src, payload) = pair.unwrap(); + assert_eq!(src, v4(80)); + assert_eq!(&payload[..], b"xyz"); + } + + #[test] + fn mid_frame_state_is_observable() { + let framed = frame(&v4(80), b"0123").unwrap(); + let mut r = DatagramReader::new(); + r.feed(&framed[..3]); + assert!(r.is_mid_frame()); + r.feed(&framed[3..]); + assert!(!r.is_mid_frame()); + } + + #[test] + fn truncated_stream_never_yields_partial_frame() { + let framed = frame(&v4(80), b"payload").unwrap(); + let mut r = DatagramReader::new(); + r.feed(&framed[..3]); + assert!(r.is_mid_frame()); + assert!(feed_one(&mut r, &[]).is_none()); + assert!(r.is_mid_frame()); + assert_eq!( + r.feed(&framed[3..framed.len() - 1]).len(), + 0, + "one byte short must stay buffered, not yield a partial frame" + ); + assert!(r.is_mid_frame()); + } + + #[test] + fn joint_bound_rejects_65507_payload_to_max_domain() { + let addr = domain(&"a".repeat(255), 53); + let err = frame(&addr, &vec![0u8; 65507]).unwrap_err(); + assert!(matches!(err, WireError::Oversize(65766, 65535))); + } + + #[test] + fn joint_bound_accepts_65507_payload_to_ipv4() { + let addr = v4(53); + let framed = frame(&addr, &vec![0u8; 65507]).unwrap(); + assert_eq!(framed.len(), 2 + 65514); + } + + #[test] + fn joint_bound_exact_limit_is_legal_and_one_more_rejects() { + for (addr, max_payload) in [ + (v4(80), 65528usize), + (v6(80), 65516usize), + (domain(&"a".repeat(255), 80), 65276usize), + ] { + let framed = frame(&addr, &vec![0u8; max_payload]).unwrap(); + assert_eq!(framed.len(), 2 + MAX_INNER_LEN); + let err = frame(&addr, &vec![0u8; max_payload + 1]).unwrap_err(); + assert!(matches!(err, WireError::Oversize(65536, 65535))); + } + } + + #[test] + fn oversize_rejected_at_frame_time() { + let addr = v4(80); + let err = frame(&addr, &vec![0u8; MAX_INNER_LEN - 6]).unwrap_err(); + assert!(matches!(err, WireError::Oversize(65536, 65535))); + } + + #[test] + fn domain_too_long_is_an_address_error() { + let addr = domain(&"a".repeat(256), 80); + let err = frame(&addr, b"").unwrap_err(); + match err { + WireError::Address(AddrErr::DomainLenTooLong(256)) => {} + other => panic!("expected Address(DomainLenTooLong(256)), got {other:?}"), + } + } + + #[test] + fn garbage_atyp_decode_is_typed_not_panic() { + let wire = [0x00, 0x03, 0x7f, 0x01, 0x02]; + let mut r = DatagramReader::new(); + let dgs = r.feed(&wire); + assert_eq!(dgs.len(), 1); + match dgs.into_iter().next().unwrap() { + Err(WireError::BadAtyp(0x7f)) => {} + other => panic!("expected BadAtyp(0x7f), got {other:?}"), + } + } + + #[test] + fn empty_wire_frame_is_a_decode_error() { + let wire = [0x00, 0x00]; + let mut r = DatagramReader::new(); + let dgs = r.feed(&wire); + assert_eq!(dgs.len(), 1); + match dgs.into_iter().next().unwrap() { + Err(WireError::EmptyFrame) => {} + other => panic!("expected EmptyFrame, got {other:?}"), + } + } + + #[test] + fn short_address_frame_is_a_decode_error() { + let wire = [0x00, 0x08, 0x04, 1, 2, 3, 4, 5, 6, 7]; + let mut r = DatagramReader::new(); + let dgs = r.feed(&wire); + assert_eq!(dgs.len(), 1); + match dgs.into_iter().next().unwrap() { + Err(WireError::ShortAddress) => {} + other => panic!("expected ShortAddress, got {other:?}"), + } + } + + #[test] + fn non_utf8_domain_is_a_decode_error() { + let wire = vec![0x00, 0x07, 0x03, 3, 0xff, 0xfe, 0xfd, 0, 80]; + let mut r = DatagramReader::new(); + let dgs = r.feed(&wire); + assert_eq!(dgs.len(), 1); + match dgs.into_iter().next().unwrap() { + Err(WireError::DomainNotUtf8(_)) => {} + other => panic!("expected DomainNotUtf8, got {other:?}"), + } + } +} diff --git a/tasks/implementation/core-wire-codec.md b/tasks/implementation/core-wire-codec.md index 83983de..67b6656 100644 --- a/tasks/implementation/core-wire-codec.md +++ b/tasks/implementation/core-wire-codec.md @@ -1,7 +1,7 @@ --- id: implementation/core-wire-codec name: Core — the ASSOCIATE datagram frame codec (src/wire.rs) -status: pending +status: completed depends_on: [implementation/core-crate-deps] scope: narrow risk: low @@ -103,8 +103,74 @@ producer stage can fail the association loud). ## Notes -> To be filled by implementation agent +- **API shape** (`src/wire.rs`, re-exported from `lib.rs` per + convention 15): `frame(addr, payload) -> Result`, + `DatagramReader::feed(&[u8]) -> Vec>` + `is_mid_frame()`, `addr_encoding_len(&TargetAddr) -> + usize`, `MAX_INNER_LEN` (65535). The reader returns per-frame + `Result`s rather than panicking or skipping: reassembly is + infallible (oversize enforced at frame time bounds the buffer by + the u16 declared length), so the only failure per completed frame + is the addr decode, typed distinctly — `BadAtyp(u8)` (the + `udp_undecodable_frame_ends_association` shape, POC #2), + `ShortAddress`, `DomainNotUtf8`, `EmptyFrame` (wire `len == 0` — + unreachable from a compliant peer; protocol garbage). The reader + consumes the frame's bytes either way and keeps going; the caller + fails the association loud on the first error. +- **The joint bound**: `frame` computes `len(addr) + len(payload)` + with `checked_add` (saturating past the bound — no unchecked u16 + arithmetic) and rejects `Oversize(total, max)` when it exceeds + `MAX_INNER_LEN`. `addr_encoding_len` exposes 7 (v4) / 19 (v6) / + `4 + domain.len()` (domain, 259 max) so callers compute the joint + bound themselves. Tests pin the load-bearing case (65507-byte + payload + 255-char domain → 65766 > 65535 → `Oversize`), the + per-form maxima at the exact limit (65276 domain / 65516 v6 / + 65528 v4) and one-over rejection. +- **Decode direction**: hand-parsed addr sub-layout over the borrowed + frame (the sync mirror of the forked `read_address` — the stage's + decode must not be async), UTF-8-validated like the fork's codec, + payload = the remainder. `TargetAddr::to_be_bytes()`'s output is + the encode direction verbatim. +- **Doc-sync**: wire.md §UDP ASSOCIATE + the byte diagram and + bast.md's BAST field description + the Annotations paragraph now + state the F-2 invariant at payload level ("a zero-length payload is + a legal empty datagram, never EOF"), with `len == 0` (zero inner + bytes) noted unreachable from a compliant peer since `addr` ≥ 7 + bytes — the residue corrected in this same change. +- A first cut returned bare `(TargetAddr, Bytes)` per frame; the + garbage-ATYP deliverable (typed decode error, not a panic) forced + the `Result` per frame — the shape the producer stage's loud-fail + needs. ## Summary -> To be filled on completion \ No newline at end of file +Implemented the ASSOCIATE datagram frame codec in `src/wire.rs` +(ADR-003): `frame` builds `[len: u16 BE][addr + data]` over +`TargetAddr::to_be_bytes()` + payload, enforcing the joint bound +(`len(addr) + len(payload) > 65535` → `Oversize` at frame time; +`checked_add`, no unchecked u16 arithmetic) with `addr_encoding_len` +exposed for callers; `DatagramReader` reassembles frames across +chunk boundaries (split frames, batched frames, partial headers) +and decodes each completed frame's addr sub-layout (ATYP-keyed, +UTF-8-validated) returning `(TargetAddr, Bytes)`, infallibly +reassembling with typed per-frame decode errors (`BadAtyp` / +`ShortAddress` / `DomainNotUtf8` / `EmptyFrame`) so the producer +stage fails loud on protocol garbage; the zero-length payload is a +legal empty datagram (the F-2 invariant at payload level), EOF is +documented as the channels-level sentinel outside the codec, and +framing-once (the egress speaks inner bytes; this layer owns the +single length prefix) is stated as the module's API-design +invariant. wire.md and bast.md's `len == 0` statements were +corrected to the payload-level F-2 phrasing in the same change. +19 unit tests cover the matrix (round-trips v4/v6/domain, empty +payload legal + not EOF, byte-by-byte split reassembly, batching, +partial-header waits, joint-bound rejection incl. the 65507 + +255-char-domain case, exact-limit acceptance per address form, +oversize at frame time, mid-frame observability, truncated-stream +buffering, all four decode-error shapes); lib.rs re-exports +`frame`, `DatagramReader`, `addr_encoding_len`, `MAX_INNER_LEN`, +`WireError`. Verified: `cargo test` (58 lib + 37 differential), +`cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, +`cargo doc --no-deps`, `cargo check --target +wasm32-unknown-unknown`, `cargo clippy --target +wasm32-unknown-unknown -- -D warnings` — all clean. \ No newline at end of file