Files
alksocks/tasks/implementation/core-wire-codec.md
T
glm-5.3-flash e196ed43e3 docs: remove em dashes from repo prose and forbid them going forward
- Rewrote em dash prose across the whole tree outside docs/reviews/
  (the historical reviews stay verbatim, matching the alktunnels and
  alktty precedent): src doc comments and inline comments (587),
  docs/architecture (319), docs/research + sdd_process (484), tests
  (361), tasks (899), fuzz (28), README/CHANGELOG/Cargo.toml (77),
  AGENTS.md (85), and the .opencode/agents prompts (109)
- Fenced content follows the precedent: comments, ASCII-diagram label
  separators, and template placeholders rewritten; wire data, JSON/BAST
  documents, byte diagrams, and transcripts survive verbatim; assert,
  expect, panic, error-display, and format message literals rewritten
  as prose; no code, fixture, or test data change
- src/rfc1928/LICENSE: the two dashes in alksocks' own provenance
  header rewritten; the MIT legal text untouched
- AGENTS.md adds Project Convention 18: no em dashes in prose; varied
  restructure, no spaced-hyphen substitutes, fenced prose rule, en
  dash numeric ranges exempt, docs/reviews/ exempt from the greps
- tasks/*.md frontmatter: the name values rewritten by the sweep are
  now quoted (a raw colon inside an unquoted YAML scalar parsed as
  null); taskgraph validate reports 37 tasks, no issues

Verification: cargo test; cargo clippy --all-targets -D warnings;
cargo fmt --check; cargo doc --no-deps; fuzz corpus replay
(cargo test --manifest-path fuzz/shared/Cargo.toml); wasm check and
clippy for wasm32-unknown-unknown with -D warnings; em dash grep over
the repo returns matches only in docs/reviews/ and the bast.md BAST
JSON data fence
2026-11-03 19:31:53 +00:00

176 lines
9.0 KiB
Markdown

---
id: implementation/core-wire-codec
name: "Core: the ASSOCIATE datagram frame codec (src/wire.rs)"
status: completed
depends_on: [implementation/core-crate-deps]
scope: narrow
risk: low
impact: component
level: implementation
---
## Description
Implement `src/wire.rs` per wire.md §The Data Plane and bast.md: the
first-party `[len: u16 BE][addr + data]` frame codec for the UDP
ASSOCIATE datagram stage (ADR-003), re-implemented in first-party
code shaped on alktunnels' length codec (`/workspace/@alkdev/
alktunnels/src/wire.rs`, the `frame_datagram`/`DatagramReader` pair, with the same
F-2 behavior, but the payload here is the RFC 1928 address encoding
plus data, not a bare payload).
The codec is the crate's one framed stage (CONNECT/BIND are raw
pass-through, with no framing). Framing is applied **exactly once, by
this layer**: `Egress::next_reply` returns *inner* bytes
(`addr + data`); the producer's frame writer adds the single length
prefix (the double-framing bug class POC #2 caught is an
API-design invariant this module's docs must state).
Deliverables:
1. **`frame(addr: &TargetAddr, payload: &[u8]) -> Result<Bytes,
WireError>`**, one frame per datagram: `[len: u16 BE][addr +
data]` where `addr + data` is `TargetAddr::to_be_bytes()` (ATYP +
addr + port) followed by the payload.
2. **The joint bound (the load-bearing constraint)**: reject
`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 65766 frame bytes. Max data: 65276 for domain-form
destinations, 65516 for IPv6, 65528 for IPv4. No unchecked u16
arithmetic on the payload bound (wire.md's explicit requirement).
Expose `addr_encoding_len(addr)` (7 v4 / 19 v6 / 259 max domain)
so callers can compute the joint bound.
3. **`DatagramReader`**, the incremental decoder: feed it whatever
`read()` yields; it reassembles frames across chunk boundaries
(split frames, batched frames, partial headers). Returns
`(TargetAddr, Bytes)` per completed frame; it decodes the addr
sub-layout (ATYP determines the address length) and splits
`addr + data`. Infallible decode direction (the oversize bound is
enforced at frame time); `is_mid_frame()` diagnostic for teardown.
4. **The F-2 invariant, correctly stated**: a **zero-length payload**
is a legal empty datagram, and never EOF. (`len == 0` is unreachable
on this wire since `addr` is always ≥ 7 bytes; the invariant that
matters is payload-level. See the doc-sync deliverable.)
5. **Doc-sync (wire.md + bast.md residue)**: both docs say "`len = 0`
is a legal empty datagram" while also pinning that `len` covers
`addr + data` (joint bound); `len = 0` is unreachable since
`addr` ≥ 7 bytes. Correct both docs to state the operative
invariant at payload level ("a zero-length payload is a legal
empty datagram, never EOF, per the F-2 invariant") in the same
change; do not silently diverge.
6. **EOF layering note in the module docs**: EOF is the
channels-level sentinel on the `BiStream`, outside this codec;
the codec never emits zero-length reads, so the layers never
collide (the alktunnels wire.rs module-doc posture).
Tests: round-trip (v4/v6/domain), empty payload legal + not EOF,
split across awkward chunks, batched frames in one chunk, partial
header waits, joint-bound rejection (the 65507 + 255-char-domain
case), oversize at frame time, mid-frame state observable, garbage
ATYP decode error (the `udp_undecodable_frame_ends_association`
shape: the decode error type must distinguish a bad ATYP so the
producer stage can fail the association loud).
## Acceptance Criteria
- [ ] `frame`/`DatagramReader`/`addr_encoding_len` implemented per
the joint bound; no unchecked u16 arithmetic
- [ ] Zero-length payload round-trips as a legal empty datagram
- [ ] Split/batch/partial-header reassembly tests pass
- [ ] Joint-bound rejection test (65507-byte payload + 255-char
domain → `Oversize`) passes
- [ ] Garbage-ATYP decode surfaces a typed error (not a panic)
- [ ] wire.md + bast.md residue corrected (payload-level F-2
statement) in the same change
- [ ] `cargo test` green; clippy `--all-targets -- -D warnings`
clean; `cargo fmt --check` clean
- [ ] `cargo check --target wasm32-unknown-unknown` +
`cargo clippy --target wasm32-unknown-unknown -- -D warnings`
clean
## References
- docs/architecture/wire.md §The Data Plane (the frame + the joint
bound arithmetic)
- docs/architecture/bast.md (the BAST contract for the frame)
- docs/architecture/decisions/003-udp-handle-swap.md (framing-once,
F-2)
- Template: `/workspace/@alkdev/alktunnels/src/wire.rs` (the length
codec + its test matrix: shape on it, do not depend on it)
- POC sketch: `/workspace/alksocks-udp-poc/src/datagram.rs`
(`frame`/`inner`/`unframe`/`addr_encoding_len`)
## Notes
- **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, with 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`, since 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, and the residue was 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, which is the shape the producer stage's loud-fail
needs.
## Summary
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.