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
This commit is contained in:
glm-5.3-flash committed 2026-11-03 19:31:53 +00:00
1 parent 9ca8c52601
commit e196ed43e3
115 files changed
+3593 -3531

No files matched your search

+143 -134
View File
@@ -1,36 +1,37 @@
//! The `composed` feature's egress + accept providers (ADR-008's
//! composed variants, ADR-009's feature gate): [`direct_egress`] — the
//! composed variants, ADR-009's feature gate): [`direct_egress`], the
//! UDP ASSOCIATE egress that dials each destination through an
//! alktunnels 0.2.0 direct-op channel
//! ([`alktunnels::TunnelSession::open_direct`], `Substrate::Udp`) —
//! and [`bindfirst_accept`] — the TCP BIND accept whose listener lives
//! ([`alktunnels::TunnelSession::open_direct`], `Substrate::Udp`);
//! and [`bindfirst_accept`], the TCP BIND accept whose listener lives
//! on a bind-first listen resource (ADR-007 variant 2 / ADR-008's
//! composed accept).
//!
//! **Per-target ACL for free** (ADR-008's composed benefit): each
//! destination rides its own `channels/tunnel/direct`-governed channel
//! — the producer's `tunnel:direct` scope gate and dial policy apply
//! per destination, no SOCKS5-layer allowlist needed (ADR-006/ADR-012:
//! whatever ACL governs the tunnel capability governs everything
//! reachable through it).
//! destination rides its own `channels/tunnel/direct`-governed channel,
//! so the producer's `tunnel:direct` scope gate and dial policy apply
//! per destination, with no SOCKS5-layer allowlist needed
//! (ADR-006/ADR-012: whatever ACL governs the tunnel capability
//! governs everything reachable through it).
//!
//! The shape is POC #2's `ComposedEgress`
//! (`/workspace/alksocks-udp-poc/src/egress.rs`), ported to the landed
//! seam types: a lazy destination→tunnel table — each new destination
//! opens one UDP-tunnel channel, a relay task per tunnel drains
//! inbound datagrams into a shared queue, the write half stays in the
//! table (LRU-capped; dropping a write half EOFs the tunnel
//! producer-side; the entry's session drop reaps the channel —
//! alktunnels ADR-005's teardown contract).
//! seam types: a lazy destination→tunnel table, where each new
//! destination opens one UDP-tunnel channel, a relay task per tunnel
//! drains inbound datagrams into a shared queue, the write half stays
//! in the table (LRU-capped; dropping a write half EOFs the tunnel
//! producer-side; the entry's session drop reaps the channel, which
//! is alktunnels ADR-005's teardown contract).
//!
//! Contracts (the landed seam types' normative docs, honored here):
//!
//! - **The readiness contract** (load-bearing, `Egress::next_reply`):
//! parks on the shared inbound queue (`recv().await` — `Pending`
//! until a relay task delivers); a closed queue (all relays ended)
//! returns `Err` — never a hot `Ok(None)`. The stage's zero-yield
//! defense (review 002 R-1) is the backstop, not the design.
//! - **Framing-once**: replies carry *inner* bytes (`addr + data` —
//! parks on the shared inbound queue (`recv().await`, hence
//! `Pending` until a relay task delivers); a closed queue (all
//! relays ended) returns `Err`, never a hot `Ok(None)`. The stage's
//! zero-yield defense (review 002 R-1) is the backstop, not the
//! design.
//! - **Framing-once**: replies carry *inner* bytes (`addr + data`;
//! the reply address is the entry's recorded destination, the RFC's
//! per-datagram source semantics); the length prefix is the wire
//! layer's, applied exactly once downstream. `send` takes the
@@ -39,34 +40,34 @@
//! wire's.
//! - **Per-datagram `send` is awaited inline** (the stage
//! interleaves sends with reply drains): a slow egress applies
//! backpressure to the association, not to other channels — the
//! backpressure to the association, not to other channels, and the
//! batch-2 composed-egress author inherits this bound knowingly
//! (wire.md's note). The table's open cost rides the send path
//! (lazy open on first datagram per destination — the POC's
//! (lazy open on first datagram per destination; the POC's
//! measured ~0.8 ms).
//! - **The resource/target mapping**: `open_direct` takes a
//! [`SubstrateAddr`] — the destination's [`TargetAddr`] renders to
//! it (IP forms direct; domain form passes through unresolved —
//! [`SubstrateAddr`], which the destination's [`TargetAddr`] renders
//! to (IP forms direct; domain form passes through unresolved,
//! producer-side resolution, the ssh `-D` semantic; ADR-012).
//!
//! **The Sync-bound wrinkle** (the batch-1 review's seam contract vs
//! alktunnels' surface): the landed `TargetHandle`/`BindTargetHandle`
//! halves are `Box<dyn AsyncRead + Send + Sync + Unpin>`, but
//! alktunnels' `TakenHalves` yields `Send + Unpin` only — and
//! alktunnels' `TakenHalves` yields `Send + Unpin` only; and
//! `alkcall::BiStream` is `Send + Unpin` but NOT `Sync` (its inner is
//! a `dyn AsyncReadWrite` trait object). The owner-wrapper posture
//! (egress.rs's documented contract: "keep-alive state a substrate
//! needs rides inside the halves — a provider-side owner wrapper")
//! takes two shapes here: [`SessionOwner`] (the generic half-wraps-
//! session wrapper — the egress's design point, exported as the
//! wrapper-aware deployment's surface) and the accept's own
//! needs rides inside the halves, a provider-side owner wrapper")
//! takes two shapes here: [`SessionOwner`] (the generic
//! half-wraps-session wrapper, the egress's design point, exported as
//! the wrapper-aware deployment's surface) and the accept's own
//! [`SyncHalves`]/[`OwnedWrite`] adapter pair (a `std::sync::Mutex`
//! around the half — `Mutex<T: Send>` is `Sync`, no unsafe — with
//! around the half; `Mutex<T: Send>` is `Sync`, no unsafe; with
//! the channel session owned inside the payload, [`ForwardedOwner`]:
//! dropping the handle drops the session AND the halves together).
//! For the egress the wrapper is not the handle payload's burden —
//! the egress owns the session in the table entry (the POC's shape);
//! the relay task owns the bare read half.
//! For the egress the wrapper is not the handle payload's burden,
//! because the egress owns the session in the table entry (the POC's
//! shape);
use std::collections::HashMap;
use std::net::SocketAddr;
@@ -91,14 +92,14 @@ use crate::egress::{
pub use crate::rfc1928::util::target_addr::TargetAddr;
use crate::wire::MAX_INNER_LEN;
/// The default LRU cap (the 256-channel default cap bounds fan-out —
/// The default LRU cap (the 256-channel default cap bounds fan-out,
/// ADR-010's posture; the cap is config, not wire).
pub const DEFAULT_MAX_TUNNELS: usize = 256;
/// The destination's render into the direct op's [`SubstrateAddr`] (the
/// resource/target mapping): IP forms direct; domain form passes
/// through unresolved (producer-side resolution, the ssh `-D`
/// semantic — ADR-012).
/// semantic, per ADR-012).
pub fn substrate_addr_of(addr: &TargetAddr) -> SubstrateAddr {
match addr {
TargetAddr::Ip(sa) => SubstrateAddr::HostPort {
@@ -114,7 +115,7 @@ pub fn substrate_addr_of(addr: &TargetAddr) -> SubstrateAddr {
/// Render the destination to the table's key form (the string the LRU
/// orders by): `host:port` per [`SubstrateAddr`]'s render (IP literal
/// or unresolved domain — the domain stays unresolved, so domain and
/// or unresolved domain; the domain stays unresolved, so domain and
/// IP destinations key distinctly, as the client addressed them).
pub fn key_of(addr: &TargetAddr) -> String {
match substrate_addr_of(addr) {
@@ -124,19 +125,20 @@ pub fn key_of(addr: &TargetAddr) -> String {
}
/// The owner wrapper (the batch-1 review's seam contract vs
/// alktunnels' surface — the owner-wrapper posture's generic shape;
/// alktunnels' surface, providing the owner-wrapper posture's generic
/// shape;
/// the composed accept meets the `+ Sync` bound with its own
/// [`SyncHalves`]/[`OwnedWrite`] adapter pair instead): an alktunnels
/// half wrapped with its [`TunnelSession`] inside. The landed handle
/// payloads carry `+ Sync` (the
/// `ChannelPlan` payload bound), alktunnels' `TakenHalves` yields
/// `Send + Unpin` only — the wrapper owns the session beside the half,
/// `Send + Unpin` only; the wrapper owns the session beside the half,
/// so dropping the wrapper drops the half and the session together
/// (the session's drop reaps the channel entry — alktunnels ADR-005).
/// Whether the wrapper is `Sync` (the accept's need) is the half's
/// own `Sync`ness — asserted by the compile-shape test against the
/// alktunnels 0.2.0 surface; the egress needs only `Send` (the relay
/// task), which the wrapper always has.
/// (the session's drop reaps the channel entry, per alktunnels
/// ADR-005). Whether the wrapper is `Sync` (the accept's need) is the
/// half's own `Sync`ness, asserted by the compile-shape test against
/// the alktunnels 0.2.0 surface; the egress needs only `Send` (the
/// relay task), which the wrapper always has.
pub struct SessionOwner<H> {
half: H,
session: TunnelSession,
@@ -187,17 +189,17 @@ pub struct ComposedEgress {
order: std::collections::VecDeque<(String, TargetAddr)>,
inbound: tokio::sync::mpsc::Receiver<Inbound>,
inbound_tx: tokio::sync::mpsc::Sender<Inbound>,
/// Live relay tasks — the readiness contract's closed-queue
/// Live relay tasks, hence the readiness contract's closed-queue
/// condition: when every relay has ended (each reported via
/// [`Inbound::Ended`]) and the queue is drained, `next_reply`
/// returns `Err` (the association end), never a hot `Ok(None)`.
/// The factory `Sender` alone does not close the queue (the
/// egress holds it) — the count is the design.
/// egress holds it), so the count is the design.
live_relays: usize,
/// Whether any tunnel has ever been opened. The lazy table starts
/// empty: a fresh association's `next_reply` parks until the first
/// `send` opens a tunnel (the producer's stage selects the channel
/// branch first on a quiescent association — the association
/// branch first on a quiescent association, and the association
/// serves until a datagram arrives or the channel EOFs). The
/// ended-relay `Err` fires only once at least one tunnel existed
/// and every relay has ended (the closed-queue condition).
@@ -205,7 +207,7 @@ pub struct ComposedEgress {
}
/// The shared inbound queue's message: a tunnel's datagram (keyed by
/// the table key — the reply address rides the entry) or a relay's
/// the table key, the reply address riding the entry) or a relay's
/// end marker (the tunnel's EOF drained).
#[derive(Debug)]
enum Inbound {
@@ -214,11 +216,11 @@ enum Inbound {
}
/// One table entry: the tunnel's write half (the udp substrate's
/// framed half — `[len: u16 BE]` frames on the tunnel wire) + the
/// framed half, `[len: u16 BE]` frames on the tunnel wire) + the
/// reply address to report for datagrams coming back through it + the
/// session (the channel-entry owner — the egress owns it, not a
/// session (the channel-entry owner; the egress owns it, not a
/// handle payload; dropping the entry EOFs the write half and reaps
/// the channel — alktunnels ADR-005).
/// the channel, per alktunnels ADR-005).
struct TableEntry {
_session: TunnelSession,
write: Box<dyn AsyncWrite + Send + Unpin>,
@@ -229,7 +231,7 @@ struct TableEntry {
/// into the egress's shared inbound queue. Exits on EOF (the tunnel
/// ended); the write half stays in the table until eviction or stage
/// exit. Framing decode is the tunnel codec's (the alktunnels
/// ADR-003) — the datagrams here are inner payloads (no RFC address
/// ADR-003), so the datagrams here are inner payloads (no RFC address
/// on the tunnel wire; the reply address is the entry's recorded
/// destination).
async fn relay_read(
@@ -255,7 +257,7 @@ async fn relay_read(
let _ = tx.send(Inbound::Ended).await;
}
/// Assemble the reply's inner bytes (`addr + data` — the entry's
/// Assemble the reply's inner bytes (`addr + data`, the entry's
/// recorded destination + the payload; framing-once: no length prefix
/// here, the wire layer's `frame_inner` adds it downstream). The
/// oversize guard mirrors the wire layer's frame bound.
@@ -276,10 +278,10 @@ fn inner_bytes(addr: &TargetAddr, payload: &[u8]) -> Result<Bytes, EgressError>
impl ComposedEgress {
/// Build the egress (the per-association ASSOCIATE seam object).
/// `client` is the channels client the direct ops ride; the
/// constructor's config carries the LRU cap (`max_tunnels` — the
/// constructor's config carries the LRU cap (`max_tunnels`, the
/// fan-out bound, config not wire; the producer's `tunnel:direct`
/// ACL governs which destinations the channels accept — the
/// per-target-ACL-for-free benefit).
/// ACL governs which destinations the channels accept, which is
/// the per-target-ACL-for-free benefit).
pub fn new(client: Arc<ChannelClient>, max_tunnels: usize) -> Self {
let (tx, rx) = tokio::sync::mpsc::channel(64);
Self {
@@ -294,7 +296,7 @@ impl ComposedEgress {
}
}
/// The entry for `addr` — open lazily on first datagram (the
/// The entry for `addr`, opened lazily on first datagram (the
/// open cost rides the send path, per-datagram awaited inline).
async fn entry_for(&mut self, addr: &TargetAddr) -> Result<&mut TableEntry, EgressError> {
let key = key_of(addr);
@@ -330,7 +332,7 @@ impl ComposedEgress {
self.order.push_back((key.clone(), addr.clone()));
// The entry was inserted above (or the key was already
// present): an absent entry here is a structural invariant
// break, not a runtime condition — surfacing it as an
// break, not a runtime condition; it is surfaced as an
// egress error instead of a panic (convention 2).
self.entries.get_mut(&key).ok_or_else(|| {
EgressError::General(format!(
@@ -366,7 +368,7 @@ impl Egress for ComposedEgress {
// A non-blocking first pass over the shared queue (the
// relay tasks may have queued replies already), then a
// real async wait on the queue (`Pending` until a
// relay task delivers — the readiness contract).
// relay task delivers; this is the readiness contract).
loop {
match self.inbound.try_recv() {
Ok(Inbound::Datagram(key, dg)) => {
@@ -376,8 +378,8 @@ impl Egress for ComposedEgress {
};
return Ok(Some(inner_bytes(&addr, &dg)?));
}
// A relay ended (its tunnel's EOF drained) —
// count it; the closed-queue condition is
// A relay ended (its tunnel's EOF drained);
// count it, and the closed-queue condition is
// every relay ended.
Ok(Inbound::Ended) => {
self.live_relays = self.live_relays.saturating_sub(1);
@@ -391,9 +393,10 @@ impl Egress for ComposedEgress {
// Every relay ended (the tunnels ended): a closed
// relay set must NOT spin the stage's select (an
// instantly-ready None would starve the channel
// branch) — end the association instead. A fresh
// association (no tunnel ever opened) parks here —
// the channel branch governs until the first
// branch), so the stage ends the association
// instead. A fresh association (no tunnel ever
// opened) parks here, and the channel branch
// governs until the first
// datagram arrives.
return Err(EgressError::General("all tunnel relays ended".into()));
}
@@ -405,7 +408,7 @@ impl Egress for ComposedEgress {
};
return Ok(Some(inner_bytes(&addr, &dg)?));
}
// A relay ended while parked — loop back: the
// A relay ended while parked; loop back, since the
// count check above decides whether the
// association ends (never a hot `Ok(None)`).
Some(Inbound::Ended) => {
@@ -425,8 +428,8 @@ impl Egress for ComposedEgress {
/// producer.md §Wiring): the `EgressFactory` the producer
/// registration consumes. One per ASSOCIATE; the stage owns the
/// returned egress; stage exit drops it (write halves dropped = EOF
/// to the tunnel producers; the entries' sessions reap the channels —
/// alktunnels ADR-005's teardown contract).
/// to the tunnel producers; the entries' sessions reap the channels,
/// per alktunnels ADR-005's teardown contract).
///
/// # Backpressure (inherited knowingly)
///
@@ -450,15 +453,16 @@ pub fn direct_egress(client: Arc<ChannelClient>, max_tunnels: usize) -> EgressFa
/// channel's `BiStream` + the forwarded op's `peer` param). One entry
/// per live BIND session; the forwarded handler (the provider's shared
/// opener half) removes and completes the entry on correlation. A
/// dropped receiver (client-gone teardown) makes completion fail — the
/// handler drops the channel stream (teardown-at-birth).
/// dropped receiver (client-gone teardown) makes completion fail, so
/// the handler drops the channel stream (teardown-at-birth).
pub type CorrelationRegistry =
Arc<tokio::sync::Mutex<HashMap<u32, oneshot::Sender<ForwardedSession>>>>;
/// What a correlated forwarded session delivers: the forwarded
/// channel's `BiStream` (raw pass-through — the SOCKS5 BIND data plane
/// is raw on both ends) + the forwarded op's `peer` param (reply#2's
/// real peer — the graduation's win). Public because the
/// channel's `BiStream` (raw pass-through, since the SOCKS5 BIND data
/// plane is raw on both ends) + the forwarded
/// op's `peer` param (reply#2's real peer, the graduation's win).
/// Public because the
/// [`CorrelationRegistry`] it travels through is public API surface
/// (the assembly holds the handle [`register_forwarded_serving`]
/// returns); constructing one is the forwarded handler's job alone.
@@ -470,19 +474,21 @@ pub struct ForwardedSession {
/// The forwarded-op serving handler (the provider's shared opener
/// half): correlates the arriving forwarded open by `listen` token and
/// OWNS the data-plane pump (R-02 — the `register_openable` wrapper
/// OWNS the data-plane pump (R-02: the `register_openable` wrapper
/// awaits this task inline, then tears the forwarded channel down; a
/// handler that returns early reaps the channel at birth). The pump
/// bridges the forwarded channel's `BiStream` to a local duplex pair:
/// the near end pumps here (this task lives exactly as long as the
/// data plane — either end's EOF ends it), the far end travels through
/// data plane, so either end's EOF ends it), the far end travels
/// through
/// the token's oneshot into the accept future's [`BindTargetHandle`]
/// payloads (dropping the handle drops the far end → the pump sees EOF
/// → the task returns → the wrapper reaps the forwarded channel —
/// teardown-at-birth, structural). A correlation miss (no waiting
/// session — client gone, or a token nobody minted) drops the stream:
/// payloads (dropping the handle drops the far end → the pump sees
/// EOF → the task returns → the wrapper reaps the forwarded channel,
/// i.e. teardown-at-birth, structural). A correlation miss (no
/// waiting
/// session (client gone, or a token nobody minted) drops the stream:
/// the task returns immediately, the same reaping shape. A dead
/// oneshot receiver makes the send fail — the task drops the stream
/// oneshot receiver makes the send fail, and the task drops the stream
/// and returns (same reaping).
fn make_forwarded_handler(registry: CorrelationRegistry) -> alktunnels::ForwardedHandler {
Arc::new(move |forwarded: TunnelForwardedParams, bidi: BiStream| {
@@ -518,15 +524,15 @@ fn make_forwarded_handler(registry: CorrelationRegistry) -> alktunnels::Forwarde
}
// The middle pump (three-party half-close would cycle:
// pump_bidi's per-direction shutdown-on-completion
// deadlocks a bridge — the SOCKS5 pump's far.write shutdown
// deadlocks a bridge: the SOCKS5 pump's far.write shutdown
// needs near.read EOF; the wire's bidi.read EOF needs the
// producer pump's far.read EOF; far.read needs near.write
// shutdown; near.write needs bidi.read EOF). The bridge
// therefore completes on EITHER direction's EOF and drops
// everything: the far halves (the SOCKS5 pump sees EOF),
// the near halves, and the BiStream (the forwarded channel
// tears down; the wrapper's inline await returns — R-02 —
// and reaps the entry).
// tears down; the wrapper's inline await returns, per
// R-02, and reaps the entry).
let (near_read, near_write) = tokio::io::split(near);
pump_bidi(bidi, near_read, near_write).await;
})
@@ -535,16 +541,16 @@ fn make_forwarded_handler(registry: CorrelationRegistry) -> alktunnels::Forwarde
/// The forwarded channel's `BiStream` reassembled after the
/// payload-split: the read half rides the [`SyncHalves`] adapter (the
/// mutex-joined form), the write half rides [`OwnedWrite`] — and the
/// mutex-joined form), the write half rides [`OwnedWrite`], and the
/// owner rides inside the write payload so dropping the handle drops
/// everything: the teardown-at-birth discipline, compiler-enforced.
/// The owner is the [`TunnelListener`] (NOT an adopted session — the
/// forwarded channel's entry is the producer's, adopted inside the
/// The owner is the [`TunnelListener`] (NOT an adopted session, since
/// the forwarded channel's entry is the producer's, adopted inside the
/// producer's `ForwardedOpener`): the listener's keep-alive holds the
/// far accept loop (and every per-accept pump it spawned — R-02)
/// far accept loop (and every per-accept pump it spawned; R-02)
/// alive; dropping the handle drops the listener → the listen channel
/// EOFs → the far teardown cascades. The edition-2021 disjoint-capture
/// trap (an earlier draft took `listener.channel_id` — a `u32` — and
/// trap (an earlier draft took `listener.channel_id`, a `u32`, and
/// dropped the listener at the factory's return, EOF-ing the listen
/// channel at birth) is the reason the listener is forced into the
/// future with a whole-struct rebinding.
@@ -554,14 +560,14 @@ struct ForwardedOwner {
/// The `BindTargetHandle` read-half adapter over the forwarded
/// channel's read half: `BiStream` is `Send + Unpin` but NOT `Sync`
/// (its inner is a `dyn AsyncReadWrite` trait object — no `Sync`
/// (its inner is a `dyn AsyncReadWrite` trait object, with no `Sync`
/// supertrait), so the raw halves cannot meet the handle payloads'
/// `+ Sync` bound directly. A `std::sync::Mutex` guard is the
/// no-unsafe Sync proof (`Mutex<T: Send>` is `Sync`); the RFC 1928
/// BIND data plane is a raw pass-through pumped by ONE `pump_bidi`
/// task at a time, so contention never occurs at runtime — the mutex
/// task at a time, so contention never occurs at runtime; the mutex
/// is the Sync-proof shape, not a concurrency mechanism. A poisoned
/// lock (a panic in one poll) unwraps via `into_inner` — a panic in
/// lock (a panic in one poll) unwraps via `into_inner`, so a panic in
/// one operation does not cascade (AGENTS.md convention 2).
struct SyncHalves {
inner: std::sync::Mutex<tokio::io::ReadHalf<tokio::io::DuplexStream>>,
@@ -582,10 +588,10 @@ impl AsyncRead for SyncHalves {
}
}
/// The write-half payload: the owner ([`ForwardedOwner`] — the
/// The write-half payload: the owner ([`ForwardedOwner`], the
/// adopted channel session) and the write half ride inside one
/// `std::sync::Mutex` (the same no-unsafe Sync proof as
/// [`SyncHalves`] — `TunnelSession` and `WriteHalf<BiStream>` are
/// [`SyncHalves`]: `TunnelSession` and `WriteHalf<BiStream>` are
/// `Send` but not `Sync`). Contention never occurs at runtime (one
/// pump task); a poisoned lock unwraps via `into_inner`.
struct OwnedWrite {
@@ -636,25 +642,25 @@ impl AsyncWrite for OwnedWrite {
}
}
/// The forwarded op's serving registration (the opener half — ADR-008
/// The forwarded op's serving registration (the opener half, ADR-008
/// §3, `register_tunnel_forwarded_openable` + this provider's
/// correlation handler): register on the SOCKS5 producer's serving
/// registry so its connection serves the forwarded op. Call once per
/// channels connection at assembly time — the registry is shared
/// channels connection at assembly time, since the registry is shared
/// across all BIND sessions every [`bindfirst_accept`] factory over
/// the same client fabricates (one token space per producer
/// connection).
///
/// The scope gate is `tunnel:forwarded` on the PRODUCER's per-call
/// identity (the ingress grant — ADR-006 Amendment 1; never implied by
/// identity (the ingress grant, ADR-006 Amendment 1; never implied by
/// `tunnel:open`). The returned registry handle feeds the factories;
/// keep it alongside the client they ride.
///
/// # Errors
///
/// A duplicate `channels/tunnel/forwarded` op id on the registry (an
/// assembly bug, not a runtime condition) — the underlying
/// registration's `String` diagnostic, surfaced (convention 2: no
/// assembly bug, not a runtime condition), and it surfaces the
/// underlying registration's `String` diagnostic (convention 2: no
/// panic path; the caller decides).
pub fn register_forwarded_serving(
core: &ChannelCore,
@@ -673,56 +679,58 @@ pub fn register_forwarded_serving(
/// The composed accept factory (ADR-007 variant 2, producer.md
/// §Wiring `composed::bindfirst_accept`): the `BindAcceptFactory` the
/// producer registration consumes — the BIND accept whose listener
/// producer registration consumes, the BIND accept whose listener
/// lives on an alktunnels 0.2.0 bind-first listen resource
/// (`register_tunnel_bindfirst_openable` + `BindFirstInjections` —
/// per-listener ACL for free, hub-relayable).
/// (`register_tunnel_bindfirst_openable` + `BindFirstInjections`,
/// giving per-listener ACL for free, hub-relayable).
///
/// Per session the factory:
///
/// 1. Opens the listen channel (`TunnelListener::open` — the
/// 1. Opens the listen channel (`TunnelListener::open`; the
/// establishment bound covers only this open-op round trip,
/// ADR-007); a failed open is a factory failure (reply#1's mapped
/// code — no phantom listener).
/// code, so no phantom listener).
/// 2. Advertises reply#1's BND.ADDR as the REAL `bound` (the open-op
/// reply's `bound` field, the alkcall 0.8.0 reply projection — the
/// reply's `bound` field, the alkcall 0.8.0 reply projection; the
/// graduation's vanilla-fidelity win; no expected-addr stand-in).
/// A producer that supplied no `bound`/`listen` (not a bind-first
/// resource) fails the factory — no stand-in on this variant.
/// resource) fails the factory, with no stand-in on this variant.
/// 3. Inserts the minted `listen` token → oneshot into the
/// correlation registry and returns the accept future parked on
/// it.
///
/// The accept future waits for the ONE forwarded session matching the
/// token (the correlation registry is assembly-side bookkeeping — the
/// token (the correlation registry is assembly-side bookkeeping; the
/// listener does not own the receipt queue), then builds the
/// [`BindTargetHandle`]: reply#2's peer is the forwarded op's `peer`
/// param (the graduation's win — no sentinel), and the data-plane
/// param (the graduation's win, with no sentinel), and the data-plane
/// halves are the [`SyncHalves`]/[`OwnedWrite`] adapters over the
/// adopted channel's `BiStream` with the [`ForwardedOwner`] (the
/// adopted channel session) riding inside the write payload — dropping
/// the handle drops the session AND the halves together.
/// adopted channel session) riding inside the write payload, so
/// dropping the handle drops the session AND the halves together.
///
/// **The client-gone teardown** (the seam contract, end-to-end): the
/// listener and the oneshot receiver ride the accept future — the
/// listener and the oneshot receiver ride the accept future, so the
/// quiet wait's `select!` drop of that future drops the
/// [`TunnelListener`] (its held write half EOFs the listen channel —
/// the far accept dispatcher tears down) AND drops the oneshot
/// [`TunnelListener`] (its held write half EOFs the listen channel,
/// so the far accept dispatcher tears down) AND drops the oneshot
/// receiver (a later forwarded open for that token finds a dead
/// oneshot and drops the channel stream) — no leaked listener, no
/// leaked channel.
/// oneshot and drops the channel stream), leaving no leaked
/// listener and no leaked channel.
///
/// Only TCP-stream listen resources are supported (the SOCKS5 BIND
/// data plane is raw on both ends; a UDP listen substrate would frame
/// — a datagram BIND variant is a different task). A non-TCP `bound`
/// data plane is raw on both ends; a UDP listen substrate would
/// frame, and a datagram BIND variant is a different task). A
/// non-TCP `bound`
/// fails the factory (`NotAllowed`).
///
/// **The 0x02 layering** (review 003 N-2): on the channels path the
/// ADR-006 command gate precedes this factory — an identity without
/// ADR-006 command gate precedes this factory, so an identity
/// without
/// `socks5:bind` never reaches it. A factory-side `NotAllowed` (0x02,
/// the same code the gate uses) therefore means the *tunnel* layer's
/// per-listener ACL (this variant's real policy source), not the
/// command gate — semantically distinct, wire-identical.
/// command gate (the two are semantically distinct, wire-identical).
pub fn bindfirst_accept(
client: Arc<ChannelClient>,
registry: CorrelationRegistry,
@@ -746,12 +754,12 @@ pub fn bindfirst_accept(
.map_err(|e| BindAcceptFactoryError::General(format!("bound render: {e}")))?,
Some(SubstrateAddr::Path { path }) => {
return Err(BindAcceptFactoryError::NotAllowed(format!(
"non-tcp bound address `{path}` — this provider serves TCP listeners only"
"non-tcp bound address `{path}`: this provider serves TCP listeners only"
)));
}
None => {
return Err(BindAcceptFactoryError::Shortage(
"the producer supplied no `bound` reply field — not a bind-first \
"the producer supplied no `bound` reply field, so this is not a bind-first \
resource; no stand-in on this variant"
.into(),
));
@@ -759,8 +767,8 @@ pub fn bindfirst_accept(
};
let token = listener.listen().ok_or_else(|| {
BindAcceptFactoryError::Shortage(
"the producer supplied no `listen` correlation token — not a bind-first \
resource; no stand-in on this variant"
"the producer supplied no `listen` correlation token, so this is not a \
bind-first resource; no stand-in on this variant"
.into(),
)
})?;
@@ -771,8 +779,8 @@ pub fn bindfirst_accept(
let fut = async move {
// Force the whole-struct capture: edition-2021 disjoint
// capture would take `listener.channel_id` (a `u32`)
// and drop the listener itself at the factory's return
// — EOF-ing the listen channel at birth. The listener
// and drop the listener itself at the factory's return,
// EOF-ing the listen channel at birth. The listener
// rides the future end-to-end (the seam contract).
let listener = listener;
let ForwardedSession { read, write, peer } = match rx.await {
@@ -784,7 +792,7 @@ pub fn bindfirst_accept(
);
// The registry entry was removed without
// delivery (the forwarded serving half tore
// down — the whole provider is going away).
// down; the whole provider is going away).
// The listener's drop tears the listen
// channel down (the held write half EOFs).
return Err(BindAcceptError::General(
@@ -802,7 +810,7 @@ pub fn bindfirst_accept(
inner: std::sync::Mutex::new(OwnedInner {
// The listener rides INSIDE the payload:
// its keep-alive holds the far accept loop
// (and every per-accept pump it spawned —
// (and every per-accept pump it spawned;
// R-02) alive for the data plane's
// lifetime; dropping the handle drops the
// listener → the listen channel EOFs →
@@ -810,7 +818,7 @@ pub fn bindfirst_accept(
// structural, compiler-enforced). The
// edition-2021 disjoint-capture trap (an
// earlier draft took `listener.channel_id`
// — a `u32` — and dropped the listener at
// (a `u32`) and dropped the listener at
// the factory's return, EOF-ing the listen
// channel at birth) is why the whole
// struct is rebound into the future above.
@@ -832,7 +840,8 @@ pub fn bindfirst_accept(
///
/// - IP literals resolve directly.
/// - A non-IP host fails (reply#1's address is a real socket address
/// on this variant — a hostname bound is the producer's resolution
/// on this variant, but a hostname bound is the producer's
/// resolution
/// surface, not the advertised address's).
///
/// The parse error is a `String` diagnostic (the factory maps it to
@@ -844,7 +853,7 @@ fn resolve_bound(host: &str, port: u16) -> Result<SocketAddr, String> {
}
/// The forwarded op's `peer` param rendered to reply#2's socket
/// address (the graduation's peer fidelity — the param is the real
/// address (the graduation's peer fidelity; the param is the real
/// peer). A `Path` peer (a unix-socket accept) has no RFC-shaped
/// address; the RFC sentinel `0.0.0.0:0` carries it (the data plane
/// is unaffected either way).
@@ -869,9 +878,9 @@ mod egress_unit_tests {
use tokio::io::AsyncWriteExt;
/// The reply-oversize guard fires (review 003 D-2): a reply whose
/// inner bytes exceed the joint bound is a `General` egress error
/// — the guard exists although the tunnel codec's u16 datagram
/// bound upstream should keep every real reply under it.
/// inner bytes exceed the joint bound is a `General` egress error,
/// though the guard exists in case the tunnel codec's u16 datagram
/// bound upstream fails to keep every real reply under it.
#[test]
fn inner_bytes_oversize_guard_fires() {
let addr = ("10.0.0.1", 53u16).to_target_addr().unwrap();
@@ -889,8 +898,8 @@ mod egress_unit_tests {
}
/// The relay task's sender-closed exit (review 003 D-2): the
/// shared queue closed with the egress — the stage exited
/// mid-relay — ends the relay's datagram pump silently (the
/// shared queue closed with the egress (the stage exited
/// mid-relay), so it ends the relay's datagram pump silently (the
/// `tx.send(..).is_err()` early return), distinct from the EOF
/// exit that still reports `Ended`.
#[tokio::test]
@@ -905,7 +914,7 @@ mod egress_unit_tests {
server_side.shutdown().await.unwrap();
drop(server_side);
let handle = tokio::spawn(relay_read(client_side, tx, "k".into()));
// The relay exits on the closed-queue send (silently) — it
// The relay exits on the closed-queue send (silently), and it
// joins promptly with no panic.
tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
@@ -914,7 +923,7 @@ mod egress_unit_tests {
}
/// The relay task's EOF exit still reports `Ended` (the count's
/// decrement path — the shape the integration tests observe
/// decrement path, the shape the integration tests observe
/// indirectly, pinned here directly).
#[tokio::test]
async fn relay_read_reports_ended_on_eof() {
@@ -937,7 +946,7 @@ mod egress_unit_tests {
"a queued datagram: {first:?}"
);
// The second datagram (the batched frame) then the EOF
// marker — drain until the `Ended` marker arrives.
// marker; drain until the `Ended` marker arrives.
let mut saw_end = false;
for _ in 0..3 {
match rx.recv().await.expect("the queue delivers") {
+159 -152
View File
@@ -3,58 +3,59 @@
//!
//! [`Socks5ChannelSession`] is the CONNECT session: it opens
//! `channels/socks5/sub` via `ChannelClient::open_channel` (trivial
//! params — ADR-001), drives the forked client typestate's
//! `use_stream` path over the channel's `BiStream` (POC #1's fit —
//! params, ADR-001), drives the forked client typestate's
//! `use_stream` path over the channel's `BiStream` (POC #1's fit:
//! `Socks5Stream` impls `AsyncRead + AsyncWrite` itself, so a session
//! over a `BiStream` is the intended use, not a hack), and after the
//! reply presents the raw byte pipe (0 B framing — the session
//! reply presents the raw byte pipe (0 B framing; the session
//! reads/writes the target's bytes directly). [`BindSession`] is the
//! BIND session: the reply#2 reader (consumer.md §The BIND client,
//! ADR-007) — reply#1 rides the forked client `request()`, reply#2 is
//! BIND session and the reply#2 reader (consumer.md §The BIND client,
//! ADR-007); reply#1 rides the forked client `request()`, reply#2 is
//! hand-read with the forked `read_address` codec (no second codec),
//! then the pass-through data plane. [`AssociateSession`] is the UDP
//! ASSOCIATE session (POC #2's shape — see its docs).
//! ASSOCIATE session (POC #2's shape; see its docs).
//!
//! The BIND session's reply#2 phase is take-guarded (review 002
//! C-1): the first `await_inbound` takes the stream — the plane owns
//! it, the session stays the teardown owner — and a second call
//! C-1): the first `await_inbound` takes the stream (the plane owns
//! it, the session stays the teardown owner), and a second call
//! returns the family's spent shape (`Ok(None)`); the reply#2 reader
//! is that one method alone (`reply1()` is a view, D-1).
//!
//! Target construction rides [`ToTargetAddr`]: IP-string forms parse
//! to IP targets; the domain form passes through unresolved (ADR-012
//! — resolution is the egress's job, never the protocol's).
//! to IP targets; the domain form passes through unresolved (ADR-012;
//! resolution is the egress's job, never the protocol's).
//!
//! [`AssociateSession`] is the UDP ASSOCIATE session (POC #2's shape,
//! consumer.md §The associate session, ADR-003): it opens the
//! channel, negotiates, sends ASSOCIATE, reads the reply — **receives
//! the sentinel `BND.ADDR` and never uses it for addressing** (the
//! channel, negotiates, sends ASSOCIATE, reads the reply, **receiving
//! the sentinel `BND.ADDR` and never using it for addressing** (the
//! handle is the channel). The datagram plane is the framed stage:
//! `send_datagram` frames once (`[len: u16 BE][addr + data]` — the
//! `send_datagram` frames once (`[len: u16 BE][addr + data]`; the
//! framing-once mirror, ADR-003) and `recv_datagram` decodes
//! incrementally with **reader state that persists across calls**
//! (mid-frame position + batched-datagram pending queue — a datagram
//! split across channel chunks survives a caller-driven receive
//! loop; POC #2's finding: the first cut dropped the reader state
//! per call, the 2-byte-prefix chunk test exposed it). `shutdown`
//! half-closes the stream — the mux's implicit-EOF sentinel reaches
//! (mid-frame position + batched-datagram pending queue, so a
//! datagram split across channel chunks survives a caller-driven
//! receive loop; POC #2's finding: the first cut dropped the reader
//! state per call, the 2-byte-prefix chunk test exposed it). `shutdown`
//! half-closes the stream; the mux's implicit-EOF sentinel reaches
//! the producer's stage as EOF and the association ends (association
//! lifetime = channel lifetime).
//!
//! Teardown ownership (alktunnels ADR-005 posture, inherited via
//! ADR-004): the session owns the adopted channel entry. On Drop it
//! reaps it (`ChannelManager::teardown_channel` — sync, best-effort;
//! `Drop` cannot await) and the data plane EOFs structurally (the
//! reaps it (`ChannelManager::teardown_channel`, sync and
//! best-effort; `Drop` cannot await) and the data plane EOFs
//! structurally (the
//! mux's implicit-EOF sentinel on the dropped write half). The
//! teardown-at-birth discipline covers the in-stream refusal path:
//! a refused ASSOCIATE tears the adopted channel down before the
//! error returns — a refused session never leaves a phantom channel.
//! error returns; a refused session never leaves a phantom channel.
//! The session does **not** implement `Clone` (compile-asserted on
//! the struct): aliasing a session would alias its teardown and its
//! reader state.
//!
//! Error surface (consumer.md §Error surface): open failures are the
//! typed [`SocksOpenError`] reasons (ADR-049 §4 — a refused session
//! typed [`SocksOpenError`] reasons (ADR-049 §4; a refused session
//! never yields a phantom channel); a refused ASSOCIATE is an RFC
//! reply code surfaced through [`SocksSessionError::Rfc`].
@@ -76,11 +77,11 @@ use crate::rfc1928::{ReplyError, Socks5Command, SocksError, UdpHeaderError};
use crate::wire::{frame, DatagramReader, WireError};
/// The three session constructors' shared prologue (review 002 N-6):
/// open the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`
/// — the trivial params, ADR-001), join the halves into the channel's
/// open the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`,
/// the trivial params, ADR-001), join the halves into the channel's
/// `BiStream`, run the command negotiation over the forked client
/// typestate, and on any negotiation failure reap the adopted channel
/// before the error returns (teardown-at-birth — the phantom-channel
/// before the error returns (teardown-at-birth; the phantom-channel
/// property, in one place instead of three). `Ok` yields the channel
/// id, the negotiated stream, and the cloned manager for the session
/// to own.
@@ -112,17 +113,17 @@ where
/// The cred-bearing front door's re-origination prologue (the
/// token-carrying twin of [`open_and_negotiate`]): open the channel
/// with the payload `auth_token` riding the `call.requested` payload
/// (CF-005 precedence (1) — the producer-side dispatch resolves the
/// (CF-005 precedence (1): the producer-side dispatch resolves the
/// token through its `ServingConfig.identity_provider` and runs the
/// ACL gate against the mapped identity), join the halves, run the
/// command negotiation, and reap on failure — the same phantom-channel
/// command negotiation, and reap on failure, the same phantom-channel
/// property. `Ok` yields the channel id, the negotiated stream, and
/// the cloned manager.
///
/// The token path needs the raw `call.requested` payload (`auth_token`
/// is a payload-level field, ADR-017 §7), so the open rides
/// `CallConnection::call_with_payload` + `adopt_channel` — the same
/// two-step `open_channel` performs, with the token added. The
/// `CallConnection::call_with_payload` + `adopt_channel` (the same
/// two-step `open_channel` performs, with the token added). The
/// client's call connection is taken once and cached in the returned
/// client (the door's `TokenClient` owns it for the door's lifetime).
async fn open_with_token_and_negotiate<T, F, Fut>(
@@ -157,7 +158,7 @@ where
/// The spent-session marker (the gate review's R-2,
/// tasks/implementation/review-protocol-core.md): [`Socks5ChannelSession
/// ::into_inner`] / [`AssociateSession::into_inner`] return it when
/// the pipe/stream was already taken — reachable only by calling
/// the pipe/stream was already taken, reachable only by calling
/// twice on a session whose inner is gone. Carries nothing: a spent
/// session's teardown is already done (the reap ran with the take).
#[derive(Debug, thiserror::Error)]
@@ -183,9 +184,9 @@ pub struct Socks5ChannelSession {
channel_id: u32,
manager: Option<ChannelManager>,
inner: Option<Socks5Stream<BiStream>>,
/// The CONNECT reply's BND.ADDR — captured at `connect` (the
/// The CONNECT reply's BND.ADDR, captured at `connect` (the
/// forked client `request()` returns it once; the front door's
/// re-origination re-encodes the vanilla reply from it — the RFC
/// re-origination re-encodes the vanilla reply from it, the RFC
/// codec at the door boundary only, ADR-003).
reply_addr: Option<TargetAddr>,
}
@@ -201,15 +202,15 @@ impl std::fmt::Debug for Socks5ChannelSession {
impl Socks5ChannelSession {
/// CONNECT to `target` through a produced SOCKS5 resource: opens
/// the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`
/// — the trivial params, ADR-001), drives the no-auth handshake
/// the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`,
/// the trivial params, ADR-001), drives the no-auth handshake
/// and the CONNECT request in-stream over the channel's
/// `BiStream`, and returns the session owning both.
///
/// Error surface: an open failure is [`SocksSessionError::Open`]
/// (the typed ADR-049 reasons; no channel was ever returned); a
/// refused target surfaces the RFC reply code through
/// [`SocksSessionError::Rfc`] — and the adopted channel is torn
/// [`SocksSessionError::Rfc`], and the adopted channel is torn
/// down before the error returns (no phantom channel).
pub async fn connect(
client: &ChannelClient,
@@ -233,8 +234,8 @@ impl Socks5ChannelSession {
/// The cred-bearing front door's CONNECT re-origination (the
/// token-carrying twin of [`Self::connect`]): the open rides the
/// payload `auth_token` (CF-005 precedence (1) — the producer's
/// identity provider resolves the door's mapped identity and its
/// payload `auth_token` (CF-005 precedence (1): the producer's
/// identity provider resolves the door's mapped identity, and its
/// scopes gate the open), then the same in-stream negotiation over
/// the channel's `BiStream`. Error surface identical.
pub async fn connect_with_token(
@@ -265,7 +266,7 @@ impl Socks5ChannelSession {
self.channel_id
}
/// The CONNECT reply's BND.ADDR — the in-band reply's bound
/// The CONNECT reply's BND.ADDR: the in-band reply's bound
/// address, captured at `connect` (the forked client `request()`
/// returns it once). The front door's re-origination re-encodes
/// the vanilla reply from it (the RFC codec at the door boundary
@@ -275,8 +276,8 @@ impl Socks5ChannelSession {
self.reply_addr.clone()
}
/// The post-CONNECT pipe as split read/write halves (0 B framing
/// — raw bytes to/from the target). The halves borrow the
/// The post-CONNECT pipe as split read/write halves (0 B framing,
/// raw bytes to/from the target). The halves borrow the
/// session: teardown stays owned (`close`/`Drop`). `None` after
/// [`Self::into_inner`] took the pipe.
pub fn split(&mut self) -> Option<SplitPipe<'_>> {
@@ -284,16 +285,16 @@ impl Socks5ChannelSession {
Some(tokio::io::split(inner))
}
/// Take the underlying [`Socks5Stream<BiStream>`] (advanced use —
/// Take the underlying [`Socks5Stream<BiStream>`] (advanced use;
/// the forked client type's socket accessors, owned-half pumps).
/// The session rides along still owning the channel's teardown:
/// drop the pair (or `close` the session) when done — dropping
/// `inner` EOFs the data plane structurally.
/// drop the pair (or `close` the session) when done, since
/// dropping `inner` EOFs the data plane structurally.
///
/// [`SpentSessionError`] on a spent session (the pipe already
/// taken): a spent session's manager entry is always reaped
/// already (`close`/`shutdown`/`Drop` all take it), so dropping
/// it loses nothing — no panic path (AGENTS.md convention 2).
/// it loses nothing (no panic path, AGENTS.md convention 2).
pub fn into_inner(mut self) -> Result<TakenInner, SpentSessionError> {
match self.inner.take() {
Some(inner) => Ok(TakenInner {
@@ -317,9 +318,9 @@ impl Socks5ChannelSession {
/// The adopted channel's teardown (`teardown_channel` returns a
/// producer-side pump-task handle the caller may abort after the
/// drain — the channels-call path's shape). The handle is
/// drain, the channels-call path's shape). The handle is
/// deliberately dropped here: the producer-side pump finishes via
/// EOF propagation (the graceful shape — aborting would cut
/// EOF propagation (the graceful shape, since aborting would cut
/// mid-flight data); `clear_all` aborts instead (the substrate's
/// abrupt path).
fn reap(&mut self) -> bool {
@@ -332,7 +333,7 @@ impl Socks5ChannelSession {
impl Drop for Socks5ChannelSession {
/// Sync-reap the adopted channel entry (`teardown_channel` is
/// sync — best-effort; `Drop` cannot await) and drop the pipe
/// sync, best-effort; `Drop` cannot await) and drop the pipe
/// (the mux's implicit-EOF sentinel EOFs the producer's pump).
/// Dropping without `close` never leaks the channel entry.
fn drop(&mut self) {
@@ -342,8 +343,8 @@ impl Drop for Socks5ChannelSession {
}
/// The UDP ASSOCIATE consumer session (POC #2's shape, consumer.md
/// §The associate session, ADR-003): the channel is the relay handle
/// — the reply's sentinel `BND.ADDR` is received and never used for
/// §The associate session, ADR-003): the channel is the relay handle;
/// the reply's sentinel `BND.ADDR` is received and never used for
/// addressing, the datagram plane is the framed stage on the channel
/// (`[len: u16 BE][addr + data]`, both directions), and the
/// association's lifetime is the channel's.
@@ -352,7 +353,7 @@ impl Drop for Socks5ChannelSession {
/// = one ASSOCIATE, convention 10) and owns its reader state: the
/// incremental [`DatagramReader`] (mid-frame position) and the
/// pending-queue of batched datagrams both **persist across
/// `recv_datagram` calls** — a datagram split across channel chunks
/// `recv_datagram` calls**; a datagram split across channel chunks
/// survives a caller-driven receive loop (POC #2's finding, the
/// first cut dropped the reader state per call). It does **not**
/// implement `Clone`:
@@ -370,17 +371,17 @@ pub struct AssociateSession {
channel_id: u32,
manager: Option<ChannelManager>,
inner: Option<Socks5Stream<BiStream>>,
/// The incremental length-codec decoder — its mid-frame state
/// The incremental length-codec decoder; its mid-frame state
/// persists across `recv_datagram` calls (a datagram may be split
/// across channel chunks; the same `DatagramReader` the producer
/// stage runs).
reader: DatagramReader,
/// Datagrams decoded beyond the one returned (a chunk may batch
/// several datagrams — drained by later `recv_datagram` calls).
/// The batched datagrams are stored decoded (`(addr, payload)` —
/// several datagrams, drained by later `recv_datagram` calls).
/// The batched datagrams are stored decoded (`(addr, payload)`,
/// the same shape `recv_datagram` returns).
pending: std::collections::VecDeque<(TargetAddr, Bytes)>,
/// The reply's BND.ADDR — received for RFC fidelity, unused for
/// The reply's BND.ADDR, received for RFC fidelity, unused for
/// addressing (wrapper-aware clients sendto the channel; ADR-003).
relay_addr: Option<TargetAddr>,
}
@@ -398,13 +399,13 @@ impl std::fmt::Debug for AssociateSession {
impl AssociateSession {
/// ASSOCIATE through a produced SOCKS5 resource: opens the
/// channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)` — the
/// channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`, the
/// trivial params, ADR-001), drives the no-auth handshake and the
/// UDP ASSOCIATE request in-stream over the channel's `BiStream`,
/// and reads the reply. The DST.ADDR of the ASSOCIATE command
/// mirrors the vanilla shape — the unspecified address (`[::]:0`),
/// mirrors the vanilla shape: the unspecified address (`[::]:0`),
/// as the forked `Socks5Datagram` sends. The reply's BND.ADDR is
/// the producer's sentinel (`127.0.0.1:0` on the in-band path) —
/// the producer's sentinel (`127.0.0.1:0` on the in-band path),
/// captured as [`Self::relay_addr`], never used for addressing
/// (ADR-003: the handle is the channel).
///
@@ -412,7 +413,7 @@ impl AssociateSession {
/// (the typed ADR-049 reasons; no channel was ever returned); a
/// refused ASSOCIATE (the missing-egress `CommandNotSupported`
/// 0x07 or a factory failure's mapped code) surfaces the RFC
/// reply code through [`SocksSessionError::Rfc`] — and the
/// reply code through [`SocksSessionError::Rfc`], and the
/// adopted channel is torn down before the error returns (no
/// phantom channel).
pub async fn associate(client: &ChannelClient) -> Result<Self, SocksSessionError> {
@@ -441,7 +442,7 @@ impl AssociateSession {
/// The cred-bearing front door's ASSOCIATE re-origination (the
/// token-carrying twin of [`Self::associate`]): the open rides
/// the payload `auth_token` (CF-005 precedence (1)). Not called
/// by the landed TCP door — its ASSOCIATE arm refuses
/// by the landed TCP door, whose ASSOCIATE arm refuses
/// RFC-faithfully before any session is opened (the datagram
/// plane is the UDP door's task, ADR-003); the constructor is
/// the session family's completeness (every command carries the
@@ -482,7 +483,7 @@ impl AssociateSession {
/// The reply's sentinel `BND.ADDR` (RFC fidelity: the relay
/// address the vanilla shape would sendto; the wrapper-aware
/// session never uses it — the handle is the channel, ADR-003).
/// session never uses it (the handle is the channel, ADR-003).
/// `None` after a taken stream (the spent-session posture).
pub fn relay_addr(&self) -> Option<TargetAddr> {
self.relay_addr.clone()
@@ -490,10 +491,10 @@ impl AssociateSession {
/// Send one datagram to `dst`: the RFC address encoding + the
/// payload, length-framed **exactly once** by the wire codec
/// ([`frame`] — the framing-once mirror, ADR-003) and written to
/// ([`frame`], the framing-once mirror, ADR-003) and written to
/// the channel. A zero-length payload is a legal empty datagram
/// (the F-2 invariant at payload level). Rejects
/// [`SocksSessionError::Rfc`] ([`WireError`]) at frame time —
/// [`SocksSessionError::Rfc`] ([`WireError`]) at frame time,
/// the joint bound (`len(addr) + len(payload) > 65535`, the u16
/// length field would wrap) and the address-encoding refusals.
pub async fn send_datagram(
@@ -516,28 +517,28 @@ impl AssociateSession {
}
/// Receive the next datagram: decode incrementally from the
/// channel's read half — `(source_addr, payload)` per datagram,
/// `Ok(None)` on channel EOF (the association's end — the
/// association lifetime is the channel's, ADR-003).
/// channel's read half (`(source_addr, payload)` per datagram),
/// `Ok(None)` on channel EOF (the association's end, per the
/// association-lifetime contract, ADR-003).
///
/// **The reader state persists across calls** (POC #2's finding,
/// ADR-003): the incremental [`DatagramReader`]'s mid-frame
/// position survives a caller-driven receive loop — a datagram
/// split across channel chunks reassembles across calls — and
/// position survives a caller-driven receive loop (a datagram
/// split across channel chunks reassembles across calls), and
/// frames decoded beyond the one returned (a chunk may batch
/// several datagrams) queue in `pending` for later calls.
///
/// Fail-loud shapes (never a silent partial datagram): a stream
/// end mid-frame is the truncated-datagram condition (an
/// `UnexpectedEof` I/O error through [`SocksSessionError::Rfc`]);
/// a frame the codec refuses (garbage ATYP — any post-reply
/// a frame the codec refuses (garbage ATYP: any post-reply
/// control byte) errors loud through [`WireError`]'s
/// `UdpHeaderError` mapping and ends the caller's loop.
pub async fn recv_datagram(
&mut self,
) -> Result<Option<(TargetAddr, Bytes)>, SocksSessionError> {
// The batched-datagram pending queue first (a chunk may have
// batched several frames — drain in order).
// batched several frames, drained in order).
if let Some(dg) = self.pending.pop_front() {
return Ok(Some(dg));
}
@@ -550,7 +551,7 @@ impl AssociateSession {
loop {
let n = socket.read(&mut chunk).await?;
if n == 0 {
// Channel EOF = association end (the clean exit) —
// Channel EOF = association end (the clean exit);
// fail-loud if a frame was in flight (never a silent
// partial datagram; the reader state is the teardown
// diagnostic).
@@ -580,7 +581,7 @@ impl AssociateSession {
/// The underlying stream as split read/write halves (the front
/// door's bridge seam: a raw pipe to a vanilla client over the
/// framed stage — the door's RFC header codec owns the frames at
/// framed stage; the door's RFC header codec owns the frames at
/// the boundary, ADR-003). `None` after the stream was taken (the
/// spent-session posture).
pub fn split(&mut self) -> Option<SplitPipe<'_>> {
@@ -588,7 +589,7 @@ impl AssociateSession {
Some(tokio::io::split(inner))
}
/// Take the underlying [`Socks5Stream<BiStream>`] (advanced use —
/// Take the underlying [`Socks5Stream<BiStream>`] (advanced use;
/// the forked client type's socket accessors, owned-half pumps;
/// the POC's `raw_mut` seam, widened). The session rides along
/// still owning the channel's teardown: drop the pair (or
@@ -597,7 +598,7 @@ impl AssociateSession {
/// [`SpentSessionError`] on a spent session (the stream already
/// taken): a spent session's manager entry is always reaped
/// already (`shutdown`/`close`/`Drop` all take it), so dropping it
/// loses nothing — no panic path (AGENTS.md convention 2), and the
/// loses nothing (no panic path, AGENTS.md convention 2), and the
/// `Err` side stays small (a spent session is not carried).
pub fn into_inner(mut self) -> Result<TakenAssociateInner, SpentSessionError> {
match self.inner.take() {
@@ -640,9 +641,9 @@ impl AssociateSession {
/// The adopted channel's teardown (`teardown_channel` returns a
/// producer-side pump-task handle the caller may abort after the
/// drain — the channels-call path's shape). The handle is
/// drain, the channels-call path's shape). The handle is
/// deliberately dropped here: the producer-side pump finishes via
/// EOF propagation (the graceful shape — aborting would cut
/// EOF propagation (the graceful shape, since aborting would cut
/// mid-flight data); `clear_all` aborts instead (the substrate's
/// abrupt path).
fn reap(&mut self) -> bool {
@@ -655,8 +656,8 @@ impl AssociateSession {
impl Drop for AssociateSession {
/// Sync-reap the adopted channel entry (`teardown_channel` is
/// sync — best-effort; `Drop` cannot await) and drop the pipe
/// (the mux's implicit-EOF sentinel EOFs the producer's stage —
/// sync, best-effort; `Drop` cannot await) and drop the pipe
/// (the mux's implicit-EOF sentinel EOFs the producer's stage,
/// the association ends with the session's lifetime). Dropping
/// without `shutdown`/`close` never leaks the channel entry.
fn drop(&mut self) {
@@ -668,7 +669,7 @@ impl Drop for AssociateSession {
/// The owned stream [`AssociateSession::into_inner`] yields, paired
/// with the session that still owns the channel's teardown.
pub struct TakenAssociateInner {
/// The teardown owner — reap via `shutdown`/`close`/`Drop`.
/// The teardown owner: reap via `shutdown`/`close`/`Drop`.
pub session: AssociateSession,
/// The framed datagram stage's stream (the forked client type
/// over the channel's `BiStream`).
@@ -678,7 +679,7 @@ pub struct TakenAssociateInner {
/// The owned pipe [`Socks5ChannelSession::into_inner`] yields, paired
/// with the session that still owns the channel's teardown.
pub struct TakenInner {
/// The teardown owner — reap via `close`/`Drop`.
/// The teardown owner: reap via `close`/`Drop`.
pub session: Socks5ChannelSession,
/// The post-CONNECT pipe (the forked client type over the
/// channel's `BiStream`).
@@ -694,7 +695,7 @@ pub type SplitPipe<'s> = (
);
/// A completed BIND reply#1 (consumer.md §The BIND client, ADR-007's
/// client-side phase reading): the channel is open, reply#1 carried
/// BND.ADDR — the advertised address the app server dials (the one
/// BND.ADDR, the advertised address the app server dials (the one
/// real address on the channels path: the local variant's bound
/// listener; the composed variant's expected far-side address; the
/// wrapper-aware sentinel `0.0.0.0:0` on the in-band default). The
@@ -704,15 +705,15 @@ pub type SplitPipe<'s> = (
/// one-reader decision): it carries the advertised address and the
/// raw pre-reply#2 stream ([`Self::raw_mut`], the front-door bridge
/// seam); the one normative reply#2 reader is
/// [`BindSession::await_inbound`] alone, take-guarded there — a
/// [`BindSession::await_inbound`] alone, take-guarded there, so a
/// fresh handle per call cannot double-read anything.
///
/// The session owns the adopted channel — teardown stays there
/// The session owns the adopted channel, so teardown stays there
/// (reap via the session; this handle borrows into it). No `Clone`
/// (the compile-assert rides [`BindSession`]).
pub struct BindReply1<'s> {
inner: &'s mut Socks5Stream<BiStream>,
/// Reply#1's BND.ADDR — the advertised address.
/// Reply#1's BND.ADDR, the advertised address.
pub bind_addr: TargetAddr,
}
@@ -725,11 +726,11 @@ impl BindReply1<'_> {
}
/// The post-reply#2 data plane (the pass-through to the accepted
/// inbound peer): 0 B framing — reads/writes ride the channel
/// inbound peer): 0 B framing; reads/writes ride the channel
/// directly. The plane **owns** the stream (review 002 C-1's
/// take-shape — the taken inner pairs with the session that stays
/// take-shape: the taken inner pairs with the session that stays
/// the teardown owner, the `TakenInner` pairing posture); the
/// [`BindSession`] keeps the channel's teardown — drop it (or
/// [`BindSession`] keeps the channel's teardown; drop it (or
/// `close` it) when done; dropping the plane EOFs the pipe
/// structurally (the mux's implicit-EOF sentinel). Accessors:
/// [`Self::into_inner`] (owned) / [`Self::get_socket_mut`]
@@ -737,7 +738,7 @@ impl BindReply1<'_> {
pub struct BindDataPlane {
inner: Socks5Stream<BiStream>,
/// Reply#2's peer address (the RFC's "the address of the incoming
/// connection" — the attribution surface, in-band).
/// connection", the attribution surface, in-band).
pub peer: TargetAddr,
}
@@ -751,7 +752,7 @@ impl BindDataPlane {
}
/// The underlying stream (the forked client type's socket
/// accessor, borrowed — the POC's `get_socket_mut` shape).
/// accessor, borrowed, i.e. the POC's `get_socket_mut` shape).
pub fn get_socket_mut(&mut self) -> &mut BiStream {
self.inner.get_socket_mut()
}
@@ -791,7 +792,7 @@ impl tokio::io::AsyncWrite for BindDataPlane {
}
}
/// The BIND consumer session (the `bind()` entry namespace — the POC
/// The BIND consumer session (the `bind()` entry namespace, the POC
/// #5 shape): opens the channel, negotiates, sends BIND, reads
/// reply#1 (`bind`), then awaits reply#2 + the data plane
/// (`await_inbound`). The session owns the adopted channel: on Drop
@@ -807,27 +808,29 @@ impl tokio::io::AsyncWrite for BindDataPlane {
/// Error surface: a refused BIND (the command gate's 0x02) or a
/// factory failure's reply#1 code surfaces from `bind` as
/// [`SocksSessionError::Rfc`] (the adopted channel is torn down
/// before the error returns — teardown-at-birth, the phantom-channel
/// property); accept-stage failures surface at `await_inbound` as
/// reply#2's mapped code ([`SocksSessionError::Rfc`]) — the channel
/// before the error returns; that is teardown-at-birth, the
/// phantom-channel property); accept-stage failures surface at
/// `await_inbound` as reply#2's mapped code
/// ([`SocksSessionError::Rfc`]); the channel
/// is still open there (the listener existed; the session lives until
/// the app closes it; the inner is restored, see the take-guard
/// below).
///
/// The reply#2 phase is take-guarded (review 002
/// C-1): the first `await_inbound` takes the inner stream — the
/// returned plane owns it (the `TakenInner` pairing posture; the
/// session stays the teardown owner) — so a second call hits the
/// C-1): the first `await_inbound` takes the inner stream (the
/// returned plane owns it; the `TakenInner` pairing posture, the
/// session stays the teardown owner), so a second call hits the
/// `None` arm and returns `Ok(None)`: the session family's spent
/// shape, with no re-read of the live data plane possible. A failed
/// read restores the inner (the phase is only consumed on success;
/// the producer ends the session on every post-reply#1 failure exit,
/// so a re-call EOFs — never a live data-plane re-read either way).
/// so a re-call EOFs and never does a live data-plane re-read either
/// way).
pub struct BindSession {
channel_id: u32,
manager: Option<ChannelManager>,
inner: Option<Socks5Stream<BiStream>>,
/// Reply#1's BND.ADDR — captured at `bind` (the forked client's
/// Reply#1's BND.ADDR, captured at `bind` (the forked client's
/// `request()` returns it once; the session carries it for the
/// app server's out-of-band dial).
bind_addr: Option<TargetAddr>,
@@ -845,17 +848,17 @@ impl std::fmt::Debug for BindSession {
impl BindSession {
/// BIND through a produced SOCKS5 resource: opens the channel
/// (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)` — the trivial
/// (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`, the trivial
/// params, ADR-001), drives the no-auth handshake and the BIND
/// request in-stream over the channel's `BiStream`, and reads
/// reply#1 (carrying BND.ADDR — the forked client `request()`'s
/// reply#1 (carrying BND.ADDR, the forked client `request()`'s
/// normal path). The returned session owns the channel; the
/// advertised address is [`BindReply1::bind_addr`].
///
/// Error surface: an open failure is [`SocksSessionError::Open`]
/// (the typed ADR-049 reasons; no channel was ever returned); a
/// refused BIND — the command gate's `ConnectionNotAllowed` 0x02
/// or a factory failure's mapped reply#1 code — surfaces the RFC
/// refused BIND (the command gate's `ConnectionNotAllowed` 0x02,
/// or a factory failure's mapped reply#1 code) surfaces the RFC
/// reply code through [`SocksSessionError::Rfc`], and the adopted
/// channel is torn down before the error returns (no phantom
/// channel).
@@ -881,7 +884,7 @@ impl BindSession {
/// The cred-bearing front door's BIND re-origination (the
/// token-carrying twin of [`Self::bind`]): the open rides the
/// payload `auth_token` (CF-005 precedence (1) — the producer's
/// payload `auth_token` (CF-005 precedence (1): the producer's
/// identity provider resolves the door's mapped identity; the
/// BIND command gate reads `socks5:bind` from it, ADR-006), then
/// the same in-stream BIND phase model over the channel's
@@ -915,12 +918,12 @@ impl BindSession {
}
/// Reply#1's view: the advertised BND.ADDR (the app server dials
/// it out-of-band — the RFC's original role for the address) and
/// it out-of-band, the RFC's original role for the address) and
/// the raw pre-reply#2 stream ([`BindReply1::raw_mut`], the
/// front-door bridge seam). A **view**, not a reader (review 002
/// D-1's one-reader decision): the one normative reply#2 reader
/// is [`Self::await_inbound`]; the handle can be re-borrowed
/// freely — it carries no read phase.
/// freely; it carries no read phase.
pub fn reply1(&mut self) -> Option<BindReply1<'_>> {
let inner = self.inner.as_mut()?;
Some(BindReply1 {
@@ -931,22 +934,22 @@ impl BindSession {
/// Await the inbound accept (reply#2) and take the pass-through
/// data plane. The one normative reply#2 reader (review 002 C-1 +
/// D-1): the phase is **take-guarded** — the read consumes the
/// D-1): the phase is **take-guarded**: the read consumes the
/// session's inner stream (the plane owns it; the session stays
/// the teardown owner) — so a second call cannot re-read the
/// the teardown owner), so a second call cannot re-read the
/// live data plane. Reply#2 is hand-read with the forked
/// `read_address` codec — reply#1's codec applied again, no
/// `read_address` codec (reply#1's codec applied again, no
/// second codec (POC #5's `read_reply2` sketch; ADR-011's
/// rationale).
///
/// `Ok(None)` after the plane was taken (the spent-session
/// posture — the doc claim this guard makes true). A failed read
/// posture, the doc claim this guard makes true). A failed read
/// restores the inner (the phase is not consumed on failure): the
/// channel stays owned (reply#1 succeeded — the listener existed;
/// channel stays owned (reply#1 succeeded; the listener existed;
/// reap via `close`/`Drop`), the pipe stays reachable
/// (`split`), and a re-call re-reads against the already-ended
/// session (the producer ends it on every post-reply#1 failure
/// exit — the read EOFs; never a live data-plane re-read).
/// exit, and the read EOFs (never a live data-plane re-read).
pub async fn await_inbound(&mut self) -> Result<Option<BindDataPlane>, SocksSessionError> {
let Some(mut inner) = self.inner.take() else {
return Ok(None);
@@ -961,8 +964,8 @@ impl BindSession {
}
/// The pass-through pipe as split read/write halves (0 B framing
/// — raw bytes to/from the accepted peer). Valid in the reply#1
/// phase (pre-reply#2 bytes ride the same channel — the
/// raw bytes to/from the accepted peer). Valid in the reply#1
/// phase (pre-reply#2 bytes ride the same channel, the
/// front-door bridge seam); post-[`Self::await_inbound`] the
/// plane owns the stream (the take-guard), so the data plane's
/// halves ride `BindDataPlane`/its `into_inner` instead. `None`
@@ -985,9 +988,9 @@ impl BindSession {
/// The adopted channel's teardown (`teardown_channel` returns a
/// producer-side pump-task handle the caller may abort after the
/// drain — the channels-call path's shape). The handle is
/// drain, the channels-call path's shape). The handle is
/// deliberately dropped here: the producer-side pump finishes via
/// EOF propagation (the graceful shape — aborting would cut
/// EOF propagation (the graceful shape, since aborting would cut
/// mid-flight data); `clear_all` aborts instead (the substrate's
/// abrupt path).
fn reap(&mut self) -> bool {
@@ -1000,7 +1003,7 @@ impl BindSession {
impl Drop for BindSession {
/// Sync-reap the adopted channel entry (`teardown_channel` is
/// sync — best-effort; `Drop` cannot await) and drop the pipe
/// sync, best-effort; `Drop` cannot await) and drop the pipe
/// (the mux's implicit-EOF sentinel EOFs the producer's pump).
/// Dropping without `close` never leaks the channel entry.
fn drop(&mut self) {
@@ -1012,14 +1015,15 @@ impl Drop for BindSession {
/// Map a datagram-frame decode error ([`WireError`]) into the RFC
/// vocabulary the session surface carries: the address-encoding
/// refusal is the forked [`AddrError`] class (the same sub-layout
/// the forked codec validates — one vocabulary), the protocol-garbage
/// the forked codec validates, hence one vocabulary), the
/// protocol-garbage
/// shapes (bad ATYP / a short frame / non-UTF-8 domain / a `len == 0`
/// frame) surface as the RFC UDP-header error class
/// ([`UdpHeaderError`]) — the post-reply-garbage semantic, the
/// consumer-side mirror of the producer stage's
/// ([`UdpHeaderError`]), mirroring the post-reply-garbage semantic as
/// the consumer-side mirror of the producer stage's
/// `UndecodableFrame` exit. The oversize family is unreachable on
/// the receive path (the length codec's frame-time bound makes a
/// declared length undecodable only via the garbage shapes) — it
/// declared length undecodable only via the garbage shapes); it maps
/// maps through the UDP-header class too (fail-loud either way).
fn wire_error(err: WireError) -> SocksSessionError {
match err {
@@ -1032,10 +1036,10 @@ fn wire_error(err: WireError) -> SocksSessionError {
}
/// Read reply#2 off the raw stream (the RFC's second reply: VER, REP,
/// RSV, ATYP, addr, port — the POC #5 `read_reply2` sketch): the 4-byte
/// RSV, ATYP, addr, port, the POC #5 `read_reply2` sketch): the 4-byte
/// head, the version pin, the reply code mapped through the forked
/// `ReplyError::from_u8`, then the address via the forked public
/// `read_address` codec — reply#1's codec applied again, **no second
/// `read_address` codec (reply#1's codec applied again, **no second
/// codec** (ADR-011's rationale).
async fn read_reply2<T>(inner: &mut Socks5Stream<T>) -> Result<TargetAddr, SocksSessionError>
where
@@ -1060,16 +1064,16 @@ where
/// The cred-bearing front door's channel surface (the
/// token-carrying twin of a `ChannelClient`'s open path): wraps the
/// client's channel-0 [`CallConnection`] so an open call can carry the
/// payload `auth_token` (CF-005 precedence (1) — the producer-side
/// payload `auth_token` (CF-005 precedence (1): the producer-side
/// dispatch resolves the token through its
/// `ServingConfig.identity_provider` and runs the ACL gate against the
/// resolved identity, ADR-017 §7). The client's call connection is
/// taken once at construction (the client's own calling surface ends
/// — a door client is a door client); the manager stays shared for
/// a door client is a door client); the manager stays shared for
/// `adopt_channel`/teardown.
///
/// The token is the credential table's *identity id* (the resolution
/// seam's vocabulary — the SOCKS password itself never rides it, and
/// seam's vocabulary; the SOCKS password itself never rides it, and
/// the vanilla client never sees the identity). The door's producer
/// resolves `token → Identity` through its
/// `ServingConfig.identity_provider`; the identity's scopes govern the
@@ -1087,7 +1091,7 @@ impl TokenClient {
///
/// [`TokenClientError::CallConnectionTaken`] when the client's
/// call connection was already taken (a client is a single
/// calling surface — the door-only contract; convention 2: no
/// calling surface, the door-only contract; convention 2: no
/// panic path on a double wrap).
pub async fn wrap(client: &ChannelClient) -> Result<Self, TokenClientError> {
let call = client
@@ -1100,7 +1104,7 @@ impl TokenClient {
})
}
/// The manager (adopt/teardown — the token sessions' prologue uses
/// The manager (adopt/teardown; the token sessions' prologue uses
/// it; the sessions' teardown owns it per session).
pub fn manager(&self) -> &ChannelManager {
&self.manager
@@ -1109,9 +1113,9 @@ impl TokenClient {
/// The raw open call with the `auth_token` payload (CF-005
/// precedence (1)): the payload shape mirrors
/// [`ChannelClient::call_open_op`]'s `call.requested` envelope with
/// the token added (ADR-017 §7 — the hub-forwarding path's shape).
/// the token added (ADR-017 §7, the hub-forwarding path's shape).
/// Returns the adopted `channel_id` (the reply's `channel_id`
/// extracted — the adopt is the caller's next step, mirroring
/// extracted; the adopt is the caller's next step, mirroring
/// `open_channel`'s two halves).
pub async fn open_with_token(&self, token: Vec<u8>) -> Result<u32, SocksOpenError> {
let payload = serde_json::json!({
@@ -1133,11 +1137,11 @@ impl TokenClient {
}
/// Why a `TokenClient` refused to construct (the typed construction
/// error — the client's calling surface was already taken;
/// error: the client's calling surface was already taken;
/// convention 2: no panic path).
#[derive(Debug, thiserror::Error)]
pub enum TokenClientError {
#[error("the channel client's call connection was already taken (a client is one calling surface — the door-only contract)")]
#[error("the channel client's call connection was already taken (a client is one calling surface, the door-only contract)")]
CallConnectionTaken,
}
@@ -1171,7 +1175,7 @@ mod tests {
async fn spent_bind_await_inbound_returns_the_spent_shape() {
// The C-1 take-guard's spent arm: a session whose inner is
// gone (taken at the first read / the teardown) returns the
// family's `Ok(None)` — no re-read, no panic.
// family's `Ok(None)`: no re-read, no panic.
let mut session = BindSession {
channel_id: 9,
manager: None,
@@ -1209,7 +1213,7 @@ mod tests {
inner: None,
reply_addr: None,
};
assert!(!session.reap(), "no manager — nothing reaped");
assert!(!session.reap(), "no manager, so nothing was reaped");
assert!(session.split().is_none(), "no pipe after teardown");
}
@@ -1217,7 +1221,7 @@ mod tests {
fn spent_session_into_inner_is_an_err_not_a_panic() {
// The `expect` the review removed (R-2): the spent-session
// path is reachable (a session whose inner is gone), so it
// must be a typed `Err` — no panic path.
// must be a typed `Err`, with no panic path.
let session = Socks5ChannelSession {
channel_id: 9,
manager: None,
@@ -1270,7 +1274,7 @@ mod tests {
pending: Default::default(),
relay_addr: None,
};
assert!(!session.reap(), "no manager — nothing reaped");
assert!(!session.reap(), "no manager, so nothing was reaped");
assert!(session.split().is_none(), "no stream after teardown");
assert!(session.relay_addr().is_none());
}
@@ -1279,7 +1283,8 @@ mod tests {
fn spent_associate_into_inner_is_an_err_not_a_panic() {
// The `expect` the review removed (R-2): the spent-session
// path is reachable (`into_inner` twice), so it must be a
// typed `Err` — no panic path. The teardown owner rides back
// typed `Err`, with no panic path. The teardown owner rides
// back
// to the caller either way.
let session = AssociateSession {
channel_id: 9,
@@ -1301,7 +1306,7 @@ mod tests {
use tokio::io::AsyncWriteExt as _;
// The frame-source side: two frames, the first one split
// across two writes (the 2-byte-prefix chunk shape — the POC
// across two writes (the 2-byte-prefix chunk shape, the POC
// finding's trigger) and the second batched into the same
// read batch. The session's reader state must survive the
// call boundary: the first call returns the first datagram
@@ -1338,7 +1343,7 @@ mod tests {
assert_eq!(src, addr);
assert_eq!(&second[..], b"second");
// EOF: both frames consumed, the queue empty — `None`.
// EOF: both frames consumed, and the queue empty means `None`.
assert!(session.recv_datagram().await.expect("eof").is_none());
}
@@ -1412,7 +1417,7 @@ mod tests {
#[tokio::test]
async fn send_datagram_on_a_spent_stream_is_the_io_arm() {
// The spent-stream `Io` arm (review 002 N-8): a session whose
// stream is gone errors loud through the typed I/O surface —
// stream is gone errors loud through the typed I/O surface,
// the mirror of the covered spent-`recv` arm.
let mut session = AssociateSession {
channel_id: 9,
@@ -1440,9 +1445,9 @@ mod tests {
);
// The spent-session teardown surfaces: `shutdown` on a spent
// session is a no-op reap (no manager — nothing reaped).
// session is a no-op reap (no manager, so nothing was reaped).
let shutdown = session.shutdown().await.expect("spent shutdown");
assert!(!shutdown, "no manager — nothing reaped");
assert!(!shutdown, "no manager, so nothing was reaped");
}
#[tokio::test]
@@ -1458,7 +1463,7 @@ mod tests {
pending: Default::default(),
relay_addr: None,
};
assert!(!session.close().await, "no manager — nothing reaped");
assert!(!session.close().await, "no manager, so nothing was reaped");
}
#[tokio::test]
@@ -1466,8 +1471,9 @@ mod tests {
use tokio::io::AsyncWriteExt as _;
// The mid-batch later-frame `Err` arm (review 002 N-8): the
// first frame of a batch decodes `Ok`, a later one is garbage
// — the batch is discarded with the session (fail-loud, never
// first frame of a batch decodes `Ok`, a later one is
// garbage, and the batch is discarded with the session
// (fail-loud, never
// a partial batch delivered while the session stays open).
let (mut peer_writer, session_reader) = tokio::io::duplex(1024);
let addr = TargetAddr::Ip("10.0.0.1:53".parse().unwrap());
@@ -1496,7 +1502,7 @@ mod tests {
"the decode garbage rides the UDP-header class: {err:?}"
);
// The pending `Ok` was dropped with the session — the queue
// The pending `Ok` was dropped with the session; the queue
// stays empty and the stream is spent for further reads (the
// error ended the caller's loop, the fail-loud posture).
assert!(session.pending.is_empty());
@@ -1507,7 +1513,7 @@ mod tests {
// The `Ok` arm of `AssociateSession::into_inner` (review 002
// N-8): the taken stream pairs with the session that stays the
// teardown owner (the `into_inner_pairs_the_pipe_with_the_
// teardown_owner` shape's ASSOCIATE mirror) — and a second
// teardown_owner` shape's ASSOCIATE mirror), and a second
// take is the spent shape.
let (_write_peer, session_writer) = tokio::io::duplex(64);
let (read_peer, session_reader) = tokio::io::duplex(64);
@@ -1579,8 +1585,9 @@ mod tests {
// The front-door-bridge surface (consumer.md) on a *live*
// stream: the halves borrow the session and the raw framed
// bytes pass through both directions (0 B framing — the door's
// RFC header codec would own the frames at the boundary).
// bytes pass through both directions (0 B framing; the
// door's RFC header codec would own the frames at the
// boundary).
let addr = TargetAddr::Ip("10.0.0.1:53".parse().unwrap());
let framed = wire_frame(&addr, b"split").unwrap().to_vec();
@@ -1610,7 +1617,7 @@ mod tests {
assert_eq!(len, 7 + 5, "one v4-address datagram frame");
drop(read);
// The write half passes through (the bridge seam's shape) —
// The write half passes through (the bridge seam's shape), so
// the peer receives the exact bytes.
write.write_all(&framed).await.unwrap();
write.flush().await.unwrap();
@@ -1646,7 +1653,7 @@ mod tests {
use tokio::io::AsyncWriteExt as _;
// The BIND front-door-bridge surface (consumer.md): the split
// halves in the reply#1 phase — pre-reply#2 bytes ride the
// halves in the reply#1 phase, so pre-reply#2 bytes ride the
// same channel (the doc's claim, exercised on a live stream).
let (mut peer_writer, session_reader) = tokio::io::duplex(1024);
peer_writer.write_all(b"raw").await.unwrap();
@@ -1676,7 +1683,7 @@ mod tests {
// The take-guard is untouched: the reader still reads the
// reply#2 phase (the split halves were a view, not a
// consumer) — the dropped peer EOFs it loud, never the spent
// consumer); the dropped peer EOFs it loud, never the spent
// shape.
let outcome = session.await_inbound().await;
assert!(
@@ -1692,7 +1699,7 @@ mod tests {
// The BIND door's half-close rides the plane's poll_shutdown
// passthrough (and poll_flush rides the write path): write →
// flush → shutdown — the bytes reach the peer and the EOF
// flush → shutdown, so the bytes reach the peer and the EOF
// sentinel follows.
let (_session_writer_unused, session_reader) = tokio::io::duplex(64);
let (mut peer_reader, session_writer) = tokio::io::duplex(64);
@@ -1747,7 +1754,7 @@ mod tests {
}
/// A session over a raw duplex (the reader internals' test
/// harness — the handshake-free path: `skip_auth` bypasses the
/// harness, a handshake-free path: `skip_auth` bypasses the
/// greeting, no reply is expected, the datagram stage rides
/// raw).
fn build_test_session(session_end: tokio::io::DuplexStream) -> AssociateSession {
+136 -120
View File
@@ -1,5 +1,5 @@
//! The consumer's TCP front door (feature `local`, ADR-005/ADR-014;
//! consumer.md §The Front Doors): the ssh `-D` local exposure — a real
//! consumer.md §The Front Doors): the ssh `-D` local exposure, a real
//! `socks5://127.0.0.1:port` endpoint vanilla clients (curl, browsers,
//! tun2proxy) point at. The door is the vanilla↔ACL boundary; the RFC
//! codec lives only here (ADR-003's "codec at front doors only").
@@ -9,13 +9,13 @@
//! - **The NoAuth door** (POC #1's raw-pipe shape): per accepted
//! connection, a literal raw pipe onto a channel `BiStream`
//! (`copy_bidirectional`, **zero protocol translation**). The
//! producer runs the whole RFC conversation through the pipe — the
//! door adds nothing. The loopback-only / wrapper-aware shape.
//! producer runs the whole RFC conversation through the pipe, and
//! the door adds nothing. The loopback-only / wrapper-aware shape.
//! - **The cred-bearing door** (RFC 1929): the door terminates the
//! local RFC conversation itself — the forked
//! local RFC conversation itself, using the forked
//! `accept_password_auth` shape (`start` →
//! `negotiate_auth(&[PasswordAuthentication])` →
//! `read_username_password` → check → `accept`/`reject`) — then
//! `read_username_password` → check → `accept`/`reject`), then
//! maps the accepted credential through the **static config table**
//! (`user:pass →` channel identity, ADR-005) and **re-originates
//! the SOCKS conversation in-band** via the token-carrying consumer
@@ -33,64 +33,66 @@
//! mapping yields an alkcall [`Identity`] (scopes included); the
//! per-credential identity rides the open-op call's `auth_token`
//! payload (CF-005 precedence (1): payload `auth_token` →
//! `identity_provider` resolution — alkcall `dispatch::
//! `identity_provider` resolution, alkcall `dispatch::
//! resolve_identity`, ADR-017 §7), never a per-credential
//! `ChannelClient`. The token is the mapped identity's id — the
//! `ChannelClient`. The token is the mapped identity's id, the
//! resolution seam's vocabulary; the password itself never rides it.
//! The producer resolves it through its `ServingConfig
//! .identity_provider` and the ACL gate reads the resolved scopes
//! exactly as for any per-call opener identity (CF-005/CF-006). The
//! door checks credentials itself — no forked `AuthMethod` impl
//! door checks credentials itself, with no forked `AuthMethod` impl
//! (ADR-005).
//!
//! **The accept path owns the idle defense** (ADR-014): an accepted
//! connection that sends no first byte within the configured window
//! is closed door-side (an accepted socket is not a channel — the cap
//! is closed door-side (an accepted socket is not a channel, so the
//! cap
//! regime does not bound it); plus the max-concurrent-sessions cap
//! (an accept beyond the bound is refused door-side, no channel
//! opened). The two v1 knobs are constructor config.
//!
//! **The BIND door**: the same listener shape; a vanilla BIND
//! conversation rides [`BindSession`] (reply#1's advertised address
//! passes through to the vanilla client unchanged — the conversation
//! is the client's own; the door adds nothing, consumer.md §The BIND
//! door's reply#2 fidelity). The BIND door rides the same door
//! variant split (NoAuth raw-pipe vs cred-bearing re-origination).
//! passes through to the vanilla client unchanged, since the
//! conversation is the client's own; the door adds nothing,
//! consumer.md §The BIND door's reply#2 fidelity). The BIND door rides
//! the same door variant split (NoAuth raw-pipe vs cred-bearing
//! re-origination).
//!
//! Wiring (consumer.md §Wiring): `FrontDoor::builder()
//! .credentials(cred_table).bind("127.0.0.1:1080").build()` →
//! `serve(channel_client)` — the NoAuth door omits `.credentials`.
//! `serve(channel_client)`; the NoAuth door omits `.credentials`.
//! `serve` runs the accept loop; per-connection failures are logged +
//! isolated (the loop continues — the alktunnels per-accept isolation
//! posture).
//! isolated (the loop continues, per the alktunnels per-accept
//! isolation posture).
//!
//! **The UDP door** ([`UdpFrontDoor`], the POC #2 test-8 shape): the
//! ASSOCIATE front door — a real UDP socket (the relay socket; the
//! ASSOCIATE front door, a real UDP socket (the relay socket; the
//! vanilla client `sendto()`s it) bridged onto one in-band
//! association. The RFC UDP-header codec lives ONLY here (ADR-003's
//! codec-at-doors-only): inbound datagrams decode through
//! `parse_udp_request` and re-send in-band as `{addr, data}` frames
//! (`send_datagram`); replies encode through `new_udp_header` and go
//! back to the client's source address. **FRAG ≠ 0 → refuse** (the
//! datagram is dropped — the v1 posture; fragmentation cannot be
//! datagram is dropped, the v1 posture; fragmentation cannot be
//! expressed in-band, and the association stays healthy). The
//! datagram pump is the two-pump shape (one pump per direction,
//! joined — the ADR-050 contract). Zero payload translation. The
//! joined, per the ADR-050 contract). Zero payload translation. The
//! association's lifetime is the channel's (ADR-003): the association
//! ends when the channel ends; the door's relay socket closes with
//! it. The door-side idle bound (ADR-014): no datagrams from the
//! client within the configured window closes the association (a
//! relay socket is not a channel — the cap regime does not bound
//! it). The reply's sentinel BND.ADDR is received and never used —
//! the handle is the channel; the real relay address is the door's
//! relay socket is not a channel; the cap regime does not bound
//! it). The reply's sentinel BND.ADDR is received and never used,
//! since the handle is the channel; the real relay address is the door's
//! own bound socket, which the vanilla client learns from the TCP
//! door's ASSOCIATE reply (the assembly layer wires the two; the UDP
//! door serves the datagram plane alone). The bind address is
//! constructor config (consumer.md: "the UDP door's bind address is
//! assembly-layer configuration"). Surface shape (recorded per the
//! task spec): a **sibling** `UdpFrontDoor` with its own builder —
//! the UDP door shares no accept loop with the TCP door (UDP has no
//! connections; one relay socket = one association) and the shared
//! task spec): a **sibling** `UdpFrontDoor` with its own builder; the
//! UDP door shares no accept loop with the TCP door (UDP has no
//! connections; one relay socket = one association), and the shared
//! shape would have forced a mode split on every knob.
use std::collections::HashMap;
@@ -114,7 +116,7 @@ use crate::wire::{DatagramReader, WireError};
use bytes::Bytes;
/// One static credential-table entry (ADR-005): `user:pass →` channel
/// identity. The table is the door's credential→identity mapping —
/// identity. The table is the door's credential→identity mapping:
/// static, declarative, assembly-layer.
#[derive(Clone, Debug)]
pub struct DoorCredential {
@@ -123,17 +125,17 @@ pub struct DoorCredential {
/// The RFC 1929 password the vanilla client presents.
pub password: String,
/// The channel identity the accepted credential maps to (scopes
/// included — the open gate and the BIND command gate read them
/// included; the open gate and the BIND command gate read them
/// producer-side). The identity's `id` doubles as the open-op
/// `auth_token` (the resolution seam's vocabulary — the password
/// `auth_token` (the resolution seam's vocabulary: the password
/// never rides it).
pub identity: Identity,
}
/// The credential→identity static config table (ADR-005): exact
/// `username`/`password` lookup. A miss rejects the RFC 1929
/// conversation door-side (no channel opened — the ACL boundary held;
/// the vanilla client got the RFC 1929 rejection).
/// conversation door-side (no channel opened, so the ACL boundary is
/// held; the vanilla client got the RFC 1929 rejection).
#[derive(Clone, Debug, Default)]
pub struct CredentialTable {
entries: HashMap<(String, String), Identity>,
@@ -169,7 +171,7 @@ impl CredentialTable {
}
/// Why one accepted connection's serving task ended (the door's
/// per-connection teardown diagnostic — logged + isolated).
/// per-connection teardown diagnostic, logged + isolated).
#[derive(Debug)]
enum DoorExit {
/// The vanilla client went away (clean end, both variants).
@@ -177,7 +179,7 @@ enum DoorExit {
/// The data plane (local pipe ↔ in-band session) ended.
PlaneEnded,
/// The local RFC half failed (a rejected credential, a malformed
/// conversation, protocol garbage) — the vanilla client's fault.
/// conversation, protocol garbage); the vanilla client's fault.
LocalRfc(SocksError),
/// The in-band open failed (the producer refused the mapped
/// identity, the target dial refused, the channel open errored).
@@ -245,10 +247,10 @@ impl FrontDoorBuilder {
self
}
/// The local bind address — the door's explicit bind decision
/// The local bind address, the door's explicit bind decision
/// (the never-binds posture: the door is the assembly layer's
/// spelled-out choice; loopback is the documented posture).
/// Required: `.build()` is a `Result` — omitting it is the
/// Required: `.build()` is a `Result`; omitting it is the
/// builder's typed construction error (convention 2: no panic
/// path).
pub fn bind(mut self, addr: impl Into<SocketAddr>) -> Self {
@@ -259,7 +261,7 @@ impl FrontDoorBuilder {
/// The idle-first-byte window (ADR-014's accept-path defense
/// knob 1): an accepted connection that sends no first byte
/// within this window is closed door-side. The window covers the
/// first byte of the local conversation — the NoAuth door's
/// first byte of the local conversation: the NoAuth door's
/// greeting bytes (read producer-side through the pipe) and the
/// cred-bearing door's greeting (read by the door's own RFC 1929
/// half).
@@ -269,11 +271,11 @@ impl FrontDoorBuilder {
}
/// The max-concurrent-*connections* cap (ADR-014's accept-path
/// defense knob 2; review 003 N-3's naming — the mechanism bounds
/// accepted connections, including ones past the idle gate that
/// are merely slow readers, not active RFC sessions): an accept
/// beyond the bound is refused door-side — the socket is closed
/// immediately, no channel opened.
/// defense knob 2; review 003 N-3's naming, for the mechanism
/// bounds accepted connections, including ones past the idle gate
/// that are merely slow readers, not active RFC sessions): an
/// accept beyond the bound is refused door-side; the socket is
/// closed immediately, with no channel opened.
pub fn max_sessions(mut self, cap: usize) -> Self {
self.max_sessions = Some(cap);
self
@@ -291,20 +293,22 @@ impl FrontDoorBuilder {
}
/// Why a door builder refused to construct (the builder's typed
/// construction error — a missing required field; convention 2: no
/// construction error, i.e. a missing required field; convention 2:
/// no
/// panic path on an omitted `.bind`).
#[derive(Debug, thiserror::Error)]
pub enum DoorBuildError {
#[error("the door requires a bind address (.bind) — the bind decision is never implied")]
#[error("the door requires a bind address (.bind); the bind decision is never implied")]
MissingBindAddr,
}
impl FrontDoor {
/// Bind the configured address and run the accept loop
/// (consumer.md §Wiring: `door.serve(channel_client)`). Per-
/// connection failures are logged + isolated — the loop continues
/// (the alktunnels per-accept isolation posture). Runs until the
/// listener fails fatally; the caller owns the task's lifetime.
/// connection failures are logged + isolated, and the loop
/// continues (the alktunnels per-accept isolation posture). Runs
/// until the listener fails fatally; the caller owns the task's
/// lifetime.
pub async fn serve(self, client: Arc<ChannelClient>) -> std::io::Result<()> {
let listener = TcpListener::bind(self.bind_addr).await?;
tracing::info!(bind = %self.bind_addr, "front door: listening");
@@ -312,7 +316,7 @@ impl FrontDoor {
}
/// Run the accept loop over an existing listener (the
/// test/assembly seam — the same accept loop without the bind).
/// test/assembly seam, the same accept loop without the bind).
pub async fn serve_on(
self,
listener: TcpListener,
@@ -327,7 +331,7 @@ impl FrontDoor {
// transport, one call connection, many per-credential
// identities riding the open-op `auth_token` payloads). The
// NoAuth door keeps the client's own calling surface
// (`open_channel` — the raw-pipe shape never carries a
// (`open_channel`; the raw-pipe shape never carries a
// token).
let token_client = match credentials {
Some(_) => Some(Arc::new(
@@ -348,11 +352,11 @@ impl FrontDoor {
};
// The session cap (ADR-014 knob 2): refuse beyond the
// bound — `try_acquire` keeps the accept loop free (a
// bound; `try_acquire` keeps the accept loop free (a
// permit returns when a serving task ends and drops its
// guard).
let Ok(permit) = live.clone().try_acquire_owned() else {
tracing::warn!(peer = %peer, "front door: session cap reached — refusing");
tracing::warn!(peer = %peer, "front door: session cap reached; refusing");
drop(stream);
continue;
};
@@ -378,9 +382,10 @@ impl FrontDoor {
}
/// Serve one accepted connection through the door variant split: the
/// idle-first-byte gate first (ADR-014 knob 1 — the door's accept
/// path owns the defense; an accepted socket is not a channel), then
/// the variant.
/// idle-first-byte gate first (ADR-014 knob 1, since the door's
/// accept
/// path owns the defense, and an accepted socket is not a channel),
/// then the variant.
async fn serve_connection(
stream: TcpStream,
peer: SocketAddr,
@@ -392,7 +397,8 @@ async fn serve_connection(
// The idle-first-byte defense: an accepted connection that sends
// no first byte within the window is closed door-side (ADR-014's
// slowloris sliver). The peek waits for readability without
// consuming — the variant's own protocol read picks the byte up.
// consuming, and the variant's own protocol read picks the byte
// up.
let mut first = [0u8; 1];
match tokio::time::timeout(idle, stream.peek(&mut first)).await {
Ok(Ok(_)) => {}
@@ -404,14 +410,14 @@ async fn serve_connection(
}
match (credentials, token_client) {
// The NoAuth door: the literal raw pipe (POC #1's shape —
// The NoAuth door: the literal raw pipe (POC #1's shape, with
// zero protocol translation).
(None, _) => raw_pipe(stream, client).await,
// The cred-bearing door: RFC 1929 terminates locally,
// re-origination rides the token sessions.
(Some(table), Some(tokens)) => cred_bearing(stream, client, table, tokens).await,
// Unreachable by construction (`serve_on` builds the pair) —
// the arm exists to keep the match exhaustive without a
// Unreachable by construction (`serve_on` builds the pair),
// so the arm exists to keep the match exhaustive without a
// panic path (convention 2).
(Some(_), None) => {
DoorExit::Open("the cred-bearing door's token surface was not built".to_string())
@@ -421,8 +427,8 @@ async fn serve_connection(
/// The NoAuth door's raw pipe (POC #1's `front_door_raw_pipe_vanilla_
/// client` shape): open a channel, then `copy_bidirectional` the
/// accepted socket onto the channel `BiStream` — the producer runs
/// the whole RFC conversation through the pipe; the door adds
/// accepted socket onto the channel `BiStream`, so the producer
/// runs the whole RFC conversation through the pipe; the door adds
/// nothing.
async fn raw_pipe(mut stream: TcpStream, client: &Arc<ChannelClient>) -> DoorExit {
let (_channel_id, send, recv) = match client
@@ -451,8 +457,8 @@ async fn raw_pipe(mut stream: TcpStream, client: &Arc<ChannelClient>) -> DoorExi
/// The door never pipes raw post-auth: the vanilla client's command
/// bytes were consumed by the door's own RFC half; the in-band path
/// re-speaks them from the parsed target. The password never crosses
/// the channel as a SOCKS password (the in-band handshake is NoAuth —
/// the mapped identity is the authorization); the channel identity
/// the channel as a SOCKS password (the in-band handshake is NoAuth,
/// so the mapped identity is the authorization); the channel identity
/// never reaches the vanilla client.
async fn cred_bearing(
mut stream: TcpStream,
@@ -469,7 +475,7 @@ async fn cred_bearing(
// The in-band re-origination: the per-credential identity rides
// the open-op call's `auth_token` payload (CF-005 precedence
// (1)). The token is the mapped identity's id — the resolution
// (1)). The token is the mapped identity's id, the resolution
// seam's vocabulary; the password itself never rides it.
let token = identity.id.as_bytes().to_vec();
@@ -486,7 +492,7 @@ async fn cred_bearing(
Err(err) => {
// The in-band refusal's mapped code is re-encoded to
// the vanilla client (the RFC codec at the door
// boundary — ADR-003): a dial refusal is reply#1's
// boundary, ADR-003): a dial refusal is reply#1's
// code, never a bare EOF.
write_refusal_reply(&mut stream, &err).await;
return DoorExit::Open(err.to_string());
@@ -508,8 +514,9 @@ async fn cred_bearing(
pipe_bind_plane(stream, session).await
}
// UDP ASSOCIATE through the TCP door is the UDP door's shape
// (the datagram plane's RFC codec boundary — ADR-003), not
// this one's: refuse RFC-faithfully — the vanilla client gets
// (the datagram plane's RFC codec boundary, ADR-003), not
// this one's: refuse RFC-faithfully, so the vanilla client
// gets
// the refused reply through the door's own codec (no phantom
// data plane, no in-band session opened).
Socks5Command::UDPAssociate => {
@@ -524,7 +531,7 @@ async fn cred_bearing(
}
}
/// The door's local RFC 1929 half (the forked surface — ADR-005's
/// The door's local RFC 1929 half (the forked surface, ADR-005's
/// accept_password_auth shape): `negotiate_auth(&[
/// PasswordAuthentication])` → `read_username_password` → check
/// through the static table → `accept`/`reject`, then
@@ -538,7 +545,8 @@ async fn local_rfc1929_half(
) -> Result<(Socks5Command, TargetAddr, Identity), DoorExit> {
// The check closure is sync (`FnMut(String, String) -> bool` over
// the forked `CheckResult`'s `bool` impl); the mapped identity is
// recorded through a sync witness (a `std` mutex — the closure
// recorded through a sync witness (a `std` mutex, since the
// closure
// runs once, synchronously).
let found: Arc<std::sync::Mutex<Option<Identity>>> = Arc::new(std::sync::Mutex::new(None));
let found_for_check = Arc::clone(&found);
@@ -547,7 +555,7 @@ async fn local_rfc1929_half(
stream,
|user, pass| {
let mapped = table.lookup(&user, &pass).cloned();
// A poisoned lock would mean a panic mid-check — the
// A poisoned lock would mean a panic mid-check; the
// `unwrap_or_else(into_inner)` convention keeps the door
// alive (AGENTS.md convention 2).
let mut slot = found_for_check.lock().unwrap_or_else(|e| e.into_inner());
@@ -573,22 +581,23 @@ async fn local_rfc1929_half(
}
/// Pipe the re-originated CONNECT session: the vanilla reply (the
/// in-band reply's BND.ADDR re-encoded — the RFC codec at the door
/// in-band reply's BND.ADDR re-encoded (the RFC codec at the door
/// boundary only, ADR-003), then the data plane ↔ the accepted socket.
/// The door already consumed the vanilla client's greeting + command
/// bytes; the post-auth bytes ride the session's data plane raw from
/// here. The pump shape is the taken inner stream
/// (`Socks5Stream<BiStream>` is itself `AsyncRead + AsyncWrite` — the
/// (`Socks5Stream<BiStream>` is itself `AsyncRead + AsyncWrite`, the
/// forked client type's intended use) against the local socket via
/// `copy_bidirectional`; the session stays the channel-teardown owner
/// (the `into_inner` pairing; dropped at the end — the EOF sentinel
/// (the `into_inner` pairing; dropped at the end, so the EOF
/// sentinel
/// reaches the producer's pump).
async fn pipe_connect_plane(
mut stream: TcpStream,
session: crate::consumer::Socks5ChannelSession,
) -> DoorExit {
// The vanilla CONNECT reply: the in-band reply's BND.ADDR
// re-encoded (the vanilla client is waiting for it — the door's
// re-encoded (the vanilla client is waiting for it; the door's
// RFC half consumed its local bytes; the in-band conversation is
// fresh and re-originated). The sentinel address is the
// RFC-compatibility surface the producer already chose; the door
@@ -621,7 +630,7 @@ async fn pipe_connect_plane(
/// Pipe the re-originated BIND session (the BIND door, cred-bearing
/// variant): reply#1's advertised address is re-encoded to the
/// vanilla client from the session's captured `bind_addr` (the
/// conversation is the vanilla client's own — the door's RFC half
/// conversation is the vanilla client's own, and the door's RFC half
/// consumed the local greeting + command bytes, and the in-band
/// session's reply#1 address passes through re-encoded; consumer.md
/// §The BIND door's reply#2 fidelity). The quiet wait rides
@@ -633,7 +642,8 @@ async fn pipe_bind_plane(
mut session: crate::consumer::BindSession,
) -> DoorExit {
// Reply#1 (the vanilla form): the in-band reply's BND.ADDR
// re-encoded — the advertised address passes through unchanged.
// re-encoded, and the advertised address passes through
// unchanged.
let bind_addr = session.reply1().map(|reply1| reply1.bind_addr.clone());
let Some(bind_addr) = bind_addr else {
session.close().await;
@@ -651,7 +661,7 @@ async fn pipe_bind_plane(
/// Reply#1's vanilla wire form, re-encoded from the in-band reply's
/// advertised address: an IP BND.ADDR rides the codec (`new_reply`);
/// a Domain BND.ADDR is the hand-written domain frame (the forked
/// codec's `new_reply` takes a `SocketAddr` only — the domain form
/// codec's `new_reply` takes a `SocketAddr` only; the domain form
/// has no codec path, so the frame is assembled here; the byte shape
/// is pinned by the unit table, review 003 N-8).
fn bind_reply1_wire(bind_addr: &TargetAddr) -> Vec<u8> {
@@ -673,10 +683,10 @@ fn bind_reply1_wire(bind_addr: &TargetAddr) -> Vec<u8> {
}
/// The BIND door's quiet wait + reply#2 + the data plane: await the
/// inbound accept through the session (`await_inbound` — the one
/// inbound accept through the session (`await_inbound`, the one
/// normative reply#2 reader), re-encode reply#2's peer to the vanilla
/// client, then pipe the pass-through plane raw (the plane owns the
/// in-band stream — `BindDataPlane` impls `AsyncRead + AsyncWrite`,
/// in-band stream: `BindDataPlane` impls `AsyncRead + AsyncWrite` for
/// the pass-through to the accepted peer; the session stays the
/// teardown owner, dropped at the end).
async fn bind_quiet_and_pipe(
@@ -688,9 +698,9 @@ async fn bind_quiet_and_pipe(
Ok(None) => return DoorExit::PlaneEnded,
// An accept-stage refusal (the mapped reply#2 code): the
// vanilla client gets that code re-encoded door-side (the RFC
// codec at the boundary — never a bare EOF), then the
// connection closes — the conversation ends where the in-band
// one did.
// codec at the boundary, never a bare EOF), then the
// connection closes, and the conversation ends where the
// in-band one did.
Err(err) => {
write_refusal_reply(&mut stream, &err).await;
session.close().await;
@@ -717,7 +727,7 @@ async fn bind_quiet_and_pipe(
}
/// Write one vanilla reply frame (the door's RFC codec at the
/// boundary — ADR-003): write + flush; a failed write is the client
/// boundary, ADR-003): write + flush; a failed write is the client
/// gone.
async fn write_vanilla_reply(stream: &mut TcpStream, reply: &[u8]) -> std::io::Result<()> {
stream.write_all(reply).await?;
@@ -727,7 +737,7 @@ async fn write_vanilla_reply(stream: &mut TcpStream, reply: &[u8]) -> std::io::R
/// Re-encode an in-band session error's refusal to the vanilla client
/// (the door's RFC codec at the boundary): an error carrying an RFC
/// reply code gets that code as the command's reply (`Succeeded`-form
/// encoding with the error's code + the placeholder BND.ADDR — a
/// encoding with the error's code + the placeholder BND.ADDR, since a
/// refused reply carries a zero address per the RFC); any other error
/// shape (open/transport/I-O) stays a bare close (nothing to encode).
/// Best-effort: a failed write means the client is gone either way.
@@ -741,7 +751,7 @@ async fn write_refusal_reply(stream: &mut TcpStream, err: &SocksSessionError) {
}
/// The UDP front door (feature `local`, the POC #2 test-8 shape): the
/// ASSOCIATE door — a real UDP relay socket bridged onto one in-band
/// ASSOCIATE door: a real UDP relay socket bridged onto one in-band
/// association. See the module docs for the shape and the codec
/// boundary (ADR-003).
pub struct UdpFrontDoor {
@@ -753,7 +763,7 @@ pub struct UdpFrontDoor {
/// door's [`DoorExit`], for the datagram plane).
#[derive(Debug)]
enum UdpDoorExit {
/// The in-band association ended (channel EOF — the association
/// The in-band association ended (channel EOF; the association
/// lifetime is the channel's, ADR-003).
AssociationEnded,
/// The client's idle bound fired (ADR-014's door-side defense).
@@ -783,7 +793,7 @@ impl UdpFrontDoor {
}
/// The builder for [`UdpFrontDoor`]. The bind address is required
/// (the explicit bind decision — never implied); the idle bound is
/// (the explicit bind decision, never implied); the idle bound is
/// the ADR-014 door-side defense knob.
#[derive(Default)]
pub struct UdpFrontDoorBuilder {
@@ -792,7 +802,7 @@ pub struct UdpFrontDoorBuilder {
}
impl UdpFrontDoorBuilder {
/// The local bind address — the relay socket's explicit bind
/// The local bind address, the relay socket's explicit bind
/// decision (loopback is the documented posture).
pub fn bind(mut self, addr: impl Into<SocketAddr>) -> Self {
self.bind_addr = Some(addr.into());
@@ -820,7 +830,7 @@ impl UdpFrontDoor {
/// Bind the configured relay address and serve the association
/// over it. Runs until the association ends (the in-band channel
/// EOF, the client idle bound, or a fatal I/O failure); the relay
/// socket closes on return — the association lifetime is the
/// socket closes on return, so the association lifetime is the
/// channel's, and the door's socket closes with it.
pub async fn serve(self, client: Arc<ChannelClient>) -> std::io::Result<()> {
let socket = UdpSocket::bind(self.bind_addr).await?;
@@ -829,17 +839,17 @@ impl UdpFrontDoor {
}
/// Serve the association over an existing relay socket (the
/// test/assembly seam — the same pump without the bind).
/// test/assembly seam, the same pump without the bind).
///
/// Posture (the peer-pin shape, OQ-SK2-09): the client's source
/// **IP** is learned from the first inbound datagram and pinned for
/// the association's lifetime; replies go to the **latest-seen
/// port** from that IP (a NAT port rebind under a stable IP keeps
/// receiving). Datagrams from a source IP other than the pinned
/// one are dropped — RFC 1928's MUST ("MUST drop any datagrams
/// one are dropped, per RFC 1928's MUST ("MUST drop any datagrams
/// arriving from any source IP address other than the one recorded
/// for the particular association"). The residual (a full client
/// IP change mid-association — a NAT *address* rebind) stops
/// IP change mid-association (a NAT *address* rebind) stops
/// receiving replies by design: the RFC's expected-peer posture,
/// revisitable per OQ-SK2-09. The loopback bind is the documented
/// posture; a wildcard-bind deployment accepts the same-IP
@@ -853,8 +863,8 @@ impl UdpFrontDoor {
let mut association = AssociateSession::associate(client.as_ref())
.await
.map_err(|err| std::io::Error::other(err.to_string()))?;
// The reply's sentinel BND.ADDR is received and never used —
// the handle is the channel (ADR-003). The real relay address
// The reply's sentinel BND.ADDR is received and never used,
// since the handle is the channel (ADR-003). The real relay address
// is this door's own bound socket; the vanilla client learns
// it from the TCP door's ASSOCIATE reply (assembly-layer
// wiring).
@@ -862,17 +872,18 @@ impl UdpFrontDoor {
// The datagram pump rides the association's split stream
// halves: one pump owns the in-band write half (socket→
// channel), the other the read half (channel→socket) — no
// shared association lock (a mutex over the whole session
// would deadlock: the channel read parks holding it while the
// socket side's send needs it). The pump is inlined (the
// channel), the other the read half (channel→socket), so no
// shared association lock holds (a mutex over the whole
// session would deadlock: the channel read parks holding it
// while the socket side's send needs it). The pump is inlined
// (the
// halves borrow the association; the borrow ends before the
// teardown below).
let Some((mut channel_read, mut channel_write)) = association.split() else {
return Ok(());
};
let socket = Arc::new(socket);
// The peer pin (OQ-SK2-09): the client's source IP — learned
// The peer pin (OQ-SK2-09): the client's source IP, learned
// from the first inbound datagram and pinned; the reply port
// refreshes to the latest-seen source port from that IP (the
// NAT-port-rebind shape). A source IP mismatch is the RFC's
@@ -883,9 +894,9 @@ impl UdpFrontDoor {
Arc::new(std::sync::RwLock::new(None));
// socket→channel: decode the RFC header (the codec boundary),
// refuse FRAG ≠ 0 (drop the datagram — the v1 posture), pin
// refuse FRAG ≠ 0 (drop the datagram, the v1 posture), pin
// the peer IP + enforce the RFC's same-IP drop rule (the pin
// reacts to *parsed* datagrams only — protocol garbage can
// reacts to *parsed* datagrams only, so protocol garbage can
// never move it), refresh the reply port, send `{addr, data}`
// in-band. Each datagram resets the idle window.
let s2c = {
@@ -944,8 +955,8 @@ impl UdpFrontDoor {
// latest-seen reply port. The association's end (the channel
// EOF) is this pump's clean exit. The decode state (the
// incremental reader + the batched-datagram pending queue)
// persists across pump iterations — the bridge owns it for the
// association's lifetime.
// persists across pump iterations, so the bridge owns it for
// the association's lifetime.
let c2s = {
let socket = Arc::clone(&socket);
let client_peer = Arc::clone(&client_peer);
@@ -984,7 +995,8 @@ impl UdpFrontDoor {
let _ = association.shutdown().await;
// A socket I/O failure is the door's fatal outcome (surfaced to
// the caller); the idle bound, the association's end, and a
// session error are the association's own ends — a clean return.
// session error are the association's own ends, so the return
// is clean.
match exit {
UdpDoorExit::Io(err) => Err(err),
_ => Ok(()),
@@ -992,7 +1004,7 @@ impl UdpFrontDoor {
}
}
/// The in-band frame send (the write half's shape — frame once with
/// The in-band frame send (the write half's shape: frame once with
/// the wire codec, write the framed bytes to the channel write half).
async fn send_inband(
write: &mut tokio::io::WriteHalf<&mut AssociationStream>,
@@ -1006,11 +1018,12 @@ async fn send_inband(
}
/// The in-band frame read (the read half's shape): a fresh-looking
/// async read whose decoder state **persists across calls** — the
/// async read whose decoder state **persists across calls**: the
/// bridge's [`InbandBridge`] owns the incremental `DatagramReader`
/// and the batched-datagram pending queue across calls (a datagram
/// split across channel chunks reassembles across reads; frames
/// decoded beyond the one returned queue for later reads — the POC
/// decoded beyond the one returned queue for later reads; this is
/// the POC
/// #2 reader-state finding, the same shape
/// `AssociateSession::recv_datagram` owns).
struct InbandBridge {
@@ -1027,7 +1040,7 @@ impl InbandBridge {
}
/// The next in-band datagram: the pending queue first (a chunk
/// may have batched several frames — drain in order), then an
/// may have batched several frames, drained in order), then an
/// incremental decode off the read half. `Ok(None)` on channel
/// EOF (the association's end); fail-loud mid-frame (never a
/// silent partial datagram).
@@ -1071,7 +1084,7 @@ impl InbandBridge {
}
/// The channel→socket pump's decode call (the bridge the pump loop
/// drives — the decode state lives in the bridge, not per call).
/// drives, so the decode state lives in the bridge, not per call).
async fn recv_inband<R>(
bridge: &mut InbandBridge,
read: &mut R,
@@ -1083,7 +1096,8 @@ where
}
/// Map a wire-decode error into the RFC vocabulary (the consumer
/// module's mapping, re-declared here — the private consumer helper is
/// module's mapping, re-declared here, since the private consumer
/// helper is
/// not part of the public surface).
fn wire_error(err: WireError) -> SocksSessionError {
match err {
@@ -1095,7 +1109,7 @@ fn wire_error(err: WireError) -> SocksSessionError {
}
}
/// The association's framed stream type (the split halves' target —
/// The association's framed stream type (the split halves' target,
/// the forked client type over the channel's `BiStream`).
type AssociationStream = crate::rfc1928::client::Socks5Stream<alkcall::core::types::BiStream>;
@@ -1108,7 +1122,7 @@ mod tests {
/// Drive one pump-shaped decode call: feed `bytes` from the peer
/// side, then decode (the recv parks until a frame completes or
/// the stream ends — the pump loop's shape).
/// the stream ends, matching the pump loop's shape).
async fn feed_then_recv<R>(
bridge: &mut InbandBridge,
read: &mut R,
@@ -1129,7 +1143,7 @@ mod tests {
async fn batched_frames_queue_not_drop() {
// Two frames batched into one feed: the first returns, the
// second queues and drains on the next call (the per-call
// reader dropped it — review 003 R-1; the pending queue is
// reader dropped it, review 003 R-1; the pending queue is
// the state that persists across the call boundary).
let (mut writer, reader) = tokio::io::duplex(1024);
let (mut channel_read, _channel_write) = tokio::io::split(reader);
@@ -1153,7 +1167,8 @@ mod tests {
#[tokio::test]
async fn eof_mid_frame_fails_loud_not_silent() {
// A frame in flight when the stream ends: the decode errors
// loud (never a silent partial datagram) — the same shape
// loud (never a silent partial datagram), matching the same
// shape
// AssociateSession::recv_datagram pins.
let (mut writer, reader) = tokio::io::duplex(1024);
let (mut channel_read, _channel_write) = tokio::io::split(reader);
@@ -1183,7 +1198,7 @@ mod tests {
async fn partial_frame_state_persists_across_calls() {
// One chunk carries frame 1 complete + the first bytes of
// frame 2: call 1 returns frame 1 (frame 2's bytes stay in
// the bridge's reader), call 2 — after the stream ends —
// the bridge's reader), and call 2 (after the stream ends)
// fails loud on the surviving mid-frame state (a per-call
// reader would report a clean Ok(None) EOF here).
let (mut writer, reader) = tokio::io::duplex(1024);
@@ -1222,8 +1237,8 @@ mod refusal_and_wire_unit_tests {
use alkcall::protocol::wire::CallError;
use tokio::io::AsyncReadExt;
/// A connected TcpStream pair (the loopback listener shape —
/// `TcpStream` has no `pair` constructor; the listener dial is
/// A connected TcpStream pair (the loopback listener shape;
/// `TcpStream` has no `pair` constructor, so the listener dial is
/// the portable way).
async fn socket_pair() -> (tokio::net::TcpStream, tokio::net::TcpStream) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -1238,7 +1253,7 @@ mod refusal_and_wire_unit_tests {
/// The BIND reply#1 wire table (review 003 N-8: the Domain arm was
/// hand-written wire code with no test): the IP form decodes
/// through the forked codec (`new_reply`'s own table covers it);
/// the Domain form's bytes are pinned exactly — VER, REP, RSV,
/// the Domain form's bytes are pinned exactly (VER, REP, RSV,
/// ATYP=domain, len, name, port.
#[test]
fn bind_reply1_wire_forms() {
@@ -1267,7 +1282,7 @@ mod refusal_and_wire_unit_tests {
}
/// `write_refusal_reply`'s non-reply-code arm (review 003 N-8): an
/// open/transport-shaped session error stays a bare close — no
/// open/transport-shaped session error stays a bare close, so no
/// bytes are written to the vanilla client.
#[tokio::test]
async fn write_refusal_reply_skips_non_reply_code_shapes() {
@@ -1278,8 +1293,8 @@ mod refusal_and_wire_unit_tests {
}));
write_refusal_reply(&mut server, &open_err).await;
// Nothing written: the client's read finds no bytes (the bare
// close is the door's caller's act — the helper itself is
// silent). A short window proves no reply bytes exist.
// close is the door's caller's act, since the helper itself
// is silent). A short window proves no reply bytes exist.
let mut probe = [0u8; 16];
let n = tokio::time::timeout(
std::time::Duration::from_millis(200),
@@ -1323,7 +1338,8 @@ mod bridge_later_batch_tests {
use tokio::io::AsyncWriteExt;
/// A decode failure in a *later* batch frame (review 003 N-8's
/// residual: the unit tests pinned the happy shapes — a garbage
/// residual: the unit tests pinned the happy shapes, where a
/// garbage
/// frame following a good one in the same chunk surfaces the
/// error, the good frame is NOT silently delivered first).
#[tokio::test]
@@ -1333,7 +1349,7 @@ mod bridge_later_batch_tests {
let addr = TargetAddr::Ip("10.0.0.7:53".parse().expect("addr"));
let mut wire = crate::wire::frame(&addr, b"good").unwrap().to_vec();
// A later frame whose length field promises more bytes than
// the chunk carries, then the stream ends — the reader's
// the chunk carries, then the stream ends, so the reader's
// state machine fails loud on the truncated later frame.
wire.extend_from_slice(&[0xff, 0xff]);
wire.extend_from_slice(b"short");
+48 -47
View File
@@ -1,12 +1,13 @@
//! The three I/O-plane seam types (ADR-004) — the dial seam (CONNECT),
//! the egress seam (UDP ASSOCIATE), the accept seam (TCP BIND) — as
//! function-shaped callbacks with their error vocabularies. These types
//! are the inversion point (AGENTS.md convention 7): the protocol layer
//! The three I/O-plane seam types (ADR-004), each a function-shaped
//! callback with its error vocabulary: the dial seam (CONNECT), the
//! egress seam (UDP ASSOCIATE), and the accept seam (TCP BIND). These
//! types are the inversion point (AGENTS.md convention 7): the
//! protocol layer
//! never names a substrate type; the feature-gated providers
//! (`local`/`composed`, batch 2) and the test harness construct them.
//!
//! **Function, not trait** (ADR-004): [`DialFn`], [`EgressFactory`],
//! and [`BindAcceptFactory`] are function-shaped callbacks — "produce
//! and [`BindAcceptFactory`] are function-shaped callbacks: "produce
//! boxed halves for a resource" is a function, not a trait (the
//! alktunnels ADR-004 finding, extended to all three SOCKS5 seams by
//! POC #1/#2/#5). [`Egress`] is the one trait: the per-association
@@ -19,29 +20,29 @@
//! - **Ownership rides the handle structurally** ([`TargetHandle`],
//! [`BindTargetHandle`]): the payloads own their halves; any
//! keep-alive state a substrate needs (e.g. a composed variant's
//! channel session) rides inside the halves — a provider-side owner
//! wrapper — so dropping the handle drops everything it dialed or
//! channel session) rides inside the halves (a provider-side owner
//! wrapper), so dropping the handle drops everything it dialed or
//! accepted. No `_` bindings of owning values: the
//! teardown-at-birth discipline, compiler-enforced by the shape.
//! - **The egress speaks inner bytes**: [`Egress::send`] takes the
//! payload only; [`Egress::next_reply`] returns `addr + data` — the
//! payload only; [`Egress::next_reply`] returns `addr + data`; the
//! frame codec lives on the wire side, applied exactly once
//! ([`crate::wire`]).
//! - **The readiness contract** ([`Egress::next_reply`]): `Pending`
//! when no reply is ready — an instantly-ready `Ok(None)` starves a
//! when no reply is ready; an instantly-ready `Ok(None)` starves a
//! biased `select!` into livelock (POC #2's structural finding).
//! - **The accept future owns its resources end-to-end**
//! ([`BindAccept`]): the quiet wait's `select!` drop IS the
//! client-gone teardown (ADR-004/ADR-007).
//! - **Refusal is RFC-faithful**: every error type maps to RFC 1928
//! reply codes via `to_reply_error()`; a dial/egress/accept failure
//! is reply-code-shaped (never coerced to a channels-level error —
//! is reply-code-shaped (never coerced to a channels-level error;
//! session-level refusal stays channels-level, target-level refusal
//! is in-stream).
//!
//! The readiness-contract behavioral tests (an instant-`Ok(None)`
//! egress must not starve the datagram stage — including the
//! zero-yield shape, the gate review's R-1,
//! egress must not starve the datagram stage, including the
//! zero-yield shape (the gate review's R-1,
//! tasks/implementation/review-protocol-core.md) live in the producer
//! module's test suite, next to the stage they pin. This module
//! carries the error→reply-code mapping tables, the sync-lift factory
@@ -58,7 +59,7 @@ use tokio::io::{AsyncRead, AsyncWrite};
pub use crate::rfc1928::util::target_addr::TargetAddr;
use crate::rfc1928::ReplyError;
/// The dialed CONNECT target — boxed halves, `+ Sync` per the
/// The dialed CONNECT target: boxed halves, `+ Sync` per the
/// `ChannelPlan` payload bound (ADR-004). Ownership rides the handle
/// structurally: the payloads own their connection halves; keep-alive
/// state a substrate needs must ride inside the halves (a provider-side
@@ -78,16 +79,16 @@ impl std::fmt::Debug for TargetHandle {
/// The dial seam (ADR-004): the substrate dial function, injected at
/// registration/assembly. Called once per CONNECT at the command-read
/// interception point with the RFC 1928 target the client requested
/// (domain form passes through unresolved — ADR-012; resolution is the
/// provider's job). Per-target refusal is the returned
/// [`SocksDialError`] (the producer replies with the mapped RFC code —
/// no phantom data plane); session-level refusal stays channels-level.
/// (domain form passes through unresolved, per ADR-012; resolution is
/// the provider's job). Per-target refusal is the returned
/// [`SocksDialError`] (the producer replies with the mapped RFC code,
/// so no phantom data plane); session-level refusal stays channels-level.
pub type DialFn = Arc<
dyn Fn(TargetAddr) -> BoxFuture<'static, Result<TargetHandle, SocksDialError>> + Send + Sync,
>;
/// A dial failure, mapped to RFC 1928 reply codes by
/// [`SocksDialError::to_reply_error`] (never a generic error —
/// [`SocksDialError::to_reply_error`] (never a generic error;
/// AGENTS.md convention 2's faithful `ReplyError` mapping).
#[derive(Debug, thiserror::Error)]
pub enum SocksDialError {
@@ -109,7 +110,7 @@ impl SocksDialError {
/// `NetworkUnreachable` → `NetworkUnreachable` (0x03),
/// `HostUnreachable` → `HostUnreachable` (0x04), `NotAllowed` →
/// `ConnectionNotAllowed` (0x02), `General` → `GeneralFailure`
/// (0x01) — the POC #1 mapping, table-tested in this module.
/// (0x01); the POC #1 mapping, table-tested in this module.
pub fn to_reply_error(&self) -> ReplyError {
match self {
Self::Refused(_) => ReplyError::ConnectionRefused,
@@ -122,9 +123,9 @@ impl SocksDialError {
}
/// One association's egress failure. Mapped to RFC reply codes only at
/// association setup ([`EgressError::to_reply_error`] — an egress that
/// association setup ([`EgressError::to_reply_error`]; an egress that
/// cannot start refuses the ASSOCIATE pre-reply); per-datagram failures
/// are not per-datagram refusals — the producer stage ends the
/// are not per-datagram refusals, so the producer stage ends the
/// association on an `Err` from the seam (the lossy-datagram posture).
#[derive(Debug, thiserror::Error)]
pub enum EgressError {
@@ -140,7 +141,7 @@ impl EgressError {
/// The RFC 1928 reply code for the ASSOCIATE refusal: `Refused` →
/// `ConnectionRefused` (0x05), `Unreachable` →
/// `NetworkUnreachable` (0x03), `General` → `GeneralFailure`
/// (0x01) — the POC #2 mapping, table-tested in this module.
/// (0x01), the POC #2 mapping, table-tested in this module.
pub fn to_reply_error(&self) -> ReplyError {
match self {
Self::Refused(_) => ReplyError::ConnectionRefused,
@@ -150,26 +151,26 @@ impl EgressError {
}
}
/// The per-association datagram egress — the ASSOCIATE seam's returned
/// The per-association datagram egress, the ASSOCIATE seam's returned
/// object and the module's one trait (ADR-004: the factory is
/// function-shaped; the instance it returns is a trait object,
/// `Box<dyn Egress + Send>`, owned by the producer's datagram stage —
/// `Box<dyn Egress + Send>`, owned by the producer's datagram stage;
/// stage exit drops it, so egress state never outlives the channel).
///
/// The stage never sees which substrate backs the association
/// (convention 7 at the egress layer).
pub trait Egress {
/// Egress one datagram to `addr`. Payload-only: the producer stage
/// decoded the wire frame and hands the bare datagram here — the
/// decoded the wire frame and hands the bare datagram here, and the
/// egress never sees a length prefix (framing is applied exactly
/// once, by the wire layer). A zero-length payload is a legal
/// empty datagram, not a signal of any kind (the F-2 invariant).
fn send(&mut self, addr: TargetAddr, payload: Bytes) -> BoxFuture<'_, Result<(), EgressError>>;
/// Yield the next inbound datagram's *inner* bytes (`addr + data`
/// — the RFC address encoding followed by the payload, exactly
/// Yield the next inbound datagram's *inner* bytes (`addr + data`,
/// the RFC address encoding followed by the payload, exactly
/// [`TargetAddr::to_be_bytes()`] + data). The length prefix is the
/// wire layer's, added by the producer's frame writer — an egress
/// wire layer's, added by the producer's frame writer; an egress
/// that prefixes again is the double-framing bug class POC #2
/// caught.
///
@@ -181,14 +182,14 @@ pub trait Egress {
/// and a hot `Ok(None)` never yields to the channel branch (nor to
/// the runtime's other tasks on a current-thread scheduler).
/// `Ok(None)` is tolerated as a spurious wakeup (the stage
/// re-parks), not as a readiness signal — implement the wait as a
/// re-parks), not as a readiness signal; implement the wait as a
/// real async park. An `Err` ends the association (the stage
/// returns; the egress drops with it).
fn next_reply(&mut self) -> BoxFuture<'_, Result<Option<Bytes>, EgressError>>;
}
/// The egress seam (ADR-004): the per-association egress factory — the
/// [`DialFn`] analogue for the datagram stage, injected at
/// The egress seam (ADR-004): the per-association egress factory (the
/// [`DialFn`] analogue for the datagram stage), injected at
/// registration/assembly. Called once per UDP ASSOCIATE; the stage owns
/// the returned egress; stage exit drops it.
pub type EgressFactory =
@@ -208,16 +209,16 @@ where
})
}
/// The one accepted inbound connection's handle — reply#2's BND.ADDR
/// The one accepted inbound connection's handle, reply#2's BND.ADDR
/// carrier plus the data-plane halves. Ownership rides the handle
/// structurally: the payloads own their accepted halves; keep-alive
/// state a substrate needs (e.g. a composed variant's channel session)
/// rides inside the halves (a provider-side owner wrapper), so the
/// compiler enforces the teardown contract — no `_` bindings of owning
/// values (the third teardown-at-birth witness, ADR-004).
/// compiler enforces the teardown contract, with no `_` bindings of
/// owning values (the third teardown-at-birth witness, ADR-004).
pub struct BindTargetHandle {
/// The accepted connection's peer address (reply#2's BND.ADDR —
/// the RFC's "client IP and port of the incoming connection").
/// The accepted connection's peer address (reply#2's BND.ADDR, the
/// RFC's "client IP and port of the incoming connection").
pub peer: TargetAddr,
pub read: Box<dyn AsyncRead + Send + Sync + Unpin>,
pub write: Box<dyn AsyncWrite + Send + Sync + Unpin>,
@@ -232,14 +233,14 @@ impl std::fmt::Debug for BindTargetHandle {
}
/// One BIND session's accept source. `bind_addr` is the address
/// reply#1 advertises (BND.ADDR — the real bound listener on the local
/// reply#1 advertises (BND.ADDR: the real bound listener on the local
/// variant, the advertised address on the composed one); the accept
/// future is awaited exactly once, in the producer's quiet-wait phase.
///
/// **The accept future owns its resources end-to-end** (ADR-004/
/// ADR-007): whatever the provider opened (the listener, the tunnel)
/// lives inside the future, so the quiet wait's `select!` drop of that
/// future IS the client-gone teardown — the listener closes when the
/// future IS the client-gone teardown; the listener closes when the
/// client vanishes, with no extra teardown path.
pub struct BindAccept {
bind_addr: SocketAddr,
@@ -248,7 +249,7 @@ pub struct BindAccept {
impl BindAccept {
/// Build the session's accept source: `bind_addr` for reply#1, the
/// one-inbound-accept future (owned end-to-end — see the type
/// one-inbound-accept future (owned end-to-end; see the type
/// docs).
pub fn new(
bind_addr: SocketAddr,
@@ -265,7 +266,7 @@ impl BindAccept {
self.bind_addr
}
/// Await the one inbound accept (the quiet wait's accept branch —
/// Await the one inbound accept (the quiet wait's accept branch,
/// exactly once; the future is consumed).
pub async fn accept(self) -> Result<BindTargetHandle, BindAcceptError> {
self.accept.await
@@ -281,7 +282,7 @@ impl std::fmt::Debug for BindAccept {
}
/// Accept-stage failures (post-reply#1): the producer maps them to RFC
/// reply codes for reply#2 — no data plane on refusal.
/// reply codes for reply#2, and no data plane exists on refusal.
#[derive(Debug, thiserror::Error)]
pub enum BindAcceptError {
#[error("accept refused: {0}")]
@@ -296,7 +297,7 @@ impl BindAcceptError {
/// The RFC 1928 reply code for reply#2 on this refusal: `Refused` →
/// `ConnectionRefused` (0x05), `NotAllowed` →
/// `ConnectionNotAllowed` (0x02), `General` → `GeneralFailure`
/// (0x01) — the POC #5 mapping, table-tested in this module.
/// (0x01), the POC #5 mapping, table-tested in this module.
pub fn to_reply_error(&self) -> ReplyError {
match self {
Self::Refused(_) => ReplyError::ConnectionRefused,
@@ -307,7 +308,7 @@ impl BindAcceptError {
}
/// Factory failures (pre-reply#1): the producer maps them to RFC reply
/// codes for reply#1 — no phantom listener.
/// codes for reply#1, with no phantom listener.
#[derive(Debug, thiserror::Error)]
pub enum BindAcceptFactoryError {
#[error("bind refused: {0}")]
@@ -324,8 +325,8 @@ impl BindAcceptFactoryError {
/// The RFC 1928 reply code for reply#1 on this refusal: `Refused` →
/// `ConnectionRefused` (0x05), `NotAllowed` →
/// `ConnectionNotAllowed` (0x02), `Shortage`/`General` →
/// `GeneralFailure` (0x01 — the RFC's catch-all; a resource
/// shortage has no dedicated code) — the POC #5 mapping,
/// `GeneralFailure` (0x01; the RFC's catch-all, since a resource
/// shortage has no dedicated code), the POC #5 mapping,
/// table-tested in this module.
pub fn to_reply_error(&self) -> ReplyError {
match self {
@@ -337,12 +338,12 @@ impl BindAcceptFactoryError {
}
}
/// The accept seam (ADR-004): the per-BIND accept factory — the
/// The accept seam (ADR-004): the per-BIND accept factory, the
/// [`DialFn`]'s mirror, injected at registration/assembly. Called once
/// per TCP BIND with the requested target. The requested port is the
/// only field a real bind can honor (domains are meaningless as bind
/// addresses); the address policy is the factory's (ADR-006/ADR-012:
/// the callbacks refuse — RFC reply codes).
/// the callbacks refuse, with RFC reply codes).
///
/// Failure mapping is two-stage (ADR-007): factory failure
/// ([`BindAcceptFactoryError`]) → reply#1 with the mapped code;
+12 -12
View File
@@ -5,10 +5,10 @@
//! [`SocksServerError`], [`ReplyError`]).
//!
//! Open errors are channels-level: a failed open never yields a
//! phantom channel (the no-phantom-channel property, ADR-049 — the
//! phantom channel (the no-phantom-channel property, ADR-049: the
//! session never exists opener-side). Target-level refusal happens
//! later, in-stream, as an RFC reply code surfaced through
//! [`SocksSessionError::Rfc`] (consumer.md §Error surface) — never as
//! [`SocksSessionError::Rfc`] (consumer.md §Error surface), never as
//! an open failure.
use alkcall::channels::client::ChannelOpenError;
@@ -17,7 +17,7 @@ pub use crate::rfc1928::server::SocksServerError;
pub use crate::rfc1928::{ReplyError, SocksError};
/// The typed open-failure vocabulary (alkcall ADR-049 §4): a failed
/// SOCKS5 channel open. A failure never yields a session — no phantom
/// SOCKS5 channel open. A failure never yields a session: no phantom
/// channel (the channel is torn down accept-side and no `channel_id`
/// was ever returned).
///
@@ -33,7 +33,7 @@ pub use crate::rfc1928::{ReplyError, SocksError};
/// [`ChannelOpenError`].
#[derive(Debug, thiserror::Error)]
pub enum SocksOpenError {
/// The open op failed — the typed [`ChannelOpenError`] carries the
/// The open op failed; the typed [`ChannelOpenError`] carries the
/// wire [`alkcall::protocol::wire::CallError`] verbatim
/// (`channel:open_failed` + `details.reason` per ADR-049 §3 when
/// the establisher refused; `FORBIDDEN`, `INVALID_INPUT`,
@@ -52,7 +52,7 @@ impl SocksOpenError {
/// The typed establishment-failure reason of a
/// `channel:open_failed` error (`dial_failed` / `unknown_resource`
/// / `resource_shortage` / `handler_error` / `timeout` — alkcall
/// / `resource_shortage` / `handler_error` / `timeout`; alkcall
/// ADR-049 §3's vocabulary, parsed). `None` for any other failure
/// shape: the registry's `FORBIDDEN`/`INVALID_INPUT` classes,
/// `channel:too_many_channels`, or local-only failures
@@ -65,7 +65,7 @@ impl SocksOpenError {
/// The typed establishment-reason vocabulary (alkcall ADR-049 §3's
/// reason codes, parsed from the wire string). One-way once consumers
/// branch on it (ADR-049's door-type note) — the string codes are the
/// branch on it (ADR-049's door-type note): the string codes are the
/// wire truth; this enum is the typed view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SocksEstablishmentReason {
@@ -83,7 +83,7 @@ pub enum SocksEstablishmentReason {
impl SocksEstablishmentReason {
/// Parse the wire `details.reason` string (ADR-049 §3's
/// vocabulary). Unknown strings are `None` — a *new* reason code
/// vocabulary). Unknown strings are `None`: a *new* reason code
/// from a newer producer must not silently alias an old variant
/// (the vocabulary is one-way; additive codes need a migration).
pub fn parse(reason: &str) -> Option<Self> {
@@ -117,8 +117,8 @@ impl std::fmt::Display for SocksEstablishmentReason {
/// The consumer session's error surface (consumer.md §Error surface):
/// open failures are channels-level and typed
/// ([`SocksOpenError`]); in-stream RFC failures (a refused target —
/// the reply code) are [`SocksSessionError::Rfc`]; the data-plane
/// ([`SocksOpenError`]); in-stream RFC failures (the refused-target
/// reply code) are [`SocksSessionError::Rfc`]; the data-plane
/// codec failures are [`SocksSessionError::Rfc`]'s inner
/// [`SocksError`] and [`SocksSessionError::Io`] is the plain I/O
/// failure.
@@ -131,7 +131,7 @@ pub enum SocksSessionError {
/// The channel open failed (the session never came to exist).
#[error("socks5 open failed: {0}")]
Open(#[from] SocksOpenError),
/// The in-stream RFC 1928 conversation failed — a refused target
/// The in-stream RFC 1928 conversation failed: a refused target
/// (the reply code, e.g. `ConnectionNotAllowed` 0x02 for a BIND
/// without `socks5:bind`), an auth rejection, a malformed
/// negotiation, or the UDP-frame codec. The RFC-faithful surface:
@@ -180,7 +180,7 @@ mod tests {
#[test]
fn open_error_non_establishment_shapes_are_none() {
// The registry's own gate classes run before the establisher —
// The registry's own gate classes run before the establisher;
// they are NOT establishment reasons (ADR-049 §3).
for code in ["FORBIDDEN", "INVALID_INPUT", "channel:too_many_channels"] {
let err = SocksOpenError::Open(ChannelOpenError::CallFailed {
@@ -215,7 +215,7 @@ mod tests {
// Open plane: a refused session never yields a phantom channel.
let open = SocksSessionError::from(open_failed_with("dial_failed"));
assert!(matches!(open, SocksSessionError::Open(_)));
// In-stream RFC refusal — the reply code rides the session type
// In-stream RFC refusal: the reply code rides the session type
// (consumer.md §Error surface), never an open failure.
let refused =
SocksSessionError::from(SocksError::ReplyError(ReplyError::ConnectionNotAllowed));
+15 -15
View File
@@ -1,4 +1,4 @@
//! alksocks — SOCKS5 (RFC 1928) as a produced resource on alkcall
//! alksocks: SOCKS5 (RFC 1928) as a produced resource on alkcall
//! channels.
//!
//! A producer/consumer protocol crate (the alktty/alktunnels shape):
@@ -6,7 +6,7 @@
//! drives the RFC 1928 conversation in-stream over the channel's
//! `BiStream`; the consumer half opens SOCKS5 channels and speaks the
//! protocol inside them. The defining posture: the protocol never
//! binds a port — kernel binds exist only as explicit, feature-gated
//! binds a port; kernel binds exist only as explicit, feature-gated
//! assembly shapes (`local`), never in the protocol layer.
//!
//! ## The landed surface (the protocol core)
@@ -14,18 +14,18 @@
//! - **The wire vocabulary and the explicit typestate** live in
//! [`rfc1928`]: a targeted fork of fast-socks5 v1.0.0's `T`-generic
//! subset (MIT, third-party upstream) carrying two deliberate
//! changes (BIND support, `new_reply` publication) — see
//! changes (BIND support, `new_reply` publication); see
//! `docs/architecture/rfc1928.md`, ADR-013, and ADR-011.
//! - **The wire codec** ([`wire`]): the ASSOCIATE datagram frame
//! (`[len: u16 BE][addr + data]`, ADR-003) — framing is applied
//! (`[len: u16 BE][addr + data]`, ADR-003): framing is applied
//! exactly once, by this layer; the egress speaks inner bytes.
//! - **The wire-stable open-op surface** ([`params`]): the
//! `channels/socks5/sub` op id, the `alk/socks5` ALPN, the
//! trivial `{}` params (ADR-001), and the two scope constants
//! (`socks5:open`, `socks5:bind` — two grants, never implied,
//! (`socks5:open`, `socks5:bind`; two grants, never implied,
//! ADR-006). The typed open-error vocabulary lives in [`error`].
//! - **The three seams** ([`egress`]): `DialFn` / `EgressFactory` /
//! `BindAcceptFactory` (ADR-004) — functions, not traits, at the
//! `BindAcceptFactory` (ADR-004): functions, not traits, at the
//! three I/O planes; ownership rides the handle structurally; the
//! egress readiness contract (`next_reply` `Pending` when nothing
//! is ready) is load-bearing API.
@@ -34,25 +34,25 @@
//! ASSOCIATE datagram stage / BIND phase model), the scope gates
//! (the open gate `socks5:open` at the registry; the command gate
//! `socks5:bind` at BIND dispatch), and
//! [`register_socks5_openable`] — substrate-agnostic over the
//! [`register_socks5_openable`], substrate-agnostic over the
//! seams; never a bind in the protocol layer.
//! - **The consumer half** ([`consumer`]): the typed channels-native
//! sessions — [`Socks5ChannelSession`] (CONNECT),
//! sessions: [`Socks5ChannelSession`] (CONNECT),
//! [`AssociateSession`] (the framed datagram stage),
//! [`BindSession`] (the reply#1/reply#2 reader) — each owning its
//! [`BindSession`] (the reply#1/reply#2 reader), each owning its
//! channel's teardown; no `Clone`. The cred-bearing front door's
//! re-origination rides the token-carrying surface (`TokenClient`
//! + the `*_with_token` constructor twins).
//!
//! The assembly layer (feature-gated, ADR-008/009): `local` (the
//! socket-backed providers — `local::tcp_dial` / `local::udp_egress`
//! / `local::bind_accept` — plus the producer-side
//! `local::ProducerDoor`) and `door` (the consumer's front doors —
//! the NoAuth raw-pipe + RFC 1929 cred-bearing `FrontDoor`, the
//! socket-backed providers, `local::tcp_dial` / `local::udp_egress`
//! / `local::bind_accept`, plus the producer-side
//! `local::ProducerDoor`) and `door` (the consumer's front doors: the
//! NoAuth raw-pipe + RFC 1929 cred-bearing `FrontDoor`, the
//! `UdpFrontDoor` with the RFC codec at the door boundary only,
//! ADR-005/003/014) ride `local`; the composed providers
//! (`composed::direct_egress` — the alktunnels direct-op egress,
//! `composed::bindfirst_accept` — the bind-first accept,
//! (`composed::direct_egress`, the alktunnels direct-op egress;
//! `composed::bindfirst_accept`, the bind-first accept;
//! ADR-007/008) ride `composed`. The default crate is protocol-only
//! and wasm-clean by construction.
//!
+21 -21
View File
@@ -1,10 +1,10 @@
//! 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 — [`tcp_dial`] (CONNECT), [`udp_egress`]
//! assembly-layer constructors: [`tcp_dial`] (CONNECT), [`udp_egress`]
//! (UDP ASSOCIATE), [`bind_accept`] (TCP BIND). These are convenience
//! constructors over the landed seam types ([`crate::egress`]); the
//! protocol layer never sees them, and **the providers ARE the explicit
//! bind decisions** — the never-binds posture (convention 9) holds
//! bind decisions**; the never-binds posture (convention 9) holds
//! because the protocol layer never references this module: every bind
//! below (the dial's connect, the egress socket's `127.0.0.1:0`, the
//! BIND listener) is the caller's explicit choice to enable this
@@ -13,24 +13,24 @@
//! The seam contracts each constructor honors (the landed types'
//! normative doc comments, `crate::egress`):
//!
//! - **Ownership rides the handle structurally** — the payloads own
//! - **Ownership rides the handle structurally**: the payloads own
//! their socket halves; dropping the handle drops the connection.
//! - **The egress speaks inner bytes** — [`Egress::send`] takes the
//! - **The egress speaks inner bytes**: [`Egress::send`] takes the
//! payload only; [`Egress::next_reply`] returns
//! `TargetAddr::to_be_bytes() + data` (the *sender's* address, the
//! RFC's per-datagram source semantics); framing-once — the length
//! RFC's per-datagram source semantics); framing-once, so the length
//! prefix is the wire layer's ([`crate::wire`]), added exactly once
//! downstream.
//! - **The readiness contract** ([`Egress::next_reply`]) — parks on
//! - **The readiness contract** ([`Egress::next_reply`]): parks on
//! `readable()` (a real async wait on the socket's receive
//! readiness), then `try_recv_from`; `WouldBlock` → the
//! spurious-wakeup `Ok(None)` shape; never a hot `Ok(None)`.
//! - **The accept future owns its resources end-to-end** — the
//! - **The accept future owns its resources end-to-end**: the
//! listener lives inside the accept future; the quiet wait's drop IS
//! the client-gone teardown.
//!
//! Resolution is the provider's job (ADR-012): domain targets/destinations
//! resolve here, at the seam — the ssh `-D` semantic, via the
//! resolve here, at the seam (the ssh `-D` semantic), via the
//! non-blocking [`tokio::net::lookup_host`] resolver.
use std::io::ErrorKind;
@@ -49,9 +49,9 @@ use crate::{
};
/// Resolve a [`TargetAddr`] to a `SocketAddr` (egress-side DNS; the
/// ssh `-D` semantic — ADR-012: resolution is the provider's job). IP
/// ssh `-D` semantic; ADR-012: resolution is the provider's job). IP
/// targets resolve directly; domain targets resolve via
/// [`tokio::net::lookup_host`] (the non-blocking resolver — review 003
/// [`tokio::net::lookup_host`] (the non-blocking resolver; review 003
/// S-1's blocking-`to_socket_addrs` residue retired; the first address
/// wins, upstream parity).
pub(crate) async fn resolve_target(addr: &TargetAddr) -> Result<SocketAddr, EgressError> {
@@ -71,7 +71,7 @@ pub(crate) async fn resolve_target(addr: &TargetAddr) -> Result<SocketAddr, Egre
}
/// Map an io error kind to the dial vocabulary (faithful-RFC mapping;
/// `NotAllowed` is never synthesized by this provider — it is a
/// `NotAllowed` is never synthesized by this provider, as it is a
/// ruleset concept, not an io one).
pub(crate) fn dial_error_of(what: &str, err: &std::io::Error) -> SocksDialError {
match err.kind() {
@@ -89,7 +89,7 @@ pub(crate) fn dial_error_of(what: &str, err: &std::io::Error) -> SocksDialError
/// The CONNECT dial seam's local-socket variant (ADR-008): a real
/// `TcpStream` per CONNECT target; domain targets resolve here (the
/// ssh `-D` semantic). The connected stream splits into the
/// [`TargetHandle`] halves (`TcpStream::into_split` — the halves are
/// [`TargetHandle`] halves (`TcpStream::into_split`; the halves are
/// `Send + Sync + Unpin`, boxed per the seam's shape). Dial failures
/// map to [`SocksDialError`] faithfully ([`dial_error_of`]);
/// `NotAllowed` is never synthesized by this provider.
@@ -136,12 +136,12 @@ async fn local_tcp_dial(target: TargetAddr) -> Result<TargetHandle, SocksDialErr
///
/// **The readiness contract is load-bearing**: [`LocalEgress`]'s
/// `next_reply` parks on `sock.readable().await` (a real async park on
/// receive readiness — `writable()` is always ready for UDP and would
/// receive readiness, since `writable()` is always ready for UDP and would
/// spin), then `try_recv_from` (`WouldBlock` → the spurious-wakeup
/// `Ok(None)` shape; a datagram arriving makes the socket readable).
/// Replies carry *inner* bytes (`TargetAddr::to_be_bytes() + data` —
/// Replies carry *inner* bytes (`TargetAddr::to_be_bytes() + data`,
/// the sender's address per the RFC's per-datagram source semantics;
/// framing-once — the length prefix is the wire layer's). `send` takes
/// framing-once: the length prefix is the wire layer's). `send` takes
/// the payload only.
pub fn udp_egress() -> EgressFactory {
Arc::new(|| {
@@ -161,7 +161,7 @@ pub struct LocalEgress {
impl LocalEgress {
/// Bind the association's socket (the explicit bind decision:
/// loopback ephemeral — a wildcard bind is the caller's variant
/// loopback ephemeral; a wildcard bind is the caller's variant
/// to add, never this constructor's default).
pub async fn bind() -> Result<Self, EgressError> {
let sock = UdpSocket::bind("127.0.0.1:0")
@@ -190,7 +190,7 @@ impl Egress for LocalEgress {
Box::pin(async move {
// A real async wait on the socket's receive readiness (a
// datagram arriving makes it readable): the stage's
// select! parks here with the channel read — no busy-loop,
// select! parks here with the channel read, so no busy-loop,
// no lost replies.
sock.readable()
.await
@@ -198,7 +198,7 @@ impl Egress for LocalEgress {
let mut scratch = vec![0u8; 65535];
match sock.try_recv_from(&mut scratch) {
Ok((n, from)) => {
// The INNER bytes (`addr + data` — the RFC's
// The INNER bytes (`addr + data`, the RFC's
// per-datagram source semantics): the length
// prefix is the wire layer's, applied exactly
// once downstream.
@@ -209,7 +209,7 @@ impl Egress for LocalEgress {
Ok(Some(Bytes::from(inner)))
}
// A spurious wakeup (the readiness contract's
// tolerated shape — the stage re-parks; this happens
// tolerated shape: the stage re-parks; this happens
// async, never a hot loop).
Err(e) if e.kind() == ErrorKind::WouldBlock => Ok(None),
Err(e) => Err(EgressError::General(format!("udp try_recv: {e}"))),
@@ -223,7 +223,7 @@ impl Egress for LocalEgress {
/// session. The requested target's port is honored when non-zero (FTP
/// active mode); port 0 binds an ephemeral port; `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 — the
/// end-to-end (the quiet wait's drop IS the client-gone teardown, as
/// listener lives inside the future, never in a separate binding);
/// `accept()`'s peer is reply#2's [`BindTargetHandle::peer`]
/// (byte-exact). Factory failures map to [`BindAcceptFactoryError`]
@@ -231,7 +231,7 @@ impl Egress for LocalEgress {
/// accept-stage failures → [`BindAcceptError`].
///
/// **Address policy is the caller's**: `bind_host` is taken
/// explicitly — loopback (`127.0.0.1`) is the POC's posture; a
/// explicitly; loopback (`127.0.0.1`) is the POC's posture; a
/// wildcard bind is the caller's explicit choice, spelled here.
pub fn bind_accept(bind_host: impl Into<std::net::IpAddr>) -> BindAcceptFactory {
let bind_host = bind_host.into();
+33 -33
View File
@@ -1,41 +1,41 @@
//! The producer-side local backend (feature `local`; producer.md
//! §Producer-Side Local Backend): the same door shape in the other
//! direction — the producer serves from a real `TcpListener`, so
//! direction: the producer serves from a real `TcpListener`, so
//! vanilla clients reach a *locally-served* SOCKS5 resource (the
//! ssh `-D` capability hosted on the producer side). Each accepted
//! TCP connection is handed to the landed substrate-free entry
//! [`Socks5Producer::serve_socks5_over`] directly — the RFC
//! [`Socks5Producer::serve_socks5_over`] directly; the RFC
//! conversation runs on the accepted socket, the seams dial locally
//! (`tcp_dial()` / `udp_egress()` / `bind_accept()`). No new
//! protocol-layer code: this module is pure accept-loop assembly.
//!
//! **The door surface** (ADR-005): the NoAuth shape is the primary
//! one here — the producer IS the RFC conversation on this path
//! one here: the producer IS the RFC conversation on this path
//! (`serve_socks5_over` runs `accept_no_auth` in-stream); there is no
//! door-side protocol half to re-originate. A cred-bearing variant
//! (RFC 1929) would wrap `accept_password_auth` before
//! `serve_socks5_over` — documented follow-up, not landed (the task
//! `serve_socks5_over` remains a documented follow-up, not landed (the task
//! spec lands the NoAuth shape unless a test drives the other).
//!
//! **The accept path owns the idle defense + the session cap**
//! (ADR-014's accept-path defense; an accepted socket is not a
//! channel — the cap regime does not bound it): the idle-first-byte
//! peek (non-consuming — the producer's own greeting read picks the
//! channel; the cap regime does not bound it): the idle-first-byte
//! peek (non-consuming, as the producer's own greeting read picks the
//! byte up) and the max-concurrent-sessions cap (an accept beyond
//! the bound is refused door-side, the socket closed immediately, no
//! session spawned) are the same two knobs the consumer door carries
//! ([`crate::door::FrontDoor`]).
//!
//! **The BIND command gate** on this path is the builder's
//! `bind_policy` (the substrate-free entry's gate — ADR-006's
//! `bind_policy` (the substrate-free entry's gate; ADR-006's
//! posture). The pump handler's identity-derived gate does **not**
//! apply here: this path has no open-op identity — the accepted
//! apply here: this path has no open-op identity, and the accepted
//! socket is the whole caller; whatever policy governs it is the
//! builder's explicit `bind_policy(bool)` and the two accept-path
//! knobs.
//!
//! **Lifetime** (the alktunnels `TunnelListener` teardown posture):
//! the accept loop runs until the caller ends it — [`ProducerDoor`]
//! the accept loop runs until the caller ends it; [`ProducerDoor`]
//! hands back a guard whose `close()` awaits the loop's end; dropping
//! the guard is a best-effort abort (the spawned sessions own their
//! sockets; an in-flight session is not force-closed by a drop, only
@@ -51,13 +51,13 @@ use tokio::sync::{watch, Semaphore};
use crate::producer::Socks5Producer;
/// Why one accepted connection's serving task ended (the backend's
/// per-connection teardown diagnostic — logged + isolated; the loop
/// per-connection teardown diagnostic, logged + isolated; the loop
/// never exits on a per-connection failure).
#[derive(Debug)]
enum BackendExit {
/// The vanilla client went away before/after the greeting.
ClientGone,
/// The RFC conversation ended (its `SocksError` — the mapped
/// The RFC conversation ended (its `SocksError`: the mapped
/// refusal, the protocol garbage, or the session end).
Session(crate::SocksError),
/// A plain I/O failure on the accepted socket.
@@ -75,7 +75,7 @@ impl std::fmt::Display for BackendExit {
}
/// The producer-side local backend (feature `local`): a real
/// `TcpListener` front door for [`Socks5Producer`] — each accepted
/// `TcpListener` front door for [`Socks5Producer`]; each accepted
/// TCP connection is served via `serve_socks5_over` (the NoAuth
/// in-stream RFC conversation; the seams dial locally). The accept
/// path owns the two ADR-014 defense knobs (the idle-first-byte
@@ -95,7 +95,7 @@ impl ProducerDoor {
}
/// The builder for [`ProducerDoor`]. The producer and the bind
/// address are required (the explicit bind decision — never implied);
/// address are required (the explicit bind decision, never implied);
/// the two defense knobs carry the consumer door's defaults.
#[derive(Default)]
pub struct ProducerDoorBuilder {
@@ -107,16 +107,16 @@ pub struct ProducerDoorBuilder {
impl ProducerDoorBuilder {
/// The producer whose `serve_socks5_over` serves every accepted
/// connection (the landed assembly shape — its seams dial/egress/
/// connection (the landed assembly shape, with its seams dial/egress/
/// accept; its `bind_policy` is this path's BIND gate).
pub fn producer(mut self, producer: Socks5Producer) -> Self {
self.producer = Some(producer);
self
}
/// The local bind address — the backend's explicit bind decision
/// The local bind address: the backend's explicit bind decision
/// (the never-binds posture: the protocol layer never binds; this
/// is the assembly layer's spelled-out choice — loopback is the
/// is the assembly layer's spelled-out choice, with loopback the
/// documented posture).
pub fn bind(mut self, addr: impl Into<SocketAddr>) -> Self {
self.bind_addr = Some(addr.into());
@@ -126,7 +126,7 @@ impl ProducerDoorBuilder {
/// The idle-first-byte window (ADR-014 knob 1): an accepted
/// connection that sends no first byte within this window is
/// closed door-side. The window covers the first byte of the RFC
/// conversation — the greeting (`05 ..`), read producer-side by
/// conversation, the greeting (`05 ..`), read producer-side by
/// `serve_socks5_over`'s own handshake (the peek is non-consuming).
pub fn idle_first_byte(mut self, window: Duration) -> Self {
self.idle_first_byte = Some(window);
@@ -134,10 +134,10 @@ impl ProducerDoorBuilder {
}
/// The max-concurrent-*connections* cap (ADR-014 knob 2; review
/// 003 N-3's naming — the mechanism bounds accepted connections,
/// 003 N-3's naming: the mechanism bounds accepted connections,
/// including ones past the idle gate that are merely slow
/// readers, not active RFC sessions): an accept beyond the bound
/// is refused door-side — the socket is closed immediately, no
/// is refused door-side; the socket is closed immediately, no
/// session spawned.
pub fn max_sessions(mut self, cap: usize) -> Self {
self.max_sessions = Some(cap);
@@ -158,20 +158,20 @@ impl ProducerDoorBuilder {
}
/// Why the producer-door builder refused to construct (the builder's
/// typed construction error — a missing required field; convention 2:
/// typed construction error, a missing required field; convention 2:
/// no panic path).
#[derive(Debug, thiserror::Error)]
pub enum ProducerDoorBuildError {
#[error("the backend requires a producer (.producer) — there is nothing to serve")]
#[error("the backend requires a producer (.producer); there is nothing to serve")]
MissingProducer,
}
/// Why the producer door's `serve` refused to start (the typed
/// construction error — `serve` carries the bind decision, so an
/// construction error: `serve` carries the bind decision, so an
/// omitted `.bind` is its error, not a panic; convention 2).
#[derive(Debug, thiserror::Error)]
pub enum ProducerDoorError {
#[error("the backend requires a bind address (.bind) before serve — the bind decision is never implied")]
#[error("the backend requires a bind address (.bind) before serve; the bind decision is never implied")]
MissingBindAddr,
#[error("tcp bind: {0}")]
Bind(#[source] std::io::Error),
@@ -179,7 +179,7 @@ pub enum ProducerDoorError {
/// The serving guard: [`ProducerDoor::serve`] returns one; `close()`
/// ends the accept loop gracefully (the listener is dropped, the
/// loop's end is awaited — the spawned sessions already running own
/// loop's end is awaited; the spawned sessions already running own
/// their sockets and finish on their own), and dropping it aborts the
/// loop task best-effort (the alktunnels `TunnelListener` teardown
/// posture).
@@ -190,12 +190,12 @@ pub struct ProducerDoorGuard {
impl ProducerDoorGuard {
/// End the accept loop gracefully: the loop task returns, and the
/// guard awaits its end. In-flight sessions are NOT force-closed —
/// guard awaits its end. In-flight sessions are NOT force-closed;
/// they own their sockets and finish with their clients (per-
/// connection isolation cuts both ways). Returns the loop task's
/// fatal outcome, if any (`None` = the loop ended by close).
///
/// The receiver is dropped before the await — the loop's
/// The receiver is dropped before the await: the loop's
/// `done_tx.closed()` fires only when every receiver is gone;
/// holding it across the await would deadlock the graceful stop.
pub async fn close(mut self) -> Option<std::io::Error> {
@@ -211,7 +211,7 @@ impl ProducerDoorGuard {
impl Drop for ProducerDoorGuard {
/// Best-effort abort: drop the loop task's handle (the loop ends
/// when its listener drops — the listener is owned by the task;
/// when its listener drops, since the listener is owned by the task;
/// an abort cancels the accept wait). Sync-only, never awaits.
fn drop(&mut self) {
if let Some(task) = self.task.take() {
@@ -222,7 +222,7 @@ impl Drop for ProducerDoorGuard {
impl ProducerDoor {
/// Bind the configured address and run the accept loop. Per-
/// connection failures are logged + isolated — the loop continues
/// connection failures are logged + isolated; the loop continues
/// (the alktunnels per-accept isolation posture). The listener's
/// lifetime is the caller's: the returned [`ProducerDoorGuard`]
/// ends it (`close()` graceful; drop best-effort abort).
@@ -246,7 +246,7 @@ impl ProducerDoor {
}
/// Run the accept loop over an existing listener (the test/
/// assembly seam — the same accept loop without the bind).
/// assembly seam: the same accept loop without the bind).
pub async fn serve_on(self, listener: TcpListener) -> ProducerDoorGuard {
let ProducerDoor {
producer,
@@ -280,11 +280,11 @@ fn serve_on_parts(
};
// The session cap (ADR-014 knob 2): refuse
// beyond the bound — `try_acquire` keeps the
// beyond the bound; `try_acquire` keeps the
// accept loop free (a permit returns when a
// serving task ends and drops its guard).
let Ok(permit) = live.clone().try_acquire_owned() else {
tracing::warn!(peer = %peer, "producer door: session cap reached — refusing");
tracing::warn!(peer = %peer, "producer door: session cap reached, refusing");
drop(stream);
continue;
};
@@ -306,9 +306,9 @@ fn serve_on_parts(
}
/// Serve one accepted connection: the idle-first-byte defense first
/// (ADR-014 knob 1 — the peek waits for readability without
/// (ADR-014 knob 1: the peek waits for readability without
/// consuming; the producer's own greeting read picks the byte up),
/// then the substrate-free entry (`serve_socks5_over` — the NoAuth
/// then the substrate-free entry (`serve_socks5_over`, the NoAuth
/// in-stream RFC conversation; the seams dial locally).
async fn serve_accepted(
stream: tokio::net::TcpStream,
+24 -24
View File
@@ -1,6 +1,6 @@
//! The local providers' unit tests: real sockets — the echo TCP
//! server for [`super::tcp_dial`] round-trips, a real UDP echo for
//! the egress ([`super::LocalEgress`], the POC #2 shape — including
//! The local providers' unit tests: real sockets, including the echo
//! TCP server for [`super::tcp_dial`] round-trips, a real UDP echo for
//! the egress ([`super::LocalEgress`], the POC #2 shape, including
//! the readiness contract's park-then-datagram shape), a real BIND
//! accept ([`super::bind_accept`], reply#1/reply#2 carrying the real
//! addresses), and the refusal mappings table-tested against the
@@ -19,7 +19,7 @@ use crate::rfc1928::consts;
const BOUND: Duration = Duration::from_secs(10);
/// Decode the inner bytes' `(addr, payload)` shape — the forked
/// Decode the inner bytes' `(addr, payload)` shape: the forked
/// `read_address` codec applied to the egress's reply (an honest
/// decode through the codec, not a hand-parse: the inner bytes are
/// `ATYP + addr + port + data`; the codec reads exactly the address
@@ -68,7 +68,7 @@ async fn echo_tcp_server() -> SocketAddr {
}
/// A UDP echo server: datagrams echo back from their sender address
/// (the egress test's far side — the POC #2 shape).
/// (the egress test's far side; the POC #2 shape).
async fn echo_udp_server() -> SocketAddr {
let sock = UdpSocket::bind("127.0.0.1:0").await.expect("bind");
let addr = sock.local_addr().expect("local_addr");
@@ -90,7 +90,7 @@ async fn echo_udp_server() -> SocketAddr {
/// The happy path: dial the echo server, round-trip bytes through the
/// boxed halves (ownership rides the handle; the halves are
/// Send+Sync+Unpin — the boxing compiles, pinned here).
/// Send+Sync+Unpin; the boxing compiles, pinned here).
#[tokio::test]
async fn tcp_dial_round_trips_through_the_boxed_halves() {
let server = echo_tcp_server().await;
@@ -110,7 +110,7 @@ async fn tcp_dial_round_trips_through_the_boxed_halves() {
}
/// The `to_target_addr`-shaped target (the `impl ToTargetAddr`
/// consumer path): a domain that resolves here (`localhost` — the
/// consumer path): a domain that resolves here (`localhost`; the
/// `-D` semantic: resolution is the provider's job) reaches the same
/// echo server; the seam received it unresolved and dialed the
/// resolved address.
@@ -133,7 +133,7 @@ async fn tcp_dial_resolves_domain_targets_here() {
}
/// An unroutable loopback port refuses: `ConnectionRefused` (0x05)
/// per the io error kind — the refusal rides the seam's error
/// per the io error kind; the refusal rides the seam's error
/// vocabulary (`to_reply_error()`), table-pinned through the mapped
/// code.
#[tokio::test]
@@ -157,7 +157,7 @@ async fn tcp_dial_connect_refused_maps_faithfully() {
);
}
/// A domain that does not resolve → `HostUnreachable` (0x04) — the
/// A domain that does not resolve → `HostUnreachable` (0x04): the
/// resolution failure is the dial seam's error, faithfully mapped.
#[tokio::test]
async fn tcp_dial_unresolvable_domain_maps_to_host_unreachable() {
@@ -178,8 +178,8 @@ async fn tcp_dial_unresolvable_domain_maps_to_host_unreachable() {
/// The datagram round-trip through a real UDP echo (the POC #2
/// shape): `send` (payload-only) → the echo → `next_reply`'s inner
/// bytes carry the sender's address + the data (no length prefix —
/// framing-once; the wire layer adds it downstream).
/// bytes carry the sender's address + the data (no length prefix, as
/// framing happens once; the wire layer adds it downstream).
#[tokio::test]
async fn udp_egress_round_trips_inner_bytes_with_sender_address() {
let echo = echo_udp_server().await;
@@ -202,14 +202,14 @@ async fn udp_egress_round_trips_inner_bytes_with_sender_address() {
assert_eq!(frame, Bytes::from(expect), "inner bytes, framed-once");
// The sender's address decodes as the echo's (the RFC's
// per-datagram source semantics — the address rides the frame).
// per-datagram source semantics; the address rides the frame).
let (src, payload) = decode_inner(&frame).await;
assert_eq!(src, dest);
assert_eq!(&payload[..], b"query");
}
/// The readiness contract with the real socket: `next_reply` parks
/// (no reply is ready → `Pending`) — the stage's select! branch would
/// (no reply is ready → `Pending`); the stage's select! branch would
/// stay parked; a datagram arriving resolves it. Pinned by racing the
/// wait against a `sleep`: no reply → not resolved by the bound; the
/// datagram → resolved. Never a hot `Ok(None)` (the send-then-drain
@@ -238,7 +238,7 @@ async fn udp_egress_next_reply_parks_until_a_datagram_arrives() {
assert_eq!(&payload[..], b"park");
// The socket drained: a second wait parks again (no reply is
// ready) — resolved only by a fresh datagram.
// ready), resolved only by a fresh datagram.
egress
.send(TargetAddr::Ip(echo), Bytes::from_static(b"again"))
.await
@@ -261,7 +261,7 @@ async fn udp_egress_next_reply_parks_until_a_datagram_arrives() {
assert_eq!(&payload[..], b"again");
}
/// Multiple datagrams in order (no flow table — per-datagram
/// Multiple datagrams in order (no flow table; per-datagram
/// addressing does the demultiplexing; the socket is the relay
/// handle).
#[tokio::test]
@@ -287,7 +287,7 @@ async fn udp_egress_round_trips_multiple_datagrams_in_order() {
}
/// A zero-length payload is a legal empty datagram (the F-2
/// invariant at payload level) — round-trips through the real
/// invariant at payload level) and round-trips through the real
/// socket.
#[tokio::test]
async fn udp_egress_zero_length_payload_round_trips() {
@@ -307,7 +307,7 @@ async fn udp_egress_zero_length_payload_round_trips() {
}
/// An undecodable destination (an unresolvable domain) fails the
/// `send` with `Unreachable` → `NetworkUnreachable` (0x03) — the
/// `send` with `Unreachable` → `NetworkUnreachable` (0x03); the
/// resolve failure is the egress's error vocabulary.
#[tokio::test]
async fn udp_egress_unresolvable_destination_maps_to_unreachable() {
@@ -366,7 +366,7 @@ async fn bind_accept_reply1_real_addr_reply2_real_peer_and_round_trip() {
);
// The client echoes what it receives (the round-trip's far side:
// the app server the RFC models — active-mode FTP's data
// the app server the RFC models, i.e. active-mode FTP's data
// listener echo).
let mut client_to_echo = client;
tokio::spawn(async move {
@@ -409,7 +409,7 @@ async fn bind_accept_reply1_real_addr_reply2_real_peer_and_round_trip() {
/// The requested port is honored when non-zero (FTP active mode):
/// the listener binds exactly the requested port, and a held port
/// refuses the factory (`Shortage`/`Refused` per the io error — the
/// refuses the factory (`Shortage`/`Refused` per the io error; the
/// bind failure is reply#1's vocabulary).
#[tokio::test]
async fn bind_accept_honors_a_requested_port_and_refuses_a_held_one() {
@@ -433,7 +433,7 @@ async fn bind_accept_honors_a_requested_port_and_refuses_a_held_one() {
);
// A free port is honored exactly (the listener is the factory's
// resource — dropped with the accept future).
// resource, dropped with the accept future).
let free = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let want = free.local_addr().expect("local_addr");
drop(free);
@@ -445,7 +445,7 @@ async fn bind_accept_honors_a_requested_port_and_refuses_a_held_one() {
}
/// The accept future owns its listener end-to-end: dropping the
/// `BindAccept` (the quiet wait's drop — the client-gone teardown)
/// `BindAccept` (the quiet wait's drop, i.e. the client-gone teardown)
/// closes the listener; the advertised address stops accepting. A
/// fresh factory on the same (now free) port re-binds.
#[tokio::test]
@@ -458,7 +458,7 @@ async fn bind_accept_drop_of_the_accept_future_closes_the_listener() {
// The listener closed with the future: a fresh bind of the same
// ephemeral port succeeds (the OS may hand the port to either
// the reuse or a fresh allocation — loopback ephemeral binds
// the reuse or a fresh allocation; loopback ephemeral binds
// succeed either way; the probe is that the re-bind is possible
// at all, i.e. the listener was dropped, not leaked).
let rebinder = TcpListener::bind(bind_addr).await;
@@ -476,7 +476,7 @@ type DialCase = (ErrorKind, &'static dyn Fn(&SocksDialError) -> bool);
type BindCase = (ErrorKind, &'static dyn Fn(&BindAcceptFactoryError) -> bool);
/// The dial map is table-tested (`dial_error_of` is a pure function
/// of `io::ErrorKind` — review 003 D-3): a refactor that swaps an arm
/// of `io::ErrorKind`; review 003 D-3): a refactor that swaps an arm
/// into the `_` catch-all fails here. The two loopback-unproducible
/// kinds (`NetworkUnreachable`, `HostUnreachable`, `TimedOut`,
/// `PermissionDenied`) ride synthetic errors of the right kind.
@@ -528,7 +528,7 @@ fn dial_error_of_maps_the_vocabulary_table() {
}
/// The dial vocabulary's reply codes stay RFC-faithful (the mapped
/// surface the producer replies with — the split's whole purpose).
/// surface the producer replies with; that is the split's whole purpose).
#[test]
fn dial_vocabulary_reply_codes_table() {
use crate::egress::SocksDialError;
+24 -24
View File
@@ -1,20 +1,20 @@
//! The trivial open-op params, the wire-stable constants, and the
//! `OperationSpec` builder — `wire.md` §The Open Op, ADR-001, ADR-006.
//! `OperationSpec` builder (`wire.md` §The Open Op; ADR-001, ADR-006).
//!
//! This module is the crate's wire surface (the one-way door): the op
//! id, the ALPN string, the two scope strings, and the trivial params
//! shape are frozen at first publish (ADR-001). The open-op params are
//! **trivially empty** — the SOCKS5 target is unknown at open time (it
//! **trivially empty**: the SOCKS5 target is unknown at open time (it
//! arrives in-stream post-negotiation); the establisher is a session
//! gate, not a dial. The empty-object schema with
//! `additionalProperties: false` keeps `{}` valid forever: any future
//! per-session option is an *additive* schema extension (ADR-001's
//! extension rule) — consumers that send `{}` stay valid.
//! extension rule), so consumers that send `{}` stay valid.
//!
//! All three RFC 1928 commands (CONNECT, BIND, UDP ASSOCIATE) ride the
//! one open op, selected in-stream at command dispatch (ADR-001); BIND
//! is scope-gated separately at that dispatch (`SOCKS5_BIND_SCOPE`,
//! ADR-006) — the command is an RFC-1928 wire fact, not a wire-format
//! ADR-006): the command is an RFC-1928 wire fact, not a wire-format
//! choice of ours.
//!
//! The typed open-error vocabulary ([`crate::error::SocksOpenError`],
@@ -29,7 +29,7 @@ use alkcall::registry::spec::{
use serde::{Deserialize, Serialize};
use serde_json::json;
/// The open-op operation id (the one SOCKS5 session op; ADR-001 —
/// The open-op operation id (the one SOCKS5 session op; ADR-001,
/// flavor-form per alkcall ADR-047: openable ALPNs are operations).
/// All three RFC 1928 commands ride this one op. Wire-stable.
pub const OP_SOCKS5_OPEN: &str = "channels/socks5/sub";
@@ -39,13 +39,13 @@ pub const OP_SOCKS5_OPEN: &str = "channels/socks5/sub";
/// per protocol. Wire-stable.
pub const SOCKS5_ALPN: &str = "alk/socks5";
/// The scope gating SOCKS5 opens (ADR-006): proxy egress — CONNECT
/// and UDP ASSOCIATE. The scope-string convention
/// The scope gating SOCKS5 opens (ADR-006): proxy egress (CONNECT
/// and UDP ASSOCIATE). The scope-string convention
/// `<alpn-family>:<capability>`. Stable once published.
pub const SOCKS5_OPEN_SCOPE: &str = "socks5:open";
/// The scope gating TCP BIND at command dispatch (ADR-006): the
/// *inbound* capability — ingress listeners. A separate grant from
/// *inbound* capability: ingress listeners. A separate grant from
/// [`SOCKS5_OPEN_SCOPE`], **never implied by it**: an identity with
/// proxy egress must not implicitly get ingress listeners. Checked
/// in-stream at command read, from the per-call opener identity
@@ -54,21 +54,21 @@ pub const SOCKS5_OPEN_SCOPE: &str = "socks5:open";
/// it. Stable once published.
pub const SOCKS5_BIND_SCOPE: &str = "socks5:bind";
/// The open-op input params (ADR-001): the trivial object. Wire-stable
/// — the shape is frozen (empty object, nothing required, nothing
/// The open-op input params (ADR-001): the trivial object. Wire-stable,
/// since the shape is frozen (empty object, nothing
/// else); a future per-session option is an additive schema extension,
/// never a format change.
///
/// `deny_unknown_fields` is the loud-rejection posture: unknown fields
/// fail at parse time, never silently ignored (matching the input
/// schema's `additionalProperties: false` — the registry's gate and
/// schema's `additionalProperties: false`; the registry's gate and
/// this serde pin agree). Empty params stay valid forever.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Socks5Params {}
impl Socks5Params {
/// The canonical wire form: the empty object — the only valid
/// The canonical wire form: the empty object, the only valid
/// params value (ADR-001).
pub fn as_json() -> serde_json::Value {
json!({})
@@ -79,22 +79,22 @@ impl Socks5Params {
/// `Sub`-typed, external, channel-open marker on the `alk/socks5`
/// ALPN, scope-gated by `socks5:open`, trivial params.
///
/// - Input schema: `{type: object}` + `additionalProperties: false` —
/// - Input schema: `{type: object}` + `additionalProperties: false`;
/// empty params stay valid forever (ADR-001's additive-only
/// extension rule); unknown fields are rejected loudly.
/// - Reply schema: `{"channel_id": <u32>, minimum 1}` — the
/// - Reply schema: `{"channel_id": <u32>, minimum 1}`, the
/// `Sub`-typed operation reply. No extra reply fields in v1 (the
/// establishment phase is a session gate and has nothing to
/// project; alkcall's `with_reply_field` seam remains available
/// additively if a future need appears — ADR-001).
/// - `required_scopes = ["socks5:open"]` — the registry runs the ACL
/// additively if a future need appears (ADR-001).
/// - `required_scopes = ["socks5:open"]`: the registry runs the ACL
/// before the open handler (ADR-006; the open gate is the
/// boundary).
///
/// `error_schemas` declare the `channel:open_failed` contract
/// (alkcall ADR-049 §3/§4 + ADR-016): the establisher's refusal
/// reasons (`dial_failed` / `unknown_resource` / `resource_shortage`
/// / `handler_error` / `timeout` — the ADR-049 §3 vocabulary, the
/// / `handler_error` / `timeout`: the ADR-049 §3 vocabulary, the
/// same five codes [`crate::error::SocksEstablishmentReason`] parses)
/// plus the registry's own `FORBIDDEN` class (the ACL gate runs
/// before the establisher; ADR-006). The wrapper's wire shape is
@@ -139,11 +139,11 @@ pub fn socks5_open_spec() -> OperationSpec {
vec![ErrorDefinition {
code: "channel:open_failed".to_string(),
description: "The channel open failed: the registry's ACL gate refused \
(FORBIDDEN — no socks5:open scope), the params payload did \
(FORBIDDEN: no socks5:open scope), the params payload did \
not match the schema (INVALID_INPUT), the per-connection \
channel cap was hit (channel:too_many_channels), or the \
establishment phase failed (details.reason ∈ the ADR-049 \
§3 vocabulary). No channel_id is returned — no phantom \
§3 vocabulary). No channel_id is returned, so no phantom \
channel (ADR-049)."
.to_string(),
schema: open_failed_schema,
@@ -160,7 +160,7 @@ pub fn socks5_open_spec() -> OperationSpec {
.with_channel_open(ChannelOpenSpec::new(SOCKS5_ALPN))
.with_description(
"Open a SOCKS5 proxy channel (RFC 1928 rides the channel BiStream; \
trivial params — the target arrives in-stream post-negotiation)",
trivial params; the target arrives in-stream post-negotiation)",
)
}
@@ -169,7 +169,7 @@ pub fn socks5_open_spec() -> OperationSpec {
/// vocabulary. `None` for any other failure shape (the registry's
/// `FORBIDDEN`, `channel:too_many_channels`, local-only failures).
///
/// Delegates to [`ChannelOpenError::establishment_reason`] — the
/// Delegates to [`ChannelOpenError::establishment_reason`] (the
/// alktunnels helper posture. The typed vocabulary lives in
/// [`crate::error`] ([`crate::error::SocksEstablishmentReason`], the
/// parsed view a consumer branches on via
@@ -191,7 +191,7 @@ mod tests {
assert_eq!(spec.op_type, OperationType::Sub);
assert_eq!(spec.visibility, Visibility::External);
// Trivial params: empty object, unknown fields rejected loudly
// (ADR-001 — empty params stay valid forever).
// (ADR-001: empty params stay valid forever).
assert_eq!(spec.input_schema["type"], "object");
assert_eq!(spec.input_schema["additionalProperties"], json!(false));
assert!(spec.input_schema.get("required").is_none());
@@ -229,7 +229,7 @@ mod tests {
#[test]
fn trivial_params_reject_unknown_fields() {
// The loud-rejection posture — the serde pin agrees with the
// The loud-rejection posture: the serde pin agrees with the
// input schema's additionalProperties: false (ADR-001).
let v = json!({"resource": "sneaky"});
assert!(serde_json::from_value::<Socks5Params>(v).is_err());
@@ -270,7 +270,7 @@ mod tests {
let enum_values = props["reason"]["enum"].as_array().expect("reason enum");
let reasons: Vec<&str> = enum_values.iter().filter_map(|v| v.as_str()).collect();
// The declared vocabulary is exactly the typed parse table
// (crate::error::SocksEstablishmentReason) — the schema and
// (crate::error::SocksEstablishmentReason); the schema and
// the typed view cannot drift.
assert_eq!(
reasons,
+173 -173
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -9,7 +9,7 @@ fast-socks5 v1.0.0 (https://github.com/dizda/fast-socks5) and are
carried in-tree by alksocks (extraction per ADR-011,
docs/architecture/decisions/011-inline-vendor.md; extraction range
git 57b107b..5b18d4d; fork point = the tree as of commit 5b18d4d,
the extraction review's gate commit — ownership per ADR-013,
the extraction review's gate commit; ownership per ADR-013,
docs/architecture/decisions/013-targeted-fork.md). Upstream has no
per-file license headers; this bundled notice is the license
obligation's carrier. The extraction cut and the fork's deliberate
@@ -19,7 +19,7 @@ Modifications after the fork point are Copyright (c) 2026 the
alksocks developers, licensed under the crate license
(MIT OR Apache-2.0, per alksocks' Cargo.toml); the upstream MIT
notice above is preserved per its terms. The upstream project is
third-party and unmodified in place — see the ADRs for what was
third-party and unmodified in place; see the ADRs for what was
changed after the fork point.
---
+10 -10
View File
@@ -1,12 +1,12 @@
//! Forked from fast-socks5 v1.0.0 (upstream `src/client.rs` —
//! Forked from fast-socks5 v1.0.0 (upstream `src/client.rs`;
//! extraction; ownership per ADR-013, see the crate root's `mod.rs`).
//!
//! Extracted region: the `T`-generic
//! client session — [`Socks5Stream<S>`] with the `use_stream` path
//! client session: [`Socks5Stream<S>`] with the `use_stream` path
//! (the version/method greeting, the method-choice read, the
//! password-auth sub-handshake, the request encoder `request_header`,
//! the reply reader `read_request_reply`, and the socket accessors)
//! plus the [`AsyncRead`]/[`AsyncWrite`] impls — the consumer half's
//! plus the [`AsyncRead`]/[`AsyncWrite`] impls, the consumer half's
//! stream session (ADR-002; POC #1's fit: a channels `BiStream` is
//! fed here directly). A `Socks5Stream` is itself a stream, so
//! `pump_bidi`-shaped consumers and the front doors can pipe through
@@ -14,10 +14,10 @@
//!
//! Not forked (rfc1928.md's not-forked inventory row 2):
//! `Socks5Datagram`
//! (struct + impl — the kernel `UdpSocket` associate client; the
//! (struct + impl: the kernel `UdpSocket` associate client; the
//! crate's associate session is first-party per ADR-003's handle
//! swap) and the `Socks5Stream<TcpStream>` convenience impl
//! (`connect`/`connect_with_password`/`connect_raw` — the
//! (`connect`/`connect_with_password`/`connect_raw`, the
//! `tokio::net::TcpStream` dialers; the consumer's transport is a
//! channels `BiStream`, not a TCP dial). Upstream
//! `Config::connect_timeout` is cut with its only consumer,
@@ -26,13 +26,13 @@
//!
//! Extraction-time adjustments (mechanical, ADR-011 §Amendment):
//! `log` calls dropped (the bindings they consumed renamed); `anyhow`
//! dropped — errors stay `SocksError`-typed (the missing-target-addr
//! dropped; errors stay `SocksError`-typed (the missing-target-addr
//! case at upstream `client.rs:285` uses the typed
//! [`SocksError::MissingTargetAddr`] variant instead of
//! `anyhow::Error::msg(...).into()`); `use_password_auth`'s
//! `unreachable!()` (upstream `client.rs:207` — `methods.get(1)`
//! `unreachable!()` (upstream `client.rs:207`, where `methods.get(1)`
//! being `Some(AuthenticationMethod::None)`) is an error return
//! ([`SocksError::AuthenticationRejected`]) — convention 2 forbids
//! ([`SocksError::AuthenticationRejected`]); convention 2 forbids
//! panics in library code, and the state is reachable only by misuse;
//! the `Socks5Datagram` error sites (`SocksError::Other(anyhow!(...))`,
//! the `"[::]:0".parse().unwrap()` sites) go with the cut region. No
@@ -41,7 +41,7 @@
//!
//! Tests are first-party (upstream's client test module rides the cut
//! net region): raw-byte mock servers over tokio duplex streams, the
//! pinned byte sequences written directly — keeping the client's wire
//! pinned byte sequences written directly, keeping the client's wire
//! expectations uncorrelated from the forked server typestate.
use crate::rfc1928::util::stream::read_exact;
@@ -208,7 +208,7 @@ where
ref username,
ref password,
}) => (username, password),
// upstream `unreachable!()` — `methods.get(1)` being
// upstream `unreachable!()` (`methods.get(1)` being
// `Some(AuthenticationMethod::None)`: reachable only by
// misuse; the faithful translation is an error return,
// not a panic (convention 2). Same message as upstream's
+7 -7
View File
@@ -1,12 +1,12 @@
//! The RFC 1928 core: a targeted fork of fast-socks5 v1.0.0's
//! `T`-generic subset (MIT, third-party upstream —
//! `T`-generic subset (MIT, third-party upstream:
//! github.com/dizda/fast-socks5; `/workspace/fast-socks5` is the
//! read-only reference checkout, kept for differential testing of
//! RFC edge cases).
//!
//! Arrived by extraction (ADR-011 §Amendment, docs/architecture/
//! decisions/011-inline-vendor.md): only the code already generic
//! over `T: AsyncRead + AsyncWrite + Unpin` was copied in-tree — the
//! over `T: AsyncRead + AsyncWrite + Unpin` was copied in-tree; the
//! kernel-net handlers, the `socks4` module, and the legacy `Config`/
//! `Authentication` layer were **not** copied. The extraction cut,
//! the fork's two carried changes (BIND support, `new_reply`
@@ -15,7 +15,7 @@
//! `docs/architecture/rfc1928.md`.
//!
//! Ownership (ADR-013, docs/architecture/decisions/013-targeted-fork.md):
//! this is fully owned code — project conventions apply
//! this is fully owned code, so project conventions apply
//! unconditionally (no vendor carve-outs, no verbatim preservation,
//! no lint-allow posture). Fork point: the tree as of the
//! extraction-review commit (`5b18d4d`); extraction provenance in
@@ -32,13 +32,13 @@
//! `use_stream` path + the `AsyncRead`/`AsyncWrite` impls) lives in
//! [`client`].
//!
//! License: upstream is MIT with no per-file headers — the bundled
//! License: upstream is MIT with no per-file headers; the bundled
//! notice (`LICENSE`, this module's directory, v1.0.0 attribution +
//! the fork-point statement) is the license obligation's carrier.
//!
//! Maintenance posture: this code co-evolves with the wrapper and is
//! the crate's only RFC 1928 wire vocabulary ("no second codec,
//! ever" — ADR-011's rationale, carried). Nothing is absorbed
//! ever": ADR-011's rationale, carried). Nothing is absorbed
//! silently from upstream; nothing waits on upstream.
pub mod client;
@@ -216,7 +216,7 @@ impl ReplyError {
/// Map a wire reply code to its variant. Codes outside the RFC
/// table (`0x09`–`0xFF`, unassigned per RFC 1928 §6) map to
/// `GeneralFailure` — the RFC's catch-all — instead of panicking:
/// `GeneralFailure`, the RFC's catch-all, instead of panicking:
/// the value comes off the wire (the client session feeds the raw
/// reply byte here), so a misbehaving peer must surface as a typed
/// error, never a panic. Upstream's `unreachable!()` arm was the
@@ -379,7 +379,7 @@ mod tests {
}
/// F-1 (review): unassigned reply codes (`0x09`–`0xFF`, RFC 1928
/// §6) surface as `GeneralFailure` — the RFC's catch-all — never
/// §6) surface as `GeneralFailure` (the RFC's catch-all), never
/// a panic. Upstream's `unreachable!()` arm was removed: the code
/// comes off the wire (`read_request_reply` → `from_u8`), so a
/// misbehaving peer must not crash the consumer task.
+16 -16
View File
@@ -1,4 +1,4 @@
//! Forked from fast-socks5 v1.0.0 (upstream `src/server.rs` —
//! Forked from fast-socks5 v1.0.0 (upstream `src/server.rs`;
//! extraction; ownership per ADR-013, see the crate root's `mod.rs`).
//!
//! Extracted region: the explicit
@@ -6,13 +6,13 @@
//! negotiation with the `AuthMethod<T>` machinery, `read_command`,
//! `reply_success`, `reply_error`, `transfer`, `wait_on_tcp`,
//! `ErrorContext<T>`), `SocksServerError`, and the published reply
//! encoder `new_reply` (carried change #2 — upstream's is private,
//! encoder `new_reply` (carried change #2: upstream's is private,
//! `fn new_reply` at `server.rs:1380`; here it is `pub` so the
//! BIND reply#2 writer and consumer reply readers reuse the
//! 10/22-byte reply form instead of re-encoding it). The cut regions
//! (the legacy `Socks5Server`/`Socks5Socket`/`Config`/`Authentication`
//! layer, the `run_*` kernel-net handlers, `DnsResolveHelper`, the
//! net-only tests) stay in `/workspace/fast-socks5` — the not-forked
//! net-only tests) stay in `/workspace/fast-socks5`, the not-forked
//! inventory row 1 in `docs/architecture/rfc1928.md`.
//!
//! Extraction-time adjustments (mechanical, ADR-011 §Amendment):
@@ -23,22 +23,22 @@
//! `SocksServerError::AuthenticationRejected` are cut; the variant
//! stays (the typestate's `accept_password_auth` produces it).
//!
//! Post-fork owned changes (extend, never replace — convention 17):
//! Post-fork owned changes (extend, never replace, per convention 17):
//! the keep-or-cut sweep (review 002 N-1) cut the producer-replaced
//! machinery — the `auth_method_enums!` macro (upstream's enum
//! machinery: the `auth_method_enums!` macro (upstream's enum
//! generator) with its generated `StandardAuthentication` enum +
//! `allow_no_auth`, `skip_auth_this_is_not_rfc_compliant`, and
//! `CheckResult`'s `Option`/`Result` impls (`bool` stays) — none had
//! `CheckResult`'s `Option`/`Result` impls (`bool` stays); none had
//! an in-tree consumer once the producer drove `negotiate_auth`
//! directly. Kept with note (the hand-port map, ADR-013):
//! `transfer`/`wait_on_tcp` are differential-suite-consumed (their
//! semantics stay pinned there), and the error vocabulary keeps its
//! upstream variants. One variant was added, not absorbed:
//! `SocksServerError::Refused` — the honest refusal vocabulary for
//! `SocksServerError::Refused`: the honest refusal vocabulary for
//! the producer's in-stream policy refusals (review 002 N-4; never
//! reaches the wire). Recorded in docs/architecture/rfc1928.md.
//!
//! The BIND note (ADR-007): BIND's driving is producer-side code —
//! The BIND note (ADR-007): BIND's driving is producer-side code;
//! the typestate already returns `TCPBind` from `read_command()`,
//! `reply_success(sock_addr)` carries reply#1's BND.ADDR, and
//! reply#2 is written with the published `new_reply`. No BIND code
@@ -159,7 +159,7 @@ impl<T> Socks5ServerProtocol<T, states::Opened> {
/// Closure-result adapter for [`Socks5ServerProtocol
/// ::accept_password_auth`]'s credential checker: `bool` (`is_good`).
/// The `Option`/`Result` impls upstream-adjacent shape were cut in
/// the keep-or-cut sweep (review 002 N-1) — every in-tree checker
/// the keep-or-cut sweep (review 002 N-1); every in-tree checker
/// returns `bool`; re-add with a typed-rejection design if a door
/// ever wants it.
pub trait CheckResult {
@@ -240,7 +240,7 @@ pub trait AuthMethodSuccessState<T> {
/// Use blank structs for individual protocol implementations;
/// `negotiate_auth` takes any slice of `AuthMethod`s, so a custom
/// selection is a slice of the method structs (upstream's
/// enum-generator macro was cut — review 002 N-1; nothing in-tree
/// enum-generator macro was cut (review 002 N-1); nothing in-tree
/// drove a multi-method slice).
pub trait AuthMethod<T>: Copy {
type StartingState;
@@ -354,7 +354,7 @@ impl<T: AsyncWrite + Unpin> PasswordAuthenticationImpl<T, password_states::Recei
/// Notify the client with a "NOT_ACCEPTABLE" reply and drop the socket.
///
/// The status byte is `0xff` — RFC 1929 requires only that a
/// The status byte is `0xff`: RFC 1929 requires only that a
/// rejection be any non-`0x00` byte; `0xff` is the convention
/// upstream picked (borrowed from the method-negotiation phase's
/// `SOCKS5_AUTH_METHOD_NOT_ACCEPTABLE` constant, which is a
@@ -530,7 +530,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin> Socks5ServerProtocol<T, states::Authenti
}
/// The RFC reply form's placeholder BND.ADDR for error replies
/// (statically constructed — upstream parsed `"0.0.0.0:0"` at runtime).
/// (statically constructed; upstream parsed `"0.0.0.0:0"` at runtime).
const V4_UNSPECIFIED_ZERO: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0));
/// Generate reply code according to the RFC.
@@ -606,14 +606,14 @@ mod tests {
/// `SocksServerError::to_reply_error`'s full mapping table (review
/// 001 L-3): `UnknownCommand` → `CommandNotSupported`,
/// `AddrError` delegates to its own table, and every remaining
/// variant falls through to `GeneralFailure` — the mapping the
/// variant falls through to `GeneralFailure`, the mapping the
/// producer's error surface relies on when mapping session errors
/// to reply codes (`try_notify!`'s secondary-error write). The
/// fall-through arms (`Io`/`FromUtf8`/`EOF`/auth/refusal variants)
/// are exercised here directly, since driving each from the wire
/// is not one-test-cheap. Upstream `src/server.rs:62-70`. The
/// table also pins the vocabulary honesty (review 002 N-4):
/// `Bug` and `Refused` are log-vocabulary variants — both fall
/// `Bug` and `Refused` are log-vocabulary variants; both fall
/// through to `GeneralFailure` (they never reach the wire from
/// the typestate; the producer's refusal sites write the RFC
/// reply before the session error).
@@ -655,13 +655,13 @@ mod tests {
}
/// The refusal vocabulary (review 002 N-4): `Refused` is the
/// honest display for the producer's in-stream policy refusals —
/// honest display for the producer's in-stream policy refusals:
/// not `BUG:` (a working-as-designed refusal is not a bug) and
/// not "Authentication rejected" (an ACL refusal is not an auth
/// failure). It is log/observability vocabulary: never encoded
/// on the wire by the typestate (only `to_reply_error` maps it,
/// and the producer's refusal sites write the RFC reply before
/// the session error). Upstream has no such variant — the
/// the session error). Upstream has no such variant; that is the
/// fork's owned addition, recorded in the module doc.
#[test]
fn refused_variant_display_is_honest_and_wire_invisible() {
+2 -2
View File
@@ -1,10 +1,10 @@
//! The RFC 1928 core's utility modules (upstream `src/util/` —
//! The RFC 1928 core's utility modules (upstream `src/util/`;
//! extraction; see the fork statement in the crate root's `mod.rs`
//! and ADR-013 for ownership).
//!
//! Upstream layout: `stream.rs` (the `read_exact!`/`ready!` macros +
//! `ConnectError`) and `target_addr.rs` (`TargetAddr` + the address
//! codec) — filled by the wire-vocabulary extraction task.
//! codec), filled by the wire-vocabulary extraction task.
pub mod stream;
pub mod target_addr;
+4 -4
View File
@@ -1,10 +1,10 @@
//! Forked from fast-socks5 v1.0.0 (upstream `src/util/stream.rs` —
//! Forked from fast-socks5 v1.0.0 (upstream `src/util/stream.rs`;
//! extraction; ownership per ADR-013, see the crate root's `mod.rs`).
//!
//! Upstream carries the `read_exact!`/`ready!` macros, `ConnectError`,
//! and the `tcp_connect*` dialers; only the `read_exact!` macro +
//! `ConnectError` rode the extraction (the dialers and the `ready!`
//! macro are the cut kernel-net region — the crate's local backend is
//! macro are the cut kernel-net region: the crate's local backend is
//! first-party tokio code, ADR-008, and nothing in the extracted
//! region used `ready!`). Extraction-time adjustment: upstream
//! exports the macro at the crate root via `#[macro_export]`; here it
@@ -13,11 +13,11 @@
//! public API).
//!
//! [`ConnectError`] is currently constructed nowhere in-tree (review
//! 001 C-1 — upstream's sole construction site was the cut
//! 001 C-1: upstream's sole construction site was the cut
//! `tcp_connect` dialer). The keep-or-cut trigger (cut if the
//! producer layer landed without consuming it) fired and the
//! disposition is **keep** (review 002 N-1): it stays as the
//! hand-port map's companion — the dial-error → reply-code mapping
//! hand-port map's companion (the dial-error → reply-code mapping
//! (`to_reply_error`) is what a future kernel-net port (e.g. the
//! dial-callback shape, ADR-004) would reuse, and its mapping is
//! pinned by the module's test.
+7 -6
View File
@@ -1,10 +1,11 @@
//! Forked from fast-socks5 v1.0.0 (upstream `src/util/target_addr.rs`
//! — extraction; ownership per ADR-013, see the crate root's
//! Forked from fast-socks5 v1.0.0 (upstream `src/util/target_addr.rs`;
//! extraction; ownership per ADR-013, see the crate root's
//! `mod.rs`).
//!
//! Upstream carries `TargetAddr`, the address codec (`read_address`),
//! and the `resolve_dns` helper; only the codec rode the extraction
//! (DNS resolution is the cut net region — `tokio::net::lookup_host`
//! (DNS resolution is the cut net region, since
//! `tokio::net::lookup_host`
//! is not in the wasm-clean graph). The `resolve_dns` method is cut
//! with its two `AddrError` variants (`DNSResolutionFailed`,
//! `NoDNSRecords`).
@@ -110,7 +111,7 @@ impl TargetAddr {
/// The blocking `ToSocketAddrs` form: IP targets resolve directly;
/// domain targets cannot be resolved here (domain resolution is the
/// egress's job — ADR-012) and error.
/// egress's job, per ADR-012) and error.
impl std::net::ToSocketAddrs for TargetAddr {
type Iter = IntoIter<SocketAddr>;
@@ -210,7 +211,7 @@ impl ToTargetAddr for &TargetAddr {
pub enum Addr {
V4([u8; 4]),
V6([u8; 16]),
// `String` — validated UTF-8 at the codec boundary (`read_address`
// `String`, validated UTF-8 at the codec boundary (`read_address`
// converts before constructing).
Domain(String),
}
@@ -218,7 +219,7 @@ pub enum Addr {
/// This function is used by the client & the server.
///
/// The domain arm's allocation is bounded by construction: the wire
/// length field is one byte, so the claimed length is at most 255 —
/// length field is one byte, so the claimed length is at most 255;
/// the attacker-controlled byte cannot ask for more. Any change that
/// widens that field (an FRAG-style extension) requires an explicit
/// cap here before the allocation.
+32 -32
View File
@@ -1,5 +1,5 @@
//! The ASSOCIATE datagram frame codec (ADR-003): the crate's one
//! framed stage — `[len: u16 BE][addr + data]` per datagram, both
//! 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
@@ -7,18 +7,18 @@
//! `frame_datagram`/`DatagramReader` (same F-2 behavior; no
//! dependency on it).
//!
//! The inner bytes are the RFC 1928 address encoding — exactly
//! 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.
//! 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 —
//! 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
//! 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.
//!
@@ -26,7 +26,7 @@
//! 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
//! (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
@@ -34,9 +34,9 @@
//! 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:
//! 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
//! `(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`].)
@@ -44,11 +44,11 @@
//! **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
//! empty datagram and a zero-byte read are different shapes; that is
//! why 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
//! 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};
@@ -60,7 +60,7 @@ 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,
/// 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;
@@ -69,12 +69,12 @@ 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
/// 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).
/// 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
/// [`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`,
@@ -87,24 +87,24 @@ const FRAME_LEN_FIELD: usize = 2;
#[derive(Debug, Error)]
pub enum WireError {
/// A frame's inner bytes (`addr + data`) exceed
/// [`MAX_INNER_LEN`] — the u16 length field would wrap. Rejected
/// [`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`).
/// refused, e.g. `AddrError::DomainLenTooLong`).
#[error("address encode: {0}")]
Address(#[from] AddrError),
/// A completed frame declared `len == 0` — zero inner bytes, no
/// 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
/// `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")]
@@ -122,7 +122,7 @@ pub enum WireError {
}
/// The RFC 1928 address encoding's length for `addr` (ATYP + addr +
/// port — exactly `TargetAddr::to_be_bytes()`'s size): 7 bytes for
/// 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`.
@@ -137,10 +137,10 @@ pub fn addr_encoding_len(addr: &TargetAddr) -> usize {
/// 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
/// 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
/// `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()?;
@@ -158,7 +158,7 @@ pub fn frame(addr: &TargetAddr, payload: &[u8]) -> Result<Bytes, WireError> {
Ok(buf.freeze())
}
/// Frame pre-encoded inner bytes (`addr + data` — exactly what
/// 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
@@ -177,14 +177,14 @@ pub fn frame_inner(inner: &[u8]) -> Result<Bytes, WireError> {
/// 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
/// `[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
/// 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
/// Reassembly is infallible (see the [`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
@@ -210,7 +210,7 @@ impl DatagramReader {
}
/// Feed raw bytes from the read half. Returns every frame that
/// completed with this feed, in order — zero or more per chunk.
/// 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.
@@ -249,13 +249,13 @@ impl DatagramReader {
}
/// `true` while a partial frame is in flight (the teardown
/// diagnostic — a clean peer never ends the stream mid-frame; a
/// 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
/// 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()
@@ -273,7 +273,7 @@ impl DatagramReader {
/// 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 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
@@ -567,7 +567,7 @@ mod tests {
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.
// 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();