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:
1 parent
ce8148d711
commit
d530eae5c4
5 files changed
+659
-15
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user