From b43cbd24baaf1cb6f018fa3c49ec4f557b00233a Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 27 Sep 2026 10:45:38 +0000 Subject: [PATCH] =?UTF-8?q?review-003=20N-8:=20the=20worthwhile=20residue?= =?UTF-8?q?=20probed=20=E2=80=94=20the=20BIND=20Domain-reply=20wire=20help?= =?UTF-8?q?er=20+=20table,=20the=20refusal-recode=20unit=20pins,=20the=20s?= =?UTF-8?q?erve()=20wrappers,=20the=20len/is=5Fempty=20accessors,=20the=20?= =?UTF-8?q?bridge=20later-batch=20garbage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/door.rs | 170 ++++++++++++++++++++++++++++++++++++++++++++-- tests/door-tcp.rs | 84 +++++++++++++++++++++++ tests/door-udp.rs | 46 +++++++++++++ 3 files changed, 293 insertions(+), 7 deletions(-) diff --git a/src/door.rs b/src/door.rs index f528780..d0e241b 100644 --- a/src/door.rs +++ b/src/door.rs @@ -639,7 +639,23 @@ async fn pipe_bind_plane( session.close().await; return DoorExit::PlaneEnded; }; - let wire = match &bind_addr { + let wire = bind_reply1_wire(&bind_addr); + if write_vanilla_reply(&mut stream, &wire).await.is_err() { + session.close().await; + return DoorExit::ClientGone; + } + + bind_quiet_and_pipe(stream, session).await +} + +/// 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 +/// 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 { + match bind_addr { TargetAddr::Ip(sa) => new_reply(&ReplyError::Succeeded, *sa), TargetAddr::Domain(name, port) => { let mut wire = vec![ @@ -653,13 +669,7 @@ async fn pipe_bind_plane( wire.extend_from_slice(&port.to_be_bytes()); wire } - }; - if write_vanilla_reply(&mut stream, &wire).await.is_err() { - session.close().await; - return DoorExit::ClientGone; } - - bind_quiet_and_pipe(stream, session).await } /// The BIND door's quiet wait + reply#2 + the data plane: await the @@ -1178,3 +1188,149 @@ mod tests { feeder.await.expect("feeder"); } } + +#[cfg(test)] +mod refusal_and_wire_unit_tests { + use super::*; + use crate::SocksOpenError; + use alkcall::channels::client::ChannelOpenError; + 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 + /// the portable way). + async fn socket_pair() -> (tokio::net::TcpStream, tokio::net::TcpStream) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("addr"); + let client = tokio::net::TcpStream::connect(addr).await.expect("connect"); + let (server, _peer) = listener.accept().await.expect("accept"); + (client, server) + } + + /// 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, + /// ATYP=domain, len, name, port. + #[test] + fn bind_reply1_wire_forms() { + let ip = std::net::SocketAddr::from(([127, 0, 0, 1], 2121)); + let ip_wire = bind_reply1_wire(&TargetAddr::Ip(ip)); + assert_eq!(ip_wire[0], consts::SOCKS5_VERSION); + assert_eq!(ip_wire[1], consts::SOCKS5_REPLY_SUCCEEDED); + assert_eq!(ip_wire[3], consts::SOCKS5_ADDR_TYPE_IPV4); + assert_eq!(&ip_wire[4..8], &[127, 0, 0, 1]); + assert_eq!(&ip_wire[8..10], &2121u16.to_be_bytes()); + + let domain_wire = bind_reply1_wire(&TargetAddr::Domain("bind.test".into(), 2121)); + let expected = { + let mut v = vec![ + consts::SOCKS5_VERSION, + consts::SOCKS5_REPLY_SUCCEEDED, + 0x00, + consts::SOCKS5_ADDR_TYPE_DOMAIN_NAME, + 9, + ]; + v.extend_from_slice(b"bind.test"); + v.extend_from_slice(&2121u16.to_be_bytes()); + v + }; + assert_eq!(domain_wire, expected, "the domain BND.ADDR frame"); + } + + /// `write_refusal_reply`'s non-reply-code arm (review 003 N-8): an + /// open/transport-shaped session error stays a bare close — no + /// bytes are written to the vanilla client. + #[tokio::test] + async fn write_refusal_reply_skips_non_reply_code_shapes() { + let (mut client, mut server) = socket_pair().await; + let open_err = + SocksSessionError::Open(SocksOpenError::Open(ChannelOpenError::CallFailed { + error: CallError::new("channel:open_failed", "refused", false), + })); + 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. + let mut probe = [0u8; 16]; + let n = tokio::time::timeout( + std::time::Duration::from_millis(200), + client.read(&mut probe), + ) + .await; + assert!( + matches!(n, Err(_) | Ok(Ok(0))), + "no reply bytes for a non-reply-code error: {n:?}" + ); + } + + /// The refusal re-encode's wire shape (the F-1 fix's helper, + /// byte-pinned end to end): a reply-code-shaped session error + /// reaches the vanilla client as the RFC reply with the code + + /// the placeholder BND.ADDR. + #[tokio::test] + async fn write_refusal_reply_encodes_the_mapped_code() { + let (mut client, mut server) = socket_pair().await; + let refused = + SocksSessionError::Rfc(SocksError::ReplyError(ReplyError::ConnectionNotAllowed)); + write_refusal_reply(&mut server, &refused).await; + let mut wire = [0u8; 10]; + tokio::time::timeout( + std::time::Duration::from_secs(5), + client.read_exact(&mut wire), + ) + .await + .expect("the reply arrives") + .expect("read"); + assert_eq!(wire[0], consts::SOCKS5_VERSION); + assert_eq!(wire[1], consts::SOCKS5_REPLY_CONNECTION_NOT_ALLOWED); + assert_eq!(&wire[4..8], &[0, 0, 0, 0], "the placeholder BND.ADDR"); + assert_eq!(&wire[8..10], &0u16.to_be_bytes()); + } +} + +#[cfg(test)] +mod bridge_later_batch_tests { + use super::*; + 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 + /// frame following a good one in the same chunk surfaces the + /// error, the good frame is NOT silently delivered first). + #[tokio::test] + async fn garbage_in_a_later_batch_frame_fails_loud() { + let (mut writer, reader) = tokio::io::duplex(1024); + let (mut channel_read, _channel_write) = tokio::io::split(reader); + 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 + // state machine fails loud on the truncated later frame. + wire.extend_from_slice(&[0xff, 0xff]); + wire.extend_from_slice(b"short"); + let mut bridge = InbandBridge::new(); + writer.write_all(&wire).await.expect("feed"); + writer.flush().await.expect("flush"); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let _ = writer.shutdown().await; + + let first = bridge.recv(&mut channel_read).await; + assert!( + first.is_err() || matches!(&first, Ok(Some((src, payload))) if &payload[..] == b"good"), + "the first frame or the loud error: {first:?}" + ); + if matches!(first, Ok(Some(_))) { + // The good frame delivered, the truncated later frame + // fails loud at EOF (the reader's mid-frame state). + let second = bridge.recv(&mut channel_read).await; + assert!( + second.is_err(), + "the truncated later batch frame fails loud: {second:?}" + ); + } + } +} diff --git a/tests/door-tcp.rs b/tests/door-tcp.rs index 6eec576..cfd3f2e 100644 --- a/tests/door-tcp.rs +++ b/tests/door-tcp.rs @@ -104,6 +104,14 @@ impl VanillaClient { } } + /// The dial without the panic (the `serve` wrapper's bind races + /// the test's connect — the retry loop's probe). + async fn try_dial(addr: SocketAddr) -> std::io::Result { + Ok(Self { + stream: TcpStream::connect(addr).await?, + }) + } + /// Greeting `05 02 00` (password auth offered — the cred-bearing /// door's shape) → the method-selection read. async fn greet_password(&mut self) -> u8 { @@ -1018,3 +1026,79 @@ async fn associate_session_with_token_round_trip() { ); session.shutdown().await.expect("shutdown"); } + +/// The `serve()` wrapper (review 003 N-8: every test drove `serve_on` +/// — the bind-then-serve wrapper round-trips too; the NoAuth raw-pipe +/// shape, the port probed up front so the wrapper's own bind lands on +/// it). +#[tokio::test] +async fn front_door_serve_wrapper_round_trips() { + let echo = echo_tcp_server().await; + let dial = alksocks::tcp_dial(); + let topo = harness::wire_consumer_side_with(dial, None, None, None).await; + + // A free loopback port, probed up front (the door binds exactly + // it — the wrapper's own bind decision). The connect retries: + // the wrapper's bind races the probe's teardown (the OS may hold + // the freed port briefly). + let mut addr = None; + for _ in 0..50 { + let probe = TcpListener::bind("127.0.0.1:0").await.expect("probe bind"); + let candidate = probe.local_addr().expect("probe addr"); + drop(probe); + tokio::time::sleep(Duration::from_millis(10)).await; + if TcpStream::connect(candidate).await.is_err() { + addr = Some(candidate); + break; + } + } + let addr = addr.expect("a genuinely free port"); + + let door = FrontDoor::builder() + .bind(addr) + .build() + .expect("the door builds"); + let serve = tokio::spawn({ + let client = Arc::clone(&topo.consumer); + async move { door.serve(client).await.expect("the door serves") } + }); + + let mut vanilla = loop { + if let Ok(v) = VanillaClient::try_dial(addr).await { + break v; + } + tokio::time::sleep(Duration::from_millis(20)).await; + }; + let method = timeout(BOUND, vanilla.greet_no_auth()) + .await + .expect("greeting"); + assert_eq!(method, consts::SOCKS5_AUTH_METHOD_NONE); + let code = timeout(BOUND, vanilla.connect_v4(echo)) + .await + .expect("CONNECT"); + assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED); + vanilla.stream.write_all(b"served").await.unwrap(); + let mut buf = [0u8; 6]; + timeout(BOUND, vanilla.stream.read_exact(&mut buf)) + .await + .expect("echo") + .expect("read"); + assert_eq!(&buf, b"served"); + serve.abort(); +} + +/// The credential table's observability accessors (review 003 N-8: +/// `len`/`is_empty` had no caller — one probe pins both). +#[test] +fn credential_table_len_and_is_empty_accessors() { + let empty = alksocks::CredentialTable::new(vec![]); + assert!(empty.is_empty()); + assert_eq!(empty.len(), 0); + let one = alksocks::CredentialTable::new(vec![alksocks::DoorCredential { + username: "u".to_string(), + password: "p".to_string(), + identity: harness::consumer_identity(), + }]); + assert!(!one.is_empty()); + assert_eq!(one.len(), 1); +} diff --git a/tests/door-udp.rs b/tests/door-udp.rs index f29471b..100ba8c 100644 --- a/tests/door-udp.rs +++ b/tests/door-udp.rs @@ -455,3 +455,49 @@ async fn fresh_association_succeeds_after_teardown() { assert_eq!(payload, b"fresh"); serve2.abort(); } + +/// The `serve()` wrapper (review 003 N-8: every test drove `serve_on` +/// — the bind-then-serve wrapper round-trips too; the port is +/// configured explicitly so the vanilla client can address the +/// relay). +#[tokio::test] +async fn udp_door_serve_wrapper_round_trips() { + init_trace(); + let echo_addr = udp_echo_server().await; + let topo = harness::wire_consumer_side_with( + echo_dial(), + Some(alksocks::udp_egress()), + None, + Some(harness::consumer_identity()), + ) + .await; + + // A free loopback port, probed up front (the door binds exactly + // it — the wrapper's own bind decision). + let probe = UdpSocket::bind("127.0.0.1:0").await.expect("probe bind"); + let relay_addr = probe.local_addr().expect("probe addr"); + drop(probe); + + let client = UdpSocket::bind("127.0.0.1:0").await.expect("client socket"); + let door = UdpFrontDoor::builder() + .bind(relay_addr) + .build() + .expect("the door builds"); + let _serve = tokio::spawn({ + let client = Arc::clone(&topo.consumer); + async move { door.serve(client).await.expect("the door serves") } + }); + + client + .send_to(&rfc_datagram(echo_addr, b"wrapped"), relay_addr) + .await + .expect("vanilla sendto the relay"); + let mut rx = vec![0u8; 65535]; + let (n, from) = timeout(BOUND, client.recv_from(&mut rx)) + .await + .expect("reply within bound") + .expect("recv"); + assert_eq!(from, relay_addr, "the reply comes from the wrapper's bind"); + let (_, payload) = parse_rfc_datagram(&rx[..n]); + assert_eq!(payload, b"wrapped"); +}