From 7497b79c05bd2a3d6bfa46c4da343a42bea9d1b2 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Mon, 21 Sep 2026 13:13:56 +0000 Subject: [PATCH] =?UTF-8?q?vendor:=20the=20wire=20vocabulary=20=E2=80=94?= =?UTF-8?q?=20consts,=20commands,=20codecs,=20errors,=20macros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract fast-socks5 v1.0.0's non-net lib.rs region into src/vendor/mod.rs (scaffolding-pinned root; the task's lib.rs target name corrected — a second root file would conflict, see task Notes): consts, Socks5Command (as_u8/from_u8 made pub), AuthenticationMethod, SocksError pruned to the extracted region's consumers (no anyhow/ Redaction/InvalidHeader/ReplySocks4Error/ArgumentInputError), the Result alias, ReplyError codec, UdpHeaderError + new_udp_header/ parse_udp_request. util/target_addr.rs gains TargetAddr/AddrError (minus resolve_dns + its two variants)/ToTargetAddr/Addr/read_address; util/stream.rs gains the read_exact!/ready! macros (de-exported to pub(crate) use) + ConnectError + to_reply_error. SocksServerError lands verbatim in server.rs early — SocksError's #[from] needs it before the typestate task. 16 first-party codec tests (round-trips, mapping tables, garbage-RSV/unknown-ATYP); no sockets, no env_logger. Newer-clippy style lints allowed at module level in vendored files (verbatim diff posture, rationale recorded). Verification: cargo test (16 pass) + --all-features; clippy --all-targets -D warnings (native + wasm32); cargo check --target wasm32-unknown-unknown; cargo fmt --check; cargo doc. Negative sweeps: no anyhow/log/tokio::net/socket2/tokio-stream/ async-trait under src/vendor/; no #[macro_export]; resolve_dns and the pruned error variants absent. --- src/vendor/mod.rs | 437 +++++++++++++++++ src/vendor/server.rs | 63 +++ src/vendor/util/stream.rs | 120 ++++- src/vendor/util/target_addr.rs | 451 +++++++++++++++++- .../implementation/vendor-wire-vocabulary.md | 106 +++- 5 files changed, 1157 insertions(+), 20 deletions(-) diff --git a/src/vendor/mod.rs b/src/vendor/mod.rs index 16d7ac6..15ea973 100644 --- a/src/vendor/mod.rs +++ b/src/vendor/mod.rs @@ -13,6 +13,14 @@ //! the extraction-time adjustments (no `anyhow`, no `log`, no //! `async-trait`) are recorded in `docs/architecture/vendor.md`. //! +//! This file is upstream's `src/lib.rs` vocabulary region (the +//! wire-vocabulary extraction task): the `consts`, the command/auth +//! enums, the reply-code codec, the UDP datagram codec, and the error +//! vocabulary pruned to the extracted region's consumers. The +//! typestate (upstream `server.rs`, minus the cut regions) lives in +//! [`server`]; the client session (upstream `client.rs`, `use_stream` +//! path) will live in [`client`]. +//! //! License: upstream is MIT with no per-file headers — the bundled //! notice in [`this module's LICENSE file`](LICENSE) (v1.0.0 //! attribution) is the license obligation's carrier. @@ -21,7 +29,436 @@ //! with the wrapper, and is the crate's only RFC 1928 wire vocabulary //! ("no second codec, ever" — ADR-011). A future upstream release is //! re-based against deliberately, never absorbed silently. +//! +//! 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::needless_lifetimes)] +#![allow(clippy::redundant_closure)] pub mod client; pub mod server; pub mod util; + +use std::fmt; +use std::io; +use thiserror::Error; +use util::stream::read_exact; +use util::target_addr::read_address; +use util::target_addr::AddrError; +use util::target_addr::TargetAddr; +use util::target_addr::ToTargetAddr; + +use tokio::io::AsyncReadExt; + +#[rustfmt::skip] +pub mod consts { + pub const SOCKS5_VERSION: u8 = 0x05; + + pub const SOCKS5_AUTH_METHOD_NONE: u8 = 0x00; + pub const SOCKS5_AUTH_METHOD_GSSAPI: u8 = 0x01; + pub const SOCKS5_AUTH_METHOD_PASSWORD: u8 = 0x02; + pub const SOCKS5_AUTH_METHOD_NOT_ACCEPTABLE: u8 = 0xff; + + pub const SOCKS5_CMD_TCP_CONNECT: u8 = 0x01; + pub const SOCKS5_CMD_TCP_BIND: u8 = 0x02; + pub const SOCKS5_CMD_UDP_ASSOCIATE: u8 = 0x03; + + pub const SOCKS5_ADDR_TYPE_IPV4: u8 = 0x01; + pub const SOCKS5_ADDR_TYPE_DOMAIN_NAME: u8 = 0x03; + pub const SOCKS5_ADDR_TYPE_IPV6: u8 = 0x04; + + pub const SOCKS5_REPLY_SUCCEEDED: u8 = 0x00; + pub const SOCKS5_REPLY_GENERAL_FAILURE: u8 = 0x01; + pub const SOCKS5_REPLY_CONNECTION_NOT_ALLOWED: u8 = 0x02; + pub const SOCKS5_REPLY_NETWORK_UNREACHABLE: u8 = 0x03; + pub const SOCKS5_REPLY_HOST_UNREACHABLE: u8 = 0x04; + pub const SOCKS5_REPLY_CONNECTION_REFUSED: u8 = 0x05; + pub const SOCKS5_REPLY_TTL_EXPIRED: u8 = 0x06; + pub const SOCKS5_REPLY_COMMAND_NOT_SUPPORTED: u8 = 0x07; + pub const SOCKS5_REPLY_ADDRESS_TYPE_NOT_SUPPORTED: u8 = 0x08; +} + +#[derive(Debug, PartialEq)] +pub enum Socks5Command { + TCPConnect, + TCPBind, + UDPAssociate, +} + +impl Socks5Command { + #[inline] + #[rustfmt::skip] + pub fn as_u8(&self) -> u8 { + match self { + Socks5Command::TCPConnect => consts::SOCKS5_CMD_TCP_CONNECT, + Socks5Command::TCPBind => consts::SOCKS5_CMD_TCP_BIND, + Socks5Command::UDPAssociate => consts::SOCKS5_CMD_UDP_ASSOCIATE, + } + } + + #[inline] + #[rustfmt::skip] + pub fn from_u8(code: u8) -> Option { + match code { + consts::SOCKS5_CMD_TCP_CONNECT => Some(Socks5Command::TCPConnect), + consts::SOCKS5_CMD_TCP_BIND => Some(Socks5Command::TCPBind), + consts::SOCKS5_CMD_UDP_ASSOCIATE => Some(Socks5Command::UDPAssociate), + _ => None, + } + } +} + +#[derive(Debug, PartialEq)] +pub enum AuthenticationMethod { + None, + Password { username: String, password: String }, +} + +#[allow(dead_code)] +impl AuthenticationMethod { + #[inline] + #[rustfmt::skip] + fn as_u8(&self) -> u8 { + match self { + AuthenticationMethod::None => consts::SOCKS5_AUTH_METHOD_NONE, + AuthenticationMethod::Password {..} => + consts::SOCKS5_AUTH_METHOD_PASSWORD + } + } + + #[inline] + #[rustfmt::skip] + fn from_u8(code: u8) -> Option { + match code { + consts::SOCKS5_AUTH_METHOD_NONE => Some(AuthenticationMethod::None), + consts::SOCKS5_AUTH_METHOD_PASSWORD => Some(AuthenticationMethod::Password { username: "test".to_string(), password: "test".to_string()}), + _ => None, + } + } +} + +impl fmt::Display for AuthenticationMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + AuthenticationMethod::None => f.write_str("AuthenticationMethod::None"), + AuthenticationMethod::Password { .. } => f.write_str("AuthenticationMethod::Password"), + } + } +} + +#[derive(Error, Debug)] +pub enum SocksError { + #[error("i/o error: {0}")] + Io(#[from] io::Error), + #[error("Auth method unacceptable `{0:?}`.")] + AuthMethodUnacceptable(Vec), + #[error("Unsupported SOCKS version `{0}`.")] + UnsupportedSocksVersion(u8), + #[error("Domain exceeded max sequence length")] + ExceededMaxDomainLen(usize), + #[error("Authentication rejected `{0}`")] + AuthenticationRejected(String), + + #[error(transparent)] + ServerError(#[from] server::SocksServerError), + + #[error(transparent)] + UdpHeaderError(#[from] UdpHeaderError), + + #[error(transparent)] + AddrError(#[from] AddrError), + + #[error("Error with reply: {0}.")] + ReplyError(#[from] ReplyError), +} + +pub type Result = core::result::Result; + +/// SOCKS5 reply code +#[derive(Error, Debug, Copy, Clone)] +pub enum ReplyError { + #[error("Succeeded")] + Succeeded, + #[error("General failure")] + GeneralFailure, + #[error("Connection not allowed by ruleset")] + ConnectionNotAllowed, + #[error("Network unreachable")] + NetworkUnreachable, + #[error("Host unreachable")] + HostUnreachable, + #[error("Connection refused")] + ConnectionRefused, + #[error("Connection timeout")] + ConnectionTimeout, + #[error("TTL expired")] + TtlExpired, + #[error("Command not supported")] + CommandNotSupported, + #[error("Address type not supported")] + AddressTypeNotSupported, +} + +impl ReplyError { + #[inline] + #[rustfmt::skip] + pub fn as_u8(self) -> u8 { + match self { + ReplyError::Succeeded => consts::SOCKS5_REPLY_SUCCEEDED, + ReplyError::GeneralFailure => consts::SOCKS5_REPLY_GENERAL_FAILURE, + ReplyError::ConnectionNotAllowed => consts::SOCKS5_REPLY_CONNECTION_NOT_ALLOWED, + ReplyError::NetworkUnreachable => consts::SOCKS5_REPLY_NETWORK_UNREACHABLE, + ReplyError::HostUnreachable => consts::SOCKS5_REPLY_HOST_UNREACHABLE, + ReplyError::ConnectionRefused => consts::SOCKS5_REPLY_CONNECTION_REFUSED, + ReplyError::ConnectionTimeout => consts::SOCKS5_REPLY_TTL_EXPIRED, + ReplyError::TtlExpired => consts::SOCKS5_REPLY_TTL_EXPIRED, + ReplyError::CommandNotSupported => consts::SOCKS5_REPLY_COMMAND_NOT_SUPPORTED, + ReplyError::AddressTypeNotSupported => consts::SOCKS5_REPLY_ADDRESS_TYPE_NOT_SUPPORTED, + } + } + + #[inline] + #[rustfmt::skip] + pub fn from_u8(code: u8) -> ReplyError { + match code { + consts::SOCKS5_REPLY_SUCCEEDED => ReplyError::Succeeded, + 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::TtlExpired, + consts::SOCKS5_REPLY_COMMAND_NOT_SUPPORTED => ReplyError::CommandNotSupported, + consts::SOCKS5_REPLY_ADDRESS_TYPE_NOT_SUPPORTED => ReplyError::AddressTypeNotSupported, + _ => unreachable!("ReplyError code unsupported."), + } + } +} + +#[derive(thiserror::Error, Debug)] +pub enum UdpHeaderError { + #[error(transparent)] + AddrError(#[from] AddrError), + #[error("could not convert target addr: {0}")] + ToTargetAddr(#[source] io::Error), + #[error("could not read UDP header: {0}")] + ReadingError(#[source] io::Error), + #[error("does not match the expected reserved field")] + GarbageInReserved, +} + +/// Generate UDP header +/// +/// # UDP Request header structure. +/// ```text +/// +----+------+------+----------+----------+----------+ +/// |RSV | FRAG | ATYP | DST.ADDR | DST.PORT | DATA | +/// +----+------+------+----------+----------+----------+ +/// | 2 | 1 | 1 | Variable | 2 | Variable | +/// +----+------+------+----------+----------+----------+ +/// +/// The fields in the UDP request header are: +/// +/// o RSV Reserved X'0000' +/// o FRAG Current fragment number +/// o ATYP address type of following addresses: +/// o IP V4 address: X'01' +/// o DOMAINNAME: X'03' +/// o IP V6 address: X'04' +/// o DST.ADDR desired destination address +/// o DST.PORT desired destination port +/// o DATA user data +/// ``` +pub fn new_udp_header(target_addr: T) -> Result, UdpHeaderError> { + let mut header = vec![ + 0, 0, // RSV + 0, // FRAG + ]; + header.append( + &mut target_addr + .to_target_addr() + .map_err(UdpHeaderError::ToTargetAddr)? + .to_be_bytes()?, + ); + + Ok(header) +} + +/// Parse data from UDP client on raw buffer, return (frag, target_addr, payload). +pub async fn parse_udp_request<'a>( + mut req: &'a [u8], +) -> Result<(u8, TargetAddr, &'a [u8]), UdpHeaderError> { + let rsv = read_exact!(req, [0u8; 2]).map_err(UdpHeaderError::ReadingError)?; + + if !rsv.eq(&[0u8; 2]) { + return Err(UdpHeaderError::GarbageInReserved); + } + + let [frag, atyp] = read_exact!(req, [0u8; 2]).map_err(UdpHeaderError::ReadingError)?; + + let target_addr = read_address(&mut req, atyp).await?; + + Ok((frag, target_addr, req)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn socks5_command_mapping() { + assert_eq!( + Socks5Command::TCPConnect.as_u8(), + consts::SOCKS5_CMD_TCP_CONNECT + ); + assert_eq!(Socks5Command::TCPBind.as_u8(), consts::SOCKS5_CMD_TCP_BIND); + assert_eq!( + Socks5Command::UDPAssociate.as_u8(), + consts::SOCKS5_CMD_UDP_ASSOCIATE + ); + assert_eq!( + Socks5Command::from_u8(consts::SOCKS5_CMD_TCP_CONNECT), + Some(Socks5Command::TCPConnect) + ); + assert_eq!( + Socks5Command::from_u8(consts::SOCKS5_CMD_TCP_BIND), + Some(Socks5Command::TCPBind) + ); + assert_eq!( + Socks5Command::from_u8(consts::SOCKS5_CMD_UDP_ASSOCIATE), + Some(Socks5Command::UDPAssociate) + ); + assert_eq!(Socks5Command::from_u8(0x00), None); + assert_eq!(Socks5Command::from_u8(0x04), None); + } + + #[test] + fn reply_error_mapping_table() { + let table = [ + (ReplyError::Succeeded, consts::SOCKS5_REPLY_SUCCEEDED), + ( + ReplyError::GeneralFailure, + consts::SOCKS5_REPLY_GENERAL_FAILURE, + ), + ( + ReplyError::ConnectionNotAllowed, + consts::SOCKS5_REPLY_CONNECTION_NOT_ALLOWED, + ), + ( + ReplyError::NetworkUnreachable, + consts::SOCKS5_REPLY_NETWORK_UNREACHABLE, + ), + ( + ReplyError::HostUnreachable, + consts::SOCKS5_REPLY_HOST_UNREACHABLE, + ), + ( + ReplyError::ConnectionRefused, + consts::SOCKS5_REPLY_CONNECTION_REFUSED, + ), + ( + ReplyError::ConnectionTimeout, + consts::SOCKS5_REPLY_TTL_EXPIRED, + ), + (ReplyError::TtlExpired, consts::SOCKS5_REPLY_TTL_EXPIRED), + ( + ReplyError::CommandNotSupported, + consts::SOCKS5_REPLY_COMMAND_NOT_SUPPORTED, + ), + ( + ReplyError::AddressTypeNotSupported, + consts::SOCKS5_REPLY_ADDRESS_TYPE_NOT_SUPPORTED, + ), + ]; + for (reply, code) in table { + assert_eq!(reply.as_u8(), code); + assert!(matches!(ReplyError::from_u8(code), r if r.as_u8() == code)); + } + } + + #[tokio::test] + async fn udp_header_round_trip_ip() { + let target = TargetAddr::Ip("192.168.0.1:80".parse().unwrap()); + let header = new_udp_header(target.clone()).unwrap(); + assert_eq!(header, vec![0, 0, 0, 1, 192, 168, 0, 1, 0, 80]); + + let (frag, parsed_target, payload) = parse_udp_request(&header).await.unwrap(); + assert_eq!(frag, 0); + assert_eq!(parsed_target, target); + assert!(payload.is_empty()); + } + + #[tokio::test] + async fn udp_header_round_trip_domain() { + let target = TargetAddr::Domain("example.com".to_owned(), 53); + let header = new_udp_header(target.clone()).unwrap(); + assert_eq!( + header, + vec![ + 0, + 0, + 0, + consts::SOCKS5_ADDR_TYPE_DOMAIN_NAME, + 11, + b'e', + b'x', + b'a', + b'm', + b'p', + b'l', + b'e', + b'.', + b'c', + b'o', + b'm', + 0, + 53, + ] + ); + + let (frag, parsed_target, payload) = parse_udp_request(&header).await.unwrap(); + assert_eq!(frag, 0); + assert_eq!(parsed_target, target); + assert!(payload.is_empty()); + } + + #[tokio::test] + async fn udp_header_rejects_garbage_reserved() { + let mut header = new_udp_header(TargetAddr::Domain("example.com".to_owned(), 53)).unwrap(); + header[1] = 0xff; + let err = parse_udp_request(&header).await.unwrap_err(); + assert!(matches!(err, UdpHeaderError::GarbageInReserved)); + } + + #[tokio::test] + async fn udp_header_carries_fragment_and_payload() { + let mut frame = new_udp_header(TargetAddr::Ip("10.0.0.1:53".parse().unwrap())).unwrap(); + frame[2] = 3; + frame.extend_from_slice(b"hello"); + let (frag, target, payload) = parse_udp_request(&frame).await.unwrap(); + assert_eq!(frag, 3); + assert_eq!(target, TargetAddr::Ip("10.0.0.1:53".parse().unwrap())); + assert_eq!(payload, b"hello"); + } + + #[tokio::test] + async fn udp_header_rejects_unknown_address_type() { + let frame = vec![0, 0, 0, 0x02, 1, 2, 3, 4, 0, 0]; + let err = parse_udp_request(&frame).await.unwrap_err(); + assert!(matches!(err, UdpHeaderError::AddrError(_))); + } + + #[test] + fn socks_error_from_variants() { + let err: SocksError = UdpHeaderError::GarbageInReserved.into(); + assert!(matches!(err, SocksError::UdpHeaderError(_))); + let err: SocksError = AddrError::IncorrectAddressType.into(); + assert!(matches!(err, SocksError::AddrError(_))); + let err: SocksError = ReplyError::CommandNotSupported.into(); + assert!(matches!(err, SocksError::ReplyError(_))); + } +} diff --git a/src/vendor/server.rs b/src/vendor/server.rs index 3b62b52..82b2bcc 100644 --- a/src/vendor/server.rs +++ b/src/vendor/server.rs @@ -1,2 +1,65 @@ //! Vendored from fast-socks5 v1.0.0 (extraction — see the module doc //! in `mod.rs`; filled by the server-typestate extraction task). +//! +//! This file currently carries only the `SocksServerError` enum — the +//! server-side wire error vocabulary, which the vendored `SocksError` +//! (`SocksError::ServerError`) references (`#[from]`), so the wire +//! vocabulary extraction needs it before the typestate itself lands. +//! Upstream defines it at the top of `server.rs`; when the typestate +//! extraction lands, the rest of the file's inventory joins it. + +use crate::vendor::util::stream::ConnectError; +use crate::vendor::AddrError; +use crate::vendor::ReplyError; +use crate::vendor::UdpHeaderError; +use std::string::FromUtf8Error; + +#[derive(thiserror::Error, Debug)] +pub enum SocksServerError { + #[error("i/o error when {context}: {source}")] + Io { + source: io::Error, + context: &'static str, + }, + #[error("string error when {context}: {source}")] + FromUtf8 { + source: FromUtf8Error, + context: &'static str, + }, + #[error(transparent)] + ConnectError(#[from] ConnectError), + #[error(transparent)] + UdpHeaderError(#[from] UdpHeaderError), + #[error(transparent)] + AddrError(#[from] AddrError), + #[error("BUG: {0}")] // should be unreachable + Bug(&'static str), + #[error("Auth method unacceptable `{0:?}`.")] + AuthMethodUnacceptable(Vec), + #[error("Unsupported SOCKS version `{0}`.")] + UnsupportedSocksVersion(u8), + #[error("Unsupported SOCKS command `{0}`.")] + UnknownCommand(u8), + #[error("Unexpected garbage received on TCP stream used for UDP proxy keep-alive: `{0}`")] + UnexpectedUdpControlGarbage(u8), + #[error("Empty username received")] + EmptyUsername, + #[error("Empty password received")] + EmptyPassword, + #[error("Authentication rejected")] + AuthenticationRejected, + #[error("End of stream")] + EOF, +} + +impl SocksServerError { + pub fn to_reply_error(&self) -> ReplyError { + match self { + SocksServerError::UnknownCommand(_) => ReplyError::CommandNotSupported, + SocksServerError::AddrError(err) => err.to_reply_error(), + _ => ReplyError::GeneralFailure, + } + } +} + +use std::io; diff --git a/src/vendor/util/stream.rs b/src/vendor/util/stream.rs index 9b41463..ff5094b 100644 --- a/src/vendor/util/stream.rs +++ b/src/vendor/util/stream.rs @@ -1,7 +1,119 @@ //! Vendored from fast-socks5 v1.0.0 (extraction — see the module doc -//! in the vendor root's `mod.rs`; filled by the wire-vocabulary -//! extraction task). +//! in the vendor root's `mod.rs`). //! //! Upstream carries the `read_exact!`/`ready!` macros, `ConnectError`, -//! and the `tcp_connect*` dialers; only the first two ride the -//! extraction (the dialers are the cut kernel-net region). +//! and the `tcp_connect*` dialers; only the macros + `ConnectError` +//! ride the extraction (the dialers are the cut kernel-net region — +//! the crate's local backend is first-party tokio code, ADR-008). +//! Extraction-time adjustment: upstream exports both macros at the +//! crate root via `#[macro_export]`; in-tree they are plain +//! `macro_rules!` scoped by `pub(crate) use` so they stay internal to +//! the vendor (implementation detail, not alksocks public API). + +/// Easy to destructure bytes buffers by naming each fields: +/// +/// # Examples (before) +/// +/// ```ignore +/// let mut buf = [0u8; 2]; +/// stream.read_exact(&mut buf).await?; +/// let [version, method_len] = buf; +/// +/// assert_eq!(version, 0x05); +/// ``` +/// +/// # Examples (after) +/// +/// ```ignore +/// let [version, method_len] = read_exact!(stream, [0u8; 2]); +/// +/// assert_eq!(version, 0x05); +/// ``` +macro_rules! read_exact { + ($stream: expr, $array: expr) => {{ + let mut x = $array; + $stream.read_exact(&mut x).await.map(|_| x) + }}; +} + +#[allow(unused_macros)] +macro_rules! ready { + ($e:expr $(,)?) => { + match $e { + std::task::Poll::Ready(t) => t, + std::task::Poll::Pending => return std::task::Poll::Pending, + } + }; +} + +pub(crate) use read_exact; +#[allow(unused_imports)] +pub(crate) use ready; + +use crate::vendor::ReplyError; +use std::io; + +#[derive(thiserror::Error, Debug)] +pub enum ConnectError { + #[error("Connection timed out")] + ConnectionTimeout, + #[error("Connection refused: {0}")] + ConnectionRefused(#[source] io::Error), + #[error("Connection aborted: {0}")] + ConnectionAborted(#[source] io::Error), + #[error("Connection reset: {0}")] + ConnectionReset(#[source] io::Error), + #[error("Not connected: {0}")] + NotConnected(#[source] io::Error), + #[error("Other i/o error: {0}")] + Other(#[source] io::Error), +} + +impl ConnectError { + pub fn to_reply_error(&self) -> ReplyError { + match self { + ConnectError::ConnectionTimeout => ReplyError::ConnectionTimeout, + ConnectError::ConnectionRefused(_) => ReplyError::ConnectionRefused, + ConnectError::ConnectionAborted(_) | ConnectError::ConnectionReset(_) => { + ReplyError::ConnectionNotAllowed + } + ConnectError::NotConnected(_) => ReplyError::NetworkUnreachable, + ConnectError::Other(_) => ReplyError::GeneralFailure, + } + } +} + +#[cfg(test)] +mod tests { + use super::ConnectError; + use crate::vendor::ReplyError; + use std::io::ErrorKind; + + #[test] + fn to_reply_error_mapping() { + assert!(matches!( + ConnectError::ConnectionTimeout.to_reply_error(), + ReplyError::ConnectionTimeout + )); + assert!(matches!( + ConnectError::ConnectionRefused(ErrorKind::ConnectionRefused.into()).to_reply_error(), + ReplyError::ConnectionRefused + )); + assert!(matches!( + ConnectError::ConnectionAborted(ErrorKind::ConnectionAborted.into()).to_reply_error(), + ReplyError::ConnectionNotAllowed + )); + assert!(matches!( + ConnectError::ConnectionReset(ErrorKind::ConnectionReset.into()).to_reply_error(), + ReplyError::ConnectionNotAllowed + )); + assert!(matches!( + ConnectError::NotConnected(ErrorKind::NotConnected.into()).to_reply_error(), + ReplyError::NetworkUnreachable + )); + assert!(matches!( + ConnectError::Other(ErrorKind::Other.into()).to_reply_error(), + ReplyError::GeneralFailure + )); + } +} diff --git a/src/vendor/util/target_addr.rs b/src/vendor/util/target_addr.rs index b61ef7f..b5351a5 100644 --- a/src/vendor/util/target_addr.rs +++ b/src/vendor/util/target_addr.rs @@ -1,7 +1,452 @@ //! Vendored from fast-socks5 v1.0.0 (extraction — see the module doc -//! in the vendor root's `mod.rs`; filled by the wire-vocabulary -//! extraction task). +//! in the vendor root's `mod.rs`). //! //! Upstream carries `TargetAddr`, the address codec (`read_address`), //! and the `resolve_dns` helper; only the codec rides the extraction -//! (DNS resolution is the cut net region). +//! (DNS resolution is the cut net region — `tokio::net::lookup_host` +//! is not in the wasm-clean graph). The `resolve_dns` method is cut +//! with its two `AddrError` variants (`DNSResolutionFailed`, +//! `NoDNSRecords`); the `ToSocketAddrs` impl's error text referencing +//! `resolve_dns` is preserved verbatim for fidelity. +//! +//! Verbatim-code lint posture: this file is upstream v1.0.0 code +//! (minus the cut region), 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::match_like_matches_macro)] // upstream style +#![allow(clippy::legacy_numeric_constants)] // u8::max_value() is upstream +#![allow(clippy::io_other_error)] // io::Error::new(Other) is upstream +#![allow(clippy::needless_lifetimes)] +#![allow(clippy::redundant_closure)] + +use crate::vendor::consts; +use crate::vendor::consts::SOCKS5_ADDR_TYPE_IPV4; +use crate::vendor::util::stream::read_exact; +use crate::vendor::ReplyError; +use std::fmt; +use std::io; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}; +use std::vec::IntoIter; +use tokio::io::{AsyncRead, AsyncReadExt}; + +/// SOCKS5 reply code +#[derive(thiserror::Error, Debug)] +pub enum AddrError { + #[error("Domain length {0} exceeded maximum")] + DomainLenTooLong(usize), + #[error("Can't read IPv4: {0}")] + IPv4Unreadable(#[source] io::Error), + #[error("Can't read IPv6: {0}")] + IPv6Unreadable(#[source] io::Error), + #[error("Can't read port number: {0}")] + PortNumberUnreadable(#[source] io::Error), + #[error("Can't read domain len: {0}")] + DomainLenUnreadable(#[source] io::Error), + #[error("Can't read domain content: {0}")] + DomainContentUnreadable(#[source] io::Error), + #[error("Can't convert address: {0}")] + AddrConversionFailed(#[source] io::Error), + #[error("Malformed UTF-8")] + Utf8(#[source] std::string::FromUtf8Error), + #[error("Unknown address type")] + IncorrectAddressType, +} + +impl AddrError { + pub fn to_reply_error(&self) -> ReplyError { + match self { + AddrError::IncorrectAddressType => ReplyError::AddressTypeNotSupported, + _ => ReplyError::ConnectionRefused, + } + } +} + +/// A description of a connection target. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TargetAddr { + /// Connect to an IP address. + Ip(SocketAddr), + /// Connect to a fully qualified domain name. + /// + /// The domain name will be passed along to the proxy server and DNS lookup + /// will happen there. + Domain(String, u16), +} + +impl TargetAddr { + pub fn is_ip(&self) -> bool { + match self { + TargetAddr::Ip(_) => true, + _ => false, + } + } + + pub fn is_domain(&self) -> bool { + !self.is_ip() + } + + pub fn to_be_bytes(&self) -> Result, AddrError> { + let mut buf = vec![]; + match self { + TargetAddr::Ip(SocketAddr::V4(addr)) => { + buf.extend_from_slice(&[SOCKS5_ADDR_TYPE_IPV4]); + + buf.extend_from_slice(&(addr.ip()).octets()); // ip + buf.extend_from_slice(&addr.port().to_be_bytes()); // port + } + TargetAddr::Ip(SocketAddr::V6(addr)) => { + buf.extend_from_slice(&[consts::SOCKS5_ADDR_TYPE_IPV6]); + + buf.extend_from_slice(&(addr.ip()).octets()); // ip + buf.extend_from_slice(&addr.port().to_be_bytes()); // port + } + TargetAddr::Domain(ref domain, port) => { + if domain.len() > u8::max_value() as usize { + return Err(AddrError::DomainLenTooLong(domain.len())); + } + buf.extend_from_slice(&[consts::SOCKS5_ADDR_TYPE_DOMAIN_NAME, domain.len() as u8]); + buf.extend_from_slice(domain.as_bytes()); // domain content + buf.extend_from_slice(&port.to_be_bytes()); + // port content (.to_be_bytes() convert from u16 to u8 type) + } + } + Ok(buf) + } + + pub fn into_string_and_port(self) -> (String, u16) { + match self { + TargetAddr::Ip(socket_addr) => (socket_addr.ip().to_string(), socket_addr.port()), + TargetAddr::Domain(domain, port) => (domain, port), + } + } +} + +// async-std ToSocketAddrs doesn't supports external trait implementation +// @see https://github.com/async-rs/async-std/issues/539 +impl std::net::ToSocketAddrs for TargetAddr { + type Iter = IntoIter; + + fn to_socket_addrs(&self) -> io::Result> { + match *self { + TargetAddr::Ip(addr) => Ok(vec![addr].into_iter()), + TargetAddr::Domain(_, _) => Err(io::Error::new( + io::ErrorKind::Other, + "Domain name has to be explicitly resolved, please use TargetAddr::resolve_dns().", + )), + } + } +} + +impl fmt::Display for TargetAddr { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + TargetAddr::Ip(ref addr) => write!(f, "{}", addr), + TargetAddr::Domain(ref addr, ref port) => write!(f, "{}:{}", addr, port), + } + } +} + +/// A trait for objects that can be converted to `TargetAddr`. +pub trait ToTargetAddr { + /// Converts the value of `self` to a `TargetAddr`. + fn to_target_addr(&self) -> io::Result; +} + +impl<'a> ToTargetAddr for (&'a str, u16) { + fn to_target_addr(&self) -> io::Result { + // try to parse as an IP first + if let Ok(addr) = self.0.parse::() { + return (addr, self.1).to_target_addr(); + } + + if let Ok(addr) = self.0.parse::() { + return (addr, self.1).to_target_addr(); + } + + Ok(TargetAddr::Domain(self.0.to_owned(), self.1)) + } +} + +impl ToTargetAddr for SocketAddr { + fn to_target_addr(&self) -> io::Result { + Ok(TargetAddr::Ip(*self)) + } +} + +impl ToTargetAddr for SocketAddrV4 { + fn to_target_addr(&self) -> io::Result { + SocketAddr::V4(*self).to_target_addr() + } +} + +impl ToTargetAddr for SocketAddrV6 { + fn to_target_addr(&self) -> io::Result { + SocketAddr::V6(*self).to_target_addr() + } +} + +impl ToTargetAddr for (IpAddr, u16) { + fn to_target_addr(&self) -> io::Result { + match self.0 { + IpAddr::V4(ipv4_addr) => (ipv4_addr, self.1).to_target_addr(), + IpAddr::V6(ipv6_addr) => (ipv6_addr, self.1).to_target_addr(), + } + } +} + +impl ToTargetAddr for (Ipv4Addr, u16) { + fn to_target_addr(&self) -> io::Result { + SocketAddrV4::new(self.0, self.1).to_target_addr() + } +} + +impl ToTargetAddr for (Ipv6Addr, u16) { + fn to_target_addr(&self) -> io::Result { + SocketAddrV6::new(self.0, self.1, 0, 0).to_target_addr() + } +} + +impl ToTargetAddr for TargetAddr { + fn to_target_addr(&self) -> io::Result { + Ok(self.clone()) + } +} + +#[derive(Debug)] +pub enum Addr { + V4([u8; 4]), + V6([u8; 16]), + Domain(String), // Vec<[u8]> or Box<[u8]> or String ? +} + +/// This function is used by the client & the server +pub async fn read_address( + stream: &mut T, + atyp: u8, +) -> Result { + let addr = match atyp { + consts::SOCKS5_ADDR_TYPE_IPV4 => { + Addr::V4(read_exact!(stream, [0u8; 4]).map_err(|err| AddrError::IPv4Unreadable(err))?) + } + consts::SOCKS5_ADDR_TYPE_IPV6 => { + Addr::V6(read_exact!(stream, [0u8; 16]).map_err(|err| AddrError::IPv6Unreadable(err))?) + } + consts::SOCKS5_ADDR_TYPE_DOMAIN_NAME => { + let len = + read_exact!(stream, [0]).map_err(|err| AddrError::DomainLenUnreadable(err))?[0]; + let domain = read_exact!(stream, vec![0u8; len as usize]) + .map_err(|err| AddrError::DomainContentUnreadable(err))?; + // make sure the bytes are correct utf8 string + let domain = String::from_utf8(domain).map_err(|err| AddrError::Utf8(err))?; + + Addr::Domain(domain) + } + _ => return Err(AddrError::IncorrectAddressType), + }; + + // Find port number + let port = read_exact!(stream, [0u8; 2]).map_err(|err| AddrError::PortNumberUnreadable(err))?; + // Convert (u8 * 2) into u16 + let port = (port[0] as u16) << 8 | port[1] as u16; + + // Merge ADDRESS + PORT into a TargetAddr + let addr: TargetAddr = match addr { + Addr::V4([a, b, c, d]) => (Ipv4Addr::new(a, b, c, d), port) + .to_target_addr() + .map_err(|err| AddrError::AddrConversionFailed(err))?, + Addr::V6(x) => (Ipv6Addr::from(x), port) + .to_target_addr() + .map_err(|err| AddrError::AddrConversionFailed(err))?, + Addr::Domain(domain) => TargetAddr::Domain(domain, port), + }; + + Ok(addr) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vendor::consts; + use tokio::io::AsyncWriteExt; + + async fn read_target_addr(bytes: &[u8]) -> Result { + let (mut client, mut server) = tokio::io::duplex(64); + client.write_all(&bytes[1..]).await.unwrap(); + drop(client); + read_address(&mut server, bytes[0]).await + } + + #[tokio::test] + async fn read_address_ipv4_round_trip() { + let addr = read_target_addr(&[consts::SOCKS5_ADDR_TYPE_IPV4, 192, 168, 0, 1, 0, 80]) + .await + .unwrap(); + assert_eq!(addr, TargetAddr::Ip("192.168.0.1:80".parse().unwrap())); + assert!(addr.is_ip()); + let encoded = addr.to_be_bytes().unwrap(); + assert_eq!( + encoded, + vec![consts::SOCKS5_ADDR_TYPE_IPV4, 192, 168, 0, 1, 0, 80,] + ); + } + + #[tokio::test] + async fn read_address_ipv6_round_trip() { + let addr = read_target_addr(&[ + consts::SOCKS5_ADDR_TYPE_IPV6, + 0x20, + 0x01, + 0x0d, + 0xb8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0x01, + 0x01, + 0xbb, + ]) + .await + .unwrap(); + assert_eq!(addr, TargetAddr::Ip("[2001:db8::1]:443".parse().unwrap())); + let encoded = addr.to_be_bytes().unwrap(); + assert_eq!( + encoded, + vec![ + consts::SOCKS5_ADDR_TYPE_IPV6, + 0x20, + 0x01, + 0x0d, + 0xb8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0x01, + 0x01, + 0xbb, + ] + ); + } + + #[tokio::test] + async fn read_address_domain_round_trip() { + let addr = read_target_addr(&[ + consts::SOCKS5_ADDR_TYPE_DOMAIN_NAME, + 11, + b'e', + b'x', + b'a', + b'm', + b'p', + b'l', + b'e', + b'.', + b'c', + b'o', + b'm', + 0, + 80, + ]) + .await + .unwrap(); + assert_eq!(addr, TargetAddr::Domain("example.com".to_owned(), 80)); + assert!(addr.is_domain()); + let encoded = addr.to_be_bytes().unwrap(); + assert_eq!( + encoded, + vec![ + consts::SOCKS5_ADDR_TYPE_DOMAIN_NAME, + 11, + b'e', + b'x', + b'a', + b'm', + b'p', + b'l', + b'e', + b'.', + b'c', + b'o', + b'm', + 0, + 80 + ] + ); + assert_eq!(addr.into_string_and_port(), ("example.com".to_owned(), 80)); + } + + #[tokio::test] + async fn read_address_unknown_atyp_is_incorrect() { + let err = read_target_addr(&[0x02, 0, 0]).await.unwrap_err(); + assert!(matches!(err, AddrError::IncorrectAddressType)); + assert!(matches!( + err.to_reply_error(), + ReplyError::AddressTypeNotSupported + )); + } + + #[test] + fn to_be_bytes_domain_too_long() { + let long_domain = "a".repeat(256); + let addr = TargetAddr::Domain(long_domain, 80); + assert!(matches!( + addr.to_be_bytes(), + Err(AddrError::DomainLenTooLong(256)) + )); + } + + #[test] + fn to_socket_addrs_domain_is_explicit_resolution_only() { + let addr = TargetAddr::Domain("example.com".to_owned(), 80); + assert!(std::net::ToSocketAddrs::to_socket_addrs(&addr).is_err()); + let ip: SocketAddr = "10.0.0.1:80".parse().unwrap(); + let addr = TargetAddr::Ip(ip); + assert_eq!( + std::net::ToSocketAddrs::to_socket_addrs(&addr) + .unwrap() + .next(), + Some(ip) + ); + } + + #[test] + fn to_target_addr_conversions() { + assert_eq!( + ("example.com", 80u16).to_target_addr().unwrap(), + TargetAddr::Domain("example.com".to_owned(), 80) + ); + assert_eq!( + ("1.2.3.4", 80u16).to_target_addr().unwrap(), + TargetAddr::Ip("1.2.3.4:80".parse().unwrap()) + ); + assert_eq!( + (IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 80u16) + .to_target_addr() + .unwrap(), + TargetAddr::Ip("1.2.3.4:80".parse().unwrap()) + ); + assert_eq!( + (Ipv6Addr::LOCALHOST, 80u16).to_target_addr().unwrap(), + TargetAddr::Ip("[::1]:80".parse().unwrap()) + ); + let target = TargetAddr::Domain("example.com".to_owned(), 80); + assert_eq!(target.to_target_addr().unwrap(), target); + } +} diff --git a/tasks/implementation/vendor-wire-vocabulary.md b/tasks/implementation/vendor-wire-vocabulary.md index 6f61ca9..cedd5f6 100644 --- a/tasks/implementation/vendor-wire-vocabulary.md +++ b/tasks/implementation/vendor-wire-vocabulary.md @@ -1,7 +1,7 @@ --- id: implementation/vendor-wire-vocabulary name: Vendor — the wire vocabulary (consts, commands, codecs, errors, macros) -status: pending +status: completed depends_on: [implementation/vendor-crate-scaffolding] scope: narrow risk: low @@ -88,24 +88,30 @@ behavior is byte-identical to upstream v1.0.0. ## Acceptance Criteria -- [ ] `src/vendor/lib.rs`, `src/vendor/util/{mod,stream,target_addr}.rs` +- [x] `src/vendor/lib.rs`, `src/vendor/util/{mod,stream,target_addr}.rs` exist with the inventory above; `cargo test` compiles -- [ ] `cargo check --target wasm32-unknown-unknown` clean -- [ ] `cargo check` (native, default features) clean -- [ ] No `anyhow`, `log`, `tokio::net`, `socket2`, `tokio-stream`, + (**boundary note**: the vocabulary lives in `src/vendor/mod.rs`, + not a second root file `src/vendor/lib.rs` — the completed + scaffolding task pinned the vendor root as `mod.rs` with upstream + file names preserved; a second root file would be an E0758 + conflict. Upstream's `lib.rs` content minus the cut regions is + what `mod.rs` now carries.) +- [x] `cargo check --target wasm32-unknown-unknown` clean +- [x] `cargo check` (native, default features) clean +- [x] No `anyhow`, `log`, `tokio::net`, `socket2`, `tokio-stream`, `async-trait` anywhere under `src/vendor/` -- [ ] `Socks5Command::as_u8`/`from_u8` are `pub` (the wrapper needs +- [x] `Socks5Command::as_u8`/`from_u8` are `pub` (the wrapper needs them; upstream had them private) -- [ ] `read_exact!`/`ready!` are not exported at the crate root (no +- [x] `read_exact!`/`ready!` are not exported at the crate root (no `#[macro_export]`); they resolve inside the vendor module -- [ ] `TargetAddr` has no `resolve_dns` method; `AddrError` has no +- [x] `TargetAddr` has no `resolve_dns` method; `AddrError` has no `DNSResolutionFailed`/`NoDNSRecords` variants -- [ ] `SocksError` has no `Other(anyhow::Error)`, no `Redaction`, no +- [x] `SocksError` has no `Other(anyhow::Error)`, no `Redaction`, no `InvalidHeader`, no `ReplySocks4Error`, no `ArgumentInputError` variants -- [ ] `cargo clippy --all-targets -- -D warnings` and +- [x] `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` clean -- [ ] Unit tests for the codecs pass: `to_be_bytes`/`read_address` +- [x] Unit tests for the codecs pass: `to_be_bytes`/`read_address` round-trip (v4/v6/domain), `new_udp_header`/`parse_udp_request` round-trip, `ReplyError::as_u8`/`from_u8` mapping table — ported from upstream's non-net test regions where extractable @@ -127,8 +133,82 @@ behavior is byte-identical to upstream v1.0.0. ## Notes -> To be filled by implementation agent +- **Module placement correction**: the task text says + `src/vendor/lib.rs`, but the completed scaffolding task pinned the + vendor root as `src/vendor/mod.rs` (skeleton with `mod` + + submodule declarations already committed, 57b107b). A second root + file (`lib.rs` alongside `mod.rs`) is an E0758 conflict, so the + vocabulary landed in `mod.rs` — upstream's `lib.rs` content minus + the cut regions, appended after the scaffolding module doc. The + review task's file-by-file diff vs upstream is unaffected (the + upstream `lib.rs` region maps to `mod.rs`; noted here for the + reviewer). +- **`SocksServerError` lands early**: `SocksError` keeps + `ServerError(#[from] server::SocksServerError)` per the task's + keep-list, but `server.rs` was a stub — a `#[from]` needs the type + now. Upstream defines `SocksServerError` at the top of + `server.rs`; it landed there verbatim (enum + `to_reply_error`, + including `UnexpectedUdpControlGarbage`) as the first item of the + server-typestate task's inventory, with a module doc noting the + early landing. The server task's remaining inventory is unchanged. +- **Verbatim-code lint posture**: the copied upstream regions trip + newer-clippy style lints (`match_like_matches_macro`, + `legacy_numeric_constants` for `u8::max_value()`, `io_other_error` + for `io::Error::new(Other)`, `needless_lifetimes`, + `redundant_closure`). Churning vendored code to satisfy a newer + clippy would break the mechanical per-file diff posture (vendor.md + §Maintenance Posture; review task criterion 1), so the lints are + allowed at module level with the rationale recorded. Behavior is + untouched. +- **`AuthenticationMethod::as_u8`/`from_u8` stay private** with + upstream's `#[allow(dead_code)]`: the client session task's + `send_version_and_methods` consumes them when it lands. Making + them pub now would be ahead of need (the task pins pub only for + `Socks5Command`). +- **`ready!` macro**: upstream's server typestate consumes it (the + `transfer`/`wait_on_tcp` region); nothing in the vocabulary region + does. It rides with `#[allow(unused_macros)]`/`#[allow(unused_imports)]` + until the typestate lands — kept verbatim now so the util diff + stays complete. +- **Tests**: the vocabulary region's unit tests are first-party + (upstream's `lib.rs` test module is the cut net-heavy region — + none of its tests are extractable); the codec tests here follow + the crate's test layout (tokio duplex for `read_address`, plain + slice parsing for `parse_udp_request`, no `env_logger`, no + sockets). `ConnectError::to_reply_error` and `AddrError:: + to_reply_error` mappings are pinned too (the dial seam + the + address-type reply mapping are wire-adjacent behavior). Vendored + enums keep upstream's derives (no `PartialEq` was added), so tests + compare via `as_u8()`/`matches!` rather than adding derives. +- **The `ToSocketAddrs` impl's error string** still names + `TargetAddr::resolve_dns()` (upstream text preserved verbatim) even + though the method is cut — noted in the module doc; a future + consumer hitting that path gets the explicit-resolution message + exactly as upstream produced it. ## Summary -> To be filled on completion \ No newline at end of file +Extracted the wire vocabulary from fast-socks5 v1.0.0 per ADR-011 +§Amendment + vendor.md: upstream `lib.rs`'s non-net region (consts, +`Socks5Command` with pub `as_u8`/`from_u8`, `AuthenticationMethod`, +the pruned `SocksError` (no `anyhow`/`Redaction`/`InvalidHeader`/ +`ConnectError`-from/`ReplySocks4Error`/`ArgumentInputError`), the +`Result` alias, `ReplyError` codec, `UdpHeaderError` + +`new_udp_header`/`parse_udp_request`) into `src/vendor/mod.rs` (the +scaffolding-pinned root; the task's `lib.rs` target name corrected — +see Notes), `AddrError`/`TargetAddr`/`ToTargetAddr`/`Addr`/ +`read_address` minus `resolve_dns` into `util/target_addr.rs`, and +the `read_exact!`/`ready!` macros (de-exported to `pub(crate) use`) ++ `ConnectError` + `to_reply_error` into `util/stream.rs`. +`SocksServerError` (enum + `to_reply_error`) landed verbatim in +`server.rs` early — `SocksError`'s `#[from]` needs it before the +typestate task lands. 16 first-party unit tests cover the codecs +(v4/v6/domain round-trips, UDP header/parse incl. frag/payload/garbage +RSV/unknown ATYP, reply-code mapping table, command mapping, +`to_reply_error` mappings, `ToTargetAddr` conversions, domain-length +cap). Verified: `cargo test` (16 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 anyhow/log/tokio::net/socket2/ +tokio-stream/async-trait in vendor code; no `#[macro_export]`; +resolve_dns + the five pruned error variants absent). \ No newline at end of file