Files
alksocks/tests/composed-egress.rs
T
glm-5.3-flash 1addedfdd0 assembly: the cross-cutting integration suite + docs sync (batch 2 closes)
- tests/assembly-integration.rs (7 tests, local + composed): the full
  -D story (vanilla cred-bearing client -> TCP door + UDP door ->
  producer w/ local providers -> real echo targets; CONNECT + BIND +
  ASSOCIATE live, one-command-per-connection, teardown + fresh
  sessions), the composed story (direct_egress + bindfirst_accept +
  real TCP dial; all three commands; the lazy-table dial-count
  witness), mixed assembly (channels-native + door clients on one
  registration; composed seams beside native sessions), the assembly
  teardown matrix (UDP-door idle bound; channel reaped; fresh
  association after), the producer door driving all three commands,
  and the -D domain-target pin (resolution at the producer's seam,
  ADR-012).
- Fix the drift the suite surfaced: ComposedEgress::next_reply ended
  fresh (never-opened-table) associations at birth — the ended-relay
  Err now fires only once a tunnel existed and every relay ended; the
  fresh table parks (the readiness contract). Regression-pinned in
  tests/composed-egress.rs (fresh_association_parks_before_any_
  tunnel_is_opened).
- Harness: the composed-accept machinery single-sourced into
  tests/harness.rs (the composed suites' shapes + the five-gate
  composed identity); composed-accept.rs consumes the shared shapes.
- Docs sync: README Current State (the assembly layer landed; both
  suites named), lib.rs crate docs (the assembly-layer paragraph),
  consumer.md (the TokenClient door-only contract + the
  one-command-per-connection posture reconciled).
- Task assembly-integration-suite: completed; Notes + Summary filled
  (the local-on-wasm failure observed: mio's unsupported-wasm error).

Verification: cargo test (375), cargo test --all-features (615),
clippy --all-targets -D warnings (default + all-features), fmt --check,
doc --no-deps (0 warnings), both wasm checks (default + composed),
local-on-wasm fails as designed.
2026-09-25 23:03:13 +00:00

434 lines
17 KiB
Rust

//! The composed egress integration tests (ADR-008's composed variant,
//! POC #2's `ComposedEgress` shape, end to end over channels): a
//! tunnel-producing peer (the alktunnels registry shape —
//! `register_tunnel_direct_openable` over a UDP echo dial), the
//! composed egress against it, datagram round-trips through the
//! tunnel (multiple destinations → multiple tunnels), the LRU
//! eviction at cap, the readiness contract re-pinned, the
//! closed-queue → association-end shape, and framing-once.
//!
//! The topology: the SOCKS5-style harness (consumer calls the
//! producer's open ops) carrying alktunnels' direct op — the producer
//! side registers `register_tunnel_direct_openable` (the
//! alktunnels 0.2.0 registry shape), the consumer's
//! `ChannelClient` is what the composed egress rides
//! (`TunnelSession::open_direct`).
#![cfg(feature = "composed")]
mod harness;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use alktunnels::params::Substrate;
use alktunnels::producer::{
DialFn as TunnelDialFn, TargetHandle as TunnelTargetHandle, TunnelEstablishError,
};
use alktunnels::wire::DatagramReader as TunnelDatagramReader;
use bytes::Bytes;
use tokio::io::{AsyncRead, AsyncWriteExt};
use alksocks::composed::{direct_egress, ComposedEgress};
use alksocks::egress::{Egress, EgressError};
use alksocks::rfc1928::util::target_addr::TargetAddr;
/// An echo dial closure over the alktunnels direct-op `DialFn` shape:
/// an in-process framed UDP pipe per dial (the framed-adapter shape —
/// the "target" decodes the tunnel codec and echoes each datagram).
/// Counts the dialed destinations (the multiple-tunnels assertion).
fn counting_udp_echo_dial(dial_count: Arc<AtomicUsize>) -> TunnelDialFn {
let dial_count = Arc::clone(&dial_count);
Arc::new(move |substrate: Substrate, backing: &str| {
let dial_count = Arc::clone(&dial_count);
let _ = backing.to_string();
Box::pin(async move {
if substrate != Substrate::Udp {
return Err(TunnelEstablishError::DialFailed(format!(
"non-udp dial not wired: {substrate:?}"
)));
}
dial_count.fetch_add(1, Ordering::SeqCst);
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
tokio::spawn(async move {
use tokio::io::AsyncReadExt;
let (mut t_read, mut t_write) = tokio::io::split(target_side);
let mut reader = TunnelDatagramReader::new();
let mut buf = vec![0u8; 16 * 1024];
loop {
let n = match t_read.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
for dg in reader.feed(&buf[..n]) {
let framed = match alktunnels::wire::frame_datagram(&dg) {
Ok(f) => f,
Err(_) => return,
};
if t_write.write_all(&framed).await.is_err() {
return;
}
let _ = t_write.flush().await;
}
}
});
let (c_read, c_write) = tokio::io::split(consumer_side);
Ok(TunnelTargetHandle {
read: Box::new(c_read),
write: Box::new(c_write),
})
})
})
}
/// Wire the topology: the producer registers the alktunnels direct op
/// (over the given dial) on its serving registry; the consumer's
/// `ChannelClient` is the direct-op caller the composed egress rides.
async fn wire_direct(dial: TunnelDialFn) -> harness::TunnelDirectTopology {
harness::wire_tunnel_direct(dial).await
}
/// The destinations the tests dial (distinct table keys).
fn dest(port: u16) -> TargetAddr {
TargetAddr::Ip(std::net::SocketAddr::from(([10, 0, 0, 1], port)))
}
/// Build the egress against the topology (the consumer's client is
/// the direct-op caller).
async fn egress_for(
topo: &harness::TunnelDirectTopology,
max_tunnels: usize,
) -> Box<dyn Egress + Send> {
let factory = direct_egress(Arc::clone(&topo.consumer), max_tunnels);
factory().await.expect("egress builds")
}
/// A datagram round-trip through the tunnel: send to `addr`, expect
/// the echo back (the reply carries `addr + data` — inner bytes).
async fn round_trip(egress: &mut (dyn Egress + Send), addr: &TargetAddr, payload: &[u8]) {
egress
.send(addr.clone(), Bytes::from(payload.to_vec()))
.await
.expect("send egresses");
let reply = tokio::time::timeout(std::time::Duration::from_secs(10), egress.next_reply())
.await
.expect("reply within timeout")
.expect("next_reply ok")
.expect("a reply");
let expected = {
let mut v = addr.to_be_bytes().expect("addr encodes");
v.extend_from_slice(payload);
Bytes::from(v)
};
assert_eq!(reply, expected, "the echo reports the destination");
}
#[tokio::test]
async fn datagrams_round_trip_per_destination_multiple_tunnels() {
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::level_filters::LevelFilter::TRACE)
.with_test_writer()
.try_init();
let dial_count = Arc::new(AtomicUsize::new(0));
let topo = wire_direct(counting_udp_echo_dial(Arc::clone(&dial_count))).await;
let mut egress = egress_for(&topo, 8).await;
tracing::info!("dial_count={dial_count:?}");
// Three destinations → three tunnels (lazy opens on first
// datagram each), round-trips on each, in order.
for port in [53u16, 123, 9999] {
round_trip(egress.as_mut(), &dest(port), b"ping").await;
}
assert_eq!(
dial_count.load(Ordering::SeqCst),
3,
"one dial per new destination"
);
// A repeat destination reuses its tunnel (no new dial).
round_trip(egress.as_mut(), &dest(53), b"again").await;
assert_eq!(
dial_count.load(Ordering::SeqCst),
3,
"the table hit reuses the tunnel"
);
// The readiness contract: with nothing in flight, `next_reply`
// parks (`Pending`), never a hot `Ok(None)`.
let mut poll = egress.next_reply();
tokio::time::timeout(std::time::Duration::from_millis(50), &mut poll)
.await
.expect_err("next_reply stays Pending when nothing is ready");
}
/// A fresh association (no tunnel ever opened) parks in `next_reply`
/// — the stage's select is governed by the channel branch until the
/// first datagram opens a tunnel (the assembly suite's drift finding:
/// the ended-relay `Err` fired on the never-opened table too, killing
/// every quiescent association at birth). Pinned on its own.
#[tokio::test]
async fn fresh_association_parks_before_any_tunnel_is_opened() {
let dial_count = Arc::new(AtomicUsize::new(0));
let topo = wire_direct(counting_udp_echo_dial(Arc::clone(&dial_count))).await;
let mut egress = egress_for(&topo, 8).await;
// The never-opened table: `next_reply` parks (Pending), never an
// instantly-ready `Err` (the stage would end the association
// before its first datagram).
{
let mut poll = egress.next_reply();
tokio::time::timeout(std::time::Duration::from_millis(100), &mut poll)
.await
.expect_err("the fresh association's next_reply parks");
}
// After the first datagram opens a tunnel, the normal contract
// holds (the round-trip works).
round_trip(egress.as_mut(), &dest(53), b"after").await;
assert_eq!(dial_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn lru_eviction_reopens_evicted_destinations() {
let dial_count = Arc::new(AtomicUsize::new(0));
let topo = wire_direct(counting_udp_echo_dial(Arc::clone(&dial_count))).await;
let mut egress = egress_for(&topo, 2).await;
// Fill the table to the cap (2): two destinations dial.
round_trip(egress.as_mut(), &dest(53), b"a").await;
round_trip(egress.as_mut(), &dest(123), b"b").await;
assert_eq!(dial_count.load(Ordering::SeqCst), 2);
// A third destination evicts the LRU entry (dest 53) and dials.
round_trip(egress.as_mut(), &dest(9999), b"c").await;
assert_eq!(
dial_count.load(Ordering::SeqCst),
3,
"the third destination dialed"
);
// The LRU (dest 53) was evicted: re-touching it re-dials (the
// cap stayed — no fourth tunnel above it).
round_trip(egress.as_mut(), &dest(53), b"redial").await;
assert_eq!(
dial_count.load(Ordering::SeqCst),
4,
"the evicted destination re-dialed"
);
// The recent entries survived: dest 9999 reuses; dest 123 was
// the eviction victim of the redial, so it re-dials (the cap
// stayed at 2 throughout).
round_trip(egress.as_mut(), &dest(9999), b"reuse").await;
assert_eq!(
dial_count.load(Ordering::SeqCst),
4,
"the recency survivor reused"
);
round_trip(egress.as_mut(), &dest(123), b"redial too").await;
assert_eq!(
dial_count.load(Ordering::SeqCst),
5,
"the eviction victim re-dialed"
);
}
/// The framing-once probe: the reply the stage would frame is exactly
/// `addr.to_be_bytes() + payload` — no double prefix. Pinned through
/// the egress's own reply (the round-trip asserts equality with the
/// inner form); here the composition is asserted at the wire boundary:
/// the egress's reply, fed through `frame_inner` (the wire layer's
/// one framing application), decodes to the original (addr, payload).
#[tokio::test]
async fn framing_once_replies_decode_through_the_wire_layer_exactly_once() {
let dial_count = Arc::new(AtomicUsize::new(0));
let topo = wire_direct(counting_udp_echo_dial(Arc::clone(&dial_count))).await;
let mut egress = egress_for(&topo, 8).await;
let addr = dest(4242);
let payload = b"frame once";
egress
.send(addr.clone(), Bytes::from_static(payload))
.await
.expect("send");
let inner = tokio::time::timeout(std::time::Duration::from_secs(10), egress.next_reply())
.await
.expect("reply within timeout")
.expect("next_reply ok")
.expect("a reply");
// The egress's reply carries NO length prefix (inner bytes).
let mut expected_inner = addr.to_be_bytes().expect("addr encodes");
expected_inner.extend_from_slice(payload);
assert_eq!(
inner,
Bytes::from(expected_inner),
"inner bytes, no double prefix"
);
// Feeding it through the wire layer's one framing application
// (`frame_inner` → `DatagramReader`) decodes the original pair.
let framed = alksocks::wire::frame_inner(&inner).expect("the wire layer frames once");
let mut reader = alksocks::wire::DatagramReader::new();
let mut decoded = None;
for dg in reader.feed(&framed) {
decoded = Some(dg.expect("frame decodes"));
}
let (src, payload_back) = decoded.expect("one datagram");
assert_eq!(src, addr);
assert_eq!(&payload_back[..], payload);
}
/// The closed-queue → association-end shape: when the tunnel's
/// producer side ends (the dial's target EOFs), the relay task exits
/// (EOF on the read half) and — with every relay ended — the egress's
/// `next_reply` returns `Err` (never a hot `Ok(None)`).
#[tokio::test]
async fn closed_queue_yields_err_not_a_hot_none() {
// The dial's "target" echoes exactly one datagram, then EOFs the
// tunnel (the write end drops after one reply).
let dial: TunnelDialFn = Arc::new(move |_substrate: Substrate, _backing: &str| {
Box::pin(async move {
let (consumer_side, mut target_side) = tokio::io::duplex(64 * 1024);
tokio::spawn(async move {
use tokio::io::AsyncReadExt;
let mut reader = TunnelDatagramReader::new();
let mut buf = vec![0u8; 16 * 1024];
'outer: loop {
let n = match target_side.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
if let Some(dg) = reader.feed(&buf[..n]).into_iter().next() {
let framed = match alktunnels::wire::frame_datagram(&dg) {
Ok(f) => f,
Err(_) => return,
};
if target_side.write_all(&framed).await.is_err() {
return;
}
// One echo, then EOF (the channel ends
// producer-side; the relay task drains the
// EOF → exits → the queue closes).
break 'outer;
}
}
let _ = target_side.shutdown().await;
});
let (c_read, c_write) = tokio::io::split(consumer_side);
Ok(TunnelTargetHandle {
read: Box::new(c_read),
write: Box::new(c_write),
})
})
});
let topo = wire_direct(dial).await;
let mut egress = egress_for(&topo, 8).await;
let addr = dest(53);
egress
.send(addr.clone(), Bytes::from_static(b"only"))
.await
.expect("send");
// The one echo comes back.
let reply = tokio::time::timeout(std::time::Duration::from_secs(10), egress.next_reply())
.await
.expect("reply within timeout")
.expect("next_reply ok")
.expect("a reply");
let mut expected = addr.to_be_bytes().expect("addr encodes");
expected.extend_from_slice(b"only");
assert_eq!(reply, Bytes::from(expected));
// The tunnel ended → the relay exited → the queue closed. The
// next `next_reply` returns `Err` (the association-end shape) —
// awaited to prove it is an `Err`, not an instantly-ready
// `Ok(None)` spinning the stage.
let result = tokio::time::timeout(std::time::Duration::from_secs(10), egress.next_reply())
.await
.expect("the closed queue surfaces (relay task drains the EOF)")
.expect_err("the closed queue returns Err");
assert!(
matches!(result, EgressError::General(ref m) if m.contains("relays ended")),
"the association-end error: {result}"
);
}
/// The domain form passes through unresolved (ADR-012 — the ssh `-D`
/// semantic): a domain destination renders its domain into the direct
/// op's target verbatim (the dial closure sees the unresolved host).
#[tokio::test]
async fn domain_destinations_pass_through_unresolved() {
let seen = Arc::new(tokio::sync::Mutex::new(None::<String>));
let seen_for_dial = Arc::clone(&seen);
let dial: TunnelDialFn = Arc::new(move |substrate: Substrate, backing: &str| {
let backing = backing.to_string();
let seen = Arc::clone(&seen_for_dial);
Box::pin(async move {
if substrate != Substrate::Udp {
return Err(TunnelEstablishError::DialFailed("udp only".into()));
}
*seen.lock().await = Some(backing);
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
tokio::spawn(async move {
use tokio::io::AsyncReadExt;
let (mut t_read, mut t_write) = tokio::io::split(target_side);
let mut reader = TunnelDatagramReader::new();
let mut buf = vec![0u8; 16 * 1024];
loop {
let n = match t_read.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
for dg in reader.feed(&buf[..n]) {
let framed = match alktunnels::wire::frame_datagram(&dg) {
Ok(f) => f,
Err(_) => return,
};
if t_write.write_all(&framed).await.is_err() {
return;
}
let _ = t_write.flush().await;
}
}
});
let (c_read, c_write) = tokio::io::split(consumer_side);
Ok(TunnelTargetHandle {
read: Box::new(c_read),
write: Box::new(c_write),
})
})
});
let topo = wire_direct(dial).await;
let mut egress = egress_for(&topo, 8).await;
let addr = TargetAddr::Domain("example.test".into(), 5353);
egress
.send(addr.clone(), Bytes::from_static(b"q"))
.await
.expect("send");
let reply = tokio::time::timeout(std::time::Duration::from_secs(10), egress.next_reply())
.await
.expect("reply within timeout")
.expect("next_reply ok")
.expect("a reply");
let mut expected = addr.to_be_bytes().expect("domain addr encodes");
expected.extend_from_slice(b"q");
assert_eq!(reply, Bytes::from(expected));
assert_eq!(
seen.lock().await.as_deref(),
Some("example.test:5353"),
"the domain passed through unresolved (producer-side resolution)"
);
}
/// The compile-shape pin: the composed egress's session shell rides
/// the table entry (Send); the owner wrapper compiles against the
/// alktunnels surface (`TakenHalves`-shaped halves + the session).
#[test]
fn composed_shapes_compile() {
fn assert_send<T: Send>() {}
assert_send::<ComposedEgress>();
assert_send::<alksocks::composed::SessionOwner<Box<dyn AsyncRead + Send + Unpin>>>();
}