alksocks 0.3.1

SOCKS5 (RFC 1928) proxy protocol over alkcall channels: targeted-forked fast-socks5 typestate core plus producer/consumer sessions. Producer/consumer protocol crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
//! 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).
//!
//! 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())
}

/// Frame pre-encoded inner bytes (`addr + data` — exactly what
/// [`crate::egress::Egress::next_reply`] returns) as one
/// `[len: u16 BE][inner]` frame. The reply-direction half of the
/// framing-once invariant: the egress speaks inner bytes (no length
/// prefix), and this adds the single prefix at the wire layer. Rejects
/// [`WireError::Oversize`] when the inner bytes exceed
/// [`MAX_INNER_LEN`] (the u16 length field would wrap).
pub fn frame_inner(inner: &[u8]) -> Result<Bytes, WireError> {
    if inner.len() > MAX_INNER_LEN {
        return Err(WireError::Oversize(inner.len(), MAX_INNER_LEN));
    }
    let mut buf = BytesMut::with_capacity(FRAME_LEN_FIELD + inner.len());
    buf.put_u16(inner.len() as u16);
    buf.put_slice(inner);
    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 {
            let Some((declared, acc)) = self.current.as_mut() else {
                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()));
                continue;
            };
            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).
    ///
    /// Kept public for the anticipated batch-2 consumer — a front-door
    /// bridge driving its own reader loop (the doors) — so a later
    /// dead-surface sweep does not flag it (review-002 N-9).
    pub fn is_mid_frame(&self) -> bool {
        self.current.is_some()
    }

    /// The decoder's retained byte count: the buffered remainder plus
    /// any in-flight frame's accumulated payload. The fuzz harness's
    /// bound assertion (`fuzz/shared`'s `datagram_codec` target)
    /// asserts against this diagnostic (the alktunnels
    /// `retained_bytes()` posture); it is not a protocol surface.
    pub fn retained_bytes(&self) -> usize {
        self.buf.len() + self.current.as_ref().map_or(0, |(_, acc)| acc.len())
    }
}

/// 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 frame_inner_frames_the_egress_inner_bytes_exactly_once() {
        let inner = {
            let mut v = v4(53).to_be_bytes().unwrap().to_vec();
            v.extend_from_slice(b"answer");
            v
        };
        let framed = frame_inner(&inner).unwrap();
        assert_eq!(&framed[..2], &(inner.len() as u16).to_be_bytes());
        assert_eq!(&framed[2..], &inner[..]);
        // The read direction decodes it back: the same (addr, payload)
        // pair the egress encoded (the round-trip shape).
        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, v4(53));
        assert_eq!(&payload[..], b"answer");
    }

    #[test]
    fn frame_inner_rejects_inner_bytes_over_the_u16_bound() {
        let err = frame_inner(&vec![0u8; MAX_INNER_LEN + 1]).unwrap_err();
        assert!(matches!(err, WireError::Oversize(65536, 65535)));
        let framed = frame_inner(&vec![0u8; MAX_INNER_LEN]).unwrap();
        assert_eq!(framed.len(), 2 + MAX_INNER_LEN);
    }

    #[test]
    fn frame_and_frame_inner_produce_identical_wires() {
        // The reply path (frame_inner over pre-encoded inner bytes)
        // and the forward path (frame over addr + payload) agree
        // byte-for-byte — one codec, two callers.
        let addr = domain("example.com", 53);
        let payload = b"payload";
        let mut inner = addr.to_be_bytes().unwrap().to_vec();
        inner.extend_from_slice(payload);
        assert_eq!(frame_inner(&inner).unwrap(), frame(&addr, payload).unwrap());
    }

    #[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:?}"),
        }
    }
}