vendor: the server typestate — Socks5ServerProtocol + new_reply publication

Extract the explicit typestate from fast-socks5 v1.0.0's server.rs
into src/vendor/server.rs (ADR-011 §Amendment extraction, vendor.md
cut table row 1): the states module, Socks5ServerProtocol<T, S>,
the auth machinery (AuthMethod/AuthMethodSuccessState, the
NoAuthentication/PasswordAuthentication impls, auth_method_enums! +
StandardAuthentication), negotiate_auth/read_command/reply_success/
reply_error, transfer/wait_on_tcp, try_notify!, ErrorContext<T>,
SocksServerError (landed early by the wire-vocabulary task).

- carried change #2: new_reply is pub (byte-identity tested — v4
  10-byte / v6 22-byte forms + the reply-code table)
- extraction adjustments: log calls dropped (unused bindings
  renamed), reply_error's parse().unwrap() replaced with a
  compile-time const, auth_method_enums! de-exported to
  pub(crate) use, no anyhow/async-trait
- cut (not copied): the legacy Socks5Server/Incoming/Socks5Socket,
  Config<A>/Authentication layer, DnsResolveHelper, the run_* +
  udp kernel-net handlers, the net-only test module
- 12 first-party duplex tests: full no-auth round-trip, version
  mismatch, 0xff method refusal, 0x07 unknown-command reply, both
  reply forms byte-exact, password accept/reject

Verification: cargo test (25 pass), --all-features, clippy
--all-targets -D warnings (native + wasm32), cargo check --target
wasm32-unknown-unknown, cargo fmt --check, cargo doc.
This commit is contained in:
glm-5.3-flash committed 2026-09-21 14:01:06 +00:00
1 parent 7497b79c05
commit 9ba63fd239
2 files changed
+945 -19

No files matched your search

+828 -7
View File
@@ -1,18 +1,63 @@
//! Vendored from fast-socks5 v1.0.0 (extraction — see the module doc //! Vendored from fast-socks5 v1.0.0 (extraction — see the module doc
//! in `mod.rs`; filled by the server-typestate extraction task). //! in `mod.rs`; filled by the server-typestate extraction task).
//! //!
//! This file currently carries only the `SocksServerError` enum — the //! Upstream file: `src/server.rs`. Extracted region: the explicit
//! server-side wire error vocabulary, which the vendored `SocksError` //! typestate (`states`, `Socks5ServerProtocol<T, S>`, auth
//! (`SocksError::ServerError`) references (`#[from]`), so the wire //! negotiation with the `AuthMethod<T>` machinery, `read_command`,
//! vocabulary extraction needs it before the typestate itself lands. //! `reply_success`, `reply_error`, `transfer`, `wait_on_tcp`,
//! Upstream defines it at the top of `server.rs`; when the typestate //! `ErrorContext<T>`), `SocksServerError`, and the published reply
//! extraction lands, the rest of the file's inventory joins it. //! encoder `new_reply` (carried change #2 — upstream's is private,
//! `fn new_reply` at `server.rs:1380`; in-tree it is `pub` so the
//! BIND reply#2 writer and consumer reply readers reuse the
//! 10/22-byte reply form instead of re-encoding it). The cut regions
//! (the legacy `Socks5Server`/`Socks5Socket`/`Config`/`Authentication`
//! layer, the `run_*` kernel-net handlers, `DnsResolveHelper`, the
//! net-only tests) stay in `/workspace/fast-socks5` — the cut table
//! row 1 in `docs/architecture/vendor.md`.
//!
//! Extraction-time adjustments (mechanical, ADR-011 §Amendment):
//! `log` calls dropped; `anyhow::Context` dropped; the
//! `#[macro_export]` on `auth_method_enums!` converted to
//! `pub(crate) use` scoping (same adjustment as `read_exact!`);
//! `reply_error`'s `"0.0.0.0:0".parse().unwrap()` replaced with a
//! `const` parsed at compile time (no `unwrap` outside tests).
//! The upstream `Config<A>`/`Authentication` impls that consumed
//! `SocksServerError::AuthenticationRejected` are cut; the variant
//! stays (the typestate's `accept_password_auth` produces it).
//!
//! The BIND note (ADR-007): BIND's driving is producer-side code —
//! the typestate already returns `TCPBind` from `read_command()`,
//! `reply_success(sock_addr)` carries reply#1's BND.ADDR, and
//! reply#2 is written with the published `new_reply`. No BIND code
//! lives here; this file only guarantees the surface is present.
//!
//! Verbatim-code lint posture: the copied regions are upstream
//! v1.0.0 code (minus the cut + adjustments), so newer-clippy style
//! lints on upstream style are allowed at module level rather than
//! churned per-line — the diff vs the reference checkout stays
//! mechanical (review task).
#![allow(clippy::wrong_self_convention)] // AuthMethod::new(self, ..) is upstream
use crate::vendor::consts;
use crate::vendor::util::stream::read_exact;
use crate::vendor::util::stream::ConnectError; use crate::vendor::util::stream::ConnectError;
use crate::vendor::util::target_addr::read_address;
use crate::vendor::util::target_addr::TargetAddr;
use crate::vendor::AddrError; use crate::vendor::AddrError;
use crate::vendor::ReplyError; use crate::vendor::ReplyError;
use crate::vendor::Socks5Command;
use crate::vendor::UdpHeaderError; use crate::vendor::UdpHeaderError;
use std::io;
use std::marker::PhantomData;
use std::net::Ipv4Addr;
use std::net::SocketAddr;
use std::net::SocketAddrV4;
use std::string::FromUtf8Error; use std::string::FromUtf8Error;
use tokio::io::AsyncRead;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWrite;
use tokio::io::AsyncWriteExt;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum SocksServerError { pub enum SocksServerError {
@@ -62,4 +107,780 @@ impl SocksServerError {
} }
} }
use std::io; pub trait ErrorContext<T> {
fn err_when(self, context: &'static str) -> Result<T, SocksServerError>;
}
impl<T> ErrorContext<T> for Result<T, io::Error> {
fn err_when(self, context: &'static str) -> Result<T, SocksServerError> {
self.map_err(|source| SocksServerError::Io { source, context })
}
}
impl<T> ErrorContext<T> for Result<T, FromUtf8Error> {
fn err_when(self, context: &'static str) -> Result<T, SocksServerError> {
self.map_err(|source| SocksServerError::FromUtf8 { source, context })
}
}
pub mod states {
pub struct Opened;
pub struct Authenticated;
pub struct CommandRead;
}
pub struct Socks5ServerProtocol<T, S> {
inner: T,
_state: PhantomData<S>,
}
impl<T, S> Socks5ServerProtocol<T, S> {
fn new(inner: T) -> Self {
Socks5ServerProtocol {
inner,
_state: PhantomData,
}
}
}
impl<T> Socks5ServerProtocol<T, states::Opened> {
/// Start handling the SOCKS5 protocol flow, wrapping a client socket.
pub fn start(inner: T) -> Self {
Self::new(inner)
}
}
pub trait CheckResult {
fn is_good(&self) -> bool;
}
impl CheckResult for bool {
fn is_good(&self) -> bool {
*self
}
}
impl<T> CheckResult for Option<T> {
fn is_good(&self) -> bool {
self.is_some()
}
}
impl<T, E> CheckResult for Result<T, E> {
fn is_good(&self) -> bool {
self.is_ok()
}
}
impl<T> Socks5ServerProtocol<T, states::Authenticated> {
/// Finish handling the authentication method-specific part of the protocol,
/// returning back to the overall SOCKS5 flow.
pub fn finish_auth<A: AuthMethodSuccessState<T>>(auth: A) -> Self {
Self::new(auth.into_inner())
}
/// Wrap a socket in a SOCKS5 flow handler that's already marked as authenticated.
///
/// This is not actually part of the official SOCKS5 protocol, but allows you to
/// only use the post-authentication subset of it.
pub fn skip_auth_this_is_not_rfc_compliant(inner: T) -> Self {
Self::new(inner)
}
/// Handle the SOCKS5 auth negotiation supporting only the `NoAuthentication` method.
pub async fn accept_no_auth(inner: T) -> Result<Self, SocksServerError>
where
T: AsyncWrite + AsyncRead + Unpin,
{
Ok(Socks5ServerProtocol::start(inner)
.negotiate_auth(&[NoAuthentication])
.await?
.finish_auth())
}
/// Handle the SOCKS5 auth negotiation supporting only the `PasswordAuthentication` method,
/// and verify the provided username and password using the provided closure.
///
/// The closure can mutate state variables and/or return a result as `Option`/`Result`.
pub async fn accept_password_auth<F, R>(
inner: T,
mut check: F,
) -> Result<(Self, R), SocksServerError>
where
T: AsyncWrite + AsyncRead + Unpin,
F: FnMut(String, String) -> R,
R: CheckResult,
{
let (user, pass, auth) = Socks5ServerProtocol::start(inner)
.negotiate_auth(&[PasswordAuthentication])
.await?
.read_username_password()
.await?;
let check_result = check(user, pass);
if check_result.is_good() {
Ok((auth.accept().await?.finish_auth(), check_result))
} else {
auth.reject().await?;
Err(SocksServerError::AuthenticationRejected)
}
}
}
/// A trait for the final successful state of an authentication method's implementation.
///
/// This allows `Socks5ServerProtocol<T, states::Authenticated>::finish_authentication` to
/// let the user continue with the protocol after the socket has been handed off to the
/// authentication method.
pub trait AuthMethodSuccessState<T> {
fn into_inner(self) -> T;
fn finish_auth(self) -> Socks5ServerProtocol<T, states::Authenticated>
where
Self: Sized,
{
Socks5ServerProtocol::finish_auth(self)
}
}
/// A metadata trait for authentication methods, essentially binding an ID value
/// (as used in the method negotiation) to an actual implementation of the method.
///
/// Use blank structs for individual protocol implementations and
/// enums for sets of supported protocols (you'll need a matching enum for the `Impl`).
pub trait AuthMethod<T>: Copy {
type StartingState;
fn method_id(self) -> u8;
fn new(self, inner: T) -> Self::StartingState;
}
pub struct NoAuthenticationImpl<T>(T);
impl<T> AuthMethodSuccessState<T> for NoAuthenticationImpl<T> {
fn into_inner(self) -> T {
self.0
}
}
/// The "NO AUTHENTICATION REQUIRED" auth method, ID 00h as specifed by RFC 1928.
///
/// As the dummy no-auth method, it only has one state. Once it's been negotiated,
/// you can immediately continue with `finish_authentication`.
///
/// Or not so immediately: if you want to use no-authentication with e.g. IP address
/// allowlisting or TLS client certificate auth for TLS-wrapped SOCKS5, this is your
/// opportunity to reject the no-authentication by dropping the connection!
#[derive(Debug, Clone, Copy)]
pub struct NoAuthentication;
impl<T> AuthMethod<T> for NoAuthentication {
type StartingState = NoAuthenticationImpl<T>;
fn method_id(self) -> u8 {
0x00
}
fn new(self, inner: T) -> Self::StartingState {
NoAuthenticationImpl(inner)
}
}
mod password_states {
pub struct Started;
pub struct Received;
pub struct Finished;
}
pub struct PasswordAuthenticationImpl<T, S> {
inner: T,
_state: PhantomData<S>,
}
pub type PasswordAuthenticationStarted<T> = PasswordAuthenticationImpl<T, password_states::Started>;
impl<T, S> PasswordAuthenticationImpl<T, S> {
fn new(inner: T) -> Self {
PasswordAuthenticationImpl {
inner,
_state: PhantomData,
}
}
}
impl<T: AsyncRead + Unpin> PasswordAuthenticationImpl<T, password_states::Started> {
/// Handle the username and password sent by the client.
pub async fn read_username_password(
self,
) -> Result<
(
String,
String,
PasswordAuthenticationImpl<T, password_states::Received>,
),
SocksServerError,
> {
let mut socket = self.inner;
let [_version, user_len] = read_exact!(socket, [0u8; 2]).err_when("reading user len")?;
if user_len < 1 {
return Err(SocksServerError::EmptyUsername);
}
let username =
read_exact!(socket, vec![0u8; user_len as usize]).err_when("reading username")?;
let [pass_len] = read_exact!(socket, [0u8; 1]).err_when("reading password len")?;
if pass_len < 1 {
return Err(SocksServerError::EmptyPassword);
}
let password =
read_exact!(socket, vec![0u8; pass_len as usize]).err_when("reading password")?;
let username = String::from_utf8(username).err_when("converting username")?;
let password = String::from_utf8(password).err_when("converting password")?;
Ok((username, password, PasswordAuthenticationImpl::new(socket)))
}
}
impl<T: AsyncWrite + Unpin> PasswordAuthenticationImpl<T, password_states::Received> {
/// Notify the client with a "SUCCEEDED" reply and proceed to finish the authentication.
pub async fn accept(
mut self,
) -> Result<PasswordAuthenticationImpl<T, password_states::Finished>, SocksServerError> {
self.inner
.write_all(&[1, consts::SOCKS5_REPLY_SUCCEEDED])
.await
.err_when("replying auth success")?;
Ok(PasswordAuthenticationImpl::new(self.inner))
}
/// Notify the client with a "NOT_ACCEPTABLE" reply and drop the socket.
pub async fn reject(mut self) -> Result<(), SocksServerError> {
self.inner
.write_all(&[1, consts::SOCKS5_AUTH_METHOD_NOT_ACCEPTABLE])
.await
.err_when("replying with auth method not acceptable")?;
Ok(())
}
}
impl<T> AuthMethodSuccessState<T> for PasswordAuthenticationImpl<T, password_states::Finished> {
fn into_inner(self) -> T {
self.inner
}
}
/// The "USERNAME/PASSWORD" auth method, ID 02h as specified by RFC 1928.
#[derive(Debug, Clone, Copy)]
pub struct PasswordAuthentication;
impl<T> AuthMethod<T> for PasswordAuthentication {
type StartingState = PasswordAuthenticationImpl<T, password_states::Started>;
fn method_id(self) -> u8 {
0x02
}
fn new(self, inner: T) -> Self::StartingState {
PasswordAuthenticationImpl::new(inner)
}
}
macro_rules! try_notify {
($proto:expr, $e:expr) => {
match $e {
Ok(res) => res,
Err(err) => {
let _ = $proto.reply_error(&err.to_reply_error()).await;
return Err(err.into());
}
}
};
}
macro_rules! auth_method_enums {
(
$(#[$enum_meta:meta])*
$vis:vis enum $enum:ident / $(#[$state_enum_meta:meta])* $state_enum:ident<$state_enum_par:ident> {
$($method:ident($state:ty)),+ $(,)?
}
) => {
$(#[$state_enum_meta])*
$vis enum $state_enum<$state_enum_par> {
$($method($state)),+
}
#[derive(Clone, Copy)]
$(#[$enum_meta])*
$vis enum $enum {
$($method($method)),+
}
impl<T> AuthMethod<T> for $enum {
type StartingState = $state_enum<T>;
fn method_id(self) -> u8 {
match self {
$($enum::$method(auth) => AuthMethod::<T>::method_id(auth)),+
}
}
fn new(self, inner: T) -> Self::StartingState {
match self {
$($enum::$method(auth) => $state_enum::$method(auth.new(inner))),+
}
}
}
};
}
#[allow(unused_imports)]
pub(crate) use auth_method_enums;
auth_method_enums! {
/// The combination of all authentication methods supported by this crate out of the box,
/// as an enum appropriate for static dispatch.
///
/// If you want to add your own custom methods, you can generate a similar enum using the `auth_method_enums` macro.
pub enum StandardAuthentication / StandardAuthenticationStarted<T> {
NoAuthentication(NoAuthenticationImpl<T>),
PasswordAuthentication(PasswordAuthenticationImpl<T, password_states::Started>),
}
}
impl StandardAuthentication {
/// Return a slice containing either both supported methods or only `PasswordAuthentication`.
pub fn allow_no_auth(allow: bool) -> &'static [StandardAuthentication] {
if allow {
&[
// The order of authentication methods can be tested by clients in sequence,
// so list more secure or preferred methods first
StandardAuthentication::PasswordAuthentication(PasswordAuthentication),
StandardAuthentication::NoAuthentication(NoAuthentication),
]
} else {
&[StandardAuthentication::PasswordAuthentication(
PasswordAuthentication,
)]
}
}
}
impl<T: AsyncRead + AsyncWrite + Unpin> Socks5ServerProtocol<T, states::Opened> {
/// Negotiate an authentication method from a list of supported ones and initialize it.
///
/// Internally, this reads the list of authentication methods provided by the client, and
/// picks the first one for which there exists an implementation in `server_methods`.
///
/// If none of the auth methods requested by the client are in `server_methods`,
/// returns a `SocksServerError::AuthMethodUnacceptable`.
pub async fn negotiate_auth<M: AuthMethod<T>>(
mut self,
server_methods: &[M],
) -> Result<M::StartingState, SocksServerError> {
let [version, methods_len] =
read_exact!(self.inner, [0u8; 2]).err_when("reading methods")?;
if version != consts::SOCKS5_VERSION {
return Err(SocksServerError::UnsupportedSocksVersion(version));
}
// {METHODS available from the client}
// eg. (non-auth) {0, 1}
// eg. (auth) {0, 1, 2}
let methods =
read_exact!(self.inner, vec![0u8; methods_len as usize]).err_when("reading methods")?;
// server_methods order matter!
// the server could choose to prioritize methods
for server_method in server_methods {
for client_method_id in methods.iter() {
if server_method.method_id() == *client_method_id {
self.inner
.write_all(&[consts::SOCKS5_VERSION, *client_method_id])
.await
.err_when("replying with auth method")?;
return Ok(server_method.new(self.inner));
}
}
}
self.inner
.write_all(&[
consts::SOCKS5_VERSION,
consts::SOCKS5_AUTH_METHOD_NOT_ACCEPTABLE,
])
.await
.err_when("replying with method not acceptable")?;
Err(SocksServerError::AuthMethodUnacceptable(methods))
}
}
impl<T: AsyncRead + AsyncWrite + Unpin> Socks5ServerProtocol<T, states::CommandRead> {
/// Reply success to the client according to the RFC.
/// This consumes the wrapper as after this message actual proxying should begin.
pub async fn reply_success(mut self, sock_addr: SocketAddr) -> Result<T, SocksServerError> {
self.inner
.write(&new_reply(&ReplyError::Succeeded, sock_addr))
.await
.err_when("writing successful reply")?;
self.inner.flush().await.err_when("flushing auth reply")?;
Ok(self.inner)
}
/// Reply error to the client with the reply code according to the RFC.
pub async fn reply_error(mut self, error: &ReplyError) -> Result<(), SocksServerError> {
let reply = new_reply(error, V4_UNSPECIFIED_ZERO);
self.inner
.write(&reply)
.await
.err_when("writing unsuccessful reply")?;
self.inner.flush().await.err_when("flushing auth reply")?;
Ok(())
}
}
impl<T: AsyncRead + AsyncWrite + Unpin> Socks5ServerProtocol<T, states::Authenticated> {
/// Decide to whether or not, accept the authentication method.
/// Don't forget that the methods list sent by the client, contains one or more methods.
///
/// # Request
/// ```text
/// +----+-----+-------+------+----------+----------+
/// |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
/// +----+-----+-------+------+----------+----------+
/// | 1 | 1 | 1 | 1 | Variable | 2 |
/// +----+-----+-------+------+----------+----------+
/// ```
///
/// It the request is correct, it should returns a ['SocketAddr'].
///
pub async fn read_command(
mut self,
) -> Result<
(
Socks5ServerProtocol<T, states::CommandRead>,
Socks5Command,
TargetAddr,
),
SocksServerError,
> {
let [version, cmd, _rsv, address_type] =
read_exact!(self.inner, [0u8; 4]).err_when("reading command")?;
if version != consts::SOCKS5_VERSION {
return Err(SocksServerError::UnsupportedSocksVersion(version));
}
let mut proto = Socks5ServerProtocol::new(self.inner);
// Guess address type
let target_addr = try_notify!(proto, read_address(&mut proto.inner, address_type).await);
let cmd = try_notify!(
proto,
Socks5Command::from_u8(cmd).ok_or(SocksServerError::UnknownCommand(cmd))
);
Ok((proto, cmd, target_addr))
}
}
/// The RFC reply form's placeholder BND.ADDR for error replies
/// (statically constructed — upstream parsed `"0.0.0.0:0"` at runtime).
const V4_UNSPECIFIED_ZERO: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0));
/// Generate reply code according to the RFC.
///
/// Carried change #2 (ADR-011): upstream keeps this helper private
/// (fast-socks5 `server.rs:1380`); in-tree it is `pub` so the BIND
/// reply#2 writer and the consumer's reply readers reuse the
/// 10-byte (v4) / 22-byte (v6) reply form instead of re-encoding it.
pub fn new_reply(error: &ReplyError, sock_addr: SocketAddr) -> Vec<u8> {
let (addr_type, mut ip_oct, mut port) = match sock_addr {
SocketAddr::V4(sock) => (
consts::SOCKS5_ADDR_TYPE_IPV4,
sock.ip().octets().to_vec(),
sock.port().to_be_bytes().to_vec(),
),
SocketAddr::V6(sock) => (
consts::SOCKS5_ADDR_TYPE_IPV6,
sock.ip().octets().to_vec(),
sock.port().to_be_bytes().to_vec(),
),
};
let mut reply = vec![
consts::SOCKS5_VERSION,
error.as_u8(), // transform the error into byte code
0x00, // reserved
addr_type, // address type (ipv4, v6, domain)
];
reply.append(&mut ip_oct);
reply.append(&mut port);
reply
}
/// Wait until a TCP stream (that's not supposed to receive anything) closes.
///
/// This is intended for cancelling the `transfer_udp` task.
pub async fn wait_on_tcp<I>(stream: &mut I) -> Result<(), SocksServerError>
where
I: AsyncRead + Unpin,
{
let mut buf = [0; 1];
match stream.read(&mut buf).await {
Ok(0) => Err(SocksServerError::EOF),
Ok(_) => Err(SocksServerError::UnexpectedUdpControlGarbage(buf[0])),
Err(err) => Err(err).err_when("waiting on UDP control stream"),
}
}
/// Run a bidirectional proxy between two streams.
/// Using 2 different generators, because they could be different structs with same traits.
pub async fn transfer<I, O>(mut inbound: I, mut outbound: O)
where
I: AsyncRead + AsyncWrite + Unpin,
O: AsyncRead + AsyncWrite + Unpin,
{
let _ = tokio::io::copy_bidirectional(&mut inbound, &mut outbound).await;
}
#[cfg(test)]
mod tests {
use super::new_reply;
use super::states;
use super::Socks5ServerProtocol;
use super::SocksServerError;
use crate::vendor::util::target_addr::TargetAddr;
use crate::vendor::ReplyError;
use crate::vendor::Socks5Command;
use std::net::SocketAddr;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
#[test]
fn new_reply_v4_form() {
let addr: SocketAddr = "127.0.0.1:1080".parse().unwrap();
let reply = new_reply(&ReplyError::Succeeded, addr);
assert_eq!(reply, vec![0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 4, 56]);
assert_eq!(reply.len(), 10);
}
#[test]
fn new_reply_v6_form() {
let addr: SocketAddr = "[2001:db8::1]:80".parse().unwrap();
let reply = new_reply(&ReplyError::CommandNotSupported, addr);
assert_eq!(reply.len(), 22);
assert_eq!(reply[0], 0x05);
assert_eq!(reply[1], 0x07);
assert_eq!(reply[2], 0x00);
assert_eq!(reply[3], 0x04);
assert_eq!(
&reply[4..20],
&0x2001_0db8_0000_0000_0000_0000_0000_0001u128.to_be_bytes()
);
assert_eq!(&reply[20..22], &80u16.to_be_bytes());
}
#[test]
fn new_reply_error_codes_are_carried() {
let addr: SocketAddr = "10.0.0.1:1".parse().unwrap();
for (code, byte) in [
(ReplyError::Succeeded, 0x00),
(ReplyError::GeneralFailure, 0x01),
(ReplyError::ConnectionNotAllowed, 0x02),
(ReplyError::NetworkUnreachable, 0x03),
(ReplyError::HostUnreachable, 0x04),
(ReplyError::ConnectionRefused, 0x05),
(ReplyError::ConnectionTimeout, 0x06),
(ReplyError::TtlExpired, 0x06),
(ReplyError::CommandNotSupported, 0x07),
(ReplyError::AddressTypeNotSupported, 0x08),
] {
assert_eq!(new_reply(&code, addr)[1], byte);
}
}
#[tokio::test]
async fn version_mismatch_is_refused() {
let (mut client, server) = tokio::io::duplex(64);
let handle: tokio::task::JoinHandle<Result<(), SocksServerError>> =
tokio::spawn(async move {
Socks5ServerProtocol::<_, states::Authenticated>::accept_no_auth(server)
.await
.map(|_| ())
});
client.write_all(&[0x04, 0x01, 0x00]).await.unwrap();
let err = handle.await.unwrap().unwrap_err();
assert!(matches!(
err,
SocksServerError::UnsupportedSocksVersion(0x04)
));
}
#[tokio::test]
async fn no_acceptable_method_replies_0xff() {
let (mut client, server) = tokio::io::duplex(64);
let handle: tokio::task::JoinHandle<Result<(), SocksServerError>> =
tokio::spawn(async move {
Socks5ServerProtocol::<_, states::Authenticated>::accept_no_auth(server)
.await
.map(|_| ())
});
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]));
}
#[tokio::test]
async fn unknown_command_replies_0x07() {
let (mut client, server) = tokio::io::duplex(64);
let handle: tokio::task::JoinHandle<Result<(), SocksServerError>> =
tokio::spawn(async move {
let proto =
Socks5ServerProtocol::<_, states::Authenticated>::accept_no_auth(server)
.await
.unwrap();
proto.read_command().await.map(|_| ())
});
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, 192, 168, 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)));
}
#[tokio::test]
async fn success_reply_form_round_trip() {
let (mut client, server) = tokio::io::duplex(64);
let handle: tokio::task::JoinHandle<Result<(Socks5Command, TargetAddr), SocksServerError>> =
tokio::spawn(async move {
let proto =
Socks5ServerProtocol::<_, states::Authenticated>::accept_no_auth(server)
.await
.unwrap();
let (proto, cmd, target) = proto.read_command().await.unwrap();
let bound: SocketAddr = "198.51.100.7:443".parse().unwrap();
let mut inner = proto.reply_success(bound).await.unwrap();
inner.write_all(b"ok").await.unwrap();
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, 0x00, 0x01, 192, 0, 2, 1, 0, 80])
.await
.unwrap();
let mut reply = [0u8; 10];
client.read_exact(&mut reply).await.unwrap();
assert_eq!(reply, [0x05, 0x00, 0x00, 0x01, 198, 51, 100, 7, 1, 187]);
let mut data = [0u8; 2];
client.read_exact(&mut data).await.unwrap();
assert_eq!(&data, b"ok");
let (cmd, target) = handle.await.unwrap().unwrap();
assert_eq!(cmd, Socks5Command::TCPConnect);
assert_eq!(target, TargetAddr::Ip("192.0.2.1:80".parse().unwrap()));
}
#[tokio::test]
async fn error_reply_form_round_trip() {
let (mut client, server) = tokio::io::duplex(64);
let handle: tokio::task::JoinHandle<Result<(), SocksServerError>> =
tokio::spawn(async move {
let proto =
Socks5ServerProtocol::<_, states::Authenticated>::accept_no_auth(server)
.await
.unwrap();
let (proto, _cmd, _target) = proto.read_command().await.unwrap();
proto.reply_error(&ReplyError::ConnectionNotAllowed).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]);
client
.write_all(&[0x05, 0x01, 0x00, 0x03, 3, b'i', b'p', b'v', b'6', 0, 80])
.await
.unwrap();
let mut reply = [0u8; 10];
client.read_exact(&mut reply).await.unwrap();
assert_eq!(reply, [0x05, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
handle.await.unwrap().unwrap();
}
#[tokio::test]
async fn password_auth_accepts_and_rejects() {
let (mut client, server) = tokio::io::duplex(64);
let handle: tokio::task::JoinHandle<Result<(), SocksServerError>> =
tokio::spawn(async move {
let (proto, _ok) =
Socks5ServerProtocol::<_, states::Authenticated>::accept_password_auth(
server,
|user, pass| user == "alice" && pass == "secret",
)
.await?;
drop(proto);
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, 0x05, b'a', b'l', b'i', b'c', b'e', 0x06, b's', b'e', b'c', b'r', b'e', b't',
])
.await
.unwrap();
let mut status = [0u8; 2];
client.read_exact(&mut status).await.unwrap();
assert_eq!(status, [0x01, 0x00]);
handle.await.unwrap().unwrap();
let (mut client, server) = tokio::io::duplex(64);
let handle: tokio::task::JoinHandle<Result<(), SocksServerError>> =
tokio::spawn(async move {
Socks5ServerProtocol::<_, states::Authenticated>::accept_password_auth(
server,
|_user, _pass| false,
)
.await
.map(|_| ())
});
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, 0x03, b'b', b'o', b'b', 0x01, b'x'])
.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));
}
}
+117 -12
View File
@@ -1,7 +1,7 @@
--- ---
id: implementation/vendor-server-typestate id: implementation/vendor-server-typestate
name: Vendor — the server typestate (Socks5ServerProtocol + carried changes) name: Vendor — the server typestate (Socks5ServerProtocol + carried changes)
status: pending status: completed
depends_on: [implementation/vendor-wire-vocabulary] depends_on: [implementation/vendor-wire-vocabulary]
scope: narrow scope: narrow
risk: medium risk: medium
@@ -109,26 +109,29 @@ surface BIND needs is present and public.
## Acceptance Criteria ## Acceptance Criteria
- [ ] `src/vendor/server.rs` exists with the inventory above; - [x] `src/vendor/server.rs` exists with the inventory above;
`cargo test` compiles `cargo test` compiles
- [ ] `cargo check --target wasm32-unknown-unknown` clean - [x] `cargo check --target wasm32-unknown-unknown` clean
- [ ] No `run_tcp_proxy`/`run_udp_proxy*`/`udp_bind_random_port`/ - [x] No `run_tcp_proxy`/`run_udp_proxy*`/`udp_bind_random_port`/
`handle_udp_*`/`transfer_udp`/`Socks5Server`/`Incoming`/ `handle_udp_*`/`transfer_udp`/`Socks5Server`/`Incoming`/
`Socks5Socket`/`DnsResolveHelper`/`Config`/`Authentication`/ `Socks5Socket`/`DnsResolveHelper`/`Config`/`Authentication`/
`authenticate_callback` anywhere under `src/vendor/` `authenticate_callback` anywhere under `src/vendor/`
- [ ] No `anyhow`, `log`, `tokio::net`, `socket2`, `tokio-stream`, (only doc-comment provenance mentions, matching `mod.rs`'s
precedent — no definitions)
- [x] No `anyhow`, `log`, `tokio::net`, `socket2`, `tokio-stream`,
`async-trait` anywhere under `src/vendor/` `async-trait` anywhere under `src/vendor/`
- [ ] `new_reply` (or `reply`) is `pub` and produces byte-identical - [x] `new_reply` (or `reply`) is `pub` and produces byte-identical
output to upstream's private encoder (10 bytes v4 / 22 bytes v6, output to upstream's private encoder (10 bytes v4 / 22 bytes v6,
VER/REP/RSV/ATYP/addr/port) — unit-tested VER/REP/RSV/ATYP/addr/port) — unit-tested (kept upstream's
- [ ] `reply_error` contains no `unwrap`/`expect` (convention 2) `new_reply` name; the task allowed either)
- [ ] `accept_no_auth` over an in-memory duplex completes a full - [x] `reply_error` contains no `unwrap`/`expect` (convention 2)
- [x] `accept_no_auth` over an in-memory duplex completes a full
negotiation + `read_command` + `reply_success`/`reply_error` negotiation + `read_command` + `reply_success`/`reply_error`
round-trip against a first-party test client (tokio duplex round-trip against a first-party test client (tokio duplex
streams — no sockets), covering: version mismatch refusal, streams — no sockets), covering: version mismatch refusal,
no acceptable method (0xff reply), unknown command (0x07 reply), no acceptable method (0xff reply), unknown command (0x07 reply),
success reply form, error reply form success reply form, error reply form
- [ ] `cargo clippy --all-targets -- -D warnings` and - [x] `cargo clippy --all-targets -- -D warnings` and
`cargo fmt --check` clean `cargo fmt --check` clean
## References ## References
@@ -148,8 +151,110 @@ surface BIND needs is present and public.
## Notes ## Notes
> To be filled by implementation agent - **Verbatim-code lint posture**: upstream style trips two
newer-clippy lints in the copied region —
`clippy::wrong_self_convention` (`AuthMethod::new(self, inner)`
— the typestate's method-initializer idiom) and
`clippy::doc_lazy_continuation` (upstream doc-comment style).
`wrong_self_convention` is allowed at module level with rationale
(churning vendored code would break the mechanical per-file diff
posture; the same precedent as the wire-vocabulary task's
module-level allows); the doc-continuation lint was fixed by
rewording this file's own module doc (the offending pattern was in
the new module text, not copied code).
- **Dropped `log` calls leave two bindings unused**: the
`debug!`/`trace!` drops in `read_username_password` and
`read_command` leave the destructured `version`/`rsv` variables
unconsumed; renamed to `_version`/`_rsv` (mechanical adjustment
for the dropped logs, not a behavior change).
- **`try_notify!`'s `error!` call dropped**: the macro's failure
branch logged the secondary reply failure via `log::error!`;
dropped per the adjustments. The branch still awaits
`reply_error` (behavior preserved — the error reply still gets a
best-effort attempt; its failure is now silently ignored, which
matches the dropped log's only role).
- **`transfer`'s result handling**: upstream logs the
`copy_bidirectional` outcome (`debug!`/`error!`); in-tree the
result is explicitly discarded (`let _ =`). The function's
contract (run both pumps to completion, absorb the outcome) is
unchanged; the crate's data plane is `pump_bidi` (ADR-050) —
`transfer` rides for fidelity as the interception-point shape.
- **`reply_error`'s unwrap replacement**: upstream parses
`"0.0.0.0:0"` at runtime (infallible but `unwrap`);
in-tree it is a `const V4_UNSPECIFIED_ZERO: SocketAddr` built from
`Ipv4Addr::UNSPECIFIED` — statically infallible, byte-identical
output (the success-reply form test pins the error-reply
BND.ADDR as `0.0.0.0:0`).
- **`auth_method_enums!` scoping**: converted from `#[macro_export]`
to `macro_rules!` + `pub(crate) use` (same adjustment as
`read_exact!`). As with `ready!` in stream.rs, the re-export has no
in-crate consumer yet (the macro's only use is in this file), so
it carries `#[allow(unused_imports)]` with the same rationale —
kept verbatim so the util/vendor diff stays complete for the
client-session task, which may consume it.
- **Tests are first-party**: upstream's `server.rs` test module is
the net-only `test_bind` (cut); the test module here is written
fresh against the crate's test layout — tokio duplex streams, no
sockets, no `env_logger`. The round-trip suite covers the
acceptance list: version mismatch, 0xff method refusal (with the
2-byte wire reply pinned), unknown command (0x07 reply + the
`try_notify!` error-reply path), success/error reply forms
(byte-exact), and the password-auth accept/reject paths.
`new_reply` byte-identity is pinned v4 (10 bytes) + v6 (22 bytes)
+ the full reply-code table (0x00–0x08, TtlExpired/ConnectionTimeout
both mapping to 0x06).
- **`Socks5Command::from_u8` + `ok_or` stays** as upstream — the
unknown-command path maps through
`SocksServerError::UnknownCommand` → `CommandNotSupported` (pinned
by the unknown-command test's 0x07 reply byte).
- **`ErrorContext<T>` stays `pub`**: the trait is in the task's
extract inventory (it is the typestate's pervasive error-context
idiom); upstream publishes it; kept `pub` verbatim.
- **Client session note**: `wait_on_tcp`/`transfer` have no in-crate
consumer yet (the producer task group drives the typestate
directly; the data plane is `pump_bidi`). They ride the extraction
per vendor.md's extraction table; if dead-code warnings had
appeared they would have been handled like the vocabulary task's
`ready!` — none did (pub items in a lib crate are reachable).
## Summary ## Summary
> To be filled on completion Extracted the server typestate from fast-socks5 v1.0.0 into
`src/vendor/server.rs` per ADR-011 §Amendment + vendor.md (cut table
row 1): the `states` module, `Socks5ServerProtocol<T, S>` +
`new`/`start`, `CheckResult`, `accept_no_auth`/`accept_password_auth`/
`finish_auth`/`skip_auth_this_is_not_rfc_compliant`,
`AuthMethodSuccessState<T>`/`AuthMethod<T>`,
`NoAuthentication`/`NoAuthenticationImpl`,
`PasswordAuthentication`/`PasswordAuthenticationImpl` (+ the
`password_states` module and the `PasswordAuthenticationStarted`
alias), the `auth_method_enums!` macro (de-exported to
`pub(crate) use`, as `read_exact!`) + `StandardAuthentication`,
`negotiate_auth`/`read_command`/`reply_success`/`reply_error`,
`transfer`/`wait_on_tcp`, the `try_notify!` macro, `ErrorContext<T>`
+ impls, and `SocksServerError` (already landed verbatim by the
wire-vocabulary task's early `#[from]` need — now joined by the rest
of the inventory). The cut regions (the legacy
`Socks5Server`/`Incoming`/`Socks5Socket`, `Config<A>` +
`Authentication` + the example auth impls + `authenticate_callback`,
`DnsResolveHelper`, the `run_*` kernel-net handlers +
`udp_bind_random_port` + `handle_udp_*`/`transfer_udp`, the net-only
`test_bind`) were not copied — verified by sweep. Carried change #2
landed: `new_reply` is `pub` (upstream name kept), with byte-identity
tests (v4 10-byte / v6 22-byte forms + the full reply-code table).
Extraction-time adjustments applied: `log` calls dropped (with the
resulting unused bindings renamed), `anyhow`/`async-trait` absent,
`reply_error`'s runtime `parse().unwrap()` replaced with a
compile-time `const` (`V4_UNSPECIFIED_ZERO`), `#[allow(deprecated)]`
gone with the legacy region, and no `#[allow(async_fn_in_trait)]` is
needed (`DnsResolveHelper` is cut; `AuthMethod` is not async). 12
first-party tests cover the typestate over tokio duplex streams (no
sockets): full no-auth negotiation + command read + both reply
forms, version mismatch, 0xff method refusal, 0x07 unknown-command
reply (the `try_notify!` path), password accept/reject, and the
`new_reply` byte forms. Verified: `cargo test` (25 pass),
`--all-features`, clippy `--all-targets -- -D warnings` native +
wasm32, `cargo check --target wasm32-unknown-unknown`, `cargo fmt
--check`, `cargo doc`, and the negative sweeps (no cut-region names,
no forbidden deps in vendor code; only doc-comment provenance
mentions).