Protocol crate foundations
Source files: 47 · checked against Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/Cargo.tomlEtemenanki/protocols/src/lib.rsEtemenanki/protocols/src/flow.rsEtemenanki/protocols/src/error.rsEtemenanki/protocols/src/macros.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/core/harness.rsEtemenanki/protocols/src/http/core.rsEtemenanki/protocols/src/ss_legacy/core.rsEtemenanki/protocols/src/ss_2022/core.rsEtemenanki/protocols/src/vmess/core.rsEtemenanki/protocols/src/tun/udp.rsEtemenanki/protocols/src/helpers/address.rsEtemenanki/protocols/src/helpers/parse.rsEtemenanki/protocols/src/helpers/crypto.rsEtemenanki/protocols/src/helpers/address_family.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/protocols/src/sniff/collector.rsEtemenanki/protocols/src/mux/demux.rsEtemenanki/protocols/src/mux/frame.rsEtemenanki/protocols/src/trojan/core.rsEtemenanki/protocols/src/trojan/protocol.rsEtemenanki/protocols/src/trojan/users.rsEtemenanki/protocols/src/vless/protocol.rsEtemenanki/protocols/src/vmess/protocol.rsEtemenanki/protocols/src/vmess/aead.rsEtemenanki/protocols/src/socks/protocol.rsEtemenanki/protocols/src/socks/server.rsEtemenanki/protocols/src/ss_legacy/protocol.rsEtemenanki/protocols/src/ss_2022/protocol.rsEtemenanki/protocols/src/tun/inbound.rsEtemenanki/protocols/tests/pipeline.rsEtemenanki/protocols/tests/pipeline/core.rsEtemenanki/protocols/tests/unit/core/mod.rsEtemenanki/protocols/tests/unit/helpers/address.rsEtemenanki/protocols/tests/unit/helpers/address_family.rsEtemenanki/app/Cargo.tomlEtemenanki/app/src/flow.rsEtemenanki/app/src/router.rsEtemenanki/app/src/serve.rsEtemenanki/app/src/config.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/outbound/udp_fanout.rsEtemenanki/concepts/src/core.rskatana/Cargo.tomlkatana/src/outbound/mod.rs
etemenanki-protocols holds every proxy protocol and transport. Every server core in it is assembled from the same small set of parts: a Flow<T> that says where a connection goes, one error type for untrusted framing, a timer whose meaning depends on the phase, a buffer for sniffed bytes, a relay tail that tracks half-closes, and a handful of parsing, address and crypto helpers.
This page covers those parts one at a time, then shows how a protocol module is laid out and what adding one involves. It assumes you know the sans-I/O contract of ProxyCoreDecode (events in, effects out), which is described on Server core. The task that drives a core is described on Server runtime.
Responsibilities
Section titled “Responsibilities”The foundation modules own:
- The flow a server core hands to its connector. It holds the destination, user, sniffed domain and client address (
protocols/src/flow.rs). - Error classification for untrusted input. It is bridged to
std::io::Error, because the concepts boundary is fixed toio::Error(protocols/src/error.rs). - The pieces every server core composes:
FlowKey/SubKey,Phase/Timing,SniffPrefix,Passthrough, and the smallest complete core,PassthroughCore(protocols/src/core/mod.rs). - A test driver that needs no sockets,
CoreHarness(protocols/src/core/harness.rs). - Shared wire helpers: the binary address codec and text authorities (
helpers/address.rs), panic-free slicing (helpers/parse.rs), small crypto primitives (helpers/crypto.rs) and the address-family policy of outbounds (helpers/address_family.rs).
They own no protocol’s wire format, no socket and no clock. A core composes these parts; it does not inherit them. It keeps a Timing and calls it at the top of every byte event, keeps a SniffPrefix while it collects a flow’s first bytes, and keeps a Passthrough for the half-close bookkeeping of its one stream outbound.
Module map and feature gates
Section titled “Module map and feature gates”protocols/src/lib.rs declares the modules below. Two of them are behind Cargo features that are off by default.
| Module | Gate | Contents |
|---|---|---|
core |
none | The shared core pieces on this page, and CoreHarness. |
error, flow, helpers, macros |
none | Shared types and helpers on this page. |
sniff |
none | The TLS SNI and HTTP Host sniffers, SNIFF_LIMIT, SNIFF_TIMEOUT. See Sniffing. |
dns |
none | The resolver outbounds dial through. See DNS. |
transports |
none | TCP, TLS, WebSocket and gRPC, accept and connect sides. See TCP and TLS transports. |
mux |
none | The mux.cool server demultiplexer that Trojan, VLESS and VMess share. See mux.cool and XUDP. |
socks |
none | SOCKS4, 4a and 5: a dedicated inbound driver (SocksInbound) instead of a core, plus the SOCKS5 client codec and UDP link. A UDP ASSOCIATE hears only the client its control connection came from, or, over a Unix socket, the exact source its request named (ExpectedSender). See SOCKS. |
http |
none | HttpCore and the HttpConnect client codec. |
trojan, vless, vmess |
none | Server cores and client codecs. |
ss_legacy, ss_2022 |
none | Shadowsocks AEAD and Shadowsocks 2022 cores and codecs. |
wireguard |
none | Outbound only: WgConnector over a userspace netstack. |
hysteria |
feature = "hysteria" |
Hysteria 2 client and server. Pulls in quinn, h3, rustls and blake2. |
tun |
all(feature = "tun", unix) |
The TUN inbound. Pulls in ipstack, tun-rs and, on Linux, rtnetlink. |
The third feature, vendored-openssl, forwards to openssl/vendored and gates no module.
The features are off by default so that a downstream does not pick up a second TLS stack or an interface-management stack on a version bump without asking for it. The consumers opt in explicitly:
| Consumer | Features enabled |
|---|---|
etemenanki-app (app/Cargo.toml) |
hysteria, tun |
katana (Cargo.toml) |
vendored-openssl, hysteria |
Crate-level lints
Section titled “Crate-level lints”protocols/src/lib.rs opens with a crate-wide deny:
#![deny( clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::arithmetic_side_effects)]The same four lints are allowed under cfg(test), because tests work on known-good inputs. The workspace gate runs cargo clippy --workspace --all-targets --all-features -- -D warnings. Production code in this crate therefore does not index a slice, unwrap, or use unchecked +, - or *. The only exceptions are a few functions that opt out of arithmetic_side_effects with a local #[allow(..., reason = "...")] that states why the arithmetic cannot overflow: the Instant deadline helpers in socks/server.rs, transports/ws/stream.rs and transports/grpc/liveness.rs, and the varint codecs of gRPC and Hysteria 2. The helpers in Parsing without panics exist because of this rule.
Key types
Section titled “Key types”Flow<T>
Section titled “Flow<T>”pub struct Flow<T> { pub destination: Destination, pub user: NetworkUser<T>, pub sniffed: Option<SniffedBehavior>, pub source: Option<IpAddr>,}
impl<T> Flow<T> { pub fn new(destination: Destination, user: NetworkUser<T>, source: Option<IpAddr>) -> Self; pub fn toward(&self, destination: Destination) -> Self;}Flow<T> is the ProxyCoreDecode::Target of every server core in the crate. It is the value a core pushes in Effect::Open, and the value the connector routes and dials. The client codecs use Target = Destination instead. On the client side the target is the upstream proxy server that the runtime dials, and the flow’s own destination is encoded inside the codec.
| Field | Set by | Meaning |
|---|---|---|
destination |
The core, from the request | network, remote (IP or domain) and port. For a UDP flow it is the first packet’s destination. Later packets carry their own address in Effect::SendTo. |
user |
The core, from its validator | NetworkUser<T>: an authorization plus user_data: Arc<T>. The app uses T = () (app/src/flow.rs → Flow). A downstream such as katana carries its own per-user payload. |
sniffed |
The core, after sniffing | Option<SniffedBehavior>, a protocol and a domain. Flow::new leaves it None. |
source |
The inbound | The client’s address, when the inbound knows it. |
The app’s router (app/src/router.rs → route_target) turns a flow into a route target. sniffed becomes the target’s sniffed domain, so a domain or geosite rule can match a flow addressed by IP. flow.source takes precedence over the listener’s address (FlowContext::source), because a QUIC inbound serves many peers from one socket and only the flow knows which peer it belongs to.
toward(destination) builds a sub-flow. The sub-flow shares the carrier’s user (the Arc is cloned) and source, and resets sniffed to None, because a sniffed domain describes the carrier’s first bytes, not the new destination. The callers are:
mux/demux.rs→Demux: one sub-flow per mux.coolNewframe. When the inbound sniffs and the sub-flow’s target is an IP, the demux then sniffs the payload of thatNewframe.trojan/core.rs→TrojanCore: a UDP association opens its outbound toward the first packet’s destination.app/src/outbound/udp_fanout.rs→FanOutLink: routes each datagram of an association as the association’s flow toward that datagram’s target.
Clone is written by hand instead of derived, so cloning a Flow<T> does not require T: Clone. The payload only ever sits behind an Arc. NetworkUser<T> in the concepts crate does the same, for katana’s per-user traffic counter, which must stay a single shared instance.
ProtocolError
Section titled “ProtocolError”pub enum ProtocolError { Truncated(&'static str), Overflow(&'static str), Malformed(&'static str), Unauthenticated(&'static str), Crypto(&'static str), Unsupported(&'static str), Io(#[from] io::Error), Other(#[from] anyhow::Error),}
impl From<ProtocolError> for io::Error { /* … */ }
pub type Result<T, E = ProtocolError> = std::result::Result<T, E>;Every public service in the crate still returns io::Error, because that is the concepts boundary. ProtocolError classifies failures inside parsers, and the From impl lets any function that returns io::Result propagate it with ?. The &'static str payload names the field or region; it never carries input bytes.
| Variant | Display |
io::ErrorKind after conversion |
|---|---|---|
Truncated |
truncated input: … |
UnexpectedEof |
Overflow |
integer overflow: … |
InvalidData |
Malformed |
malformed: … |
InvalidData |
Unsupported |
unsupported: … |
InvalidData |
Unauthenticated |
authentication failed: … |
PermissionDenied |
Crypto |
cryptographic operation failed: … |
Other |
Other |
the wrapped error | Other |
Io |
the wrapped error | the wrapped error, returned unchanged |
At this revision, production code constructs only Truncated, Overflow and Malformed. They come from the slice helpers and from the parsers of SOCKS, mux.cool, VMess, the two Shadowsocks families, Hysteria 2 and DNS. Unauthenticated, Crypto and Unsupported are declared but not raised. The server cores build an authentication failure directly as an io::Error. TrojanCore, VlessCore, VMessCore and ShadowsocksCore use kind PermissionDenied (for example trojan: invalid user), and their core tests assert on that kind. Ss2022Core reports an unknown identity as InvalidData (shadowsocks-2022: unknown identity), and HttpCore answers a failed login with a 407 response instead of an error.
FlowKey and SubKey
Section titled “FlowKey and SubKey”pub enum FlowKey { Direct, Sub(SubKey),}
pub struct SubKey { pub id: u16, pub generation: u32,}Both derive Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord and Hash. That covers the bounds ProxyCoreDecode::Key asks for (Copy + Ord + Send + Sync + 'static). A core whose connection carries either one flow or a mux.cool carrier (TrojanCore, VlessCore, VMessCore) uses FlowKey as its key. FlowKey::Direct is the connection’s own flow, and FlowKey::Sub is one mux sub-flow.
The generation exists because of a runtime rule: Effect::Open on a key that is still live fails the connection with RuntimeError::DuplicateKey (open reuses a live outbound key). A mux.cool peer may reuse a session id right after ending it, while the old outbound is still being torn down. Demux therefore increments its generation: u32 counter with wrapping_add(1) on every accepted New frame and mints SubKey { id, generation }, so the reused id becomes a different key. Demux::session_id accepts an outbound event only if the stored SubKey for that id matches the whole key. Downlink bytes that arrive for a retired generation therefore produce no frames.
The server cores in the crate and their keys:
| Core | Module | Key |
TransportAddr |
BUF_SIZE |
Uses Timing |
|---|---|---|---|---|---|
PassthroughCore |
core |
Single |
() |
8 KiB | yes |
HttpCore |
http |
Single |
() |
MAX_HEAD (64 KiB) |
yes |
TrojanCore |
trojan |
FlowKey |
() |
16 KiB | yes |
VlessCore |
vless |
FlowKey |
() |
16 KiB | yes |
VMessCore |
vmess |
FlowKey |
() |
32 KiB | yes |
ShadowsocksCore |
ss_legacy |
Single |
() |
20 KiB | yes |
Ss2022Core |
ss_2022 |
Single |
() |
32 KiB | yes |
Hy2StreamCore |
hysteria::server |
Single |
() |
8 KiB | yes |
Hy2UdpCore |
hysteria::server |
u32 (session id) |
() |
16 KiB | no, it runs its own sweep |
TunUdpCore |
tun |
Single |
SocketAddr |
8 KiB | yes |
Phase, Timing and Expired
Section titled “Phase, Timing and Expired”pub enum Phase { Handshake, Sniff, Relay, Closing }
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);pub const RELAY_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
pub enum Expired { Handshake, Sniff, Idle }
pub struct Timing { phase: Phase, armed: bool }
impl Timing { pub fn new() -> Self; pub fn phase(&self) -> Phase; pub fn is_established(&self) -> bool; pub fn touch<C: ProxyCoreDecode>(&mut self, fx: &mut Effects<'_, C>); pub fn enter<C: ProxyCoreDecode>(&mut self, phase: Phase, fx: &mut Effects<'_, C>); pub fn expired<C: ProxyCoreDecode>(&mut self, fx: &mut Effects<'_, C>) -> Expired;}
pub fn handshake_timed_out() -> io::Error;The runtime has exactly one deadline timer, driven by Effect::SetDeadline(Option<Duration>), so Timing lets the phase decide what the deadline means. Every arm pushes Effect::SetDeadline(Some(after)), and arming again replaces the previous deadline. Timing is Copy and Default; Default is new().
| Phase | enter(phase) arms |
touch |
expired returns |
What the core does |
|---|---|---|---|---|
Handshake |
HANDSHAKE_TIMEOUT (10 s) |
Arms HANDSHAKE_TIMEOUT only if nothing is armed |
Expired::Handshake |
Returns Err(handshake_timed_out()) |
Sniff |
SNIFF_TIMEOUT (300 ms) |
Nothing | Expired::Sniff |
Opens the flow with what it collected |
Relay |
RELAY_IDLE_TIMEOUT (300 s) |
Re-arms RELAY_IDLE_TIMEOUT |
Expired::Idle, after pushing Effect::Finish and moving to Closing |
Returns Ok(0) |
Closing |
RELAY_IDLE_TIMEOUT |
Re-arms RELAY_IDLE_TIMEOUT |
Same as Relay |
Returns Ok(0) |
expired also clears armed. is_established() is true in Relay and Closing. A core enters Relay once the request is parsed and nothing more is needed from the client: for a single TCP flow that is right when it pushes Open, before the dial completes; a UDP association or a mux.cool carrier enters it before any Open. It is false during Sniff. Every core that keeps a Timing forwards it as its own is_established(); Hy2UdpCore has no handshake and always returns true. The application reads it to tell a client that never finished its request from one that is being served.
What arms and re-arms the deadline:
- The handshake deadline is armed once, by the first
touch.Timing::newstarts inHandshakewith nothing armed, and latertouchcalls inHandshakedo nothing. The limit therefore counts from the client’s first bytes, and a slow trickle of bytes does not extend it.handshake_timed_out()is anio::Errorof kindTimedOutwith the textclient did not complete its request in time. - The sniff window is fixed.
enter(Phase::Sniff)armsSNIFF_TIMEOUTandtouchleaves it alone, so bytes that arrive during sniffing do not extend it. - The idle deadline is re-armed on every byte event. Cores call
touchat the top ofEvent::TransportandEvent::Outbound, and ofEvent::Datagramwhen they carry UDP.TunUdpCore, whose transport is a datagram socket, calls it onEvent::TransportDatagramandEvent::Datagram. EOF, connect and error events do not call it. This is what makesRELAY_IDLE_TIMEOUTan idle limit and not a lifetime. - Cores enter only
SniffandRelay.Closingis reached only through an idle expiry.
A runtime delivers no event before the client speaks, so Timing alone cannot catch a client that connects and sends nothing. The drivers above the core cover that case with the same constant:
| Driver | Watchdog | Error on expiry |
|---|---|---|
app/src/serve.rs → drive |
While the core is not established, wraps each runtime.next() in tokio::time::timeout(HANDSHAKE_TIMEOUT, …) |
inbound handshake timed out after 10s |
tun/inbound.rs → serve_stream |
Same loop over a PassthroughCore runtime |
tun: the client never spoke |
socks/server.rs → SocksInbound |
Wraps the whole SOCKS handshake in HANDSHAKE_TIMEOUT; the relay then uses its own RELAY_IDLE_TIMEOUT sleep |
handshake_timed_out() |
drive takes an Established bound, which the established! macro in app/src/serve.rs implements for HttpCore, TrojanCore, VlessCore, VMessCore, ShadowsocksCore and Ss2022Core. See Serving.
stateDiagram-v2 [*] --> Handshake: Timing new, unarmed Handshake --> Handshake: first touch arms HANDSHAKE_TIMEOUT Handshake --> Sniff: enter Sniff, IP destination and sniffing on Handshake --> Relay: enter Relay Sniff --> Relay: Found, Exhausted, EOF or Expired Sniff Relay --> Relay: touch re-arms RELAY_IDLE_TIMEOUT Relay --> Closing: Expired Idle pushes Finish Handshake --> [*]: Expired Handshake, core returns an error Closing --> [*]
SniffPrefix
Section titled “SniffPrefix”pub struct SniffPrefix { collector: Collector }
impl SniffPrefix { pub fn new() -> Self; pub fn push(&mut self, plain: &[u8]) -> (usize, Verdict); pub fn held(&self) -> &[u8]; pub fn found(&self) -> Option<&SniffedBehavior>; pub fn result(&self) -> Option<SniffedBehavior>; pub fn clear(&mut self); pub fn is_empty(&self) -> bool;}SniffPrefix wraps sniff::collector::Collector, a bounded accumulator of a flow’s first plaintext bytes with no clock of its own. push takes at most Collector::remaining() bytes. The budget is SNIFF_LIMIT (4 KiB) across however many events the bytes arrive in. push returns how many bytes it took and a Verdict:
Verdict |
Meaning | What the core does |
|---|---|---|
Found |
A sniffer recognised a TLS SNI or an HTTP Host. |
Opens the flow now. |
More |
Nothing recognised yet, and budget is left. | Consumes the returned count and waits. |
Exhausted |
The budget is spent without a match. | Opens the flow without a sniffed domain. |
The caller consumes exactly the returned count. Once the budget is spent, push takes 0 bytes and keeps returning Exhausted.
The bytes are copied into the collector, so they stay in the core’s held buffer (ProxyCoreDecode::held returns prefix.held()) until the flow opens. The core then pushes Effect::Open with flow.sniffed = prefix.result(), followed by Effect::ForwardHeld { range: 0..held.len() } when it holds any bytes, and calls clear() at the top of the next byte event. That is safe because of the runtime’s held-buffer rule: while a held forward is queued, the runtime delivers no byte event, so at the start of a byte event every earlier held range has been applied. result() clones the sniffed value and leaves the bytes in place for that forward.
worth_sniffing(&destination) in sniff/mod.rs is true only for an IP destination. A flow that already names a domain is routed by that name, so cores skip the sniff phase for it and do not spend up to SNIFF_TIMEOUT on it.
Passthrough<K>
Section titled “Passthrough<K>”pub struct Passthrough<K> { key: K, outbound_eof: bool, transport_eof: bool }
impl<K: Copy> Passthrough<K> { pub fn new(key: K) -> Self; pub fn key(&self) -> K; pub fn is_done(&self) -> bool; pub fn on_transport<C: ProxyCoreDecode<Key = K>>(&self, data: &[u8], fx: &mut Effects<'_, C>) -> usize; pub fn on_outbound<C: ProxyCoreDecode<Key = K>>(&self, data: &[u8], fx: &mut Effects<'_, C>) -> io::Result<usize>; pub fn on_outbound_eof<C: ProxyCoreDecode<Key = K>>(&mut self, fx: &mut Effects<'_, C>); pub fn on_transport_eof<C: ProxyCoreDecode<Key = K>>(&mut self, fx: &mut Effects<'_, C>); pub fn on_outbound_gone<C: ProxyCoreDecode<Key = K>>(&mut self, fx: &mut Effects<'_, C>);}
pub fn staging_full() -> io::Error;Passthrough is the relay tail of a protocol with one stream outbound. Most protocols frame or encrypt their transport side, so such a core decodes its own transport bytes and uses only the half-close bookkeeping. on_transport and on_outbound are the verbatim forms that a plaintext relay uses: PassthroughCore, Trojan and VLESS after the header, the HTTP CONNECT tunnel, and a Hysteria 2 proxy stream.
| Method | State change | Effects |
|---|---|---|
on_transport(data) |
none | Forward { key, range: 0..data.len() }; returns data.len() |
on_outbound(data) |
none | Stages data toward the transport; returns data.len(), or Err(staging_full()) |
on_outbound_eof |
outbound_eof = true |
ShutdownTransport, then Finish if the transport already ended |
on_transport_eof |
transport_eof = true |
Shutdown { key }, then Finish if the outbound already ended |
on_outbound_gone |
both flags set | ShutdownTransport, Finish |
is_done() is true once both flags are set. on_outbound_gone handles a failed connect or an outbound error: nothing more can move, so the core finishes, and bytes already staged toward the transport are still written. staging_full() is io::Error::other("staging room below the core's declared reserve"). Reaching it means the core staged more than its STAGING_RESERVE promised. That is a bug in the core, not a peer error, because the runtime polls an outbound only when staging has room for the reserve plus the payload.
Passthrough is Copy, and the byte methods take &self. Cores copy it out of their state enum (let relay = *relay;) before they call them. This keeps the borrow of self.state separate from the self.prefix.clear() call that follows.
PassthroughCore<T>
Section titled “PassthroughCore<T>”pub struct PassthroughCore<T> { pending: Option<Flow<T>>, sniff: bool, prefix: SniffPrefix, relay: Passthrough<Single>, timing: Timing,}
impl<T> PassthroughCore<T> { pub const BUF_SIZE: usize = 8 * 1024; pub fn new(flow: Flow<T>) -> Self; pub fn sniffing(flow: Flow<T>) -> Self; pub fn is_established(&self) -> bool;}
impl<T: Send + Sync + 'static> ProxyCoreDecode for PassthroughCore<T> { type Key = Single; type Target = Flow<T>; type Error = io::Error; type TransportAddr = (); const STAGING_RESERVE: usize = 0; fn handle(&mut self, event: Event<'_, Self>, fx: &mut Effects<'_, Self>) -> Result<usize, io::Error>; fn held(&self) -> &[u8];}The smallest complete core. It serves a flow whose destination is known before the first byte and relays it verbatim in both directions. The TUN inbound uses it for each TCP connection, because the IP stack has already told it where the connection goes. sniffing(flow) turns the sniff phase on only when worth_sniffing says the destination is an IP. PassthroughCore is also the reference for how the parts fit together:
| Event | Handling |
|---|---|
Transport while pending and sniffing |
Enters Sniff on the first one, calls prefix.push, and opens when the verdict is not More. Returns the count taken. |
Transport otherwise |
Opens if not open yet, then touch, prefix.clear(), relay.on_transport. |
Outbound |
touch, prefix.clear(), relay.on_outbound. |
TransportEof |
Opens if still pending, with whatever was sniffed, then relay.on_transport_eof. |
OutboundEof |
relay.on_outbound_eof. |
ConnectFailed, OutboundError |
Drops the pending flow, then relay.on_outbound_gone. |
Deadline |
Expired::Handshake returns Err(handshake_timed_out()), Expired::Sniff opens, and Expired::Idle returns Ok(0). |
Connected, datagram events |
Ignored. |
Opening pushes Effect::Open { key: Single, target: flow }, then Effect::ForwardHeld over the held prefix when it is not empty, and then enters Relay. A non-sniffing core opens on its first Transport event, so the effects of that event are, in order, Open, SetDeadline(RELAY_IDLE_TIMEOUT) from enter, SetDeadline(RELAY_IDLE_TIMEOUT) from touch, and Forward.
CoreHarness<C>
Section titled “CoreHarness<C>”pub const HARNESS_STAGING: usize = 64 * 1024;
pub struct CoreHarness<C: ProxyCoreDecode> { pub core: C, effects: EffectList<C>, staging: WriteBuffer<HARNESS_STAGING>, packets: Option<PacketList<C::TransportAddr>>,}
impl<C: ProxyCoreDecode> CoreHarness<C> { pub fn new(core: C) -> Self; pub fn over_datagrams(core: C) -> Self; pub fn event(&mut self, event: Event<'_, C>) -> Result<(usize, Vec<Effect<C>>), C::Error>; pub fn transport(&mut self, data: &mut [u8]) -> Result<(usize, Vec<Effect<C>>), C::Error>; pub fn feed(&mut self, data: &mut [u8]) -> Result<(usize, Vec<Effect<C>>), C::Error>; pub fn outbound(&mut self, key: C::Key, data: &mut [u8]) -> Result<(usize, Vec<Effect<C>>), C::Error>; pub fn staged(&mut self) -> Vec<u8>; pub fn staged_packets(&mut self) -> Vec<(Vec<u8>, C::TransportAddr)>; pub fn held(&self, range: std::ops::Range<usize>) -> Vec<u8>;}A stand-in for the runtime that a test drives by hand. The unit tests of the cores use it. It does no I/O and keeps no clock, so a test delivers Event::Deadline itself.
eventdelivers one event and returns the consumed count and the effects pushed during that call.transportdelivers oneEvent::Transport.feedcalls it again on the unconsumed tail while the core makes progress, the way the runtime does, and stops when the core consumes 0 bytes. It shifts the ranges ofForwardandSendToso they are absolute in the slice you passed.ForwardHeldandSendToHeldranges index the held buffer and are left alone; resolve them withheld(range).outbounddeliversEvent::Outboundfor one key.stagedtakes everything staged toward the transport so far. Staged bytes accumulate in the harness’sHARNESS_STAGING(64 KiB) buffer until a test takes them.over_datagramsbuilds a harness for a datagram transport, soEffects::stage_toworks.staged_packetsreturns each staged packet with its peer, in order.
The harness does not enforce the runtime’s scheduling rules: the staging reserve check, the held-buffer pin, FrameTooLarge, DuplicateKey and the other RuntimeError checks. The pipeline tests cover those by running each core under the real runtime.
Data flow
Section titled “Data flow”A typical single-stream core keeps Timing and SniffPrefix as fields and moves through a state enum that holds a Passthrough once it relays. TrojanCore is a good one to read. For a CONNECT to an IP with sniffing on, the exchange looks like this:
sequenceDiagram participant R as Runtime participant C as Core participant P as SniffPrefix R->>C: Event Transport with the request header C->>R: SetDeadline HANDSHAKE_TIMEOUT from touch C->>C: parse header, look up user, Flow new C->>R: SetDeadline SNIFF_TIMEOUT from enter Sniff R->>C: Event Transport with the first payload C->>P: push payload P-->>C: taken count and Verdict Found C->>R: Open with the sniffed flow C->>R: ForwardHeld over the held prefix C->>R: SetDeadline RELAY_IDLE_TIMEOUT from enter Relay R->>R: dial, then apply the held forward R->>C: Event Outbound with reply bytes C->>R: SetDeadline RELAY_IDLE_TIMEOUT from touch C->>C: prefix clear C->>R: stage the reply toward the transport
If the sniff deadline fires first, Event::Deadline yields Expired::Sniff and the core opens with sniffed = None and the bytes it holds. If the destination names a domain, or sniffing is off, the core opens right after the header. A UDP association and a mux.cool carrier skip Sniff and enter Relay directly.
Address encoding
Section titled “Address encoding”AddressCodec
Section titled “AddressCodec”pub struct AddressCodec { pub ipv4: u8, pub domain: u8, pub ipv6: u8, pub port_first: bool,}
impl AddressCodec { pub const SOCKS: Self; pub const VMESS: Self; pub const MAX_LEN: usize = 1 + 1 + 255 + 2;
pub fn encoded_len(remote: &Remote) -> usize; pub fn write_slice(&self, out: &mut [u8], remote: &Remote, port: u16) -> Option<usize>; pub async fn read<R: AsyncRead + Unpin>(&self, r: &mut R) -> io::Result<(Remote, u16)>; pub async fn read_destination<R: AsyncRead + Unpin>(&self, r: &mut R, network: DialNetwork) -> io::Result<Destination>; pub fn read_slice(&self, data: &[u8]) -> io::Result<(Remote, u16, usize)>; pub fn write_buf(&self, out: &mut BytesMut, remote: &Remote, port: u16); pub async fn write<W: AsyncWrite + Unpin>(&self, w: &mut W, remote: &Remote, port: u16) -> io::Result<()>;}A port of Xray’s AddressParser. An address is a type byte, the address bytes and a big-endian port. The protocols differ only in the three type values and in whether the port comes first, so a codec is just those four fields.
AddressCodec::SOCKS, port last:
| Field | Size | Meaning |
|---|---|---|
| type | 1 | 0x01 IPv4, 0x03 domain, 0x04 IPv6 |
| address | 4, 16, or 1 + n | IPv4 octets; IPv6 octets; or a length byte n followed by n domain bytes |
| port | 2 | Big-endian |
AddressCodec::VMESS, port first:
| Field | Size | Meaning |
|---|---|---|
| port | 2 | Big-endian |
| type | 1 | 0x01 IPv4, 0x02 domain, 0x03 IPv6 |
| address | 4, 16, or 1 + n | As above |
| Layout | Used by |
|---|---|
SOCKS |
SOCKS5 (socks/protocol.rs), Trojan (trojan/protocol.rs), Shadowsocks (ss_legacy/protocol.rs), Shadowsocks 2022 (ss_2022/protocol.rs) |
VMESS |
VLESS (vless/protocol.rs), mux.cool frames (mux/frame.rs), VMess (vmess/protocol.rs) |
Every module except VMess declares its layout once as pub const ADDR: AddressCodec = …;. VMess names AddressCodec::VMESS at its two call sites.
encoded_len counts the type byte and the port: 7 bytes for IPv4, 19 bytes for IPv6, and n + 4 bytes for a domain of n bytes. MAX_LEN is 259, the size with a 255-byte domain. Protocols use it to size fixed header buffers, for example the Shadowsocks client’s STAGING_RESERVE.
The slice and async forms share these decoding rules:
- An unknown type byte is
InvalidDatawithunknown address type: <n>. - A domain must be non-empty UTF-8 made only of ASCII letters, digits,
-,.and_. Anything else isInvalidData(empty domain name,non-utf8 domain,invalid domain name: …). - A domain that starts with a digit or
[and parses as an IP (brackets stripped) becomesRemote::IpAddr, the way Xray’smaybeIPPrefixdoes. - In
read_slice, a short input is aTruncatederror, which reaches the caller asUnexpectedEof, so a caller can wrap it inneed_more. The returned count is the number of bytes consumed, port included. The asyncreadreturns theUnexpectedEofof the underlyingread_exactinstead.
write_slice writes into a fixed slice and returns None when the slice is too short or the domain is longer than 255 bytes. Cores use this form to seal into staging room. write_buf appends to a BytesMut and is used where the output grows; unlike write_slice, it does not check the 255-byte domain limit, so the caller must.
Text authorities
Section titled “Text authorities”pub fn format_authority(dest: &Destination) -> String;pub fn parse_authority(raw: &str, default_port: u16) -> io::Result<Destination>;These serve the protocols that carry the target as text: the HTTP proxy server, for a CONNECT target and for the host of a plain proxied request (http/core.rs); the HTTP client’s CONNECT line (http/protocol.rs → build_connect_request); and Hysteria 2’s TCP requests and UDP messages (hysteria/connector.rs, hysteria/server/inbound.rs, hysteria/server/datagrams.rs).
format_authoritybrackets IPv6, as in[2001:db8::1]:443.parse_authoritytrims the input and acceptshost,host:port,[v6]and[v6]:port. It usesdefault_portwhen the port is absent, leaves domains unresolved, and returns a TCPDestination.parse_authorityrejects an empty host (empty authority host), an unbracketed IPv6 (ambiguous authority (bracket IPv6 literals)), an invalid port (invalid port), an unclosed bracket (malformed IPv6 authority) and trailing data after a bracketed literal (trailing data after IPv6 authority). All of these areInvalidData.
Address-family policy
Section titled “Address-family policy”helpers/address_family.rs decides which of a destination’s resolved IPs an outbound may use. It answers two independent questions:
pub enum AddressFamilyStrategy { Auto, Ipv4Only, Ipv6Only, PreferIpv4, PreferIpv6 }pub struct FamilySupport { ipv4: bool, ipv6: bool }
pub async fn resolve_candidates( context: &str, dest: &Destination, strategy: AddressFamilyStrategy, support: FamilySupport, resolver: &Resolver,) -> io::Result<Vec<IpAddr>>;
pub fn select_candidate_ips( resolved: Vec<IpAddr>, strategy: AddressFamilyStrategy, support: FamilySupport,) -> Vec<IpAddr>;
pub async fn destination_to_socketaddrs( dest: &Destination, strategy: AddressFamilyStrategy, resolver: &Resolver,) -> io::Result<Vec<SocketAddr>>;- Policy (
AddressFamilyStrategy, defaultAuto) is what the operator asked for. ItsFromStris lenient: it trims, lowercases and maps-to_, and accepts aliases such asipv4,v4,4,ipv4only,prefer_v6andv6_prefer. The empty string isAuto. Anything else isAddressFamilyStrategyParseError(unknown address family strategy). - Capability (
FamilySupport) is which families the outbound can source traffic from. A dialer that leaves source selection to the kernel passesFamilySupport::both(), and the kernel fails fast withENETUNREACHfor an unusable family. WireGuard runs a userspace netstack with no routing table, so it derives support from its tunnel addresses withFamilySupport::from_addrs.
select_candidate_ips keeps every resolved address that passes both checks, in resolver order. The Prefer* strategies then apply a stable sort, so the other family stays as a fallback instead of being dropped. Keeping every candidate, not only the first, is what lets a dialer move on when the leading address is unreachable. A domain that resolves to nothing is NotFound with <context>: destination did not resolve. When nothing survives the filter, no_candidate_error returns AddrNotAvailable with <context>: no usable <strategy> destination address for <host>:<port>. It appends (local address supports …) whenever the caller’s FamilySupport is not both families, so a kernel-routed dialer never gets the clause.
transports/connect.rs, hysteria/connection.rs, app/src/outbound/freedom.rs and app/src/balancer.rs go through destination_to_socketaddrs, whose context is dial. wireguard/connector.rs calls resolve_candidates with its own FamilySupport. See Dialers.
Parsing without panics
Section titled “Parsing without panics”pub fn take<'a, I>(data: &'a [u8], index: I, what: &'static str) -> Result<&'a [u8], ProtocolError>where I: SliceIndex<[u8], Output = [u8]>;
pub fn take_array<const N: usize>(data: &[u8], at: usize) -> Result<[u8; N], ProtocolError>;
pub fn need_more<T>(result: std::io::Result<T>) -> std::io::Result<Option<T>>;These three functions in helpers/parse.rs replace slice[a..b] and slice[a..b].try_into().unwrap(), which the crate-level lints reject:
takeborrows a sub-slice for any range form (..b,a..,a..b,..) and maps an out-of-range access toTruncated(what).take_array::<N>copiesNbytes from offsetat. It computes the end offset withchecked_add(overflow isOverflow("field offset")) and bounds-checks the range (short input isTruncated("fixed-size field")). Use it for reads such asu16::from_be_bytes(take_array::<2>(data, at)?).need_moreis for slice parsers that receive a growing buffer and are called again when more bytes arrive. It turns anUnexpectedEoferror intoOk(None)and leaves every other error an error.
The resulting shape for a header parser is fn parse(buf: &[u8]) -> io::Result<Option<(Header, usize)>>:
| Return | Meaning for the core |
|---|---|
Ok(None) |
Not enough bytes yet. Consume 0 and wait for the next Event::Transport. |
Ok(Some((header, used))) |
A whole header. Consume used. |
Err(e) |
The peer is not speaking this protocol. Fail the connection. |
trojan/protocol.rs → parse_request_header shows the idiom:
let hash = match need_more(take_array::<HASH_LEN>(buf, 0).map_err(io::Error::from))? { Some(hash) => hash, None => return Ok(None),};let Some(crlf) = need_more(take_array::<2>(buf, HASH_LEN).map_err(io::Error::from))? else { return Ok(None);};if crlf != CRLF { return Err(io::Error::new( io::ErrorKind::InvalidData, "trojan: not trojan protocol (missing CRLF after hash)", ));}Keep need_more to the results of slice parsing. An UnexpectedEof from a real read means the peer closed, and need_more would turn it into a wait. Compute lengths read from the wire with checked_add or saturating_add, and check fixed fields (versions, reserved bytes, separators, commands) explicitly: a wrong separator must be an error, not a wait.
byte_newtype! (protocols/src/macros.rs, exported at the crate root) declares a Clone + Copy newtype over [u8; N] with a pub const fn as_bytes(&self) -> &[u8; N]. vmess/aead.rs uses it for GcmKey (16 bytes), GcmNonce (12 bytes) and ConnNonce (8 bytes), so values with different roles do not mix at a call site. Role-named constructors keep the pairs of the same size apart.
Crypto helpers
Section titled “Crypto helpers”pub fn evp_bytes_to_key(password: &[u8], key_len: usize) -> Vec<u8>;pub fn hkdf_sha1_ss_subkey(master_key: &[u8], salt: &[u8], out: &mut [u8]);pub fn increment_le(nonce: &mut [u8]);pub fn ct_eq(a: &[u8], b: &[u8]) -> bool;| Function | What it computes | Callers |
|---|---|---|
evp_bytes_to_key |
OpenSSL EVP_BytesToKey with MD5 and no salt: D_i = MD5(D_(i-1) ‖ password), concatenated and truncated to key_len. This is how Shadowsocks derives the master key from a password. |
ss_legacy/users.rs; the Shadowsocks outbounds of the app (app/src/outbound/mod.rs) and of katana (src/outbound/mod.rs) |
hkdf_sha1_ss_subkey |
HKDF-SHA1 with the salt, the master key as input key material and the info string ss-subkey, filling out. This is the per-session Shadowsocks AEAD subkey. |
ss_legacy/aead.rs |
increment_le |
Adds one to a little-endian counter in place, carrying across bytes and wrapping at the top. | The chunk nonces in ss_legacy/aead.rs and ss_2022/crypto.rs |
ct_eq |
Constant-time equality through subtle::ConstantTimeEq. Slices of different length compare unequal. |
trojan/users.rs → Validator::get; the response-salt checks in ss_2022/protocol.rs and ss_2022/codec.rs; hysteria/server/authenticator.rs |
hkdf_sha1_ss_subkey cannot fail for Shadowsocks subkey sizes of 16 or 32 bytes: HKDF-SHA1 fails only above 255 × 20 bytes of output. On that unreachable path it zero-fills out instead of panicking, because of the crate lints.
Invariants
Section titled “Invariants”| Invariant | Mechanism | Pinned by |
|---|---|---|
| Production code in the crate does not panic on input | Crate-level deny of unwrap_used, expect_used, indexing_slicing and arithmetic_side_effects; take, take_array and checked arithmetic |
cargo clippy --workspace --all-targets --all-features -- -D warnings |
| A short slice waits, a malformed one fails | Truncated maps to UnexpectedEof, and need_more maps only that kind to None |
request_header_is_parsed_from_a_slice_once_whole in protocols/tests/unit/trojan/protocol.rs and in protocols/tests/unit/vless/protocol.rs |
| The handshake deadline is armed once and counts from the first bytes | Timing.armed; touch in Handshake |
timing_arms_handshake_once_then_idle_per_byte_event in protocols/tests/unit/core/mod.rs |
| The relay deadline is an idle limit | touch re-arms RELAY_IDLE_TIMEOUT on every byte event |
Same test |
| An idle expiry finishes the connection | Timing::expired pushes Effect::Finish and moves to Closing |
Same test; passthrough_core_finishes_when_the_outbound_fails_or_idles |
| Handshake and sniff expiry are left to the core | expired pushes nothing for them |
timing_reports_handshake_and_sniff_expiry_to_the_core |
Sniffing never holds more than SNIFF_LIMIT bytes |
SniffPrefix::push takes at most Collector::remaining() |
sniff_prefix_takes_no_more_than_its_budget |
| Sniffed bytes are forwarded, not lost | Open then ForwardHeld over the held prefix; clear only at the next byte event |
sniff_prefix_finds_a_host_across_pushes_and_keeps_the_bytes, a_sniffing_passthrough_core_holds_the_prefix_and_opens_with_the_host, a_sniffing_passthrough_core_opens_on_the_sniff_deadline |
| A flow addressed by domain is never held for sniffing | worth_sniffing |
a_sniffing_passthrough_core_with_a_domain_target_opens_at_once |
| A relay finishes only when both halves have closed | The two Passthrough flags |
passthrough_half_closes_each_side_and_finishes_on_the_second |
| A reused mux session id never collides with the outbound still going away | SubKey.generation, incremented on every New |
stream_sessions_open_forward_and_end_with_fresh_generations in protocols/tests/unit/mux/demux.rs |
Encoded addresses round-trip and encoded_len is exact |
AddressCodec |
write_slice_matches_write_buf_and_bounds_itself in protocols/tests/unit/helpers/address.rs |
Prefer* keeps the other family as a fallback |
Stable sort in select_candidate_ips |
prefer_ipv4_keeps_ipv6_as_fallback in protocols/tests/unit/helpers/address_family.rs |
| A netstack outbound never gets an address it cannot source | FamilySupport::from_addrs |
auto_skips_families_without_a_local_address |
Failure paths and cancellation
Section titled “Failure paths and cancellation”- Handshake timeout. On
Expired::Handshakethe core returnsErr(handshake_timed_out())(TimedOut), and the runtime ends the connection. A client that sends nothing at all is ended by the driver’s watchdog instead (see the table underPhase,TimingandExpired). - Unknown user. Trojan, VLESS, VMess and Shadowsocks return an
io::Errorof kindPermissionDenied, and Shadowsocks 2022 returnsInvalidData; the connection ends without a reply. The HTTP core instead stages a407response, thenShutdownTransportandFinish. - Malformed framing. Parsers return
InvalidData, directly or as a convertedProtocolError, and the error ends the connection. Nothing insniffcan fail a connection: bytes it does not recognise yield no sniffed domain. - Outbound failure.
ConnectFailedorOutboundErrorleads toPassthrough::on_outbound_gone, which pushesShutdownTransportandFinish. Staged bytes are still written. - Staging overrun.
staging_full()fails the connection. It signals a wrongSTAGING_RESERVEin the core, not a hostile peer. - Cancellation. Cores own no tasks, timers or sockets, so dropping a core is always safe. Cancellation happens above them: the app spawns each connection under its generation’s
CancellationToken(see Generations and reload), and dropping the runtime drops the core.
Limits
Section titled “Limits”| Constant | Value | Defined in |
|---|---|---|
HANDSHAKE_TIMEOUT |
10 s | protocols/src/core/mod.rs |
RELAY_IDLE_TIMEOUT |
300 s | protocols/src/core/mod.rs |
SNIFF_TIMEOUT |
300 ms | protocols/src/sniff/mod.rs |
SNIFF_LIMIT |
4 KiB (4096 bytes) | protocols/src/sniff/mod.rs |
PassthroughCore::BUF_SIZE |
8 KiB | protocols/src/core/mod.rs |
PassthroughCore::STAGING_RESERVE |
0 | protocols/src/core/mod.rs |
HARNESS_STAGING |
64 KiB | protocols/src/core/harness.rs |
AddressCodec::MAX_LEN |
259 bytes | protocols/src/helpers/address.rs |
Longest domain AddressCodec can encode |
255 bytes (one length byte) | protocols/src/helpers/address.rs |
Layout of a protocol module
Section titled “Layout of a protocol module”Most modules follow the same split. Not every module has every file, and a module adds files for what is specific to it (VMess adds aead.rs, keys.rs, framing.rs and session.rs; Shadowsocks adds aead.rs or crypto.rs).
| File | Holds | Examples |
|---|---|---|
mod.rs |
Module docs and the public re-exports | trojan/mod.rs: pub use core::TrojanCore; |
protocol.rs |
Wire primitives: constants, the module’s ADDR codec, slice parsers returning io::Result<Option<(T, usize)>>, encoders, and async read and write forms used by tests |
trojan/protocol.rs → parse_request_header, encode_request_header |
core.rs |
The server core: a state enum, Timing, SniffPrefix, a Passthrough once relaying, BUF_SIZE, STAGING_RESERVE, is_established |
TrojanCore, VlessCore, VMessCore, ShadowsocksCore, Ss2022Core, HttpCore |
codec.rs |
Client codecs implementing ProxyCoreEncodeHandshake plus ProxyCoreEncode (streams) or ProxyCoreEncodeDatagram (UDP) |
TrojanStream, TrojanDatagram, VlessStream, SsStream, HttpConnect |
config.rs |
Server configuration types | VlessServerConfig, HttpServerConfig, SocksServerConfig |
users.rs, validator.rs, accounts.rs |
User tables, generic over the payload T and handing out Arc<T>. The core holds them behind an Arc. Some modules keep their server config here too (TrojanServerConfig, ShadowsocksServerConfig, Ss2022ServerConfig). |
trojan::Validator, vless::Validator, vmess::AccountValidator |
The exceptions are structural:
- SOCKS is served by its own driver (
socks/server.rs→SocksInbound), not by a core. - Hysteria 2 owns its QUIC endpoint and runs one runtime per proxy stream (
Hy2StreamCore) and one per connection’s datagrams (Hy2UdpCore). - TUN owns its device and runs one runtime per TCP connection (
PassthroughCore) and one per client source’s UDP (TunUdpCore). - WireGuard has only an outbound connector.
Unit tests live outside src/, under protocols/tests/unit/<module>/<file>.rs. They are compiled into the module they test, so they can reach private items:
#[cfg(test)]#[path = "../../tests/unit/trojan/core.rs"]mod tests;Pipeline tests run a server core and a client codec against each other under the real runtime. They are modules of protocols/tests/pipeline.rs, one file per protocol under protocols/tests/pipeline/. The hysteria and tun modules are gated by the same features as the code they test.
Adding a protocol
Section titled “Adding a protocol”-
Write the wire primitives in
protocols/src/<name>/protocol.rs. Parse from slices withtake,take_arrayand checked arithmetic, returnOk(None)throughneed_morewhile bytes are missing, and check every fixed field (version, reserved bytes, lengths, command) explicitly. ReuseAddressCodec::SOCKSorAddressCodec::VMESSif the address layout matches; otherwise declare a newAddressCodecvalue. Put the result inpub const ADDR. -
Write the server core in
core.rs. ImplementProxyCoreDecodewithTarget = Flow<T>,Error = io::Error,Key = Single(orFlowKeyif it can carry mux.cool) andTransportAddr = ()for a stream transport. Calltiming.touch(fx)at the top of every byte event,enter(Phase::Sniff)orenter(Phase::Relay)once the request is parsed, and handle all threeExpiredvalues. DeclareBUF_SIZEfor the largest frame the protocol admits and aSTAGING_RESERVEthat covers everything one call stages beyond its payload, and exposeis_established(). -
Write the client codecs in
codec.rsagainstProxyCoreEncodeHandshakeplusProxyCoreEncodeand, if the protocol carries UDP,ProxyCoreEncodeDatagram. See Client runtime. -
Add users and configuration in
users.rsorvalidator.rsandconfig.rs, generic overT. Compare secrets withct_eqand scan the whole table, astrojan::Validator::getdoes. Never log credentials or keys. -
Register the module in
protocols/src/lib.rs. Put it behind a new Cargo feature if it brings a heavy dependency tree, ashysteriaandtundo, and gate its pipeline test module the same way. -
Test it. Write unit tests in
protocols/tests/unit/<name>/that drive the core throughCoreHarness, including negative tests for malformed input, truncated input and unknown users. Add a pipeline test inprotocols/tests/pipeline/<name>.rsand register it inprotocols/tests/pipeline.rs. -
Wire it into the app. Add the settings structs in
app/src/config.rs, aStreamProtocolvariant and its build arm inapp/src/inbound/mod.rs, adrive::<{ Core::<()>::BUF_SIZE }, _, _>arm inapp/src/serve.rswith the core’s name added to theestablished!list, and anOutboundvariant inapp/src/outbound/mod.rs. See Build pipeline. -
Run the gates:
cargo fmt --all -- --check,cargo test --workspaceandcargo clippy --workspace --all-targets --all-features -- -D warnings.
| Test | File | Covers |
|---|---|---|
timing_arms_handshake_once_then_idle_per_byte_event |
protocols/tests/unit/core/mod.rs |
One handshake arm, an idle re-arm per event, and an idle expiry that pushes Finish |
timing_reports_handshake_and_sniff_expiry_to_the_core |
same | Expired::Handshake and Expired::Sniff push nothing |
sniff_prefix_finds_a_host_across_pushes_and_keeps_the_bytes |
same | A Host header split across two pushes; held bytes kept until clear |
sniff_prefix_takes_no_more_than_its_budget |
same | The SNIFF_LIMIT cap and Exhausted |
passthrough_half_closes_each_side_and_finishes_on_the_second |
same | Half-close order and Finish |
passthrough_core_opens_on_the_first_bytes_and_relays_verbatim |
same | The order of open, deadline and forward |
passthrough_core_finishes_when_the_outbound_fails_or_idles |
same | ConnectFailed and an idle Deadline |
a_sniffing_passthrough_core_holds_the_prefix_and_opens_with_the_host |
same | Sniff, Open with the domain, ForwardHeld, then clear |
a_sniffing_passthrough_core_opens_on_the_sniff_deadline |
same | Opening on Expired::Sniff without a domain |
a_sniffing_passthrough_core_with_a_domain_target_opens_at_once |
same | No sniff phase for a domain destination |
passthrough_runtime_relays_a_tcp_flow_and_half_closes |
protocols/tests/pipeline/core.rs |
PassthroughCore relaying 100,000 bytes under the real runtime |
write_slice_matches_write_buf_and_bounds_itself |
protocols/tests/unit/helpers/address.rs |
Both layouts round-trip; encoded_len; a short output and a 256-byte domain are rejected |
authority_ipv4_with_port, authority_domain_default_port, authority_ipv6_bracketed |
same | parse_authority |
parses_address_family_strategy_aliases, auto_skips_families_without_a_local_address, auto_keeps_resolver_order_when_both_families_are_supported, ipv4_only_filters_to_ipv4, prefer_ipv4_keeps_ipv6_as_fallback, a_kernel_routed_dialer_is_limited_by_policy_alone, the_capability_clause_is_omitted_for_a_kernel_routed_dialer |
protocols/tests/unit/helpers/address_family.rs |
Aliases, filtering, fallback order and error text |
request_header_is_parsed_from_a_slice_once_whole |
protocols/tests/unit/trojan/protocol.rs, protocols/tests/unit/vless/protocol.rs |
need_more returns None at every cut of a header |
stream_sessions_open_forward_and_end_with_fresh_generations |
protocols/tests/unit/mux/demux.rs |
A new SubKey generation per New |
evp_key_known_answer |
protocols/tests/unit/ss_legacy/aead.rs |
evp_bytes_to_key against MD5 of the password |
unknown_user_is_refused, unknown_uuid_is_refused, an_unknown_password_is_refused |
protocols/tests/unit/trojan/core.rs, protocols/tests/unit/vless/core.rs, protocols/tests/unit/ss_legacy/core.rs |
PermissionDenied from the cores |