Skip to content

5.0.0: remove Uri and the timeouts from the public API, add Shadowsocks - #3

Merged
Titlehhhh merged 35 commits into
masterfrom
feature/robustness-api
Sep 30, 2026
Merged

Titlehhhh merged 35 commits into
masterfrom
feature/robustness-api

Conversation

@Titlehhhh

Copy link
Copy Markdown
Owner

This release removes Uri and the read and write timeouts from the public API, and adds Shadowsocks. It also closes two holes in the exception contract of ConnectAsync. A failed TLS handshake and a failed socket creation escaped as raw exceptions instead of ProxyProtocolException. A checker found both while it ran the public API over thousands of real nodes.

Breaking changes

Removed from the public API:

  • ProxyClientFactory and its Instance. The same Create methods are now static on Proxy. Their parameters are now proxyLink and credentials (before: link and networkCredential).
  • IProxyClient.ProxyUri and ProxyClient.ProxyUri. A Uri cannot hold most vmess:// links. For vless, trojan and vmess it kept only scheme://host:port. For the classic proxies it held the password, so anything that logged it logged the password.
  • Proxy.ConnectAsync(Uri, ...): all three overloads.
  • ProxyUriExtensions with both ConnectThroughProxyAsync overloads.
  • IProxyClient.ReadTimeout and WriteTimeout, with their ProxyClient implementations. The library copied them to Socket.ReceiveTimeout and SendTimeout, which apply only to synchronous calls. Every handshake is asynchronous, so they never bounded a connect.

New enum members. An exhaustive switch needs a default arm:

  • ProxyType.Shadowsocks
  • ProxyErrorCode.TlsHandshakeFailed

Changed behavior:

  • A failed SslStream handshake now gives ProxyProtocolException with TlsHandshakeFailed. This covers the HTTPS proxy, Trojan, and VLESS or VMess with security=tls. Before, an expired certificate or a refused SNI escaped as a raw AuthenticationException. A REALITY failure is still RealityHandshakeException.
  • A cancellation from your own CancellationToken is now OperationCanceledException in every phase. Before, the TCP connect reported it as ProxyProtocolException with ConnectionFailed. A timeout is still ProxyErrorCode.Timeout.
  • HttpsProxyClient sends the proxy name as SNI and checks the proxy certificate against it. Before, it used the CONNECT target, so every HTTPS proxy failed the default check unless its certificate named the target.
  • Percent-escaped credentials in classic proxy links (http, https, socks4, socks4a, socks5) are now decoded. For example, socks5://user:p%40ss@host sends the password p@ss.
  • A target host with a space or an ASCII control character gives ArgumentException. So does such a proxy host in a constructor or in Proxy.Create(type, ...). A link with such a host gives FormatException.
  • A SOCKS4 or SOCKS4a user id with a NUL gives ArgumentException, from a constructor and from a link.
  • ConnectAsync(Stream, host, port) now checks its arguments like the other overloads. A null host or a port outside 1-65535 gives ArgumentException.
  • The library refuses a control character in a ws or httpupgrade path or Host value. A link gives FormatException, and a client constructor gives ArgumentException. A space in the path now goes out as %20.
  • The library refuses a REALITY configuration with an undecodable pbk or sid, a server name over 253 characters, more than 16 ALPN protocols, or an ALPN protocol that is empty or over 255 bytes. A link gives FormatException, and the VlessClient constructor gives ArgumentException. Before, these failed at connect.
  • On options that you build yourself, an empty Sni or HostHeader now counts as absent. The TLS or REALITY server name falls through to the next value.
  • socks4://, socks4a:// and socks5:// links without a host give FormatException, the same as http://.
  • The socket is dual-mode unless you set LocalEndPoint. A proxy name with both IPv4 and IPv6 addresses gets both tries, in the order that the resolver returns.
  • ProxyHost is an IPv6 address without brackets, however the host arrived.
  • A read on a disposed tunnel stream can throw ObjectDisposedException synchronously instead of from the returned ValueTask.

Migration

4.x 5.0
ProxyClientFactory.Instance.Create(link) Proxy.Create(link)
ProxyClientFactory.Instance.Create(type, host, port, credentials) Proxy.Create(type, host, port, credentials)
Proxy.ConnectAsync(uri, host, port, ...) Proxy.ConnectAsync(uri.OriginalString, host, port, ...)
Proxy.ConnectAsync(uri, stream, host, port) Proxy.Create(uri).ConnectAsync(stream, host, port)
uri.ConnectThroughProxyAsync(host, port, ...) Proxy.Create(uri).ConnectAsync(host, port, ...)
client.ProxyUri client.ToString() for logs, ProxyHost and ProxyPort for the address, or client.SourceLink for the full link
client.ReadTimeout / client.WriteTimeout Pass a CancellationToken to reads and writes, or set ReadTimeout / WriteTimeout on the returned stream when its CanTimeout is true
catch (AuthenticationException) around ConnectAsync catch (ProxyProtocolException ex) when (ex.ErrorCode == ProxyErrorCode.TlsHandshakeFailed)

Proxy.Create(Uri) stays. Use it with WebProxy.Address or IWebProxy.GetProxy.

SourceLink is set only when Proxy.Create(string) or Proxy.TryCreate made the client. A client from a constructor or from FromShareLink has null there.

New

  • Shadowsocks AEAD over TCP: ShadowsocksClient, ShadowsocksOptions, ShadowsocksShareLink. The supported ciphers are aes-128-gcm, aes-192-gcm, aes-256-gcm and chacha20-ietf-poly1305. Both link grammars parse: the legacy base64 blob and SIP002. The library refuses every other cipher and every plugin= by name, before it writes a byte. chacha20-ietf-poly1305 needs OS support: Windows 11 or Server 2022, not Windows 10.
  • The connect target as an EndPoint: IProxyClient.ConnectAsync(EndPoint, ...), IProxyClient.ConnectAsync(Stream, EndPoint, ...) and Proxy.ConnectAsync(string, EndPoint, ...). This is the shape that SocketsHttpHandler.ConnectCallback gives, so an HttpClient goes through any supported proxy in one line. On IProxyClient they are default interface members, so your own implementations get them without a change.
  • Proxy.TryCreate(link, out client) and Proxy.TryCreate(link, out client, out error). They never throw, whatever the input. The error string starts with the exception type, so you can group the refusals of a large subscription.
  • IProxyClient.SourceLink: the link text that made the client. It keeps the uuid, sni and transport that scheme://host:port loses. It is a default interface member.
  • ProxyClient.ToString() gives scheme://host:port without credentials.
  • The package declares itself trimmable and AOT-compatible. It builds with no IL warnings on all four targets.

Fixed

  • The library now reaches a proxy at an IPv6 address. Before, it reported a healthy IPv6 node as ConnectionFailed.
  • HTTP CONNECT puts an IPv6 target in brackets.
  • A SOCKS string of 256 to about 1000 bytes gives SocksStringTooLong, not a raw OverflowException.
  • A failed bind or handle exhaustion during socket creation gives ProxyProtocolException, not a raw SocketException.
  • A timeout that fired while the handshake returned could dispose the socket of a stream already given to the caller. The timeout is now a linked cancellation, and nothing it registers outlives the call.
  • A CR LF in a target host could inject headers and a second request into HTTP CONNECT. A NUL could split a SOCKS4a host or user id. A CR LF in a ws path, host= or sni= could inject headers toward the node. The library now refuses all of these.
  • A password with @, #, / or ? no longer makes a client constructor or Proxy.Create(type, ...) throw UriFormatException.
  • Buffers that held credentials go back to the shared pool cleared. So do the pooled arrays that the HTTP CONNECT, WebSocket and REALITY code used for tunnel bytes.
  • A vmess:// link with @ in the #remark now parses. An empty "ps" no longer hides the #remark.
  • Share-link base64 decodes the same on .NET 10 and .NET 11.
  • REALITY is as strict as the Go client around the Finished message of the server. It refuses application data before the Finished and handshake bytes after it.
  • The REALITY client sends ALPN as UTF-8, as SslStream and Xray do.

Performance

  • Tunnel streams no longer box their async state machines on reads that complete asynchronously.
  • The REALITY key schedule computes each HKDF output as its single block, with no allocation per call.

Not in this release

  • grpc and xhttp transports, Hysteria2 and TUIC (QUIC), and UDP.
  • Vision's TLS-in-TLS splice. It is a throughput optimization, and the wire format is complete.
  • A browser-grade ClientHello fingerprint for REALITY. See docs/reality-fingerprint-plan.md.
  • Shadowsocks AEAD-2022, stream ciphers, plugins, and ws or TLS transport for Shadowsocks.

Full Changelog: v4.0.0...v5.0.0

Verification

  • dotnet build -c Release: the library builds with 0 warnings on net8.0, net9.0, net10.0 and net11.0.
  • dotnet test -c Release with QPN_DOCKER_TESTS=1 and QPN_XRAY_PATH (Xray 26.3.27): 969 passed on net10.0 and net11.0. This includes the Docker cases against Xray and sing-box and the REALITY cases against a local Xray.
  • 3 skipped: they need an external HTTP or SOCKS5 proxy in HTTP_PROXY_URI / SOCKS5_PROXY_URI.
  • The 3 DualStackConnectTest cases failed locally only because that machine refuses a connect to ::1. CI on this PR runs them.
  • A reflection dump of the public API of 4.0.0 and of this branch gives the removals and additions listed above.

🤖 Generated with Claude Code

Titlehhhh and others added 30 commits September 7, 2026 01:21
…ract

Found by pointing a checker at the public API and running it over thousands of
real nodes — neither hole is visible from the happy path, and neither is
visible to a caller that catches Exception.

The exception contract. ConnectAsync is supposed to throw ProxyProtocolException
for anything that goes wrong on the wire, NotSupportedException for a link
describing something we cannot speak, and the ArgumentException family for the
caller's own mistake. Two paths broke it. CreateSocket() sat outside the guarded
region in both overloads, so a failed bind or handle exhaustion under a few
thousand concurrent checks escaped as a raw SocketException. And
AuthenticationException derives from SystemException, not IOException, so it
slipped past the `ex is IOException or SocketException` guard entirely — meaning
an expired certificate or an SNI the server will not serve, the most common way
a TLS-carried node dies, was never reported as a proxy error at all.
Internal/TlsHandshake now owns every client-side handshake so there is one place
for that translation, under the new ProxyErrorCode.TlsHandshakeFailed. The code
is appended, so existing values keep their numbers.

Bulk parsing. Proxy.TryCreate returns the reason a link was rejected and never
throws, whatever the input: a subscription is other people's text, one bad line
in a thousand must not end a run, and a rejection naming an exception type the
parsers do not raise on purpose is how a parser bug makes itself visible instead
of hiding behind a caller's catch.

IProxyClient.SourceLink carries the text a client was built from. ProxyUri
cannot stand in: for vless, trojan and vmess it is only scheme://host:port, so
a node written out that way has lost its uuid, sni and transport and cannot be
reached again. Added as a default interface member, so nothing implementing the
interface breaks.

ProxyClientFactory is gone; its methods are static on Proxy, which was already
the static entry point and — unlike ProxyClient — is not also the base class
implementations derive from. That matters concretely: Create must return
IProxyClient rather than ProxyClient, because a future QUIC client cannot derive
from it, and a Create on a type named ProxyClient returning something else reads
wrong. The singleton Instance goes with it; the class had no state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ShadowsocksClient joins the share-link family next to VLESS, Trojan and
VMess. Four AEAD ciphers: aes-128-gcm, aes-192-gcm, aes-256-gcm and
chacha20-ietf-poly1305. Salt length equals key length, so aes-192-gcm
carries a 24-byte salt.

Master key is EVP_BytesToKey(MD5, no salt) over the password; each
direction derives its own subkey with HKDF-SHA1 and info "ss-subkey".
The nonce is a 12-byte little-endian counter that advances after every
AEAD operation, twice per chunk. A chunk carries the plaintext length in
two sealed bytes, then the sealed payload; a decrypted length above
0x3FFF is rejected, never masked.

The server sends its salt only after the target replies, so the reader
takes it lazily on the first Read. ConnectAsync writes salt and address
header eagerly and returns. A FIN at a chunk boundary is EOF; anywhere
else it is an error, and so is a failed tag.

ShadowsocksShareLink reads both grammars in the wild: the legacy blob
where the whole authority is base64, and SIP002 with base64, base64url
or plain method:password userinfo. Padding may be absent or arrive as
%3D. Port defaults to 8388. StripHtmlAmpPrefix keeps plugin= from
hiding behind &amp;.

Everything else fails by name before a byte is written: the 2022-blake3
family, legacy stream ciphers, none and plain, xchacha20-ietf-poly1305
and the CCM/GCM-SIV/SM4 variants the BCL cannot do, and every plugin.
chacha20-ietf-poly1305 also checks ChaCha20Poly1305.IsSupported, which
is false on every shipped Windows 10.

Wire bytes are pinned to vectors from an independent Python generator
validated against RFC 5869, RFC 8439, the NIST GCM cases and the OpenSSL
binary. The two-chunk vector is what catches a wrong nonce increment.
Docker cases tunnel through Xray and sing-box on both.

Write seals consecutive chunks into one send; Read fills a window with
one inner read and opens straight into the caller's buffer when it fits.
DeriveSubkey builds HKDF from HMACSHA1 one-shots, the client salt lives
in an InlineArray, and the share-link parser slices spans instead of
allocating substrings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhwLwckpNR8tqpgrbXK9Fv
A SIP002 userinfo made of base64 characters was decoded even when it was
just the password: the bytes then hold a ':' by chance and everything
before it became the method. 1057 of 8069 real ss:// links from a public
list parsed that way. The client refused them at connect time, so the
bytes were never wrong — but TryParse said yes, and a caller filtering a
subscription kept them.

The base64 branch now requires the decoded method to be short ASCII:
letters, digits, '-', '_', '.', '+'. Text the producer wrote itself is
untouched, so an unknown but well-formed name still parses and the
client names it when it refuses. Parse rate over that list drops from
99.0% to the honest 86.9%.

CorpusCheck learns ss://: its own row in the parse report, its own pool
in --live, and a redactor that prints nothing at all for the legacy
grammar, where method, password, host and port share one base64 blob.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HttpHelper wrote the target host as given, so ::1 went out as "CONNECT ::1:443" and
"Host: ::1:443". Neither parses: RFC 9112 takes the authority from RFC 3986, where an IPv6
literal is bracketed, and without the brackets the address's colons run into the port.
SOCKS accepted both spellings, since IPAddress.TryParse takes brackets, so the same call
worked or failed depending on the proxy family. A host that already carries brackets is
left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
Both socket paths, ProxyClient.CreateSocket and the Uri fast path in Proxy, created an
IPv4-only socket. Connecting it to an IPv6 address throws NotSupportedException, which the
connect guard turned into ConnectionFailed: a healthy node at an IPv6 address was reported
dead, and a mass checker cannot tell that apart from a real failure.

The socket is now dual-mode, Socket(SocketType, ProtocolType), which also lets a proxy name
that resolves to both families try both, in the order the OS resolver returns them. A
LocalEndPoint still decides the family, since binding is the caller choosing an interface.
CreateSocket now disposes the socket when setup or the bind fails, instead of leaving the
handle to the finalizer.

The IPv6 tests need a machine that can listen on ::1 and report as skipped elsewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
HttpsProxyClient set TargetHost to the CONNECT target, so SNI named the site being
tunnelled to and the proxy's certificate was checked against that name. Under the default
validation every HTTPS proxy failed unless its certificate happened to name the target.
The TLS session is with the proxy, so it now names the proxy, as the Uri path in
ProxyConnector already did. The bug dates from the first commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
IProxyClient.ConnectAsync and Proxy.ConnectAsync(link, ...) accept a DnsEndPoint or an
IPEndPoint next to host and port. It is the shape Socket.ConnectAsync has and the one
SocketsHttpHandler.ConnectCallback hands over, so an HttpClient goes through any proxy
this library speaks with a one-line callback.

On IProxyClient they are default interface members forwarding to the host-and-port
overloads, so an implementation that does not derive from ProxyClient gets them without
a change; ProxyClient implements them publicly so they are reachable on the class too.
An IPv4-mapped IPv6 address, which is how a dual-mode socket reports an IPv4 peer, is
sent as IPv4 rather than under an IPv6 address type. Any other EndPoint is an
ArgumentException, which keeps ConnectAsync's exception contract.

No EndPoint overloads were added next to the Uri ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
EncodeString wrote into the rented request buffer and cast the length with checked((byte)).
ArrayPool rounds the 513-byte request up to 1024, so a username, password or host of 256 to
about 1000 UTF-8 bytes fit the buffer, overflowed the cast, and left ConnectAsync as a raw
OverflowException instead of ProxyErrorCode.SocksStringTooLong. The write is now capped at
255 bytes with Encoding.UTF8.TryGetBytes, as ProxyAddress already does.

The request buffer, which held the SOCKS5 username and password or the SOCKS4 user id, now
goes back to the pool cleared when there were credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
The scratch buffer holding user:password and the CONNECT request holding its base64 both
went back to ArrayPool.Shared as they were, against the repo's own rule for credential
buffers. Not unit-tested: whether the next Rent hands back the same array is a pool
implementation detail, so such a test could pass without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
RealityTlsStream and WebSocketStream did not override Read(Span<byte>) and
Write(ReadOnlySpan<byte>), so Stream's fallback ran: it rents an array, reads or writes
through it and returns it to the shared pool uncleared, leaving decrypted application data,
or under WebSocket the tunnel bytes including a VLESS id, for the next renter to read.

RealityTlsStream now copies a record already in hand straight into the span and only goes
through the async path to wait for the next record; its write copies into a rented array and
clears it. WebSocketStream keeps the rented array but clears it. ReadByte and WriteByte on
RealityTlsStream no longer allocate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
The grammar check looked for '@' anywhere in the payload, including the '#remark' producers
append after the base64, so "#@channel" or "#Node @ Telegram" sent the link to the URI
parser and it failed as "not a well-formed URI". The '@' now only counts before the fragment.

Also, "ps":"" no longer hides a remark given in the fragment. An empty ps with no fragment
still gives an empty remark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
ParseCredentials split Uri.UserInfo as it was, still percent-encoded, so
socks5://user:p%40ss@host sent "p%40ss" to the proxy. A password with ':', '@' or '/' can
only be written into a link escaped, which made exactly those unusable. Each half is now
unescaped after the split.

The client constructor composed ProxyUri from the raw credentials, so a password with '@',
'#', '/' or '?' made it throw UriFormatException, including from Proxy.Create(type, host,
port, credentials). They are escaped now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
BREAKING CHANGE, for 5.0.0. Removed:
- IProxyClient.ProxyUri and ProxyClient.ProxyUri
- Proxy.ConnectAsync(Uri, string, int, ...) with and without a timeout
- Proxy.ConnectAsync(Uri, Stream, string, int, ...)
- ProxyUriExtensions, with both ConnectThroughProxyAsync overloads

A Uri cannot hold most vmess:// links or legacy ss:// ones. For vless, trojan, vmess and ss it
kept only scheme://host:port, dropping the uuid, sni and transport. It could not hold a
password with '@' at all, and anything that logged ProxyUri logged the password in clear.

Instead:
- Proxy.ConnectAsync(string link, ...) and Proxy.Create(string) take every scheme.
- Proxy.Create(Uri) stays, as an adapter for WebProxy.Address and IWebProxy.GetProxy.
- ProxyClient.ToString() is scheme://host:port, without credentials, for logs.
- ProxyHost is unbracketed for IPv6 however the host arrived; a Uri authority used to give
  "[::1]" where a share link gave "::1".

Removing the Uri fast path also removes three defects that lived only there: its https branch
authenticated TLS directly instead of through TlsHandshake, so a certificate failure escaped as
AuthenticationException; it rethrew raw IOException and SocketException from the handshake; and
it carried a third copy of the connect-timeout logic. ProxyConnector now dispatches on
ProxyType rather than scheme strings, and HttpHelper no longer takes the Uri it never used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
ProxyClient had the connect-and-handshake logic twice, and the timeout overload ran it under a
timer that disposed the socket and set a flag. Three defects followed from that shape:
- the timer was disposed only after the handshake returned, so a timeout firing in that window
  disposed the socket of a stream already handed back, and no exception was raised;
- new NetworkStream sat outside the guard, so a socket disposed underneath it threw a raw
  IOException;
- an invalid timeout threw from CreateTimer after the socket existed, and nothing disposed it.

Both overloads now share ConnectCoreAsync. The timeout is a CancellationTokenSource linked to
the caller's token, passed to the connect and to every await of the handshake, and nothing it
registers outlives the call. An invalid timeout is rejected before any socket exists.

The caller's own cancellation is now OperationCanceledException carrying the caller's token, in
every phase; the TCP connect used to report it as ConnectionFailed while the handshake threw it
bare. A timeout stays ProxyErrorCode.Timeout. AGENTS.md item 18 records the rule.

ConnectCancellationTest pins the contract. Only its invalid-timeout cases fail on the old code:
the race, and a cancellation landing in the TCP connect itself, cannot be reproduced
deterministically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
Every TLS 1.3 key-schedule output fits in the first HKDF-Expand block, T(1) =
HMAC(secret, HkdfLabel || 0x01), and the REALITY auth key is Extract followed by that same one
block. Both now call HMACSHA256/384.HashData directly instead of HKDF.Expand and
HKDF.DeriveKey, which allocate on every call on net9 and net10; a handshake makes sixteen
Expand calls and one DeriveKey. ExpandLabel refuses an output longer than a hash and a hash
other than SHA-256 or SHA-384, and zeroes its scratch block. The PRK is zeroed too.

Pinned by TlsKeyScheduleTest (RFC 8448's trace) and RealityAuthTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
A plain async ValueTask override boxes its state machine whenever it completes asynchronously,
which for a tunnel stream is most reads, for as long as the connection lives, once per layer.

PrefixedStream, VlessResponseStream and VmessResponseStream are pass-throughs once their prefix
or header is consumed, so their ReadAsync is no longer async: it returns the inner ValueTask
directly and keeps an async method only for the header read. VmessStream, WebSocketStream and
VisionStream do real work per call, so their async methods use
PoolingAsyncValueTaskMethodBuilder, as ShadowsocksStream and RealityTlsStream already did.

VisionStream's fill loops are now Stream.ReadAtLeast/ReadAtLeastAsync, and their throwOnEof
parameter, which every caller passed as false, is gone.

One visible difference: a read on a disposed pass-through stream now throws
ObjectDisposedException synchronously instead of from the returned ValueTask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
… headers

The hand-written case-insensitive compare and SP/HTAB trim are Ascii.EqualsIgnoreCase and
MemoryExtensions.Trim(" \t"u8). Not Ascii.Trim, which would also strip \v, \f and \r.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
HttpsProxyClient installed a default RemoteCertificateValidationCallback that accepted exactly
what SslStream accepts without one: no policy errors. The only thing it changed was the
failure. TlsHandshake carries the handshake's message into TlsHandshakeFailed, and a callback's
refusal reads "The remote certificate was rejected by the provided
RemoteCertificateValidationCallback" where SslStream's own names the chain error. The callback
is now null unless the caller set one. The ALPN list is one shared instance instead of a new
list per connection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
IsAotCompatible marks the package trimmable and makes a reflection-based dependency, a
JsonSerializer call for instance, fail the build rather than a user's published app. The
library builds with no IL warnings on all four targets: the vmess JSON is read with
JsonDocument, which does not reflect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
…as wrong

The host and port checks in ProxyClient use ArgumentException.ThrowIfNullOrEmpty and the
ArgumentOutOfRangeException helpers. The types and parameter names are unchanged; the
messages now include the rejected value, and the host message no longer says "between 0 and
256 characters" for a limit of 255. IProxyClient described the TimeSpan timeout as "in
milliseconds".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
In a base64 group ending "==" the last data character carries four bits no byte uses, and two
before a single "=". Go's decoder, which Xray and most link producers run, ignores them, and
so does .NET 10's Convert. .NET 11's rejects the group. The same vmess or ss link therefore
parsed on one of the test project's targets and not on the other.

ShareLinkBase64.TryNormalize is now the one place share-link base64 is prepared: the url-safe
alphabet mapped to the standard one, whitespace dropped, padding completed, and those trailing
bits cleared, which decodes to the bytes both older decoders produced. It replaces the two
copies of that loop in VmessShareLink and ShadowsocksShareLink. The vmess relaxed decoder now
clears its pooled char buffer, which held the base64 of a JSON carrying the user id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
…ecting

VlessClient decoded pbk and parsed sid inside ConnectAsync, so a bad value left that call as a
FormatException, which is not one of the exceptions it may throw. Both are now checked where
the configuration enters: VlessShareLink.TryParse refuses the link with the value named, and
the VlessClient constructor throws ArgumentException for hand-built options. The decoded key is
kept on the client instead of being decoded on every connect. A REALITY configuration with no
key at all is still NotSupportedException at connect, as before.

The key goes through ShareLinkBase64, so one whose last character has unused bits set decodes
on .NET 11 as it does on .NET 10. RealityAuth gains TryDecodePublicKey and TryParseShortId;
ParseShortId throws from the latter.

Test changes: Client_Reality_MalformedPublicKey_ThrowsFormatBeforeWriting pinned the old
behaviour and is replaced by tests that the link and the constructor refuse the value. Two
parsing fixtures used pbk=PUBKEY, which is not a key and is now refused; they use a real one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
BREAKING CHANGE, for 5.0.0. IProxyClient.ReadTimeout and WriteTimeout are removed with their
ProxyClient implementations, and CreateSocket no longer copies them to Socket.ReceiveTimeout
and SendTimeout.

They were documented as the timeout for sending and receiving data through the proxy, and never
were. Socket.ReceiveTimeout and SendTimeout bound only synchronous calls: in a loopback probe an
async read with ReceiveTimeout=300 was still pending after 1.5 s, while a synchronous read timed
out at 308 ms. Every handshake in the library is asynchronous, so neither property ever applied
inside ConnectAsync.

The one thing they did do is lost. A caller's own synchronous Read or Write on the returned
stream honoured them when that stream was the raw NetworkStream, which is the HTTP and SOCKS
tunnels with no overread. Migration: set Stream.ReadTimeout / WriteTimeout on the returned
stream when its CanTimeout is true, or pass a CancellationToken to async reads and writes.
ConnectAsync(..., TimeSpan timeout, ...) already bounds the connect and the handshake.

The RecordingClient test double drops the two members. CorpusCheck's live prober drops two
assignments that never took effect, since all its I/O is async. The README loses them from the
Factory API example and the options table and says how to bound reads instead, and AGENTS.md
records the removal next to ProxyUri's. No test asserted on socket timeouts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
Two places where the managed REALITY client accepted more than Go's crypto/tls client
does. Neither was exploitable: the bytes only reached a caller once the peer had also
passed the REALITY certificate HMAC check, so they came from the server itself. But no
real server needs either leniency, and REALITY's server side is Go's crypto/tls.

Application data before the server's Finished. A record of application data under
the server's handshake keys was kept in a list and handed to the caller as the first
bytes of the tunnel. RFC 8446 §2 says application data MUST NOT be sent before the
Finished, and Go's client refuses it in readRecordOrCCS (src/crypto/tls/conn.go):

    case recordTypeApplicationData:
        if !handshakeComplete || expectChangeCipherSpec {
            return c.in.setErrorLocked(c.sendAlert(alertUnexpectedMessage))
        }

It is now a RealityHandshakeException, and the plumbing that existed only for it is
gone: the Leftover list and its 64 KiB cap, the cap of 64 empty application-data
records during the handshake (Go refuses the first, empty or not), and
RealityTlsStream's third constructor parameter with the copy it fed. The handshake loop
stays bounded without them; ChangeCipherSpec count, flight length and message size keep
their own limits.

Handshake bytes after the server's Finished. Once the Finished is read, reads switch to
the application keys, and handshake bytes left over from the Finished's record were
dropped without a word. RFC 8446 §5.1: handshake messages MUST NOT span key changes,
and an implementation that detects one MUST terminate with unexpected_message. The
client already checked this after the ServerHello. It now checks after the Finished
too, where Go does: certificate and Finished verified, its own Finished not yet sent.
Go's check sits in conn.go and is called from readServerFinished in
handshake_client_tls13.go:

    func (c *Conn) setReadTrafficSecret(...) error {
        // Ensure that there are no buffered handshake messages before changing the
        // read keys, since that can cause messages to be parsed that were encrypted
        // using old keys which are no longer appropriate.
        if c.handLen() != 0 {
            ...
            return errors.New("tls: handshake buffer not empty before setting read traffic secret")
        }

Tests: HostilePeerTest gains a peer that holds real keys: an X25519 exchange, the RFC
8446 key schedule, a certificate bound to the REALITY auth key, and a Finished over the
real transcript. It is the only way to reach a check made after the ServerHello. Its
unbent flight, with application data under the application keys in the same burst,
completes and delivers that data. That control is what shows the two refusals are
caused by the bent part: application data under the handshake keys before the Finished,
and a NewSessionTicket sharing the Finished's record. Against the previous client both
refusal tests fail, because it completed the handshake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
SocksHelper rents one buffer for every message it writes, sized by BufferSize = 513, with a
comment naming SOCKS5 username/password authentication as the largest message. It is not. A
SOCKS4a request is VN, CD, DSTPORT and DSTIP, a user id of up to 255 bytes and its NUL, and a
host of up to 255 bytes and its NUL: 520 bytes. It worked only because
ArrayPool.Shared.Rent(513) returns a 1024-byte array. A buffer of exactly 513 would have
refused a host of 250 to 255 bytes after a 255-byte user id as too long, and thrown
IndexOutOfRangeException for a 249-byte one when writing its NUL.

The constant is now 520. Its comment lists the largest size of every message the helper
builds, and of the replies it reads into the same buffer. Nothing changes at run time, because
the pool hands out the same array.

Test: BufferSize_HoldsTheLargestMessageTheHelperWrites sends the longest SOCKS4a request and
the longest SOCKS5 authentication through a stream that records its largest write. It checks
the SOCKS4a bytes and requires BufferSize to equal the larger of the two writes. BufferSize is
internal so the test can read it. Against the old constant, made internal but still 513, the
test fails on both TFMs with expected 513, actual 520.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
…4 user id

HTTP CONNECT writes the target host into the request line and the Host header as it is, and
ValidateArguments checked only null, empty and length. Reproduced against a loopback proxy:
Proxy.Create("http://127.0.0.1:P").ConnectAsync("example.com HTTP/1.1\r\nX-Injected: yes\r\n\r\n
GET /admin HTTP/1.1\r\nHost: internal.local\r\nX-Pad: x", 443) returned a stream, and the proxy
received an injected header and a second request. SOCKS4 and SOCKS4a strings are NUL-terminated.
A host "good.example\0GET /admin HTTP/1.1\r\n\r\n" over socks4a put everything after the NUL on
the wire as tunnel data. A Socks4aClient with the user id "alice\0evil.example" asking for
good.example made a SOCKS4a server read user alice and host evil.example.

A target host containing a space or an ASCII control character (0x00-0x1F, 0x7F) is the
caller's mistake, so ProxyClient.ValidateArguments throws ArgumentException for it. The message
names the code point and index and does not repeat the host. Non-ASCII names are still allowed.
The check lives there, not in HttpHelper and SocksHelper, because it is about the argument, not
one wire format. No host name contains these characters, and a NUL cuts a name short wherever
it reaches a resolver: on this machine Dns.GetHostAddresses("localhost\0evil.example") returned
localhost's addresses.

ValidateArguments did not run on every path. ConnectAsync(host, port) and the EndPoint overloads
reached it, but ConnectAsync(Stream, host, port), which callers use directly, skipped it on all
nine clients. Each override now runs it before any byte is written or any TLS handshake starts.
That brings the rest of the check to those overloads too: a null or empty host, a host over 255
characters, and a port outside 1-65535 are now ArgumentException there, as they already were
elsewhere. Before, a null host reached the protocol code (a NullReferenceException in
HttpHelper), and a port of 65616 would have gone out as 80 in a SOCKS5 request.

A SOCKS4 user id containing a NUL is refused where the credential enters: the Socks4Client and
Socks4aClient constructors that take a NetworkCredential. Proxy.Create also reaches them for
socks4:// and socks4a:// links with a user. NetworkCredential is mutable, so SocksHelper checks
again before writing the request. The message never contains the user id.

The new tests fail against the old library (stashed, with the new tests kept): 20 of 243 in the
affected classes on each TFM.
- HttpHelperTest: the reproduction through every overload against LoopbackConnectProxy, and
  NUL, TAB, LF, CR, 0x1F, space and DEL in a host through the stream overload.
- ProxyFactoryTest: a host with CR LF, refused by all nine link families through the
  DnsEndPoint and stream overloads, with nothing written.
- Socks5HelperTest: a NUL in a SOCKS4a host; a NUL in a user id through both constructors and
  both link schemes, with no message naming it; and a user id changed after construction.
Client_InternationalisedTargetHost_IsStillSentAsUtf8 guards against refusing too much, and
passes on both the old library and the new.

ShadowsocksShareLinkTest.Client_ConnectAsync_HostNameTooLong_FailsBeforeWriting used 300 ASCII
characters, which the stream overload now refuses with ArgumentException before the address
encoder sees them. It now uses 200 Cyrillic letters (400 bytes), so it still tests the
encoder's own limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
…tion enters

The REALITY ClientHello is written as one TLS record, and nothing bounded what went into it.
Reproduced: new VlessClient(new VlessOptions { Security = Reality, RealityPublicKey = <valid>,
Sni = new string('s', 70000) }) made ConnectAsync throw InvalidOperationException ("A two-byte
vector cannot hold 70000 bytes.") from TlsWriter.EndVector. A link with a 17 000-character sni=
made it throw ArgumentOutOfRangeException ("A TLS record carries at most 16384 bytes") from the
record layer's WriteAsync. Both happened after the TCP connect, and neither is an exception
ConnectAsync may throw. Separately, alpn=h%C3%A9 was accepted, but TlsClientHello encoded ALPN
with Encoding.ASCII, so the wire carried "h?".

TlsClientHello.TryValidate checks the name and the list against three documented bounds:
- a server name of at most 253 characters in the A-label form SNI sends (a DNS name without
  its trailing dot);
- at most 16 ALPN protocols;
- each protocol 1 to 255 bytes of UTF-8 (RFC 7301's one-byte length, which
  SslApplicationProtocol also enforces).
At all three maxima the hello comes to about 4.5 KB, which leaves the rest of the 16 384-byte
record as room for a browser fingerprint; Chrome's hello is about 1.7 KB. The same check refuses
a name ToALabel cannot convert, which used to surface from inside the handshake as an
ArgumentException.

The check runs where the configuration enters, like the pbk and sid checks. VlessShareLink
refuses a REALITY link with the reason (FormatException from Parse), and the VlessClient
constructor throws ArgumentException for options built by hand. The name is Sni ?? HostHeader ??
Host, and VlessOptions.RealityServerName now holds that precedence in one place. The parser, the
constructor and BuildRealityOptions all read it, so sni=, host= and the server address are all
bounded. Only security=reality is checked; the TLS path hands the name and ALPN to SslStream.

ALPN is now encoded as UTF-8. That is what SslApplicationProtocol puts on the wire for the same
list under security=tls, and what Go, and so Xray, sends for a string. Refusing non-ASCII instead
would reject, for REALITY only, a link that the TLS path and Xray both accept.

Tests are in VlessTest. Four of them fail against the old library (stashed) on each TFM:
- an over-long name through sni=, host=, Proxy.Create and all three option fields, with 253
  characters and an internationalised name still accepted;
- a name IdnMapping cannot convert;
- 17 protocols, a 256-byte one and a 256-byte non-ASCII one refused by the link, and 17
  protocols and an empty one refused by the constructor, with 16 protocols of 255 bytes accepted;
- ALPN "hé" encoded as the bytes of SslApplicationProtocol("hé") (the old encoder wrote 02 68 3F).
Reality_HelloAtTheLargestAllowedNameAndAlpn_FitsOneRecord builds a hello at every maximum at
once, checks that one more of each is refused, and writes the hello through the record layer as
one record. It uses the new constants, so it does not compile against the old library and was
left out of that run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
…roxy host

The ws and httpupgrade transports write the configured path into the request line, and the Host
header value (host=, else sni, else the server address) into the Host header, as they are. A share
link percent-decodes both, so path=%2Fws%0D%0AX-Injected:%20yes, or the same in host= or sni=, made
VLESS, Trojan and VMess send their node's server, or the CDN in front of it, a header of the link's
writing; a path could as well have ended the header block and added a second request. Options built
by hand with a CR or LF in Path, HostHeader or Sni did the same.

ProxyTransport.TryValidateRequest refuses an ASCII control character (0x00-0x1F, 0x7F) in the path
and in the Host header value, for ws and httpupgrade. It runs where the configuration enters: the
VLESS and Trojan parsers and both VMess grammars report it as a FormatException, and the three
client constructors throw ArgumentException. The message names the field, the code point and the
index, never the value, because a path can carry a secret. The raw-TCP transport sends neither
value, so a tcp link with junk in an unused path is left working. VlessOptions, TrojanOptions and
VmessOptions gain TransportHostHeader, so the check and the request read the same value.

A space in the path is not refused; it goes out as %20. A request target cannot hold one (RFC 9112
section 3.2), and a server splitting the request line at it reads the rest as the HTTP version. But a
server can be configured with such a path, Xray's client builds this request with Go's net/url, which
writes a space in a path as %20, and 68 of the 10 822 vless links in the cached real-world corpus
carry one in their decoded ws path. They used to go out as "GET /a b HTTP/1.1".

The proxy host: ProxyClient's constructor only length-checked it, and a NUL cuts a name short at the
resolver, as 1e33046 measured. It now refuses what ValidateArguments refuses in a target, a space or
an ASCII control character, with ArgumentException. Every client, options built by hand and
Proxy.Create(ProxyType, ...) come through it. Through links, Uri already refused such a host, raw or
percent-encoded, for http, https, socks4, socks4a, socks5, vless, trojan and the vmess URI form. The
ss:// authority is scanned by hand and a vmess JSON "add" holds any string, and both built a client
with the host as it was; those two parsers now refuse it as a FormatException.

An empty Sni on options built by hand was sent as the server name: RealityServerName was
Sni ?? HostHeader ?? Host, and the VLESS, Trojan and VMess TLS paths passed the same expression to
SslStream, so the hello carried none of the names the options gave. TlsHandshake.ResolveServerName
treats an empty sni or host header as absent, as ResolveHostHeader already did, and the options'
ServerName (renamed from RealityServerName) now gives REALITY, every TLS TargetHost and the name in
a TLS error. A share link never produces an empty value.

Tests: 29 new cases on each TFM. Against the old library (stashed, new tests kept) 28 fail on each
TFM, each on its own assertion, and Parse_ControlCharacterInFieldsTheTransportNeverSends_IsLeftAlone,
which guards against refusing too much, passes.
- TransportTest: CR LF, LF, NUL and DEL in path=, host= and sni= refused by the vless, trojan and
  vmess URI links; CR LF in the path, host and sni of a vmess JSON link, refused by the check rather
  than as unreadable JSON; NUL, TAB, LF, CR, 0x1F and DEL in Path, HostHeader and Sni refused by all
  three constructors on both transports. A space in the path goes out as %20 through a link over ws
  and through built options over httpupgrade; the old request line was "GET /a b?ed=2048 HTTP/1.1".
- ProxyFactoryTest: NUL, TAB, LF, CR, 0x1F, space and DEL in the proxy host refused by the HTTP
  client, Proxy.Create(ProxyType) with and without credentials and the VLESS, Trojan, VMess and
  Shadowsocks constructors; a NUL or CR LF in the host of all eight link grammars refused as
  malformed, the vmess JSON and both ss grammars by the new check.
- VlessTest, TrojanTest, VmessClientTest: an empty sni falls back to the host header in the REALITY
  and TLS hellos, and an empty sni and host header to the server address for REALITY.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
…es on

The suite runs the Debug build, so these are live in every test run. Each checks something only a
bug in this library can break, and each guards a place where breaking it would not fail on the spot:
a pooled buffer rounded up past the size a formula asked for, a zero-length read that reads as end
of stream, or an HMAC that takes a key of any length. Nothing a peer, a share link or a caller
controls is asserted; those stay throws, because an assert is gone from the Release build.

- TlsRecordStream: room in the inbound buffer before a transport read (a 0-byte read would come back
  as end of stream mid-record, which RealityTlsStream takes for an orderly close); and StageRecord's
  payload within MaxPlaintext with room for the record (_outboundPlain is rented at 16 385 bytes and
  comes back at 32 KiB, so an oversized record would be sealed without a word).
- TlsRecordProtection's constructor and TlsKeySchedule.ExpandLabel: a secret exactly one hash long.
  Every caller in the library, the tests and the benchmarks passes one.
- ShadowsocksStream: SealRun takes something from a non-empty payload (otherwise WriteAsync loops
  forever), and FreeSpace leaves the room its own comment promises.
- Request sizes the pool's rounding would hide: the SOCKS4 request against BufferSize, the VLESS
  request against MaxRequestSize (at the write, as length, since BuildRequest itself takes any flow),
  the Trojan request, the HTTP CONNECT request and the HTTP upgrade request against their computed
  sizes, and ProxyAddress's destination against MaxLength. Every caller of WriteTypeAndAddress,
  including ShadowsocksCryptoTest's direct call to BuildAddressHeader, passes at least MaxLength.
- VisionStream: room for the read after Compact(1) (a 0-byte read in Undecided mode would switch the
  stream to Raw and hand the UUID and padding to the caller), a non-zero step when content is taken
  or padding skipped (otherwise a 0 that reads as end of stream, or a loop that never reads), a whole
  header before ReadFrameHeader, and the first frame's padding within MaxFrame.
- VmessStream: OpenChunk's plaintext length (a mismatch makes the AEAD throw ArgumentException, which
  the CryptographicException translation lets out of ReadAsync), and SealChunk's plaintext within
  MaxSendPlaintextSize.
- TlsWriter, in Debug only: a stack of the vectors begun and not yet ended. EndVector must end the
  innermost one, with the prefix size it began with, and ToArray must find none open. The check
  first suggested, that the prefix being patched still reads zero, misses a size one short (it lands
  on a zero byte of the same placeholder) and every empty vector.
- RealityTlsClient: HandshakeSecrets.At within its six secrets, now a named constant that Rent uses
  too; and no read or write protection installed yet when the first keys go in.
- RealityTlsStream.TakePending: a record in hand and a non-empty buffer (a 0 would read as end of
  stream).
- Sha256Core: whole blocks, an eight-word state and a full schedule for Absorb and Finish (the
  schedule is expanded through Unsafe.Add, unchecked); an eight-word IV; a digest of whole words that
  fits its destination.
- VmessKdf.Derive: one path element or three.

Left out: an assert that the application-epoch protections replace disposed ones. The handshake
disposes both on the two lines before, and TlsRecordProtection has no disposed state an assert could
read. None were added inside per-byte or per-limb loops (X25519, the SHA rounds, CRC, FNV), where test
vectors already pin the behaviour.

Verified with the asserts live: dotnet test (Debug) passed 922, skipped 37, of 959 on net10.0 and
net11.0, with no DebugAssertException in the log. The library builds with 0 warnings on all four
TFMs in Release and in Debug (the TlsWriter check compiles only in Debug), the solution with the
two known CA2022 warnings in TlsRecordStreamTest, and tools/CorpusCheck cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…already was

Proxy.Create(string) documents FormatException for a link whose scheme is known but which is
malformed. "http://" was reported that way, because Uri refuses an http URI with no host.
"socks4://", "socks4a://" and "socks5://" were not: Uri takes an empty authority for a scheme it has
no rules of its own for, so those links parsed, reached the client constructor as an empty host and
left as an ArgumentException. The same broken link changed exception type with its scheme, and
ArgumentException is the type the contract keeps for a caller's own mistake.

The classic branch of Proxy.Create(string) now refuses a parsed link with an empty host as a
FormatException, before any client exists. Proxy.Create(Uri) is unchanged; there the Uri is the
caller's own argument.

Found while tightening Create_MalformedKnownScheme_ThrowsFormat, which asserted
ThrowsAny<Exception> for "socks5://" and so would also have passed on a failed Debug.Assert. It now
asserts FormatException for socks4://, socks4a://, socks5:// and http://.
Against the old Proxy.cs (stashed, test kept) it fails on both TFMs at socks4://: expected
FormatException, actual ArgumentException.

Left as it was: "socks5://example.com", with no port, still leaves as an ArgumentOutOfRangeException.
Uri reports the missing port as -1 for these schemes and the constructor refuses a negative port,
where http:// gets 80 from Uri. Refusing that link as malformed and defaulting to port 1080 are both
defensible, and choosing between them is not a fix.

Verified: dotnet test (Debug, asserts live) passed 922, skipped 37, of 959 on net10.0 and net11.0.
The library builds in Release with 0 warnings on all four TFMs, the solution with the two known
CA2022 warnings in TlsRecordStreamTest, and tools/CorpusCheck cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Titlehhhh and others added 5 commits September 14, 2026 17:54
…d assert

Under dotnet test a failed Debug.Assert is a DebugAssertException thrown into the test that hit it,
so a test that accepts any exception passes it. These did. Every row's exception was checked by
running it, and none varies:
- ProxyFactoryTest.Create_MalformedLink_NeverEchoesTheCredential took ThrowsAny<Exception>; all four
  links are FormatException.
- ProxyFactoryTest.TryCreate_BadLink_ReturnsFalseWithAReason required only that the reason contain
  "Exception", which "DebugAssertException: ..." does. Each row now names its type:
  ArgumentException for the empty, blank and scheme-less text, NotSupportedException for hysteria2
  and rc4-md5, FormatException for the broken ss userinfo, the vmess payload that is not base64 JSON
  and the 31-character vless id.
- ShadowsocksShareLinkTest.Errors_NeverEchoTheCredential took ThrowsAny<Exception>. Each row now
  names its type: FormatException for the three malformed authorities, NotSupportedException for the
  refused cipher, the plugin and the three swapped-field links.
- VlessTest.HtmlEscapedRealityLink_StartsATlsHandshake_NotACleartextRequest took ThrowsAny<Exception>
  around a handshake over an empty MemoryStream, which ends as a RealityHandshakeException.
- FactoryTest.BadProtocolUri caught Exception and checked its type, so it could not hide an assert,
  but it passed when nothing was thrown; it now asserts NotSupportedException.

The audit that found these named four sites by line at 8e6166a. VlessTest.cs:281,
ProxyFactoryTest.cs:315 and ShadowsocksShareLinkTest.cs:264 are three of the tests above;
ProxyFactoryTest.cs:241, Create_MalformedKnownScheme_ThrowsFormat, was tightened in a7ae68e
together with the fix it turned up. TryCreate_BadLink_ReturnsFalseWithAReason and
FactoryTest.BadProtocolUri were not on that list.

The remaining catch blocks in the tests rethrow or filter on named exception types, so a
DebugAssertException passes through them.

Verified: dotnet test (Debug, asserts live) passed 922, skipped 37, of 959 on net10.0 and net11.0,
with every row of the tightened tests passing and no DebugAssertException, and dotnet test -c Release
gave the same counts. The library builds in Release with 0 warnings on all four TFMs, the solution
with the two known CA2022 warnings in TlsRecordStreamTest, and tools/CorpusCheck cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… may be asserted

The library now asserts its internal invariants (ad3d356), and the suite runs the Debug build, so
they are live in every test run. AGENTS.md's Testing section says what that means for someone
writing a test or an assert: a failed one fails only the test that hit it, and aborts the whole run
from a thread nobody awaits; an assert is for an invariant only a bug in this library can break,
never for anything a peer, a share link or a caller controls, because it is gone from the Release
build; and a test that catches Exception, in whatever form, hides one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An earlier line-ending pass left the file ending in a lone CR, and git's text=auto detection
then treated it as binary: stored unnormalised, with its diffs shown as binary. It now ends in
CRLF like the rest of the working tree and is stored with LF like every other source file. Line
endings only; no content changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HttpResponseParser returned its buffer to ArrayPool.Shared uncleared, both
when it grew and on Dispose. After a CONNECT 200 or an HTTP upgrade 101 the
buffer holds the tunnel bytes the server sent past the headers, the same
class of leak 11c1f81 fixed in RealityTlsStream and WebSocketStream.
PrefixedStream.WrapIfNeeded copies OverreadBytes before Dispose, so
clearing does not touch data a caller still reads.

Verified: dotnet test -c Release with QPN_DOCKER_TESTS=1 and QPN_XRAY_PATH:
969 passed, 3 skipped (external proxy env vars) on net10.0 and net11.0. The
3 DualStackConnectTest cases fail only because this machine refuses a
connect to ::1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
QuickProxyNet.csproj: PackageReleaseNotes describe 5.0.0 instead of 4.0.0,
Description and PackageTags name Shadowsocks, and MinVerMinimumMajorMinor
is 5.0, so untagged builds version as 5.0.0-alpha.

README.md: the error code table gains StringTooLong, TransportUpgradeFailed
and TlsHandshakeFailed. A "Many links at once" section shows Proxy.TryCreate
and SourceLink. QuickProxyNet/README.md, the package readme, names
Shadowsocks, the ss scheme, TryCreate and the EndPoint target.
docs/README.md lists shadowsocks.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Titlehhhh
Titlehhhh merged commit 9b4391f into master Sep 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant