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

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

433 lines
15 KiB
Rust

//! The consumer-half integration tests: `Socks5ChannelSession`
//! (CONNECT) end to end through the harness's consumer-side topology
//! (the producer's real command-dispatch path over channels; the
//! alkcall 0.8.0 establishment surface consumed from the consumer's
//! own `ChannelClient`).
//!
//! Covered: connect → echo round-trip (payload bytes both
//! directions); the dial-witness target forms (IP-string parses to an
//! IP target, domain passes through unresolved (ADR-012)); refused
//! dial → the RFC reply code surfaced as a typed session error; the
//! scope-less FORBIDDEN open → typed open error, no session; drop
//! teardown (no leaked channel entries, the connection stays
//! healthy); half-close semantics (the write-half shutdown EOFs the
//! producer's pump cleanly); the compile-asserted no-`Clone` posture.
mod harness;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use alksocks::consumer::{Socks5ChannelSession, TakenInner};
use alksocks::egress::SocksDialError;
use alksocks::error::SocksSessionError;
use alksocks::rfc1928::consts;
use alksocks::rfc1928::util::target_addr::{TargetAddr, ToTargetAddr};
use alksocks::rfc1928::SocksError as SocksErrorKind;
use harness::{
echo_dial, refusing_dial, wire_consumer_side, wire_consumer_side_with, ConsumerSideTopology,
};
/// A `SocksOpenError`-carrying open failure (the FORBIDDEN probe's
/// expectation shape).
fn expect_forbidden(err: &SocksSessionError) -> bool {
match err {
SocksSessionError::Open(open) => match open.open_ref() {
alkcall::channels::client::ChannelOpenError::CallFailed { error } => {
error.code == "FORBIDDEN"
}
_ => false,
},
_ => false,
}
}
/// A dial that always refuses (the refusal-riding-in-stream probe's
/// dial; the RFC reply code must surface through the session type,
/// not as an open failure).
fn refusing_session_dial() -> alksocks::egress::DialFn {
refusing_dial(SocksDialError::Refused("target down".into()))
}
/// Both managers hold only channel 0 (the phantom-channel property,
/// asserted from both sides). The producer's entry is reaped by its
/// open-op wrapper when the pump handler exits (an await-able
/// teardown that races the consumer-side reap; this probe polls past
/// the race instead of asserting instantly.
async fn no_leaked_channels(topo: &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: `connect` → the session is a raw byte pipe → echo
/// round-trip both directions; the dial seam saw the parsed target;
/// the establisher witnessed the opener identity (CF-006).
#[tokio::test]
async fn connect_echo_round_trip_and_dial_witness() {
let topo = wire_consumer_side(echo_dial()).await;
let mut session = Socks5ChannelSession::connect(
topo.consumer.as_ref(),
("echo.test", 443u16).to_target_addr().unwrap(),
)
.await
.expect("the session connects");
// The `split()`-returned halves are the last use of the session;
// the tail test's alias import lives here.
session
.split()
.expect("post-reply pipe")
.1
.write_all(b"ping")
.await
.expect("write the probe");
let (mut read, _) = session.split().expect("post-reply pipe");
let mut buf = [0u8; 4];
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"ping");
// The establisher witnessed the per-call opener identity.
assert_eq!(
topo.identity_witness.lock().await.as_deref(),
Some("consumer")
);
session.close().await;
no_leaked_channels(&topo).await;
}
/// Target construction (ADR-012): an IP-string form parses to an IP
/// target (the dial seam receives `TargetAddr::Ip`); the domain form
/// passes through unresolved (`TargetAddr::Domain`). Both ride the
/// same happy path.
#[tokio::test]
async fn target_construction_ip_strings_parse_domains_pass_through() {
let targets = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let topo = wire_consumer_side(harness::witnessing_echo_dial(Arc::clone(&targets))).await;
// The domain form: passes through unresolved (the dial's job).
let session = Socks5ChannelSession::connect(
topo.consumer.as_ref(),
("echo.test", 443u16).to_target_addr().unwrap(),
)
.await
.expect("domain connect");
session.close().await;
// The IP-string form: parsed to an IP target by `ToTargetAddr`.
let session = Socks5ChannelSession::connect(
topo.consumer.as_ref(),
("10.0.0.1", 8443u16).to_target_addr().unwrap(),
)
.await
.expect("ip-string connect");
session.close().await;
let dialed = targets.lock().await.clone();
assert_eq!(
dialed,
vec![
TargetAddr::Domain("echo.test".to_owned(), 443),
TargetAddr::Ip(std::net::SocketAddr::from(([10, 0, 0, 1], 8443)))
],
"the dial seam received the parsed target forms (ADR-012)"
);
no_leaked_channels(&topo).await;
}
/// A refused dial: the RFC reply code surfaces through the session
/// type (`SocksSessionError::Rfc`, the in-stream refusal, never an
/// open failure), and no phantom channel remains on either side.
#[tokio::test]
async fn refused_dial_surfaces_rfc_reply_code_no_phantom_channel() {
let topo = wire_consumer_side(refusing_session_dial()).await;
let err = Socks5ChannelSession::connect(
topo.consumer.as_ref(),
("down.test", 80u16).to_target_addr().unwrap(),
)
.await
.expect_err("the dial refused; the reply code rides the session error");
match &err {
SocksSessionError::Rfc(SocksErrorKind::ReplyError(code)) => {
assert_eq!(code.as_u8(), consts::SOCKS5_REPLY_CONNECTION_REFUSED);
}
other => panic!("expected an Rfc reply-code refusal, got {other:?}"),
}
// Teardown-at-birth: the adopted channel is reaped before the
// error returns (no phantom channel, either side).
no_leaked_channels(&topo).await;
// The connection is healthy: a fresh connect through a working
// dial succeeds immediately.
let topo2 = wire_consumer_side(echo_dial()).await;
let session = Socks5ChannelSession::connect(
topo2.consumer.as_ref(),
("echo.test", 443u16).to_target_addr().unwrap(),
)
.await
.expect("fresh connect after the refusal");
session.close().await;
}
/// A scope-less identity: the registry's FORBIDDEN open failure is
/// the typed `SocksOpenError` through `SocksSessionError::Open`;
/// no session, no phantom channel (the open gate is the boundary).
#[tokio::test]
async fn forbidden_open_is_a_typed_open_error_with_no_session() {
let bare = alkcall::core::auth::Identity {
id: "scopeless".to_string(),
scopes: vec![],
resources: Default::default(),
};
let topo = wire_consumer_side_with(echo_dial(), None, None, Some(bare)).await;
let err = Socks5ChannelSession::connect(
topo.consumer.as_ref(),
("gated.test", 443u16).to_target_addr().unwrap(),
)
.await
.expect_err("a scope-less identity fails closed");
assert!(
expect_forbidden(&err),
"FORBIDDEN is the open error: {err:?}"
);
no_leaked_channels(&topo).await;
}
/// Drop teardown: dropping a live session mid-pipe reaps the adopted
/// channel entry (no leak) and the connection stays healthy: a
/// fresh connect succeeds immediately after.
#[tokio::test]
async fn drop_teardown_reaps_and_connection_stays_healthy() {
let topo = wire_consumer_side(echo_dial()).await;
let mut session = Socks5ChannelSession::connect(
topo.consumer.as_ref(),
("dropped.test", 443u16).to_target_addr().unwrap(),
)
.await
.expect("the session connects");
session
.split()
.expect("pipe")
.1
.write_all(b"mid")
.await
.expect("write before the drop");
let (mut read, _) = session.split().expect("pipe");
let mut buf = [0u8; 3];
read.read_exact(&mut buf).await.expect("echo before drop");
assert_eq!(&buf, b"mid");
// Drop mid-stream (the teardown-ownership posture).
drop(session);
no_leaked_channels(&topo).await;
// The connection is healthy: a fresh session round-trips.
let mut fresh = Socks5ChannelSession::connect(
topo.consumer.as_ref(),
("fresh.test", 443u16).to_target_addr().unwrap(),
)
.await
.expect("fresh connect after the drop");
fresh
.split()
.expect("pipe")
.1
.write_all(b"again")
.await
.expect("write");
let (mut read, _) = fresh.split().expect("pipe");
let mut buf = [0u8; 5];
tokio::time::timeout(
std::time::Duration::from_secs(10),
read.read_exact(&mut buf),
)
.await
.expect("echo timed out")
.expect("read the fresh echo");
assert_eq!(&buf, b"again");
fresh.close().await;
no_leaked_channels(&topo).await;
}
/// Half-close semantics: shutting the write half EOFs the producer's
/// pump cleanly (the EOF sentinel), while the read half stays alive
/// until the target's data flows. The pump's completion is the
/// producer-side evidence (the handler tracks the data plane; R-02).
#[tokio::test]
async fn half_close_write_eof_ends_the_producer_pump() {
let topo = wire_consumer_side(echo_dial()).await;
let session = Socks5ChannelSession::connect(
topo.consumer.as_ref(),
("halfclose.test", 443u16).to_target_addr().unwrap(),
)
.await
.expect("the session connects");
let channel_id = session.channel_id();
assert!(channel_id >= 1, "a producer-allocated id");
assert!(topo.consumer.manager().has_channel(channel_id));
// Split into owned halves via `into_inner` (the advanced-use
// accessor), then half-close the write half.
let TakenInner {
session: _owner,
inner,
} = session.into_inner().expect("the session is live");
let (mut read, mut write) = tokio::io::split(inner);
write.write_all(b"pre").await.expect("write");
let mut buf = [0u8; 3];
read.read_exact(&mut buf).await.expect("echo");
assert_eq!(&buf, b"pre");
write.shutdown().await.expect("half-close the pipe");
// The producer's pump observes the EOF sentinel and ends (the
// two-pump shape's shutdown-on-completion; the echo target's
// pump exits with it). The session-side observable: the read
// half drains to EOF.
drop(write);
let mut eof = [0u8; 1];
tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
match read.read(&mut eof).await {
Ok(0) | Err(_) => break,
Ok(_) => continue,
}
}
})
.await
.expect("the read resolves after the half-close");
drop(read);
drop(_owner);
no_leaked_channels(&topo).await;
}
/// `into_inner` yields the forked client type paired with the
/// teardown-owning session; the pair's `close` reaps the channel.
#[tokio::test]
async fn into_inner_pairs_the_pipe_with_the_teardown_owner() {
let topo = wire_consumer_side(echo_dial()).await;
let session = Socks5ChannelSession::connect(
topo.consumer.as_ref(),
("inner.test", 443u16).to_target_addr().unwrap(),
)
.await
.expect("the session connects");
let channel_id = session.channel_id();
let TakenInner { session, inner } = session.into_inner().expect("the session is live");
assert!(topo.consumer.manager().has_channel(channel_id));
let (mut read, write) = tokio::io::split(inner);
let mut buf = [0u8; 1];
// The pipe is idle; the close below reaps the entry.
let _ = tokio::time::timeout(std::time::Duration::from_millis(50), read.read(&mut buf)).await;
drop(write);
drop(read);
let reaped = session.close().await;
assert!(reaped, "the adopted channel entry existed");
no_leaked_channels(&topo).await;
}
/// The no-`Clone` posture: the type is `!Clone` because its fields
/// are (the `compile_fail` doctest pins the type-level posture).
#[test]
fn session_type_is_not_clone() {
// A `Clone`-bound assertion on a non-`Clone` type cannot be a
// runtime test; the doctest is the compile-assert. This probe
// pins the Send shape the type promises.
fn assert_send<T: Send>() {}
assert_send::<Socks5ChannelSession>();
}
/// The read half stays alive across a half-close (the echo flows
/// after the write half's EOF was already seen by the producer): a
/// payload written before the shutdown round-trips, and the read
/// half observes the target's own EOF after it (the two-pump
/// half-close semantics, both directions).
#[tokio::test]
async fn both_directions_survive_across_halves() {
let topo = wire_consumer_side(echo_dial()).await;
let mut session = Socks5ChannelSession::connect(
topo.consumer.as_ref(),
("halves.test", 443u16).to_target_addr().unwrap(),
)
.await
.expect("the session connects");
// Write half: two writes, flush each (the chunk stream carries
// them in order).
let (_, mut write) = session.split().expect("pipe");
write.write_all(b"one").await.expect("write one");
write.flush().await.expect("flush");
write.write_all(b"two").await.expect("write two");
write.flush().await.expect("flush");
drop(write);
// Read half: the echo arrives in order.
let (mut read, _) = session.split().expect("pipe");
let mut buf = [0u8; 6];
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"onetwo");
session.close().await;
no_leaked_channels(&topo).await;
}
/// The `split()` accessor's `Option` shape: `None` after the pipe
/// was taken (the spent-session posture; the borrow checker makes
/// the direct post-`into_inner` call the only reachable shape).
#[tokio::test]
async fn close_after_into_inner_reaps() {
let topo = wire_consumer_side(echo_dial()).await;
let session = Socks5ChannelSession::connect(
topo.consumer.as_ref(),
("spent.test", 443u16).to_target_addr().unwrap(),
)
.await
.expect("the session connects");
let channel_id = session.channel_id();
let TakenInner { session, .. } = session.into_inner().expect("the session is live");
// The inner pipe was taken; `close` still reaps the entry.
assert!(session.close().await, "the entry existed before the close");
no_leaked_channels(&topo).await;
assert!(channel_id >= 1);
}