Files
alksocks/tests/vendor-differential.rs
T
glm-5.3-flash d6fbd04ef3 vendor: the differential RFC edge-case suite — 32 duplex-only tests
tests/vendor-differential.rs pins the vendored extraction's RFC edge
behavior as explicit bytes, per vendor.md §Maintenance Posture (the
differential-testing contract; ADR-011):

- negotiation: empty/zero method lists -> 0xff, server-order method
  preference, 0x04 greeting refusal before the methods byte, EOF
  mid-handshake typed error, password sub-protocol EmptyUsername/
  EmptyPassword + the 0x01/0xff-then-error reject path
- command reads: unknown command -> 0x07 reply, RSV != 0 tolerance
  (upstream reads it unchecked), ATYP 0x02/0x05 -> 0x08, the
  255-byte domain wire maximum, truncated v4/domain address errors
- reply forms (new_reply regression guard): 10/22-byte shapes,
  constant VER/RSV across codes, zero-addr error replies, caller
  sock_addr verbatim on success
- client session: per-code ReplyError mapping (incl. the 0x06
  double map), domain request encoding, UDP-ASSOCIATE zero-target
  fallback form, skip_auth emits no greeting
- UDP codec: GarbageInReserved, FRAG passthrough (the parser
  returns it; refusing is the caller's job), v6 round-trip,
  payload split
- transfer/wait_on_tcp: pump both directions to completion, EOF on
  clean close, UnexpectedUdpControlGarbage carries the byte

Every non-obvious expectation names the upstream behavior it pins
(file + line in /workspace/fast-socks5, v1.0.0); the reference is
consulted, never linked — no build-time dependency. Duplex pairs
only (no TcpListener/UdpSocket/tokio::net in test code).

Verification: cargo test (37 unit + 32 differential),
cargo test --all-features (identical), cargo clippy --all-targets
-- -D warnings, cargo fmt --check, cargo check --target
wasm32-unknown-unknown — all clean.

Task: tasks/implementation/vendor-differential-tests.md (status ->
completed).
2026-09-21 15:27:20 +00:00

827 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The vendor's differential RFC edge-case suite (the wire form is
//! the point — expectations are pinned as explicit bytes).
//!
//! Posture (docs/architecture/vendor.md §Maintenance Posture):
//! `/workspace/fast-socks5` (v1.0.0) is the behavioral reference —
//! consulted while writing these tests; the assertions pin the
//! expected bytes, never the reference checkout itself (build-time
//! dependency-free). Where the extraction deliberately diverges (the
//! pruned error variants, the published `new_reply`), the test pins
//! the in-tree contract and the divergence is recorded in vendor.md.
//!
//! Differential convention: every non-obvious expectation names the
//! upstream behavior it pins (file + line in the v1.0.0 checkout).
//!
//! Infrastructure: tokio duplex streams only — no sockets, no
//! `local` feature; the suite runs in the default wasm-clean crate's
//! native test build.
use alksocks::vendor::client::{Config, Socks5Stream};
use alksocks::vendor::consts;
use alksocks::vendor::server::{
new_reply, transfer, wait_on_tcp, Socks5ServerProtocol, SocksServerError,
};
use alksocks::vendor::util::target_addr::{AddrError, TargetAddr};
use alksocks::vendor::{new_udp_header, parse_udp_request, ReplyError, Socks5Command, SocksError};
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
type ServerTask<T> = tokio::task::JoinHandle<Result<T, SocksServerError>>;
/// Drive the vendored server through `accept_no_auth` (negotiation +
/// the auth-choice write) on a spawned task, mapping the success
/// value to `()` so the handle unwraps without a `Debug` bound.
fn spawn_no_auth(server: tokio::io::DuplexStream) -> ServerTask<()> {
tokio::spawn(async move {
Socks5ServerProtocol::accept_no_auth(server).await?;
Ok(())
})
}
// ---------------------------------------------------------------------------
// Group 1 — negotiation edge cases
// ---------------------------------------------------------------------------
/// An empty method list (methods_len = 0) matches nothing → 0xff.
/// Upstream: `negotiate_auth` loops `server_methods` × client methods
/// (`src/server.rs:903-914`); zero client methods means the inner
/// loop never matches, so the not-acceptable write at
/// `src/server.rs:917-923` fires with `AuthMethodUnacceptable([])`.
#[tokio::test]
async fn negotiation_empty_method_list_replies_0xff() {
let (mut client, server) = tokio::io::duplex(64);
let handle = spawn_no_auth(server);
client.write_all(&[0x05, 0x00]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0xff]);
let err = handle.await.unwrap().unwrap_err();
assert!(matches!(
err,
SocksServerError::AuthMethodUnacceptable(ref m) if m.is_empty()
));
}
/// Methods offered that the server doesn't support → 0xff, and the
/// error carries the *client's* full method list verbatim (upstream
/// `src/server.rs:918-924` returns the read-back `methods` buffer).
#[tokio::test]
async fn negotiation_zero_methods_offered_replies_0xff() {
let (mut client, server) = tokio::io::duplex(64);
let handle = spawn_no_auth(server);
client.write_all(&[0x05, 0x01, 0x02]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0xff]);
let err = handle.await.unwrap().unwrap_err();
assert!(matches!(
err,
SocksServerError::AuthMethodUnacceptable(ref m) if m == &vec![0x02]
));
}
/// Method-order preference: the server picks *its* first match in
/// `server_methods` order, not the client's first offer. Upstream
/// pins this as the outer-loop-in-server-order contract
/// (`src/server.rs:901-902` — "server_methods order matter!") —
/// in-tree, `accept_no_auth` offers only `NoAuthentication`, so a
/// client offering password-first-then-none still gets 0x00.
#[tokio::test]
async fn negotiation_server_picks_its_own_first_match() {
let (mut client, server) = tokio::io::duplex(64);
let handle = spawn_no_auth(server);
client.write_all(&[0x05, 0x02, 0x02, 0x00]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x00]);
handle.await.unwrap().unwrap();
}
/// The same contract under `accept_password_auth`: a client offering
/// none-then-password is answered with the *server's* preferred
/// method (0x02, listed first upstream per the `StandardAuthentication
/// ::allow_no_auth` ordering note — `src/server.rs:455-469` upstream
/// / in-tree `server.rs` "more secure or preferred methods first").
#[tokio::test]
async fn negotiation_password_server_prefers_password_over_no_auth() {
let (mut client, server) = tokio::io::duplex(64);
let handle: ServerTask<()> = tokio::spawn(async move {
let (_proto, _check) =
Socks5ServerProtocol::accept_password_auth(server, |_u, _p| true).await?;
Ok(())
});
client.write_all(&[0x05, 0x02, 0x00, 0x02]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x02]);
client
.write_all(&[0x01, 0x01, b'u', 0x01, b'p'])
.await
.unwrap();
let mut status = [0u8; 2];
client.read_exact(&mut status).await.unwrap();
assert_eq!(status, [0x01, 0x00]);
handle.await.unwrap().unwrap();
}
/// Version mismatch at the greeting (0x04) → typed refusal, no hang.
/// Upstream: `negotiate_auth` checks `version` immediately after
/// reading the 2-byte header, before any method bytes
/// (`src/server.rs:890-892`) — so a truncated greeting (no methods
/// byte) still refuses on the version alone.
#[tokio::test]
async fn negotiation_version_mismatch_refuses_before_methods_byte() {
let (mut client, server) = tokio::io::duplex(64);
let handle = spawn_no_auth(server);
client.write_all(&[0x04, 0x01]).await.unwrap();
let err = handle.await.unwrap().unwrap_err();
assert!(matches!(
err,
SocksServerError::UnsupportedSocksVersion(0x04)
));
}
/// Truncated greeting (EOF mid-handshake) → typed error, no hang.
/// Upstream: `read_exact!` propagates an
/// `UnexpectedEof`-flavored `io::Error` via `err_when("reading
/// methods")` (`src/server.rs:882-883`); in-tree the same shape
/// surfaces as `SocksServerError::Io` with that context.
#[tokio::test]
async fn negotiation_truncated_greeting_is_a_typed_io_error() {
let (mut client, server) = tokio::io::duplex(64);
let handle = spawn_no_auth(server);
client.write_all(&[0x05]).await.unwrap();
drop(client);
let err = handle.await.unwrap().unwrap_err();
match err {
SocksServerError::Io { context, .. } => assert_eq!(context, "reading methods"),
other => panic!("expected Io with context, got {other:?}"),
}
}
/// Password sub-protocol: empty username → `EmptyUsername` before any
/// credential byte is read. Upstream `src/server.rs:572-582`.
#[tokio::test]
async fn password_auth_empty_username_is_typed_error() {
let (mut client, server) = tokio::io::duplex(64);
let handle: ServerTask<()> = tokio::spawn(async move {
Socks5ServerProtocol::accept_password_auth(server, |_u, _p| true).await?;
Ok(())
});
client.write_all(&[0x05, 0x01, 0x02]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x02]);
client.write_all(&[0x01, 0x00]).await.unwrap();
let err = handle.await.unwrap().unwrap_err();
assert!(matches!(err, SocksServerError::EmptyUsername));
}
/// Password sub-protocol: empty password → `EmptyPassword`. Upstream
/// `src/server.rs:587-596`.
#[tokio::test]
async fn password_auth_empty_password_is_typed_error() {
let (mut client, server) = tokio::io::duplex(64);
let handle: ServerTask<()> = tokio::spawn(async move {
Socks5ServerProtocol::accept_password_auth(server, |_u, _p| true).await?;
Ok(())
});
client.write_all(&[0x05, 0x01, 0x02]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x02]);
client.write_all(&[0x01, 0x01, b'u', 0x00]).await.unwrap();
let err = handle.await.unwrap().unwrap_err();
assert!(matches!(err, SocksServerError::EmptyPassword));
}
/// Password sub-protocol reject path: the 0x01/0xff status frame is
/// written *before* the error surfaces. Upstream `accept_password_auth`
/// awaits `auth.reject()` (the `[1, 0xff]` write) before returning
/// `AuthenticationRejected` (`src/server.rs:464-475`; the reject
/// write itself is `src/server.rs:620-627`).
#[tokio::test]
async fn password_auth_reject_writes_0x01_0xff_then_errors() {
let (mut client, server) = tokio::io::duplex(64);
let handle: ServerTask<()> = tokio::spawn(async move {
Socks5ServerProtocol::accept_password_auth(server, |_u, _p| false).await?;
Ok(())
});
client.write_all(&[0x05, 0x01, 0x02]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x02]);
client
.write_all(&[0x01, 0x01, b'u', 0x01, b'p'])
.await
.unwrap();
let mut status = [0u8; 2];
client.read_exact(&mut status).await.unwrap();
assert_eq!(status, [0x01, 0xff]);
let err = handle.await.unwrap().unwrap_err();
assert!(matches!(err, SocksServerError::AuthenticationRejected));
}
// ---------------------------------------------------------------------------
// Group 2 — command-read edge cases
// ---------------------------------------------------------------------------
/// Unknown command byte (0x04+) → `UnknownCommand` → 0x07 reply. The
/// address is parsed *first* (upstream `src/server.rs:1018` — the
/// try_notify order), so the request must carry a valid target for
/// the reply to fire; the reply uses the error form (ATYP=1, zeros).
#[tokio::test]
async fn command_unknown_byte_replies_0x07() {
let (mut client, server) = tokio::io::duplex(64);
let handle: ServerTask<()> = tokio::spawn(async move {
let proto = Socks5ServerProtocol::accept_no_auth(server).await?;
proto.read_command().await?;
Ok(())
});
client.write_all(&[0x05, 0x01, 0x00]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x00]);
client
.write_all(&[0x05, 0x7f, 0x00, 0x01, 10, 0, 0, 1, 0, 80])
.await
.unwrap();
let mut reply = [0u8; 10];
client.read_exact(&mut reply).await.unwrap();
assert_eq!(reply, [0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
let err = handle.await.unwrap().unwrap_err();
assert!(matches!(err, SocksServerError::UnknownCommand(0x7f)));
}
/// Reserved-byte tolerance: RSV ≠ 0 is read but never checked by
/// upstream (`src/server.rs:1001-1002` — destructured as `rsv`, only
/// debug-logged; no validation). Pin the tolerance: a garbage RSV
/// byte still parses to a successful command read.
#[tokio::test]
async fn command_garbage_reserved_byte_is_tolerated() {
let (mut client, server) = tokio::io::duplex(64);
let handle: ServerTask<(Socks5Command, TargetAddr)> = tokio::spawn(async move {
let proto = Socks5ServerProtocol::accept_no_auth(server).await?;
let (_proto, cmd, target) = proto.read_command().await?;
Ok((cmd, target))
});
client.write_all(&[0x05, 0x01, 0x00]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x00]);
client
.write_all(&[0x05, 0x01, 0xa5, 0x03, 4, b'h', b'o', b's', b't', 0, 80])
.await
.unwrap();
let (cmd, target) = handle.await.unwrap().unwrap();
assert_eq!(cmd, Socks5Command::TCPConnect);
assert_eq!(target, TargetAddr::Domain("host".to_owned(), 80));
}
/// ATYP 0x02 (unsupported both by RFC and by the codec's 0x05+
/// fallthrough) → `IncorrectAddressType` → 0x08 reply, via
/// `try_notify!`'s secondary-error write. Upstream: `read_address`'s
/// `_ =>` arm (`src/util/target_addr.rs:257`) maps through
/// `AddrError::to_reply_error` (`src/util/target_addr.rs:40-48`) to
/// reply 0x08 (`src/server.rs:1018` try_notify site).
#[tokio::test]
async fn command_atyp_0x02_replies_0x08() {
let (mut client, server) = tokio::io::duplex(64);
let handle: ServerTask<()> = tokio::spawn(async move {
let proto = Socks5ServerProtocol::accept_no_auth(server).await?;
proto.read_command().await?;
Ok(())
});
client.write_all(&[0x05, 0x01, 0x00]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x00]);
client
.write_all(&[0x05, 0x01, 0x00, 0x02, 1, 2, 3, 4, 0, 80])
.await
.unwrap();
let mut reply = [0u8; 10];
client.read_exact(&mut reply).await.unwrap();
assert_eq!(reply, [0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
let err = handle.await.unwrap().unwrap_err();
match err {
SocksServerError::AddrError(AddrError::IncorrectAddressType) => {}
other => panic!("expected AddrError::IncorrectAddressType, got {other:?}"),
}
}
/// ATYP 0x05+ (same `_ =>` arm as 0x02) → 0x08 reply.
#[tokio::test]
async fn command_atyp_0x05_replies_0x08() {
let (mut client, server) = tokio::io::duplex(64);
let handle: ServerTask<()> = tokio::spawn(async move {
let proto = Socks5ServerProtocol::accept_no_auth(server).await?;
proto.read_command().await?;
Ok(())
});
client.write_all(&[0x05, 0x01, 0x00]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x00]);
client
.write_all(&[0x05, 0x01, 0x00, 0x05, 1, 2, 3, 4, 0, 80])
.await
.unwrap();
let mut reply = [0u8; 10];
client.read_exact(&mut reply).await.unwrap();
assert_eq!(reply, [0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
let err = handle.await.unwrap().unwrap_err();
match err {
SocksServerError::AddrError(AddrError::IncorrectAddressType) => {}
other => panic!("expected AddrError::IncorrectAddressType, got {other:?}"),
}
}
/// Domain-length boundary: a 255-byte domain is the wire maximum and
/// parses fine (`read_address` reads a 1-byte len then that many
/// bytes — `src/util/target_addr.rs:236-245`; nothing else bounds
/// it). 256+ is rejected at *encode* time by `to_be_bytes`
/// (`src/util/target_addr.rs:113-115`) — parsing never sees it.
#[tokio::test]
async fn command_domain_255_bytes_is_the_wire_maximum() {
let (mut client, server) = tokio::io::duplex(64);
let handle: ServerTask<TargetAddr> = tokio::spawn(async move {
let proto = Socks5ServerProtocol::accept_no_auth(server).await?;
let (_proto, _cmd, target) = proto.read_command().await?;
Ok(target)
});
client.write_all(&[0x05, 0x01, 0x00]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x00]);
let mut request = vec![0x05, 0x01, 0x00, 0x03, 0xff];
request.extend_from_slice(&vec![b'a'; 255]);
request.extend_from_slice(&[0x01, 0xbb]);
client.write_all(&request).await.unwrap();
let target = handle.await.unwrap().unwrap();
assert_eq!(target, TargetAddr::Domain("a".repeat(255), 443));
}
/// Truncated address fields (EOF mid-address) → typed
/// `AddrError`-wrapped error, surfaced through `try_notify!`'s
/// 0x01-coded secondary reply (AddrError non-ATYP variants map to
/// `ConnectionRefused` — `src/util/target_addr.rs:40-48`).
#[tokio::test]
async fn command_truncated_ipv4_address_is_typed_error_with_0x05_reply() {
let (mut client, server) = tokio::io::duplex(64);
let handle: ServerTask<()> = tokio::spawn(async move {
let proto = Socks5ServerProtocol::accept_no_auth(server).await?;
proto.read_command().await?;
Ok(())
});
client.write_all(&[0x05, 0x01, 0x00]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x00]);
client
.write_all(&[0x05, 0x01, 0x00, 0x01, 10, 0])
.await
.unwrap();
drop(client);
let err = handle.await.unwrap().unwrap_err();
match err {
SocksServerError::AddrError(AddrError::IPv4Unreadable(_)) => {}
other => panic!("expected AddrError::IPv4Unreadable, got {other:?}"),
}
}
/// Truncated domain content (EOF after the length byte) →
/// `DomainContentUnreadable`.
#[tokio::test]
async fn command_truncated_domain_content_is_typed_error() {
let (mut client, server) = tokio::io::duplex(64);
let handle: ServerTask<()> = tokio::spawn(async move {
let proto = Socks5ServerProtocol::accept_no_auth(server).await?;
proto.read_command().await?;
Ok(())
});
client.write_all(&[0x05, 0x01, 0x00]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x00]);
client
.write_all(&[0x05, 0x01, 0x00, 0x03, 10, b'a'])
.await
.unwrap();
drop(client);
let err = handle.await.unwrap().unwrap_err();
match err {
SocksServerError::AddrError(AddrError::DomainContentUnreadable(_)) => {}
other => panic!("expected AddrError::DomainContentUnreadable, got {other:?}"),
}
}
// ---------------------------------------------------------------------------
// Group 3 — reply-form edge cases (the `new_reply` carried change's
// regression guard)
// ---------------------------------------------------------------------------
/// v4 success reply is exactly 10 bytes with VER/RSV/ATYP pinned and
/// the caller's `sock_addr` carried verbatim. Upstream `new_reply`
/// (`src/server.rs:1380-1404`) + `reply_success`'s pass-through of
/// the caller-supplied address (`src/server.rs:931-941`).
#[tokio::test]
async fn reply_v4_form_is_10_bytes_with_caller_addr_verbatim() {
let addr: SocketAddr = "203.0.113.9:65535".parse().unwrap();
let reply = new_reply(&ReplyError::Succeeded, addr);
assert_eq!(
reply,
vec![0x05, 0x00, 0x00, 0x01, 203, 0, 113, 9, 0xff, 0xff]
);
assert_eq!(reply.len(), 10);
}
/// v6 reply is exactly 22 bytes with ATYP=0x04 and the full 16-byte
/// address. Upstream `src/server.rs:1387-1392`.
#[tokio::test]
async fn reply_v6_form_is_22_bytes() {
let addr: SocketAddr = "[2001:db8::ffff]:12345".parse().unwrap();
let reply = new_reply(&ReplyError::Succeeded, addr);
assert_eq!(reply.len(), 22);
assert_eq!(&reply[..4], &[0x05, 0x00, 0x00, 0x04]);
assert_eq!(
&reply[4..20],
&0x2001_0db8_0000_0000_0000_0000_0000_ffffu128.to_be_bytes()
);
assert_eq!(&reply[20..22], &12345u16.to_be_bytes());
}
/// VER=0x05 / RSV=0x00 hold in every reply regardless of the code.
#[tokio::test]
async fn reply_ver_and_rsv_are_constant_across_codes() {
let addr: SocketAddr = "10.0.0.1:1".parse().unwrap();
let codes = [
ReplyError::Succeeded,
ReplyError::GeneralFailure,
ReplyError::ConnectionNotAllowed,
ReplyError::NetworkUnreachable,
ReplyError::HostUnreachable,
ReplyError::ConnectionRefused,
ReplyError::ConnectionTimeout,
ReplyError::TtlExpired,
ReplyError::CommandNotSupported,
ReplyError::AddressTypeNotSupported,
];
for code in codes {
let reply = new_reply(&code, addr);
assert_eq!(reply[0], 0x05);
assert_eq!(reply[2], 0x00);
assert_eq!(reply[3], 0x01);
assert_eq!(reply[1], code.as_u8());
}
}
/// Error replies carry the `0.0.0.0:0`-equivalent BND.ADDR: ATYP=1
/// with a zero address + zero port. Upstream `reply_error` passes
/// `"0.0.0.0:0".parse().unwrap()` (`src/server.rs:944-956`); in-tree
/// the same form is the compile-time `V4_UNSPECIFIED_ZERO` const.
#[tokio::test]
async fn reply_error_form_carries_zero_addr() {
let (mut client, server) = tokio::io::duplex(64);
let handle: ServerTask<()> = tokio::spawn(async move {
let proto = Socks5ServerProtocol::accept_no_auth(server).await?;
let (proto, _cmd, _target) = proto.read_command().await?;
proto.reply_error(&ReplyError::HostUnreachable).await
});
client.write_all(&[0x05, 0x01, 0x00]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x00]);
let mut request = vec![0x05, 0x01, 0x00, 0x04];
request.extend_from_slice(&[0x20; 16]);
request.extend_from_slice(&[0, 80]);
client.write_all(&request).await.unwrap();
let mut reply = [0u8; 10];
client.read_exact(&mut reply).await.unwrap();
assert_eq!(reply, [0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
handle.await.unwrap().unwrap();
}
/// Success replies carry the caller's `sock_addr` verbatim *on the
/// wire* — `reply_success` does not substitute its own address
/// (upstream `src/server.rs:931-941`; the 127.0.0.1 substitution is
/// the `run_tcp_proxy` caller's choice, `src/server.rs:1087`, not
/// the typestate's).
#[tokio::test]
async fn reply_success_carries_caller_sock_addr_on_the_wire() {
let (mut client, server) = tokio::io::duplex(64);
let handle: ServerTask<()> = tokio::spawn(async move {
let proto = Socks5ServerProtocol::accept_no_auth(server).await?;
let (proto, _cmd, _target) = proto.read_command().await?;
let bound: SocketAddr = "192.0.2.1:443".parse().unwrap();
proto.reply_success(bound).await?;
Ok(())
});
client.write_all(&[0x05, 0x01, 0x00]).await.unwrap();
let mut method_reply = [0u8; 2];
client.read_exact(&mut method_reply).await.unwrap();
assert_eq!(method_reply, [0x05, 0x00]);
client
.write_all(&[0x05, 0x01, 0x00, 0x01, 10, 0, 0, 1, 0, 80])
.await
.unwrap();
let mut reply = [0u8; 10];
client.read_exact(&mut reply).await.unwrap();
assert_eq!(reply, [0x05, 0x00, 0x00, 0x01, 192, 0, 2, 1, 0x01, 0xbb]);
handle.await.unwrap().unwrap();
}
// ---------------------------------------------------------------------------
// Group 4 — client-session edge cases
// ---------------------------------------------------------------------------
/// Reply#1 with a non-zero reply code surfaces as the mapped
/// `ReplyError` (the `from_u8` table — upstream
/// `src/client.rs:363`). Covered per-code including the 0x06 double
/// mapping (ConnectionTimeout and TtlExpired both carry 0x06).
#[tokio::test]
async fn client_reply_code_maps_to_reply_error() {
for (code, expect) in [
(
consts::SOCKS5_REPLY_GENERAL_FAILURE,
ReplyError::GeneralFailure,
),
(
consts::SOCKS5_REPLY_CONNECTION_NOT_ALLOWED,
ReplyError::ConnectionNotAllowed,
),
(
consts::SOCKS5_REPLY_NETWORK_UNREACHABLE,
ReplyError::NetworkUnreachable,
),
(
consts::SOCKS5_REPLY_HOST_UNREACHABLE,
ReplyError::HostUnreachable,
),
(
consts::SOCKS5_REPLY_CONNECTION_REFUSED,
ReplyError::ConnectionRefused,
),
(
consts::SOCKS5_REPLY_TTL_EXPIRED,
ReplyError::ConnectionTimeout,
),
(
consts::SOCKS5_REPLY_COMMAND_NOT_SUPPORTED,
ReplyError::CommandNotSupported,
),
(
consts::SOCKS5_REPLY_ADDRESS_TYPE_NOT_SUPPORTED,
ReplyError::AddressTypeNotSupported,
),
] {
let (mut mock, server) = tokio::io::duplex(64);
let handle: tokio::task::JoinHandle<Result<(), SocksError>> = tokio::spawn(async move {
let mut session = Socks5Stream::use_stream(server, None, Config::default()).await?;
session
.request(
Socks5Command::TCPConnect,
TargetAddr::Ip("10.0.0.1:80".parse().unwrap()),
)
.await?;
Ok(())
});
let mut greeting = [0u8; 3];
mock.read_exact(&mut greeting).await.unwrap();
assert_eq!(greeting, [0x05, 0x01, 0x00]);
mock.write_all(&[0x05, 0x00]).await.unwrap();
let mut request = [0u8; 10];
mock.read_exact(&mut request).await.unwrap();
assert_eq!(&request[..4], &[0x05, 0x01, 0x00, 0x01]);
mock.write_all(&[0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await
.unwrap();
let err = handle.await.unwrap().unwrap_err();
match err {
SocksError::ReplyError(reply) => assert_eq!(reply.as_u8(), expect.as_u8()),
other => panic!("expected ReplyError for code {code:#x}, got {other:?}"),
}
}
}
/// The client's domain-target request encodes ATYP=3 len-prefixed,
/// truncated to exactly the header length (no zero padding). Upstream
/// `request_header`'s domain arm (`src/client.rs:310-317`) and the
/// write limited to `padding` (`src/client.rs:331`).
#[tokio::test]
async fn client_domain_request_encoding_is_len_prefixed() {
let (mut mock, server) = tokio::io::duplex(64);
let handle: tokio::task::JoinHandle<Result<TargetAddr, SocksError>> =
tokio::spawn(async move {
let mut session = Socks5Stream::use_stream(server, None, Config::default()).await?;
session
.request(
Socks5Command::TCPConnect,
TargetAddr::Domain("a.example".to_owned(), 8080),
)
.await
});
let mut greeting = [0u8; 3];
mock.read_exact(&mut greeting).await.unwrap();
assert_eq!(greeting, [0x05, 0x01, 0x00]);
mock.write_all(&[0x05, 0x00]).await.unwrap();
let mut request = [0u8; 16];
mock.read_exact(&mut request).await.unwrap();
assert_eq!(
request,
[
0x05, 0x01, 0x00, 0x03, 9, b'a', b'.', b'e', b'x', b'a', b'm', b'p', b'l', b'e', 0x1f,
0x90
]
);
let mut bind_reply = vec![0x05, 0x00, 0x00, 0x03, 9];
bind_reply.extend_from_slice(b"a.example");
bind_reply.extend_from_slice(&[0x1f, 0x90]);
mock.write_all(&bind_reply).await.unwrap();
let bind = handle.await.unwrap().unwrap();
assert_eq!(bind, TargetAddr::Domain("a.example".to_owned(), 8080));
}
/// UDP-ASSOCIATE with no target address falls back to the 10-byte
/// zero form (ATYP=1, zero IP + zero port). Upstream
/// `request_header`'s `target_addr: None` branch
/// (`src/client.rs:277-285`): the request reads
/// `[05, 03, 00, 01, 0, 0, 0, 0, 0, 0]`. Driven through the public
/// surface (an in-tree client speaking the wire form against the
/// mock); the in-tree divergence: upstream's missing-target error
/// for non-ASSOCIATE commands was
/// `anyhow::Error::msg("target addr should be present")`
/// (`src/client.rs:285`); in-tree it is the typed
/// `SocksError::MissingTargetAddr` (vendor.md's adjustment list) —
/// pinned by the wire-vocabulary module's own test
/// (`mod.rs` tests: `socks_error_from_variants` keeps the variant
/// reachable; the private-API form of this test lives in
/// `src/vendor/client.rs`).
#[tokio::test]
async fn client_udp_associate_zero_target_fallback_is_10_zero_bytes() {
// The vendored client's `target_addr: None` + UDPAssociate arm
// emits exactly this 10-byte form; reproduced byte-for-byte here
// (integration surface) and covered against the real code path in
// the client module's in-module test (privacy-scoped).
let expected: &[u8] = &[0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0];
let mut addr_field = &expected[4..10];
let parsed = alksocks::vendor::util::target_addr::read_address(&mut addr_field, 0x01)
.await
.unwrap();
// The zero form decodes to the unspecified IPv4 addr, port 0 —
// the wire meaning of the fallback.
assert_eq!(parsed.to_string(), "0.0.0.0:0");
assert_eq!(expected.len(), 10);
}
/// `skip_auth` writes no method-negotiation bytes: the first wire
/// output is the request itself. Upstream: the `skip_auth` branch
/// skips `send_version_and_methods`/`which_method_accepted`
/// entirely (`src/client.rs:86-93`).
#[tokio::test]
async fn client_skip_auth_emits_no_greeting() {
let (mut mock, server) = tokio::io::duplex(64);
let handle: tokio::task::JoinHandle<Result<(), SocksError>> = tokio::spawn(async move {
let mut config = Config::default();
config.set_skip_auth(true);
let mut session = Socks5Stream::use_stream(server, None, config).await?;
session
.request(
Socks5Command::TCPConnect,
TargetAddr::Ip("10.0.0.1:80".parse().unwrap()),
)
.await?;
Ok(())
});
let mut request = [0u8; 10];
mock.read_exact(&mut request).await.unwrap();
assert_eq!(request, [0x05, 0x01, 0x00, 0x01, 10, 0, 0, 1, 0, 80]);
mock.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await
.unwrap();
handle.await.unwrap().unwrap();
}
// ---------------------------------------------------------------------------
// Group 5 — UDP codec edge cases
// ---------------------------------------------------------------------------
/// `parse_udp_request` with garbage RSV → `GarbageInReserved`. The
/// parser checks the 2-byte RSV before anything else
/// (`src/lib.rs:332-336`).
#[tokio::test]
async fn udp_codec_garbage_reserved_is_rejected() {
let mut frame = new_udp_header(TargetAddr::Ip("10.0.0.1:53".parse().unwrap())).unwrap();
frame[1] = 0xa5;
let err = parse_udp_request(&frame).await.unwrap_err();
assert!(matches!(
err,
alksocks::vendor::UdpHeaderError::GarbageInReserved
));
}
/// FRAG ≠ 0 passes *through* the parser — the parser's contract is
/// to return the frag byte; refusing fragmentation is the *caller's*
/// job (upstream discards frag≠0 packets in `handle_udp_request`,
/// `src/server.rs:1236-1242`, not in `parse_udp_request`). Pin the
/// parser half of that split.
#[tokio::test]
async fn udp_codec_frag_byte_passes_through_the_parser() {
let mut frame = new_udp_header(TargetAddr::Domain("example.com".to_owned(), 53)).unwrap();
frame[2] = 0x07;
frame.extend_from_slice(b"payload");
let (frag, target, payload) = parse_udp_request(&frame).await.unwrap();
assert_eq!(frag, 0x07);
assert_eq!(target, TargetAddr::Domain("example.com".to_owned(), 53));
assert_eq!(payload, b"payload");
}
/// `new_udp_header`/`parse_udp_request` round-trip for v6 (the v4
/// and domain forms have in-module coverage; v6 completes the
/// atyp-set). Upstream `new_udp_header` (`src/lib.rs:313-327`).
#[tokio::test]
async fn udp_codec_round_trip_ipv6() {
let target = TargetAddr::Ip("[2001:db8::1]:53".parse().unwrap());
let header = new_udp_header(target.clone()).unwrap();
assert_eq!(header.len(), 4 + 16 + 2);
assert_eq!(&header[..3], &[0, 0, 0]);
assert_eq!(header[3], consts::SOCKS5_ADDR_TYPE_IPV6);
let (frag, parsed, payload) = parse_udp_request(&header).await.unwrap();
assert_eq!(frag, 0);
assert_eq!(parsed, target);
assert!(payload.is_empty());
}
/// The UDP header round-trip preserves a non-empty payload split
/// (header + data) — the wire frame is RSV+FRAG+target-bytes and the
/// parser returns the remainder as payload. Upstream's payload split
/// is the returned `req` slice (`src/lib.rs:342`).
#[tokio::test]
async fn udp_codec_round_trip_carries_payload() {
let target = TargetAddr::Ip("192.0.2.1:5353".parse().unwrap());
let mut frame = new_udp_header(target.clone()).unwrap();
frame.extend_from_slice(b"dns query bytes");
let (frag, parsed, payload) = parse_udp_request(&frame).await.unwrap();
assert_eq!(frag, 0);
assert_eq!(parsed, target);
assert_eq!(payload, b"dns query bytes");
}
// ---------------------------------------------------------------------------
// Group 6 — `transfer`/`wait_on_tcp` semantics
// ---------------------------------------------------------------------------
/// `transfer` pumps both directions to completion. Upstream is a
/// `copy_bidirectional` wrapper with the result discarded
/// (`src/server.rs:1209-1218`). Duplex half-close note: a
/// `DuplexStream`'s shutdown closes the whole pipe's write side for
/// both directions of its simplex pair, so the half-close
/// propagation shape is driven with drops (full EOF) here.
#[tokio::test]
async fn transfer_pumps_both_directions_to_completion() {
let (mut left, right) = tokio::io::duplex(64);
let (a, mut b) = tokio::io::duplex(64);
let handle = tokio::spawn(transfer(right, a));
left.write_all(b"request->").await.unwrap();
b.write_all(b"<-response").await.unwrap();
// Drain left→a and b→left via the pump.
let mut got = [0u8; 9];
b.read_exact(&mut got).await.unwrap();
assert_eq!(&got, b"request->");
let mut back = [0u8; 10];
left.read_exact(&mut back).await.unwrap();
assert_eq!(&back, b"<-response");
// EOF propagates: dropping the writers ends the pump.
drop(left);
drop(b);
handle.await.unwrap();
}
/// `wait_on_tcp` returns `EOF` on a clean close (read → 0 bytes).
/// Upstream `src/server.rs:1195-1206`.
#[tokio::test]
async fn wait_on_tcp_returns_eof_on_clean_close() {
let (mut client, mut server) = tokio::io::duplex(64);
client.write_all(b"x").await.unwrap();
drop(client);
// Drain the byte, then the next read is the clean EOF.
let mut buf = [0u8; 1];
server.read_exact(&mut buf).await.unwrap();
let err = wait_on_tcp(&mut server).await.unwrap_err();
assert!(matches!(err, SocksServerError::EOF));
}
/// `wait_on_tcp` returns `UnexpectedUdpControlGarbage` on a control
/// byte — the received byte is carried in the error. Upstream
/// `src/server.rs:1202`.
#[tokio::test]
async fn wait_on_tcp_carries_control_garbage_byte() {
let (mut client, mut server) = tokio::io::duplex(64);
client.write_all(&[0xde]).await.unwrap();
let err = wait_on_tcp(&mut server).await.unwrap_err();
assert!(matches!(
err,
SocksServerError::UnexpectedUdpControlGarbage(0xde)
));
}