- 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
480 lines
18 KiB
Rust
480 lines
18 KiB
Rust
//! The consumer UDP ASSOCIATE integration tests: `AssociateSession`
|
|
//! end to end through the harness's consumer-side topology (the
|
|
//! producer's real command-dispatch path over channels; the framed
|
|
//! datagram stage, ADR-003, driven by the wrapper-aware client, POC
|
|
//! #2's shape).
|
|
//!
|
|
//! Covered: associate → sentinel reply (received, never used for
|
|
//! addressing) → send/recv round-trips (multiple in-order datagrams,
|
|
//! one association); the split-datagram persistence test (reader
|
|
//! state persists across `recv_datagram` calls (the POC's
|
|
//! 2-byte-prefix chunk finding); empty-payload datagram round-trip
|
|
//! (F-2, never EOF); shutdown → the producer's stage ends and a
|
|
//! fresh associate succeeds immediately; Drop teardown (drop
|
|
//! mid-stream → the producer's stage sees EOF, the connection stays
|
|
//! healthy); the ASSOCIATE refusal surfaced as a typed error with no
|
|
//! phantom channel; CONNECT + ASSOCIATE on the same registration
|
|
//! (the regression guard extended).
|
|
|
|
mod harness;
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
use alksocks::consumer::{AssociateSession, Socks5ChannelSession};
|
|
use alksocks::egress::EgressError;
|
|
use alksocks::error::SocksSessionError;
|
|
use alksocks::rfc1928::consts;
|
|
use alksocks::rfc1928::util::target_addr::{TargetAddr, ToTargetAddr};
|
|
use alksocks::SocksError as SocksErrorKind;
|
|
|
|
use harness::{echo_dial, echo_egress, refusing_egress, wire_consumer_side_with};
|
|
|
|
/// The no-leaked-channels probe (the connect/bind suites' shape,
|
|
/// re-pinned for the ASSOCIATE session): both managers hold only
|
|
/// channel 0 (the producer's entry reaps by its open-op wrapper);
|
|
/// the probe polls past the reap race.
|
|
async fn no_leaked_channels(topo: &harness::ConsumerSideTopology) {
|
|
let deadline = std::time::Duration::from_secs(5);
|
|
let consumer_ids = topo.consumer.manager().channel_ids();
|
|
assert!(
|
|
consumer_ids.iter().all(|&id| id == 0),
|
|
"consumer leaked channel entries: {consumer_ids:?}"
|
|
);
|
|
tokio::time::timeout(deadline, async {
|
|
loop {
|
|
let producer_ids = topo.producer_manager.channel_ids();
|
|
if producer_ids.iter().all(|&id| id == 0) {
|
|
return;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("the producer's handler-exit teardown reaps the entry");
|
|
}
|
|
|
|
/// The happy path: `associate` → the reply's sentinel `BND.ADDR` is
|
|
/// received (RFC fidelity) and never used for addressing → send/recv
|
|
/// round-trips through the echo egress: multiple datagrams, in
|
|
/// order, on one association; the establisher witnessed the opener
|
|
/// identity (CF-006).
|
|
#[tokio::test]
|
|
async fn associate_round_trips_multiple_datagrams_one_association() {
|
|
let topo = wire_consumer_side_with(
|
|
echo_dial(),
|
|
Some(echo_egress()),
|
|
None,
|
|
Some(harness::consumer_identity()),
|
|
)
|
|
.await;
|
|
|
|
let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
|
|
.await
|
|
.expect("the association associates");
|
|
|
|
// The reply's BND.ADDR is the producer's sentinel (127.0.0.1:0,
|
|
// ADR-003): received for RFC fidelity, never used for addressing.
|
|
let relay = assoc
|
|
.relay_addr()
|
|
.expect("the reply carried BND.ADDR")
|
|
.into_string_and_port();
|
|
assert_eq!(relay.0, "127.0.0.1", "the in-band sentinel BND.ADDR");
|
|
assert_eq!(relay.1, 0);
|
|
|
|
let target = TargetAddr::Ip("10.0.0.1:53".parse().unwrap());
|
|
for i in 0..5u8 {
|
|
let payload = vec![i; 10];
|
|
assoc
|
|
.send_datagram(&target, &payload)
|
|
.await
|
|
.expect("send datagram");
|
|
}
|
|
for i in 0..5u8 {
|
|
let (src, payload) =
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
|
.await
|
|
.expect("recv within timeout")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(src, target, "the echo reports the destination");
|
|
assert_eq!(payload, bytes::Bytes::from(vec![i; 10]), "in-order echo");
|
|
}
|
|
|
|
// The association is alive: another datagram round-trips.
|
|
assoc.send_datagram(&target, b"alive").await.expect("send");
|
|
let (_, payload) =
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(&payload[..], b"alive");
|
|
|
|
assert!(
|
|
topo.consumer.manager().has_channel(assoc.channel_id()),
|
|
"the adopted channel entry exists while associated"
|
|
);
|
|
|
|
let reaped = assoc.shutdown().await.expect("half-close");
|
|
assert!(reaped, "the adopted channel entry existed");
|
|
no_leaked_channels(&topo).await;
|
|
}
|
|
|
|
/// The split-datagram persistence test (POC #2's finding: the
|
|
/// reader state must persist across `recv_datagram` calls): a large
|
|
/// datagram's frame (under the u16 joint bound but well over the 16
|
|
/// KB read buffer) spans multiple channel reads AND multiple
|
|
/// `recv_datagram` calls; the first call returns the earlier small
|
|
/// datagram while the large one is mid-flight, the next call
|
|
/// reassembles it across its internal reads, and a trailing small
|
|
/// datagram batched in the same read batch drains through the
|
|
/// pending queue.
|
|
#[tokio::test]
|
|
async fn recv_datagram_state_persists_across_calls() {
|
|
let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;
|
|
|
|
let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
|
|
.await
|
|
.expect("the association associates");
|
|
let target = TargetAddr::Ip("10.0.0.2:53".parse().unwrap());
|
|
|
|
// The order tells the story: "first" completes early; the 60 KiB
|
|
// datagram (60007 inner bytes, legal but over the 16 KB read
|
|
// buffer) is mid-flight when the first call returns; "third"
|
|
// batches after it.
|
|
assoc
|
|
.send_datagram(&target, b"first")
|
|
.await
|
|
.expect("send the first datagram");
|
|
let big = vec![7u8; 60 * 1024];
|
|
assoc
|
|
.send_datagram(&target, &big)
|
|
.await
|
|
.expect("send the big datagram");
|
|
assoc
|
|
.send_datagram(&target, b"third")
|
|
.await
|
|
.expect("send the third datagram");
|
|
|
|
let (src, first) =
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(src, target);
|
|
assert_eq!(&first[..], b"first", "the first datagram, in order");
|
|
|
|
// The large datagram reassembles across the call boundary (its
|
|
// mid-frame state persisted into this call), the POC finding's
|
|
// shape.
|
|
let (src, second) =
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(src, target);
|
|
assert_eq!(&second[..], &big[..], "the split datagram reassembled");
|
|
|
|
// The trailing datagram rode the same read batches: the pending
|
|
// queue carries it to this call.
|
|
let (_, third) =
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(&third[..], b"third", "the batched datagram drained");
|
|
|
|
let reaped = assoc.shutdown().await.expect("half-close");
|
|
assert!(reaped);
|
|
no_leaked_channels(&topo).await;
|
|
}
|
|
|
|
/// An empty-payload datagram is a legal datagram (never EOF; the
|
|
/// F-2 invariant, end to end through the echo egress), and the
|
|
/// association survives it (pre → empty → post, in order).
|
|
#[tokio::test]
|
|
async fn empty_payload_datagram_round_trips_and_survives() {
|
|
let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;
|
|
|
|
let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
|
|
.await
|
|
.expect("the association associates");
|
|
let target = TargetAddr::Ip("10.0.0.3:53".parse().unwrap());
|
|
|
|
assoc
|
|
.send_datagram(&target, b"pre")
|
|
.await
|
|
.expect("send pre");
|
|
assoc
|
|
.send_datagram(&target, b"")
|
|
.await
|
|
.expect("an empty payload is a legal datagram");
|
|
assoc
|
|
.send_datagram(&target, b"post")
|
|
.await
|
|
.expect("send post");
|
|
|
|
let (_, pre) = tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(&pre[..], b"pre");
|
|
|
|
let (src, empty) =
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(src, target);
|
|
assert!(
|
|
empty.is_empty(),
|
|
"the empty-payload datagram round-trips (never EOF)"
|
|
);
|
|
|
|
let (_, post) = tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(&post[..], b"post");
|
|
|
|
let reaped = assoc.shutdown().await.expect("half-close");
|
|
assert!(reaped);
|
|
no_leaked_channels(&topo).await;
|
|
}
|
|
|
|
/// 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), and a fresh associate
|
|
/// succeeds immediately after (the POC's test 4, from the consumer
|
|
/// side; the same registration is unpoisoned).
|
|
#[tokio::test]
|
|
async fn shutdown_ends_the_association_and_a_fresh_associate_succeeds() {
|
|
let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;
|
|
|
|
let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
|
|
.await
|
|
.expect("the association associates");
|
|
let channel_id = assoc.channel_id();
|
|
let target = TargetAddr::Ip("10.0.0.4:53".parse().unwrap());
|
|
assoc
|
|
.send_datagram(&target, b"before")
|
|
.await
|
|
.expect("send before shutdown");
|
|
let (_, echoed) =
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(&echoed[..], b"before");
|
|
|
|
// Half-close: the producer's stage exits on the EOF sentinel.
|
|
assoc.shutdown().await.expect("shutdown");
|
|
assert!(
|
|
!topo.consumer.manager().has_channel(channel_id),
|
|
"the adopted channel entry was reaped"
|
|
);
|
|
|
|
// The connection is healthy: a fresh associate round-trips.
|
|
let mut fresh = AssociateSession::associate(topo.consumer.as_ref())
|
|
.await
|
|
.expect("fresh associate after the shutdown");
|
|
fresh
|
|
.send_datagram(&target, b"fresh")
|
|
.await
|
|
.expect("send after fresh associate");
|
|
let (_, echoed) =
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), fresh.recv_datagram())
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(&echoed[..], b"fresh");
|
|
let reaped = fresh.shutdown().await.expect("shutdown");
|
|
assert!(reaped);
|
|
no_leaked_channels(&topo).await;
|
|
}
|
|
|
|
/// Drop teardown: dropping a live association mid-stream reaps the
|
|
/// adopted channel entry (no leak), the producer's stage sees the
|
|
/// structural EOF (the mux's implicit-EOF sentinel on the dropped
|
|
/// write half), and the connection stays healthy: a fresh associate
|
|
/// succeeds immediately after.
|
|
#[tokio::test]
|
|
async fn drop_teardown_reaps_and_connection_stays_healthy() {
|
|
let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;
|
|
|
|
let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
|
|
.await
|
|
.expect("the association associates");
|
|
let channel_id = assoc.channel_id();
|
|
assoc
|
|
.send_datagram(TargetAddr::Ip("10.0.0.5:53".parse().unwrap()), b"mid")
|
|
.await
|
|
.expect("send before the drop");
|
|
let (_, echoed) =
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(&echoed[..], b"mid");
|
|
|
|
// Drop mid-stream (the teardown-ownership posture).
|
|
drop(assoc);
|
|
assert!(
|
|
!topo.consumer.manager().has_channel(channel_id),
|
|
"the adopted channel entry was reaped on Drop"
|
|
);
|
|
no_leaked_channels(&topo).await;
|
|
|
|
// The connection is healthy: a fresh associate round-trips.
|
|
let mut fresh = AssociateSession::associate(topo.consumer.as_ref())
|
|
.await
|
|
.expect("fresh associate after the drop");
|
|
let target = TargetAddr::Ip("10.0.0.5:53".parse().unwrap());
|
|
fresh
|
|
.send_datagram(&target, b"again")
|
|
.await
|
|
.expect("send after the drop");
|
|
let (_, echoed) =
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), fresh.recv_datagram())
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(&echoed[..], b"again");
|
|
fresh.shutdown().await.expect("shutdown");
|
|
no_leaked_channels(&topo).await;
|
|
}
|
|
|
|
/// A missing egress seam: the ASSOCIATE refusal surfaces the RFC
|
|
/// reply code through the session type (`CommandNotSupported` 0x07,
|
|
/// ADR-008's missing-callback class), and no phantom channel remains
|
|
/// on either side (teardown-at-birth).
|
|
#[tokio::test]
|
|
async fn missing_egress_refusal_surfaces_rfc_reply_code_no_phantom_channel() {
|
|
let topo = wire_consumer_side_with(echo_dial(), None, None, None).await;
|
|
|
|
let err = AssociateSession::associate(topo.consumer.as_ref())
|
|
.await
|
|
.expect_err("no egress wired; the ASSOCIATE must refuse");
|
|
|
|
match &err {
|
|
SocksSessionError::Rfc(SocksErrorKind::ReplyError(code)) => {
|
|
assert_eq!(code.as_u8(), consts::SOCKS5_REPLY_COMMAND_NOT_SUPPORTED);
|
|
}
|
|
other => panic!("expected an Rfc reply-code refusal, got {other:?}"),
|
|
}
|
|
|
|
no_leaked_channels(&topo).await;
|
|
|
|
// The connection is healthy: wiring the egress serves ASSOCIATE.
|
|
let topo2 = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;
|
|
let mut assoc = AssociateSession::associate(topo2.consumer.as_ref())
|
|
.await
|
|
.expect("fresh associate after the refusal");
|
|
assoc
|
|
.send_datagram(TargetAddr::Ip("10.0.0.6:53".parse().unwrap()), b"fresh")
|
|
.await
|
|
.expect("send");
|
|
let (_, echoed) =
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(&echoed[..], b"fresh");
|
|
assoc.shutdown().await.expect("shutdown");
|
|
no_leaked_channels(&topo2).await;
|
|
}
|
|
|
|
/// An egress-factory failure: the mapped RFC reply code surfaces from
|
|
/// `associate` (reply#1's code, since the factory refused before the
|
|
/// reply), and no phantom channel remains.
|
|
#[tokio::test]
|
|
async fn egress_factory_refusal_surfaces_the_mapped_reply_code() {
|
|
let topo = wire_consumer_side_with(
|
|
echo_dial(),
|
|
Some(refusing_egress(EgressError::Unreachable(
|
|
"no egress".into(),
|
|
))),
|
|
None,
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
let err = AssociateSession::associate(topo.consumer.as_ref())
|
|
.await
|
|
.expect_err("the egress factory refused");
|
|
match &err {
|
|
SocksSessionError::Rfc(SocksErrorKind::ReplyError(code)) => {
|
|
assert_eq!(code.as_u8(), consts::SOCKS5_REPLY_NETWORK_UNREACHABLE);
|
|
}
|
|
other => panic!("expected an Rfc reply-code refusal, got {other:?}"),
|
|
}
|
|
no_leaked_channels(&topo).await;
|
|
}
|
|
|
|
/// CONNECT + ASSOCIATE on the same registration (the regression
|
|
/// guard extended): one ALPN, both commands, interleaved: an
|
|
/// association and a CONNECT session live concurrently on the same
|
|
/// registration, both serving.
|
|
#[tokio::test]
|
|
async fn connect_and_associate_served_on_the_same_registration() {
|
|
let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;
|
|
|
|
let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
|
|
.await
|
|
.expect("the association associates");
|
|
let mut connect = Socks5ChannelSession::connect(
|
|
topo.consumer.as_ref(),
|
|
("echo.test", 443u16).to_target_addr().unwrap(),
|
|
)
|
|
.await
|
|
.expect("the CONNECT session connects");
|
|
|
|
// The association round-trips while the CONNECT pipe is open.
|
|
let target = TargetAddr::Ip("10.0.0.7:53".parse().unwrap());
|
|
assoc
|
|
.send_datagram(&target, b"udp")
|
|
.await
|
|
.expect("send datagram");
|
|
let (_, echoed) =
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("a datagram (not eof)")
|
|
.expect("a datagram");
|
|
assert_eq!(&echoed[..], b"udp");
|
|
|
|
// The CONNECT pipe round-trips while the association is open.
|
|
connect
|
|
.split()
|
|
.expect("pipe")
|
|
.1
|
|
.write_all(b"tcp")
|
|
.await
|
|
.expect("write");
|
|
let (mut read, _) = connect.split().expect("pipe");
|
|
let mut buf = [0u8; 3];
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_secs(10),
|
|
read.read_exact(&mut buf),
|
|
)
|
|
.await
|
|
.expect("echo timed out")
|
|
.expect("read the echo");
|
|
assert_eq!(&buf, b"tcp");
|
|
|
|
assoc.shutdown().await.expect("shutdown");
|
|
connect.close().await;
|
|
no_leaked_channels(&topo).await;
|
|
}
|