review 001 remediation: the forked core's findings resolved

Batch 1 (F-1 + N-1 + S-2 + N-4):
- reply_success/reply_error write replies with write_all (partial-write
  hazard closed; the err_when-chaining idiom suppresses
  clippy::unused_io_amount — reproduced fresh via a five-variant probe)
- rfc1928.md's write_all bullet records both conversions and the real
  rule (replies are single-shot, all-or-nothing) + the lint blind spot
- reject's doc names the 0xff vocabulary choice (RFC 1929 needs only
  non-0x00); Addr::Domain's deliberating comment pinned

Batch 2 (coverage: L-1 + L-2 + L-3 + S-1):
- client v6 request encoding pinned (22 bytes, ATYP=4, slice bounds)
- Socks5Stream::shutdown() EOF/BrokenPipe propagation pinned
- wait_on_tcp's io-error arm + SocksServerError::to_reply_error's full
  mapping table + read_command's command-phase version mismatch covered
- S-1: read_address's 1-byte-field bound documented; decode-side
  255/256 boundary pinned both faces

Batch 3 (posture notes: C-1, C-2, N-2, N-3 — decided keep-with-note):
- ConnectError: hand-port map's companion (cut if the producer layer
  lands without consuming it); StandardAuthentication: ADR-005
  auth-split is the anticipated consumer; CheckResult same shape
- tracing: the fork's declared logging seam, noted in Cargo.toml

Coverage 92.63% → 93.09% regions / 95.40% → 96.07% lines; the named
gaps closed, the remainder is accepted-uncovered with disposition.
Review doc carries the re-verification + resolution record.

Verification: cargo test (39 lib + 37 differential), clippy
(all-targets + wasm32) -D warnings, fmt, doc, wasm check, MSRV 1.88,
publish dry-run — all green.
This commit is contained in:
glm-5.3-flash committed 2026-09-23 06:25:27 +00:00
1 parent 4bd25c093e
commit 7bd5d6a7e7
7 files changed
+445 -21

No files matched your search

+70 -2
View File
@@ -141,6 +141,14 @@ impl<T> Socks5ServerProtocol<T, states::Opened> {
}
}
/// Closure-result adapter for [`Socks5ServerProtocol
/// ::accept_password_auth`]'s credential checker: `bool`,
/// `Option<T>`, and `Result<T, E>` all work (`is_good`). In-tree
/// callers use `bool` only; the `Option`/`Result` impls anticipate
/// the producer layer's username/password mapping (typed rejections
/// want `Result`-returning checkers — review 001 N-3). Same
/// keep-or-cut decision as `StandardAuthentication` when the producer
/// layer lands.
pub trait CheckResult {
fn is_good(&self) -> bool;
}
@@ -349,6 +357,14 @@ impl<T: AsyncWrite + Unpin> PasswordAuthenticationImpl<T, password_states::Recei
}
/// Notify the client with a "NOT_ACCEPTABLE" reply and drop the socket.
///
/// The status byte is `0xff` — RFC 1929 requires only that a
/// rejection be any non-`0x00` byte; `0xff` is the convention
/// upstream picked (borrowed from the method-negotiation phase's
/// `SOCKS5_AUTH_METHOD_NOT_ACCEPTABLE` constant, which is a
/// different protocol phase's vocabulary). The bytes interoperate;
/// the pairing works only because both in-tree sides write/check
/// the same arbitrary constant.
pub async fn reject(mut self) -> Result<(), SocksServerError> {
self.inner
.write_all(&[1, consts::SOCKS5_AUTH_METHOD_NOT_ACCEPTABLE])
@@ -434,6 +450,13 @@ auth_method_enums! {
/// 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.
///
/// Posture (review 001 C-2): currently uncalled in-tree — the
/// typestate's `negotiate_auth` is driven with the single-method
/// slices everywhere. Kept as the negotiation machinery's
/// combinator: the producer layer's per-door auth selection
/// (ADR-005's auth-split) is its anticipated consumer. If the
/// producer layer lands without consuming it, cut it then.
pub enum StandardAuthentication / StandardAuthenticationStarted<T> {
NoAuthentication(NoAuthenticationImpl<T>),
PasswordAuthentication(PasswordAuthenticationImpl<T, password_states::Started>),
@@ -513,7 +536,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin> Socks5ServerProtocol<T, states::CommandR
/// 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))
.write_all(&new_reply(&ReplyError::Succeeded, sock_addr))
.await
.err_when("writing successful reply")?;
@@ -526,7 +549,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin> Socks5ServerProtocol<T, states::CommandR
pub async fn reply_error(mut self, error: &ReplyError) -> Result<(), SocksServerError> {
let reply = new_reply(error, V4_UNSPECIFIED_ZERO);
self.inner
.write(&reply)
.write_all(&reply)
.await
.err_when("writing unsuccessful reply")?;
@@ -656,6 +679,51 @@ mod tests {
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
/// `SocksServerError::to_reply_error`'s full mapping table (review
/// 001 L-3): `UnknownCommand` → `CommandNotSupported`,
/// `AddrError` delegates to its own table, and every remaining
/// variant falls through to `GeneralFailure` — the mapping the
/// producer's error surface relies on when mapping session errors
/// to reply codes (`try_notify!`'s secondary-error write). The
/// fall-through arms (`Io`/`FromUtf8`/`EOF`/auth variants) are
/// exercised here directly, since driving each from the wire is
/// not one-test-cheap. Upstream `src/server.rs:62-70`.
#[test]
fn to_reply_error_mapping_table() {
assert!(matches!(
SocksServerError::UnknownCommand(0x7f).to_reply_error(),
ReplyError::CommandNotSupported
));
assert!(matches!(
SocksServerError::AddrError(crate::rfc1928::AddrError::IncorrectAddressType)
.to_reply_error(),
ReplyError::AddressTypeNotSupported
));
for fall_through in [
SocksServerError::EOF,
SocksServerError::EmptyUsername,
SocksServerError::EmptyPassword,
SocksServerError::AuthenticationRejected,
SocksServerError::UnsupportedSocksVersion(0x04),
SocksServerError::Bug("test"),
SocksServerError::AuthMethodUnacceptable(vec![0x02]),
SocksServerError::UnexpectedUdpControlGarbage(0xde),
SocksServerError::Io {
source: std::io::Error::other("x"),
context: "reading",
},
SocksServerError::FromUtf8 {
source: String::from_utf8(vec![0xff]).unwrap_err(),
context: "converting",
},
] {
assert!(matches!(
fall_through.to_reply_error(),
ReplyError::GeneralFailure
));
}
}
#[test]
fn new_reply_v4_form() {
let addr: SocketAddr = "127.0.0.1:1080".parse().unwrap();
+9
View File
@@ -11,6 +11,15 @@
//! is a plain `macro_rules!` scoped by `pub(crate) use` so it stays
//! internal to the RFC 1928 core (implementation detail, not alksocks
//! public API).
//!
//! [`ConnectError`] is currently constructed nowhere in-tree (review
//! 001 C-1 — upstream's sole construction site was the cut
//! `tcp_connect` dialer). It is kept as the hand-port map's
//! companion: the dial-error → reply-code mapping
//! (`to_reply_error`) is what a future kernel-net port (e.g. the
//! dial-callback shape, ADR-004) would reuse, and its mapping is
//! pinned by the module's test. If the producer layer lands without
//! consuming it, cut it then.
/// Easy to destructure bytes buffers by naming each fields:
///
+10 -2
View File
@@ -204,10 +204,18 @@ impl ToTargetAddr for TargetAddr {
pub enum Addr {
V4([u8; 4]),
V6([u8; 16]),
Domain(String), // Vec<[u8]> or Box<[u8]> or String ?
// `String` — validated UTF-8 at the codec boundary (`read_address`
// converts before constructing).
Domain(String),
}
/// This function is used by the client & the server
/// This function is used by the client & the server.
///
/// The domain arm's allocation is bounded by construction: the wire
/// length field is one byte, so the claimed length is at most 255 —
/// the attacker-controlled byte cannot ask for more. Any change that
/// widens that field (an FRAG-style extension) requires an explicit
/// cap here before the allocation.
pub async fn read_address<T: AsyncRead + Unpin>(
stream: &mut T,
atyp: u8,