--- id: implementation/local-providers name: Local (the socket-backed seam providers, tcp_dial / udp_egress / bind_accept) status: completed depends_on: [] scope: moderate risk: medium impact: component level: implementation --- ## Description The first batch-2 task: the `local` feature's first-party socket-backed providers, the three seams' faithful-RFC constructors (ADR-008's local variants, ADR-009's feature gate). These are the assembly-layer convenience constructors over the landed seam types (`src/egress.rs`); the protocol layer never sees them. Deliverables: 1. **`Cargo.toml` feature wiring**: `local = ["tokio/net"]` (the feature's own socket providers; `socket2` only if a provider actually needs it, though the POCs ran tokio-only, so default to NOT adding it). tokio stays `default-features = false` in `[dependencies]`; the `net` feature rides `local` alone (ADR-009). Verify the default graph stays net-free (`cargo tree` spot-check) and the default wasm checks stay green. 2. **`src/local/mod.rs`** (feature `local`): the provider constructors, named per producer.md §Wiring: - `tcp_dial()` → `DialFn`: `TcpStream::connect` per CONNECT target; domain targets resolve here (the ssh `-D` semantic; per ADR-012 resolution is the provider's job); the connected stream splits into the `TargetHandle` halves (`Box`; `TcpStream::into_split` halves are Send+Sync, so verify the boxing compiles). Dial failures map to `SocksDialError` faithfully (`ConnectionRefused` for connect-refused, `NetworkUnreachable`/`HostUnreachable` per `io::ErrorKind`, `NotAllowed` never synthesized by this provider). - `udp_egress()` → `EgressFactory`: one real `UdpSocket` per association (bind `127.0.0.1:0`, the assembly-layer bind decision, made explicit here), per-datagram `send_to`/`recv_from`, no flow table; domain destinations resolve at the egress (`resolve_target`-shaped; port the POC #2 `LocalEgress` helper). **The readiness contract is load-bearing**: `next_reply` parks on `sock.readable().await` then `try_recv_from` (`WouldBlock` → the `Ok(None)` spurious-wakeup shape; a real async park, never a hot `Ok(None)`); replies carry *inner* bytes (`TargetAddr::to_be_bytes() + data`, the sender's address per the RFC's per-datagram source semantics; framing-once, so the length prefix is the wire layer's). `send` takes payload only. Egress failures map to `EgressError` (bind failure → `General`; resolve failure → `Unreachable`). - `bind_accept()` → `BindAcceptFactory`: a real `TcpListener` per BIND session (the faithful RFC shape, ADR-007 variant 1): the requested target's port honored when non-zero (FTP active mode), port 0 → ephemeral; `local_addr()` is reply#1's `bind_addr`; the accept future owns the listener end-to-end (the quiet wait's drop IS the client-gone teardown, and the listener lives inside the future, never in a separate binding); `accept()`'s peer is reply#2's `BindTargetHandle.peer` (byte-exact). Factory failures → `BindAcceptFactoryError` (bind refused/held port → `Refused` or `Shortage` per the io error; accept-stage failures → `BindAcceptError`). Address policy is the caller's: the constructor takes the bind host (loopback default is the POC's posture; a wildcard bind is the caller's explicit choice). - Module doc: names the ADRs (008/009), the never-binds posture (the providers ARE the explicit bind decisions), and the seam contracts each constructor must honor (readiness, ownership, accept-future-owns, framing-once). 3. **Tests** (`src/local/` unit + a `tests/local-providers.rs` integration file): real sockets (dev-deps already carry full tokio). Echo TCP server for `tcp_dial` round-trips; a real UDP echo for the egress (datagram round-trip through the producer stage, the POC #2 shape); a real BIND accept (dial the listener, reply#1/reply#2 carry the real addresses); the readiness contract re-pinned with the real socket egress (park-then-datagram, no spin); refusal mappings (connect-refused → 0x05 through the producer's reply). ## Acceptance Criteria - [x] `cargo test` passes; `cargo test --all-features` passes - [x] `cargo clippy --all-targets -- -D warnings` clean; `cargo fmt --check` clean - [x] Default wasm checks stay green (`cargo check`/`clippy --target wasm32-unknown-unknown -- -D warnings`), meaning `local` code is cfg-gated out of the default build - [x] Default dependency graph has no `tokio/net`/`mio`/`socket2` (`cargo tree` spot-check); `local = ["tokio/net"]` wired - [x] `tcp_dial`/`udp_egress`/`bind_accept` land with the exact producer.md §Wiring names and the landed seam types - [x] The egress readiness contract honored (real async park; no hot `Ok(None)`), pinned by test - [x] The accept future owns its listener end-to-end, pinned by a drop-witness test (client-gone → listener closed) - [x] Refusal mappings table-tested against the landed error types' `to_reply_error()` ## References - docs/architecture/decisions/008-provider-defaults.md (the local variants), 009-feature-gates.md (the feature layout), 004 (the seams), 007 (BIND variant 1), 012 (resolution at the egress) - docs/architecture/producer.md §Wiring, §The Three Seams - POC reference: `/workspace/alksocks-udp-poc/src/egress.rs` (`LocalEgress`, the readiness shape), `/workspace/alksocks-bind- poc/src/accept.rs` (`local_bind_accept`) - Landed seams: `src/egress.rs` (the contracts' normative doc comments) ## Notes > Filled by the implementation agent (2026-09-25). - `Cargo.toml`: `local = ["tokio/net"]` (the feature's own socket providers; no `socket2`, because the POCs ran tokio-only and nothing here needs it). tokio stays `default-features = false` with the wasm-clean subset; the `net` feature rides `local` alone (ADR-009). `cargo tree` spot-checks: the default graph (`-e normal,features --no-default-features`) carries no `tokio/net`/`mio`/`socket2` (inverting `-i mio` prints nothing); with `--features local --target all` the `net` feature appears rooted at `alksocks feature "local"`, dragging `mio` + `socket2` in through tokio (the dev-dep `full` also appears in dev builds, pre-existing and test-only, not the feature's doing). - `src/local/mod.rs` (feature `local`, one module file + a `tests` submodule per convention 15): - `tcp_dial()` → `DialFn`: `TcpStream::connect` per CONNECT target; domain targets resolve here (the ssh `-D` semantic, with ADR-012 in play), iterating the resolved address set (the `-D` connect-multi-address posture: first success wins, the last failure is the reported error); `into_split` halves boxed as `Send + Sync + Unpin` (compiles, pinned by the round-trip test). Refusal mapping (`dial_error_of`): `ConnectionRefused` → `SocksDialError::Refused`, `NetworkUnreachable`/`TimedOut` → `NetworkUnreachable`, `HostUnreachable` → `HostUnreachable`, resolution failure → `HostUnreachable`, everything else → `General`; `NotAllowed` never synthesized. - `udp_egress()` → `EgressFactory`: one real `UdpSocket` per association (bind `127.0.0.1:0`, the explicit assembly-layer bind decision, `LocalEgress::bind`); per-datagram `send_to`/`recv_from`, no flow table; domain destinations resolve at the egress (`resolve_target`, porting the POC #2 `LocalEgress` helper ported). The readiness contract is the load-bearing shape: `next_reply` parks on `sock.readable().await` (a real async park on receive readiness, since `writable()` is always ready for UDP and would spin), then `try_recv_from` (`WouldBlock` → the spurious-wakeup `Ok(None)`); replies carry *inner* bytes (`TargetAddr::to_be_bytes() + data`, the sender's address per the RFC's per-datagram source semantics; framing-once); `send` takes payload only. Bind failure → `General`; resolve failure → `Unreachable`. - `bind_accept(bind_host)` → `BindAcceptFactory`: a real `TcpListener` per BIND session (ADR-007 variant 1); the requested port honored when non-zero, port 0 → ephemeral; `local_addr()` is reply#1's `bind_addr`; the accept future owns the listener end-to-end (the listener lives inside the future, so the quiet wait's drop IS the client-gone teardown); `accept()`'s peer is reply#2's `BindTargetHandle.peer` (byte-exact). Factory failures → `BindAcceptFactoryError` (`PermissionDenied`/`AddrInUse` → `Shortage`, `AddrNotAvailable` → `Refused`, else `General`); accept-stage failures → `BindAcceptError` (`General`). Address policy is the caller's: the constructor takes the bind host (`IpAddr`-convertible; loopback is the POC's posture, and the wildcard bind is the caller's explicit `0.0.0.0`). - Module doc names ADR-008/009, the never-binds posture (the providers ARE the explicit bind decisions), and the four seam contracts (readiness, ownership-rides-the-handle, framing-once, accept-future-owns). - `src/lib.rs`: `#[cfg(feature = "local")] pub mod local;` + the `tcp_dial`/`udp_egress`/`bind_accept`/`LocalEgress` re-exports; the crate doc's "not yet present" paragraph updated (the composed providers remain pending). - Tests: 12 unit tests (`src/local/tests.rs`: the TCP echo round-trip incl. domain resolution, connect-refused/unresolvable mappings, the UDP echo round-trips (inner bytes + sender address, in-order, empty payload), the park-then-datagram readiness probe, the BIND accept (real reply#1/reply#2, requested-port honored, held-port refusal, the drop-closes-the-listener witness)) + 6 integration tests (`tests/local-providers.rs`, feature-gated with `#![cfg(feature = "local")]`: CONNECT through a real echo target, connect-refused → 0x05 through the producer's reply, ASSOCIATE through the producer's datagram stage with the real egress, the readiness contract re-pinned through the stage (park-then-datagram, no spin), BIND end-to-end with real addresses both directions, and the client-gone-mid-quiet-wait drop-witness (the listener's port frees)). - Deliberate deviations from the POC code: the dial iterates the full resolved address set (the POC resolved to the first address); `TimedOut` maps to `NetworkUnreachable` and `PermissionDenied` to `General` in the dial table (the POC had no io-error mapping at all); `bind_accept` takes the bind host as a parameter (the POC hardcoded `127.0.0.1`) so the wildcard bind is the caller's spelled-out choice; the held-port factory refusal maps to `Shortage`/`Refused` per the io kind (the POC mapped everything to `Shortage`). ## Summary > Filled on completion (2026-09-25). Implemented the `local` feature's socket-backed seam providers (ADR-008's local variants, ADR-009's feature gate), the three seams' faithful-RFC assembly-layer constructors over the landed seam types (`src/egress.rs`): `tcp_dial()` (real `TcpStream` per CONNECT, domain resolution at the provider, faithful `SocksDialError` mapping), `udp_egress()` (one real `UdpSocket` per association bound `127.0.0.1:0`, per-datagram send/recv, the POC #2 `LocalEgress` readiness shape: park on `readable()`, `try_recv_from`, inner bytes `to_be_bytes() + data`, framing-once), and `bind_accept(bind_host)` (a real `TcpListener` per BIND session, requested port honored, the accept future owning the listener end-to-end, two-stage failure mapping). The protocol layer never sees them; these are feature-gated (`local = ["tokio/net"]`), the default graph stays net/mio/socket2-free (`cargo tree` spot-checked both ways), and the wasm checks stay green. 18 new tests (12 unit + 6 integration) pin: the real-socket round-trips through the producer's actual dispatch path (echo TCP, UDP echo through the framed datagram stage, BIND end-to-end with byte-exact real addresses), the readiness contract with the real socket (park-then-datagram, no spin), the accept-future-owns-its-listener contract (drop-witness: client-gone → the advertised port frees), and the refusal mappings table-tested against the landed error types' `to_reply_error()` (connect-refused → 0x05 through the producer's reply, held port → `Shortage`/`Refused` → the RFC catch-all, unresolvable domain → `HostUnreachable`/`NetworkUnreachable`). Verification: `cargo test` (157 green across the 9 targets) + `cargo test --all-features` green, `cargo clippy --all-targets -- -D warnings` clean (default + `--all-features`), `cargo fmt --check` clean, both wasm checks (`cargo check` + `cargo clippy -D warnings` on `wasm32-unknown-unknown`) clean, `cargo tree` spot-checks: the default graph carries no `tokio/net`/`mio`/`socket2` and `--features local --target all` roots the `net` feature at `alksocks feature "local"`.