wire: the ASSOCIATE datagram frame codec — frame + DatagramReader (ADR-003)

- frame(addr, payload): [len: u16 BE][addr + data] over
  TargetAddr::to_be_bytes() + payload; the joint bound enforced at
  frame time (checked_add; 65507 + 255-char domain = 65766 > 65535 →
  Oversize) with addr_encoding_len exposed (7 v4 / 19 v6 / 259 max
  domain) and MAX_INNER_LEN pinned
- DatagramReader: incremental reassembly across chunk boundaries
  (split frames, batched frames, partial headers), infallible,
  returning per-frame Results; the addr sub-layout decoded by hand
  (ATYP-keyed, UTF-8-validated) with typed decode errors —
  BadAtyp / ShortAddress / DomainNotUtf8 / EmptyFrame — so the
  producer stage fails the association loud on protocol garbage
  (udp_undecodable_frame_ends_association, POC #2)
- module docs state the framing-once invariant (the egress speaks
  inner bytes; this layer owns the single length prefix — the
  double-framing bug class POC #2 caught), the F-2 invariant at
  payload level (a zero-length payload is a legal empty datagram,
  never EOF; wire len == 0 is unreachable garbage), and the EOF
  layering (the channels-level sentinel, outside the codec)
- doc-sync: wire.md + bast.md residue corrected — "len == 0 is a
  legal empty datagram" replaced with the payload-level F-2
  statement (len == 0 unreachable since addr >= 7 bytes)
- lib.rs re-exports frame, DatagramReader, addr_encoding_len,
  MAX_INNER_LEN, WireError (convention 15)

Verification: cargo test (58 lib + 37 differential), clippy
--all-targets -- -D warnings, fmt --check, doc --no-deps, and both
wasm checks (check + clippy on wasm32-unknown-unknown) — all clean.
This commit is contained in:
glm-5.3-flash committed 2026-09-23 15:02:24 +00:00
1 parent ce8148d711
commit d530eae5c4
5 files changed
+659 -15

No files matched your search

+6 -4
View File
@@ -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
+9 -4
View File
@@ -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
```
+2
View File
@@ -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};
+573 -4
View File
@@ -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<Bytes, WireError> {
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<Result<(TargetAddr, Bytes), WireError>> {
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<Result<(TargetAddr, Bytes), WireError>> {
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:?}"),
}
}
}
+69 -3
View File
@@ -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<Bytes, WireError>`,
`DatagramReader::feed(&[u8]) -> Vec<Result<(TargetAddr, Bytes),
WireError>>` + `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
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.