Conformance Audit

A requirement-by-requirement matrix of what each protocol crate actually does, checked against its RFC/spec — modeled on Gumdrop's own RFC-COMPLIANCE.md. Every row below was produced by reading the Rust source directly (file:line cited), not by trusting doc comments or README claims — several rows below disagree with hopf's own docs pages, and those are called out explicitly rather than smoothed over. Every nonconformant or unimplemented row is tracked as a GitHub issue (label conformance).

Method and status vocabulary

Each protocol crate was audited by reading its source against the RFC(s)/spec(s) it targets, cross-checked where a comparable Gumdrop (the Java predecessor this project ports) implementation exists — Gumdrop's own RFC-COMPLIANCE.md served as a candidate requirements checklist, re-verified independently against hopf's Rust code rather than copied. Every Compliant row cites the file and, where useful, the line range that implements it. Status vocabulary, matching Gumdrop's:

Security substrate (hopf-core foundation)

Implemented today

AreaImplementationNotes
Native crypto libraryAWS-LC via aws-lc-rs / aws-lc-sysBoringSSL-lineage C library; the only native crypto dependency in production anywhere in this workspace
TCP TLS 1.2 / 1.3 + STARTTLSIn-tree, hopf-core::tlsHybrid-first PQC KX (X25519MLKEM768 preferred, RFC 10024's other two hybrid groups as alternatives). Default acceptor_from_pem/connector_from_pem negotiate 1.3 vs 1.2 on one TCP port (TcpTlsVersionPolicy)
DTLS 1.2 / 1.3In-tree, hopf-core::dtls/dtls12Same version policy as TCP (DtlsVersionPolicy = TcpTlsVersionPolicy) via dtls_server_engine/dtls_client_engine. DTLS 1.2 has real OpenSSL interop, including over the reactor UDP driver; DTLS 1.3 is verified over real UDP sockets but only against itself. The driver is hopf-core::dtls::driver
QUIC transport (RFC 9000)In-tree, hopf-quicFull Hopf-owned transport/connection/stream/UDP-driver implementation — no external QUIC library dependency
QUIC-TLS (RFC 9001)Shared hopf-core::tls::handshake (TLS 1.3), wired from hopf-quicSame in-tree handshake engine as TCP TLS 1.3; no TLS 1.2 on QUIC. See the QUIC section's PQC row for a caveat on key-exchange group selection
HTTP/3In-tree in hopf-http (feature h3)QPACK and H3 framing are Hopf-owned
Encrypted Client HelloIn-tree, hopf-core::tls::ech (HPKE in hopf-core::crypto::hpke)Shared-mode ECH on the TLS 1.3 engine for TCP, DTLS 1.3 and QUIC; DNS bootstrap in hopf-dns::ech. See the TLS section
DKIM / DNSSEC verifyDirect aws-lc-rs in hopf-smtp / hopf-dnsRSA / Ed25519 / ECDSA; Ed448 via ed448-goldilocks-plus; ML-DSA (FIPS 204) certificate verification also shipped in hopf-core::crypto::x509
UDP datagram I/Ohopf-core (UdpDatagramHandler)Cleartext; DTLS now exists as a separate session layer above it (hopf-core::dtls/dtls12), not fused into the datagram handler itself
TLS Certificate Compressionhopf-core::tls (handshake/cert_compression.rs, HandshakeConfig::certificate_compression)RFC 8879, Brotli (algorithm 2) only, on by default; TLS 1.3, DTLS 1.3 and QUIC through the shared engine (not TLS 1.2, which the RFC does not cover). A client offers compress_certificate and inflates a server's CompressedCertificate incrementally as it arrives; output is capped by the declared uncompressed_length (itself capped at 256 KiB), so a decompression bomb is cut off before any chain parsing. A server compresses its chain for a client that offered Brotli whenever that makes it smaller. Unknown or unoffered algorithms are refused (illegal_parameter), bad bodies or wrong lengths are bad_certificate. Not done: zlib/zstd, and the mTLS direction (a server's compress_certificate in CertificateRequest, compressing the client certificate). Verified against real GnuTLS in both directions
Post-quantum signatures in TLS 1.3 (ML-DSA)hopf-core::tls (handshake/verify.rs), crypto::signature, crypto::x509FIPS 204 ML-DSA-44/65/87 as mldsa44/65/87 (0x0904-0x0906): server and client (mTLS) CertificateVerify signing and verification, plus chain verification. TLS 1.3, DTLS 1.3 and QUIC. Credentials are a standard PKCS#8 PEM key and PEM certificate loaded with the normal acceptor_from_pem/connector_from_pem helpers; generate_ml_dsa_self_signed makes a minimal self-signed identity for development and tests (rcgen has no ML-DSA). Not TLS 1.2 (no codepoint; such a server refuses the handshake), and no hybrid PQ+classical certificate chains. Verified against real OpenSSL 3.6.4 in both directions at all three levels

Remaining

AreaTargetNotes
GSSAPI / Kerberos SASLOptional gssapi feature on hopf-authGumdrop parity (RFC 4752); KDC/keytab work on StorageExecutor; acceptable once AWS-LC is the accepted native foundation
Other transport familiesSeparate stacks on same primitivesSSH/SFTP/SCP (SSH KEX, not TLS), DNSCrypt, CoAP/OSCORE — not DTLS-interoperable

Details: Architecture → Security substrate, TLS → Independent verification, QUIC implementation status.

TLS — RFC 8446 / RFC 5246 (in-tree, hopf-core::tls)

TLS 1.3 and TLS 1.2 are implemented in-tree in hopf-core::tls (HandshakeEngine/TlsRecordEngine for 1.3, tls12::{Tls12Engine, Tls12RecordEngine} for 1.2).

RequirementSectionStatusNotes
TLS 1.3 handshake state machine, record layerRFC 8446CompliantIn-tree HandshakeEngine (hopf-core/src/tls/engine.rs) + TlsRecordEngine (hopf-core/src/tls/record.rs); RFC 8448 known-answer vectors for the key schedule
TLS 1.2 handshake state machine, record layerRFC 5246CompliantIn-tree tls12::Tls12Engine/Tls12RecordEngine (hopf-core/src/tls/tls12/); AEAD-only (GCM + ChaCha20-Poly1305), CBC permanently excluded
TLS / DTLS version selection on one portRFC 8446 §4.2.1 / RFC 9147 §5.3CompliantTcpTlsVersionPolicy (tls/tcp_version_policy.rs): default Negotiate buffers the first flight, prefers 1.3 when advertised, else 1.2, then one record engine for the rest (no renegotiation). TCP: TlsVariant::Negotiating via default PEM helpers or acceptor_from_pem_with_tcp_version_policy. UDP: DtlsEngine::Negotiating via dtls_server_engine/dtls_client_engine (DtlsVersionPolicy alias). Pin with Tls13Only/Tls12Only or *_tls12 PEM helpers. QUIC is TLS 1.3 only. RFC 8446 §4.1.3 downgrade sentinel on the negotiating client 1.2-fallback path is not implemented yet
Cipher suite selectionRFC 8446 §B.4 / RFC 5289 / RFC 7905CompliantAES_128_GCM_SHA256/CHACHA20_POLY1305_SHA256 (TLS 1.3, tls/engine.rs); ECDHE-ECDSA/RSA with AES-128/256-GCM and ChaCha20-Poly1305 (TLS 1.2, tls12/engine.rs). No CBC, no NULL/export/RC4 — permanent exclusion, not a gap
Key-exchange group selection / hybrid PQCRFC 10024Compliantcrypto::kx_policy::KxPolicy (hopf-core/src/crypto/kx_policy.rs) — X25519MLKEM768 preferred, SecP256r1MLKEM768/SecP384r1MLKEM1024 as alternatives, classical X25519/P-256/P-384 fallback
Session resumption (RFC 5077 tickets / PSK)RFC 8446 §2.2 / RFC 5077CompliantTLS 1.3: handshake::ticket (hopf-core/src/tls/handshake/ticket.rs), PSK-DHE only (forward secrecy on resumption). TLS 1.2: tls12::ticket. Both share tls::ticket_keys::TicketKeys for in-place key rotation without breaking in-flight tickets
AEAD key usage limits (rekey/close as a key approaches its safety margin)RFC 8446 §5.5 / RFC 9325 §4.4CompliantAll four record layers (TLS 1.3 TCP, TLS 1.2, DTLS 1.3, DTLS 1.2) act on the AES-GCM 2^24.5-record confidentiality limit; TLS 1.3 TCP and DTLS 1.3 self-rekey via KeyUpdate, the TLS 1.2 layers close (no in-protocol rekey exists for them)
record_size_limit extension (TLS 1.3, DTLS 1.3)RFC 8449Complianthopf-core::tls engine (shared by the TCP and DTLS 1.3 record layers). The extension is sent in ClientHello and answered in EncryptedExtensions. Once both sides have sent it, each caps the protected records it writes at the peer's limit (counting the whole inner plaintext, content-type octet included, so a limit of N carries N-1 content octets) and treats a received record over its own advertised limit as a fatal record_overflow. Handshake messages are fragmented to fit, including the Certificate; DTLS handshake fragments account for their 12-octet header; records sent in the clear are exempt. A value below 64 is a fatal illegal_parameter; a client value above 16385 is accepted and clamped (RFC 8449 §4). A server always honours a client that sends the extension, whether or not an operator configured a limit, since the point of the extension is clients that cannot buffer full-size records; the configured limit only chooses what the server answers with (default 16385, no restriction on what it receives). A client offers it only when configured, so wire behaviour is unchanged unless asked for. Configure with HandshakeConfig::record_size_limit, or wrap any acceptor or connector with acceptor_with_record_size_limit / connector_with_record_size_limit. Not used on QUIC. Verified against GnuTLS 3.8 in both roles: the handshake completes (including after a HelloRetryRequest), and neither side ever exceeds the other's advertised limit. A DTLS send_application_data call larger than the peer's limit is refused rather than split, because one call is one datagram
record_size_limit extension (TLS 1.2, DTLS 1.2)RFC 8449Not implementedThe TLS 1.2 and DTLS 1.2 engines do not send, read or answer it; TlsVariant::set_record_size_limit returns false for them and the wrappers leave them unchanged
HPKE, base modeRFC 9180Complianthopf-core::crypto::hpke, composed from the AWS-LC primitives already wrapped (aws-lc-rs exposes no HPKE): KEMs DHKEM(X25519, HKDF-SHA256) and DHKEM(P-256, HKDF-SHA256); KDFs HKDF-SHA256/384/512; AEADs AES-128-GCM, AES-256-GCM, ChaCha20-Poly1305. Verified against the RFC 9180 Appendix A.1, A.2 and A.3 vectors (sealed ciphertexts and exporter output). Only base mode: no PSK or authenticated modes, no export-only AEAD
Encrypted Client Hello (TLS 1.3, DTLS 1.3, QUIC)RFC 9849Partialhopf-core::tls::ech, engine hooks in tls/engine/ech.rs. Client: selects an ECHConfig and the client-preferred KDF/AEAD the config advertises (select_config), encrypts a padded ClientHelloInner into a ClientHelloOuter authenticated by ClientHelloOuterAAD, keeps inner and outer transcripts until the server's first reply shows acceptance (ServerHello random or the HelloRetryRequest extension), and reuses the HPKE context across a HelloRetryRequest. On rejection it authenticates the certificate for the config's public_name, then aborts with ech_required without ever reporting success, handing the caller the server's retry_configs only when at least one is usable and the attempt was not itself a retry. GREASE ECH is sent when no config is available. EchClientConfig::required turns a missing or unusable config into an early ech_required failure. Server: unwraps the outer hello (including ech_outer_extensions expansion, non-zero padding and TLS 1.2 checks), confirms acceptance, sends retry_configs to clients that used a stale key, and supports several configs, retired keys, and keys loaded from PEM. Supported HPKE suites for ECH: the table above; KEM fixed by the config's key, KDF/AEAD chosen from its cipher_suites. Gaps: shared mode only (no split-mode backend); a real ECH offer does not resume sessions or use 0-RTT (no GREASE PSK in the outer hello); the outer hello omits ALPN. Verified against an independent implementation (Go 1.27) in both roles for TCP TLS 1.3, including a HelloRetryRequest, rejection with retry configs, and GREASE; DTLS 1.3 and QUIC are loopback-only. Configure with HandshakeConfig::{ech_client, ech_server} or connector_with_ech / acceptor_with_ech
ECH configuration from DNSRFC 9849 §1, RFC 9460 §9Complianthopf-dns::ech: HTTPS-record lookup through the reactor-affine resolver (never blocking a connection), CNAME/AliasMode chains, TTL cache, static pinning, and connector_with_ech_cache. Trust model: the config is only as trustworthy as the DNS answer, so use a pinned config, an authenticated resolver path (DoT/DoH/DoQ or DNSSEC) or required where ECH is mandatory. Lookups are per host name (a connector is not given the port). Not yet wired into a protocol client
max_fragment_length extensionRFC 6066 §4, RFC 8449 §5N/ANot implemented; RFC 8449 deprecates it in favour of record_size_limit, and a peer that sends it is simply ignored, as the RFC requires of a server that supports record_size_limit
TLS 1.2 server and client key typesRFC 8422 §5PartialRSA (PKCS#1 v1.5 and PSS), ECDSA P-256 and P-384, and Ed25519 for the server's ServerKeyExchange and for client CertificateVerify. Ed25519 uses ed25519(0x0807), is offered by the client, and signs the raw message with pure EdDSA (for CertificateVerify, the raw handshake bytes); a server holding an Ed25519 key refuses a client that did not offer it. The ECDHE_ECDSA suites accept any of the elliptic or Edwards keys (an ECDSA P-384 key previously found no usable suite). Verified against rustls in both roles and against OpenSSL 3.6 (Ed25519 and P-384 servers; Ed25519 client authentication both ways). Gap: the client advertises only secp256r1 in supported_groups (P-256 is the only ECDHE curve implemented), and a strict server such as OpenSSL refuses an ECDSA certificate on a curve the client did not advertise (RFC 8422 §5.1), so a hopf client cannot connect to a P-384-certificate server of that kind. Ed25519 and P-256 certificates are unaffected
ALPN negotiationRFC 7301CompliantTLS 1.3, TLS 1.2 and DTLS 1.2 (which shares the TLS 1.2 engine), all with one policy. The server picks by its own preference order and refuses a client whose offer has no overlap with a fatal no_application_protocol (§3.2) rather than silently completing unnegotiated; a client refuses a protocol it never offered, an ALPN extension it did not solicit, and (TLS 1.2) a ServerHello list that is not exactly one well-formed name (§3.1). A server answers only a client that offered. It is negotiated afresh on every handshake, an abbreviated (ticket-resumed) TLS 1.2 handshake included. The result is SecurityInfo::alpn() on both sides, including in STARTTLS handlers (SMTP, IMAP, POP3, FTP). Configure with HandshakeConfig::alpn / Tls12Config::alpn, or wrap any acceptor or connector with acceptor_with_alpn / connector_with_alpn: the *_tls12 builders take no protocol list, so the wrappers are how they get one. Verified against rustls on TLS 1.2 in both roles
Client certificate authentication (mTLS)RFC 8446 §4.3.2 / RFC 5246 §7.4.4CompliantClientAuthPolicy (None/Request/Require) on both engines' configs; verified peer cert exposed via SecurityInfo::peer_certificate_fingerprint/peer_certificate_chain; acceptor_from_pem_with_client_auth/connector_from_pem_with_client_cert (hopf-core/src/tls/pem.rs)
SNI — client sideRFC 6066 §3CompliantSent in every ClientHello when HandshakeConfig::server_name is set (handshake/messages.rs)
SNI — server side (per-name cert selection)RFC 6066 §3CompliantHandshakeConfig::server_resolver (tls/engine.rs), consulted with the client's real SNI ahead of the single fixed server credential; acceptor_from_pem_with_sni convenience constructor (tls/pem.rs) — default + hostname-keyed map, unmatched/absent SNI falls back to the default
Public WebPKI trust—Compliantpublic_trust_connector/crypto::trust::public_trust_store() — native OS trust store, falling back to a vendored webpki-roots bundle
"Insecure"/pinned trust helpers clearly scoped—Compliantinsecure_connector documented as never-use-where-identity-matters (opportunistic STARTTLS only); DANE (hopf_dns::dane::verify_dane_chain) and DANE-style pinning go through connector_with_verify_override instead of a fixed root set
TLS-from-accept (always-on)—CompliantTcpListenerConfig::with_tls → immediate handshake on connect (hopf-core/src/connection.rs)
STARTTLS-style deferred upgrade (server)—CompliantEndpoint::start_tls(); used by SMTP/POP3/IMAP/FTP control handlers
STARTTLS-style deferred upgrade (client)—CompliantEndpoint::start_client_tls(connector, server_name); used by SMTP/IMAP/POP3 clients
Close/shutdown via close_notifyRFC 8446 §6.1CompliantTlsRecordEngine::send_close_notify()/Tls12RecordEngine::send_close_notify(), invoked before flush on close
Handshake/decode error mapping—CompliantProtocol violations reported via TlsProtocolError to the embedder (TlsEventSink/TlsRecordSink::protocol_error); no wire-level TLS alert-code capability exists yet (only close_notify is a real wire alert) — a known, deliberate scope limitation
PEM loading (PKCS#8 only)—CompliantPEM parsing in tls/pem.rs; legacy PKCS#1/SEC1 keys must be re-encoded to PKCS#8, explicit error message names the fix
DTLS over UDP: reactor-driven driverRFC 6347, RFC 9147Complianthopf-core::dtls::driver (listen, connect, DtlsSocket, DtlsObserver): a low-level seam only, no ProtocolHandler/Endpoint layer. One UDP socket per reactor with an engine per peer address (DtlsEngine::V13, V12, or Negotiating for both versions on one port), datagrams and retransmit timers fed to the engine, a bounded session table (max_sessions), a sweep for stalled handshakes and idle sessions, and raw established/data/closed events, so DoDTLS and CoAPS can build on it. Verified over real UDP loopback (both versions, negotiating acceptor with 1.3 and 1.2 clients, including recovery of lost datagrams through the engines' retransmit timers) and against OpenSSL 3.6 DTLS 1.2 as client and as server, including the address-bound cookie exchange. Gaps: DTLS 1.3 sends no HelloRetryRequest cookie (RFC 9147 §5.1), so a DTLS 1.3 listener is amplification-prone against spoofed sources; one socket lives on one reactor; DTLS 0-RTT and Connection IDs are not implemented
Address-bound HelloVerifyRequest cookieRFC 6347 §4.2.1CompliantDtls12Config::cookie_binding mixes the client's source address into the cookie (HMAC(secret, binding || random)); the driver's peer_cookie_binding(peer) supplies it. A cookie issued for one address does not validate for another. Set require_cookie together with a binding
DTLS 1.3 real-peer interopRFC 9147DeferredEngine-level milestone shipped; verified loopback (Hopf-to-Hopf) only so far — no independent-implementation cross-check exists for DTLS 1.3 yet

HTTP/1.1 — RFC 9112 / RFC 9110

Server — Request Line and Status Line (RFC 9112 §3-4)

RequirementSectionStatusNotes
Request-line parsing, CTL rejection in method§3Complianth1/server_codec.rs:321-421,332-335
400 for malformed request-line§3Compliantserver_codec.rs:395-420
414/431 for oversized URI/header line§3, §5Complianttoken_too_long(), server_codec.rs:809-817, driven by HttpLimits::max_line_length
501 for unrecognized methodRFC 9110 §15.6.2Compliantis_default_method(), server_codec.rs:340-343
505 HTTP-Version Not Supported§3CompliantOnly HTTP/1.0 and 1.1 accepted (server_codec.rs:380-387)
HTTP/2 prior-knowledge preface detected/rejected at H1 layer§3Compliantserver_codec.rs:381-383 → 505 (routing to H2 handled one layer up)

Server — Field Syntax (RFC 9112 §5)

RequirementSectionStatusNotes
field-line = field-name ":" OWS field-value OWS§5.1Compliantserver_codec.rs:423-502,552-558
obs-fold continuation handling§5.2Complianth1/scan.rs:161-167, folded with a single SP (server_codec.rs:447-456)
Header field-name character validation§5.1 / RFC 9110 §5.6.2Compliantis_valid_header_name() (utils.rs:57-73) accepts the full RFC tchar set, shared with the method-token check (is_token()/is_tchar(), utils.rs:5-17)
Header field-value CTL/obs-text validation§5.5Compliantis_valid_header_value() (utils.rs:79-84) rejects CTL bytes other than HTAB, called from header_value() (server_codec.rs:516-529) before a value is accepted — including trailers
431 Request Header Fields Too Large§5Compliantserver_codec.rs:429-435 (count), token_too_long() (line length)
Host header required exactly once (HTTP/1.1)§3.2Compliantend_headers(), server_codec.rs:563-582, 400 if count != 1
Case-insensitive header lookup§5.1CompliantHeaders::get(), headers.rs:74-79

Server — Message Body Length and Transfer Coding (RFC 9112 §6-7)

RequirementSectionStatusNotes
Transfer-Encoding overrides Content-Length§6.3Compliantend_headers(), server_codec.rs:593-612
Reject invalid/multi-coding Transfer-Encoding§6.3CompliantOnly exact single chunked token accepted (utils.rs:36-54), else 400
Multiple/conflicting Content-Length header fields → error§6.3Compliantend_headers() collects every content-length occurrence and requires them to parse to the same decimal value — a single differing value is 400 (server_codec.rs:344-367); identical duplicates (the RFC-permitted case) still work
Chunked encoding (chunk-size/data/final chunk)§7.1Compliantserver_codec.rs:504-550,735-765
Chunk extensions parsed/ignored; quoted chunk-ext rejected§7.1.1Compliantserver_codec.rs:513-519
Chunk-size / aggregate body limits enforced§7.1CompliantHttpLimits::max_chunk_size (10 MiB) / max_request_body (default 16 MiB)
Trailer section after last chunk§7.1.2PartialParsed but values discarded (server_codec.rs:484-496) — documented (docs/http/overview.html: "trailers are ignored on the wire")
411 Length Required (HTTP/1.1, no framing header)§6.3Compliantserver_codec.rs:663-666
Read-until-close body (HTTP/1.0 only)§6.3Compliantserver_codec.rs:659-662,774-788

Server — Methods, Upgrade, Status Codes (RFC 9110)

RequirementSectionStatusNotes
Method = token, validated§9.1Compliantutils.rs:6-14
HEAD: no response body written§9.3.2Complianth1/response.rs:335-364
CONNECT tunnel§9.3.6CompliantVia ServerWriter::upgrade() (h1/response.rs:378-393), no built-in framing beyond the hook
OPTIONS * auto-responder§9.3.7N/ATarget accepted; no built-in 200+Allow auto-responder — fully delegated to the app
TRACE echo (message/http)§9.3.8Rejected by defaultTRACE omitted from is_default_method(); H1 answers 501 (same as any unimplemented method). No message/http echo path
Expect: 100-continue → interim 100§10.1.1Compliantserver_codec.rs:624-633
101 Switching Protocols / Upgrade§7.8Complianth1/response.rs:378-393; with Capsule-Protocol: ?1, post-upgrade bytes are Capsule Protocol (capsule.rs, RFC 9297 §3)
h2c cleartext UpgradeRFC 9113 §3.2 legacyCompliantHandled in CleartextHttpEndpoint, see HTTP/2 table
1xx informational responses (server send)§15.2Complianth1/response.rs:301-317
Status codes / reason phrases§15Partialreason_phrase() covers ~30 codes, falls back to "Unknown" for the rest (status.rs:6-40) — advisory-only text, functionally harmless
Date response header§6.6.1Complianthttp_date_now() (IMF-fixdate, GMT — utils.rs:121-127) is set when absent at every response-flush point: H1 flush_response_headers() (h1/response.rs:277), H2 flush_one_server_stream() (h2/endpoint.rs:1111-1113), H3 H3Writer::flush() (h3/endpoint.rs:123-125)
Server response header§10.2.4CompliantDefaults to hopf (h1/response.rs:272-273)
Auto-chunk response when length unknown (1.1)§8.6Complianth1/response.rs:275-285

Client (RFC 9112 / RFC 9110)

RequirementSectionStatusNotes
Request-line format, Host always emitted§3.1-3.2Complianth1/client_codec.rs:593-628
Chunked request body encoding§7.1Compliantclient_codec.rs:558-590
Status-line / header parsing, obs-fold§4-5Compliantclient_codec.rs:255-340
Content-Length / read-until-close body framing§6.3Compliantclient_codec.rs:410-413,462-470
Multiple/conflicting Content-Length in response§6.3CompliantSame duplicate-field handling as the server side (client_codec.rs:260-277)
Chunked response body + trailers§7.1-7.1.2PartialChunk decode fully implemented; trailers consumed but discarded — no response_trailers callback fires for H1 at all (only H2/H3)
1xx informational responses handled correctly§15.2Compliantend_response_headers() (client_codec.rs:234-247) discards a 1xx after surfacing it via ClientHandler::informational_response and returns Next::FirstLine to keep reading the same connection for the real final status line; exercised split across separate receive() calls too. Production dial (connect_http) drives this through H1ClientCodec<Box<dyn ClientHandler>>, so the blanket impl ClientHandler for Box<dyn ClientHandler> (stream/client.rs) must forward every method to the boxed handler, including informational_response
204/304/HEAD → no body§15.3.5,§15.4.5,§9.3.2Compliantclient_codec.rs:383-389
Connection: close handling, persistent by default§9.3,§9.6Compliantclient_codec.rs:118-121,353-360
Automatic redirect following§15.4 (client-optional)N/ANo redirect logic anywhere — deliberate low-level Stream API design, caller decides from :status/Location
401/407 automatic re-authentication retry§11.6.1/§11.7.1Not implementedNo retry loop; server-side auth factories have no client-side counterpart

HTTP/2 — RFC 9113 / HPACK RFC 7541

Server — Connection Startup, Frames, Streams

RequirementSectionStatusNotes
ALPN h2, cleartext prior-knowledge, h2c Upgrade§3.2-3.4Compliantdispatch.rs:50-77, h2/cleartext.rs:95-127,180-215,300-324 (validates Connection/Upgrade/base64url HTTP2-Settings)
Client preface + server SETTINGS on connect§3.4Complianth2/endpoint.rs:1272-1289,497-530
9-octet frame header, unknown types ignored§4.1Complianth2/frame.rs:115-139, h2/parser.rs:196-198
DATA/HEADERS (+PADDED/+PRIORITY strip)§6.1-6.2Complianth2/frame.rs:227-269
HEADERS/CONTINUATION reassembly locked to one stream§4.3,§6.10Complianth2/parser.rs:125-135, h2/endpoint.rs:734-767
PRIORITY tolerated/ignored (deprecated)§5.3.2N/AIgnored; hopf advertises SETTINGS_NO_RFC7540_PRIORITIES=1 (RFC 9218 §2.1) and schedules via Extensible Prioritization instead
Extensible Prioritization (Priority header + PRIORITY_UPDATE)RFC 9218Compliantpriority.rs parses u/i; server SETTINGS includes SETTINGS_NO_RFC7540_PRIORITIES=1; PRIORITY_UPDATE (type 0x10) on stream 0; flush_server_streams orders by urgency then serves non-incremental streams one-by-one per urgency
RST_STREAM§6.4Complianth2/endpoint.rs:1014-1022
SETTINGS parse + ACK (HEADER_TABLE_SIZE/MAX_FRAME_SIZE/INITIAL_WINDOW_SIZE/ENABLE_PUSH/MAX_CONCURRENT_STREAMS)§6.5Complianth2/endpoint.rs:730-800; peer's ENABLE_PUSH is validated (0/1, else GOAWAY PROTOCOL_ERROR) and stored, peer's MAX_CONCURRENT_STREAMS is stored and enforced against client-initiated stream creation (start_client_request(), h2/endpoint.rs:612-617)
SETTINGS ACK timeout§6.5.3CompliantCloses with GOAWAY(SETTINGS_TIMEOUT) if the peer never ACKs the initial SETTINGS frame (arm_settings_ack_timer(), h2/endpoint.rs:570-596); cancelled in on_settings() once the ACK arrives; tested end-to-end over a real socket in integration.rs (h2_settings_ack_timeout_closes_with_goaway, h2_settings_ack_in_time_cancels_timeout)
PUSH_PROMISE rejected (server never pushes)§6.6N/A (by design)Client sending PUSH_PROMISE to the server → GOAWAY PROTOCOL_ERROR (h2/endpoint.rs:983-996), matches docs
PING + ACK§6.7Complianth2/endpoint.rs:1002-1012
Server-initiated keepalive PINGpracticeNot implementedNo periodic PING logic
GOAWAY send/receive§6.8CompliantImmediate GOAWAY via send_goaway() (h2/endpoint.rs:1178-1181) for errors; shutdown_gracefully() (h2/endpoint.rs:1192-1210, server role) does the recommended two-phase pattern — an initial GOAWAY(2^31-1, NO_ERROR) refuses new streams (process_server_headers_block()) while in-flight ones keep draining, then a final GOAWAY with the true last-stream-id and connection close once server_streams empties (drain check in receive(), h2/endpoint.rs:1612-1614)
WINDOW_UPDATE (zero-increment rejected)§6.9Complianth2/endpoint.rs:1028-1040
Stream IDs odd/strictly-increasing, MAX_CONCURRENT_STREAMS enforced§5.1.1-5.1.2CompliantHttpLimits::max_concurrent_streams (limits.rs, default 100) is advertised via SETTINGS (h2/endpoint.rs:527-538) and enforced with RST_STREAM(REFUSED_STREAM) in process_server_headers_block() (h2/endpoint.rs:867-876)
Formal stream-state machine§5.1CompliantStreamState (h2/endpoint.rs:92-105, Idle/Open/Closed) is computed on demand by stream_state() (h2/endpoint.rs:1021-1041) from last_stream_id plus server_streams/client_streams presence — stream IDs are monotonic and never reused, so no separate ever-growing closed-set is needed. DATA on an idle stream closes the connection with GOAWAY(PROTOCOL_ERROR); DATA on a closed stream gets RST_STREAM(STREAM_CLOSED) instead of being silently dropped; RST_STREAM on an idle stream is likewise a connection error, while RST_STREAM on an already-closed stream is tolerated per §6.4
Connection + stream flow control accounting (receive)§5.2,§6.9Complianth2/flow.rs:57-82
Default initial window 65535, SETTINGS-driven adjustment§6.9.2Complianth2/flow.rs:12,116-121
Flow control respected on send (server → client DATA)§5.2,§6.9Compliantwrite_data_flow_controlled() (h2/endpoint.rs:1146-1183) caps each chunk at the peer's available window and stops when it reaches zero; unsent bytes are requeued in shared.body and retried from flush_one_server_stream() (h2/endpoint.rs:1204+) on the next receive() (e.g. after a WINDOW_UPDATE)
Flow control respected on send (client → server DATA)§5.2,§6.9Compliantstart_client_request() sends the request body through the same write_data_flow_controlled() helper; any unsent remainder is buffered in H2ClientStream::pending_body and retried by flush_client_streams() (h2/endpoint.rs:1185-1202) on the next receive()

HPACK — RFC 7541

RequirementSectionStatusNotes
Static table (61 entries)Appendix AComplianth2/hpack/static_table.rs:15
Dynamic table insert/evict, 32-byte overhead rule§4.1,§4.3Complianth2/hpack/dynamic.rs:67-77,114-122
Oversized entry clears table; dynamic table size update§4.4,§6.3Compliantdynamic.rs:67-73, decode.rs:66-70
Integer representation, multi-byte prefix§5.1Compliantdecode.rs:127-160, encode.rs:91-107; guards runaway continuation
String literal + Huffman (encode picks shorter)§5.2Compliantdecode.rs:164-183, encode.rs:78-87; Huffman decode verified against RFC 7541 Appendix C.4
Indexed / literal-with-indexing / literal-without / never-indexed field lines§6.1-6.2.3Compliantdecode.rs:41-65
Decompression failure → COMPRESSION_ERRORRFC 9113 §6.5.2Complianth2/endpoint.rs:797-803,848-854
SETTINGS_MAX_HEADER_LIST_SIZE enforced against decoded listsRFC 9113 §6.5.2Compliantheader_list_size() (h2/endpoint.rs:1410-1412) sums the RFC 7541 §4.1 name+value+32 accounting model over the decoded list; process_server_headers_block() rejects with RST_STREAM(ENHANCE_YOUR_CALM) if that exceeds DEFAULT_MAX_HEADER_LIST_SIZE or the field count exceeds HttpLimits::max_header_count (server-received requests only)

Message Semantics and Client (RFC 9113/9110)

RequirementSectionStatusNotes
Pseudo-header presence/ordering/uniqueness validation§8.3.1Compliantvalidate_request_header_block() (h2/endpoint.rs:1433-1476), called from process_server_headers_block(): rejects unknown/duplicate/misordered pseudo-headers and requires :method/:scheme/:path (regular requests and RFC 8441 Extended CONNECT) or :method/:authority only (plain CONNECT) with RST_STREAM(PROTOCOL_ERROR)
Connection-specific headers rejected, TE only "trailers"§8.2.2CompliantSame validate_request_header_block() rejects Connection/Keep-Alive/Proxy-Connection/Transfer-Encoding/Upgrade and any TE value other than trailers
Extended CONNECT / WebSocket (RFC 8441)RFC 8441CompliantSETTINGS_ENABLE_CONNECT_PROTOCOL advertised; h2/response.rs:191-206
Capsule Protocol on upgraded streamsRFC 9297 §3CompliantWhen request headers carry Capsule-Protocol: ?1, post-upgrade DATA is parsed as capsules (capsule.rs); DATAGRAM capsules go to ProtocolUpgradeHandler::datagram_received
Client: prior-knowledge and TLS-ALPN dial, client SETTINGS§3.2-3.4,§6.5.2Complianth2/endpoint.rs:536-542,1247-1261
Client: h2c Upgrade dial (HTTP/1.1 → H2)RFC 7540 §3.2CompliantH2cUpgradeClientEndpoint / connect_http2_upgrade() (h2/client_upgrade.rs) send the request as HTTP/1.1 with Upgrade: h2c + base64url HTTP2-Settings; a 101 response promotes the connection to H2Endpoint::client_after_h2c_upgrade() (stream 1 continues onto the already-started handler), while any other response completes as plain HTTP/1.1 — verified end-to-end against hopf's own server-side h2c Upgrade over a real socket (integration.rs::h2c_upgrade_round_trip_over_real_socket)
Client: request pseudo-headers auto-filled§8.3.1Complianth2/endpoint.rs:215-225 uses Headers::add_pseudo() so an auto-filled :scheme/:authority is inserted before any regular field the caller already set (e.g. host) rather than appended after it, which would otherwise violate the pseudo-header-ordering rule this same section requires of requests
Client: response HEADERS → trailers dispatch§8.1Complianth2/endpoint.rs:847-882
Client: GOAWAY reception fails in-flight streams§6.8Complianton_goaway() (h2/endpoint.rs:1133-1160) parses the peer's last-stream-id and, for the client role, removes and fails every stream above it via the new ClientHandler::request_failed callback (stream/client.rs, default no-op) instead of leaving them to hang
Client: 1xx informational responses§15.2Compliantprocess_client_response_headers() (h2/endpoint.rs) now surfaces a 1xx status via ClientHandler::informational_response without consuming the "first HEADERS" slot, so the real final response still lands on response_headers rather than being mistaken for trailers — the bug the original "needs verification" note anticipated. Covered by client_informational_response_tests (single interim response, multiple interim responses, and the unaffected no-interim-response case)
Client: DATA send chunked at MAX_FRAME_SIZE§4.2Complianth2/endpoint.rs:589-603

HTTP/3 — RFC 9114 / QPACK RFC 9204

An end-to-end integration test exists and was inspected: crates/hopf-http/src/h3/endpoint.rs:487-536 (h3_get_hello_over_quic) drives a real GET/200 round-trip over hopf-quic.

Server

RequirementSectionStatusNotes
ALPN h3, TLS 1.3 only§3,§3.1Complianthopf-quic/src/lib.rs:26, config.rs:104,117
Control stream (type 0x00) + SETTINGS on connect§6.2.1,§7.2.4Complianth3/endpoint.rs / h3/client.rs open the control stream and send SETTINGS via frame::write_settings() advertising SETTINGS_QPACK_MAX_TABLE_CAPACITY=4096, SETTINGS_QPACK_BLOCKED_STREAMS=0 (RFC 9204 §5), SETTINGS_MAX_FIELD_SECTION_SIZE=8192 (RFC 9114 §4.2.2), SETTINGS_ENABLE_CONNECT_PROTOCOL=1 (RFC 9220), and SETTINGS_H3_DATAGRAM=1 (RFC 9297 §2.1.1)
Peer SETTINGS frame parsed/validated§7.2.4CompliantH3UniStream::settings_frame() requires SETTINGS as the first control-stream frame (H3_MISSING_SETTINGS otherwise, including when GREASE precedes it) and rejects a second SETTINGS with H3_FRAME_UNEXPECTED. Decodes entries via frame::parse_settings(), stores SETTINGS_ENABLE_CONNECT_PROTOCOL (absent → Some(false)), SETTINGS_H3_DATAGRAM (absent → Some(false); value > 1 → H3_SETTINGS_ERROR), SETTINGS_QPACK_MAX_TABLE_CAPACITY (defaulting to 0 when absent per RFC 9204 §5), and SETTINGS_MAX_FIELD_SECTION_SIZE (absent → unlimited) in H3PeerState, and applies the peer QPACK capacity to the local encoder via H3Qpack::apply_peer_max_table_capacity. Wakes settings_waiters so deferred Extended CONNECT can proceed
SETTINGS_MAX_FIELD_SECTION_SIZE enforced§4.2.2,§7.2.4.1,§10.5.1CompliantAdvertised as 8192 on connect. Inbound HEADERS whose decompressed size (name+value+32 per field) exceeds that ceiling, or whose field count exceeds HttpLimits::max_header_count, abort the stream with H3_EXCESSIVE_LOAD instead of hanging. Outbound sections that would exceed a peer-advertised ceiling are refused the same way (client request / server response flush)
QPACK encoder/decoder critical streams openedRFC 9204 §4.2Complianth3/endpoint.rs opens both and saves their stream keys. The encoder starts at capacity 0 and only emits Set Dynamic Table Capacity after the peer's SETTINGS advertises a non-zero SETTINGS_QPACK_MAX_TABLE_CAPACITY; that instruction is flushed on the next drive() tick. Premature peer FIN/reset of either QPACK critical stream closes the connection with H3_CLOSED_CRITICAL_STREAM (same path as the control stream)
Incoming uni-stream type discrimination§6.2CompliantH3UniStream::classify() (h3/endpoint.rs) reads the RFC 9114 §6.2 type-byte varint (correctly handling multi-byte GREASE values) and dispatches control/QPACK-encoder/QPACK-decoder/push/unknown; unknown types are tolerated per §9. A duplicate control or QPACK critical stream closes with H3_STREAM_CREATION_ERROR; an inbound push stream (type 0x01) closes with H3_ID_ERROR on the client (hopf never sends MAX_PUSH_ID) or H3_STREAM_CREATION_ERROR on the server, via Endpoint::close_connection()
Premature closure of critical unidirectional streams§6.2.1 / RFC 9204 §4.2CompliantH3UniStream::disconnected / error close the connection with H3_CLOSED_CRITICAL_STREAM when the peer control, QPACK-encoder, or QPACK-decoder stream ends (FIN or abrupt teardown). Unknown/GREASE uni streams may close without error
HEADERS/DATA → request start/body, FIN → complete§4.1Complianth3/endpoint.rs:336-386
Mandatory request pseudo-header validation§4.3.1CompliantH3RequestStream::headers_frame() (h3/endpoint.rs) validates via pseudo_headers::validate_request_pseudo_headers(), shared with the HTTP/2 fix (presence/ordering/uniqueness, RFC 9220 Extended CONNECT). A malformed request never reaches the application handler and the stream is aborted with H3_MESSAGE_ERROR via Endpoint::abort() — a real QUIC RESET_STREAM + STOP_SENDING, not just a graceful close
Frame type+length varint, split-point resilience, oversize protection§7.1-7.2Complianth3/varint.rs:9-40, h3/parser.rs (every byte-offset split tested), 16 MiB cap — though the resulting error is ignored by the caller, see below
CANCEL_PUSH / PUSH_PROMISE / MAX_PUSH_ID (no server push)§7.2.3,§7.2.5,§7.2.7,§4.6Compliant (N/A by design)Frame-type constants and parser dispatch defined in h3/frame.rs / h3/parser.rs. hopf never sends MAX_PUSH_ID, so push is not permitted: client request-stream PUSH_PROMISE → H3_ID_ERROR; server request-stream PUSH_PROMISE → H3_FRAME_UNEXPECTED; control-stream PUSH_PROMISE → H3_FRAME_UNEXPECTED; CANCEL_PUSH on control → H3_ID_ERROR (any ID exceeds the unset budget); client receipt of MAX_PUSH_ID → H3_FRAME_UNEXPECTED; server ignores peer MAX_PUSH_ID (never pushes). Wrong-stream placement of CANCEL_PUSH/MAX_PUSH_ID → H3_FRAME_UNEXPECTED. Unknown/GREASE frames remain ignored per §9
Response HEADERS with :status, DATA, trailers, FIN§4.1,§4.3.2Complianth3/endpoint.rs:119-142
Request trailers (second HEADERS received)§4.1CompliantH3RequestStream::headers_frame() delivers a second HEADERS frame to the new ServerHandler::request_trailers callback (default no-op) instead of discarding it
Extended CONNECT / WebSocket-over-H3RFC 9220CompliantServer upgrade via h3/response.rs; client Extended CONNECT gated on peer SETTINGS_ENABLE_CONNECT_PROTOCOL (H3ClientStream::start_request) — deferred until SETTINGS if unknown, fail-fast with request_failed when the peer did not advertise support
Extensible PrioritizationRFC 9218CompliantPriority header on requests; PRIORITY_UPDATE (0xF0700) on the control stream updates H3PeerState::stream_priority; urgency mapped to hopf-quic's own SendStream::set_priority; non-incremental same-urgency responses gated one-at-a-time via non_inc_active
HTTP/3 Datagrams (QUIC DATAGRAM)RFC 9297 §2.1CompliantSETTINGS_H3_DATAGRAM=1 always advertised; peer setting stored in H3PeerState. Inbound QUIC DATAGRAMs demuxed by quarter-stream-ID (h3/datagram.rs) via QuicConnection::decode_datagram; delivered to the request stream's ProtocolHandler::datagram_received. Streams without datagram semantics abort with H3_DATAGRAM_ERROR (0x33). Outbound via send_http3_datagram / Endpoint::send_datagram
Capsule Protocol + DATAGRAM capsuleRFC 9297 §3Compliantcapsule.rs: streaming Type/Length/Value parser; DATAGRAM capsule type 0x00; unknown types forwarded to ProtocolUpgradeHandler::capsule_received. Capsule-Protocol: ?1 on the request enables capsule mode on the H3 data stream after upgrade
HTTP/3 + QPACK application error-code namespace§8.1 / RFC 9204 §6CompliantFull RFC registry exposed as u32 constants in h3/frame.rs (re-exported from hopf_http::h3 / hopf_http): all 17 H3_* codes (H3_NO_ERROR…H3_VERSION_FALLBACK) plus QPACK_DECOMPRESSION_FAILED / QPACK_ENCODER_STREAM_ERROR / QPACK_DECODER_STREAM_ERROR, and RFC 9297 H3_DATAGRAM_ERROR (0x33). Numeric values locked by h3_and_qpack_error_codes_match_rfc_registry
Malformed-frame handling → connection error§8.1CompliantH3UniStream's frame_error/data_frame/headers_frame callbacks (h3/endpoint.rs) now set a pending connection-error code (H3_FRAME_ERROR / H3_FRAME_UNEXPECTED) applied via Endpoint::close_connection() in receive()'s tail — a real QUIC CONNECTION_CLOSE with the H3 application error code, not silence
GOAWAY sending (server shutdown)§5.2CompliantNew QuicConnection::disconnecting() hook (hopf-quic/src/hooks.rs) fires for every live connection right before an explicit QuicDriverHandle::shutdown(); H3ServerConnection::disconnecting() writes a GOAWAY on the already-open control stream announcing the last client-initiated stream ID accepted (derived from an accepted-request counter via RFC 9000 §2.1's ID-numbering formula, no raw StreamId needed). Not a true two-phase drain — in-flight streams are still abandoned when the driver stops right after — and the client role sends nothing (hopf never pushes, so a client GOAWAY's push-ID payload would be meaningless)
GOAWAY reception§5.2CompliantH3UniStream::goaway_frame() parses via frame::parse_goaway(), stores the ID in H3PeerState.goaway_received, rejects a non-monotonic (higher) ID with H3_ID_ERROR, and on the client rejects a GOAWAY whose ID is not a client-initiated bidirectional stream ID (id % 4 != 0). After any peer GOAWAY, H3ClientConnection::drain_pending_opens refuses further open_bi calls (RFC 9114 §5.2: MUST NOT initiate new requests). Client-sent GOAWAY (push ID) remains unused — hopf never pushes
Stream cancellation (RESET_STREAM/STOP_SENDING)§8CompliantNew Endpoint::abort(error_code) (hopf-core, default falls back to graceful close()) is implemented for real by QuicStreamEndpoint::abort() (hopf-quic/src/stream.rs): a StreamQueues::reset_error_code request is picked up by the driver's stream loop and applied via Connection::send_stream(id).reset() + recv_stream(id).stop() — genuine RFC 9000 §3.5/§3.6 abrupt cancellation, reachable from the H3 layer via the malformed-request/response paths above
QPACK dynamic tableRFC 9204 §3.2,§4.3,§4.4CompliantReal absolute-indexed dynamic table (h3/qpack/dynamic.rs) with per-entry reference counting so an outstanding-and-unacknowledged entry is never evicted. A stateful Encoder/Decoder pair (h3/qpack/encoder.rs, decoder.rs) and the encoder-stream/decoder-stream instruction codecs (encoder_stream.rs, decoder_stream.rs) implement Set Dynamic Table Capacity, Insert With Name/Literal Name, Duplicate, Section Acknowledgment, Stream Cancellation, and Insert Count Increment. hopf's own encoder runs strictly non-blocking — it never references an entry the peer hasn't acknowledged, so Base = Required Insert Count = Known Received Count always and no post-base indexing is ever emitted — consistent with advertising SETTINGS_QPACK_BLOCKED_STREAMS=0. The decoder still fully resolves arbitrary spec-compliant peer encodings, including post-base indices, but rejects (QPACK_DECOMPRESSION_FAILED) a Required Insert Count it can't yet satisfy rather than buffering/blocking. A driver-level QuicConnection::drive() hook flushes queued instruction bytes onto encoder/decoder streams. Decoder capacity is a fixed 4096-byte constant advertised via SETTINGS_QPACK_MAX_TABLE_CAPACITY; the encoder starts at 0 and grows only up to min(4096, peer SETTINGS_QPACK_MAX_TABLE_CAPACITY) once peer SETTINGS arrive
QPACK Huffman codingRFC 9204 §4.1.2/Appendix ACompliantField-line strings and encoder-stream instruction strings (name and value) are Huffman-coded whenever that's shorter, else literal, via the shared h3/qpack/strings.rs helper and the same Huffman table as HPACK (h2/hpack/huffman.rs)
0-RTT at the H3 layer—N/AEarly data off by default in hopf-quic builders; H3 hooks still attach only on Event::Connected (no 0-RTT H3 path). Plain connect_quic clients do open early when has_0rtt() — see the QUIC transport row below

Client

RequirementSectionStatusNotes
ALPN/TLS1.3, control+QPACK streams, request bi-stream, pseudo-headers§3,§6.1-6.2,§4.3.1Complianth3/client.rs:35-99
HEADERS+DATA request framing, FIN to complete§4.1Complianth3/client.rs:167-176
:status extraction from response§4.3.2Compliantvalidate_response_status() (h3/client.rs) requires :status to be present, first, a well-formed 3-digit numeric value, and the only pseudo-header in the response; a malformed response calls the new ClientHandler::request_failed and stops the stream instead of dispatching response_headers
1xx informational responses (client)§4.1 / RFC 9110 §15.2CompliantH3ClientStream::headers_frame surfaces interim 1xx HEADERS via ClientHandler::informational_response without consuming the final-response slot, so a following final HEADERS lands on response_headers rather than being mistaken for trailers. Covered by interim_1xx_then_final_response_dispatch_correctly, multiple_interim_responses_all_surfaced, and no_interim_response_final_headers_then_trailers
Response DATA/trailers/FIN → completion§4.1Complianth3/client.rs:193-252
Request trailers (client-sent)§4.1CompliantClientWriter::trailers (default no-op for H1/H2); H3 client encodes a second HEADERS frame before stream FIN (h3/client.rs)
GOAWAY reception§5.2CompliantShared H3UniStream::goaway_frame() path; client additionally stops drain_pending_opens / open_bi after any peer GOAWAY (session multi-request path included)
Extended CONNECT (client-initiated)RFC 9220CompliantH3ClientStream::start_request requires peer SETTINGS_ENABLE_CONNECT_PROTOCOL=1 before sending CONNECT + :protocol; defers via settings_waiters + poke until SETTINGS arrives. Proven by extended_connect_rejected_when_peer_did_not_advertise, extended_connect_sent_when_peer_advertised, and the deferred SETTINGS tests in h3/client.rs
End-to-end GET verified live—Complianth3/endpoint.rs:487-536 integration test, real client GET through connect_h3/listen_h3

QPACK — RFC 9204

RequirementSectionStatusNotes
Required Insert Count / Base encoding§4.5.1.1-2CompliantReal wrapped-modulo Required Insert Count encoding (h3/qpack/insert_count.rs); hopf's own encoder always emits Base = Required Insert Count = Known Received Count (sign 0, delta 0) per its non-blocking policy, decoded back via the same formula on the peer side
Field line representations (static + dynamic, incl. post-base)§4.5.2-6CompliantAll 5 representations are decoded (h3/qpack/decoder.rs); hopf's own encoder emits only the static and before-base dynamic forms — never post-base, see the non-blocking-policy note above (h3/qpack/encoder.rs)
Encoder-stream instructions (Set Dynamic Table Capacity, Insert With Name/Literal Name, Duplicate)§4.3CompliantWrite + streaming parse in h3/qpack/encoder_stream.rs; applied against the receiver's mirrored table in h3/qpack/decoder.rs. hopf's own encoder never emits Duplicate (its insertion policy is "insert every not-already-present pair," no promote-before-eviction optimization) but fully parses and applies one from a peer that does
Decoder-stream instructions (Section Acknowledgment, Stream Cancellation, Insert Count Increment)§4.4CompliantWrite + streaming parse in h3/qpack/decoder_stream.rs; applied against the sender's Encoder in h3/qpack/encoder.rs. Stream Cancellation is emitted from both H3RequestStream::error() and H3ClientStream::error() (which also finish the request/response, because abnormal QUIC teardown now calls error instead of disconnected) so a reset stream doesn't permanently pin dynamic-table entries open
Dynamic table eviction safety (never evict a referenced, unacknowledged entry)§3.2.2-3CompliantPer-entry reference counting in h3/qpack/dynamic.rs; the encoder's insert() refuses (falling back to a literal) rather than evict a still-referenced entry
Static table (99 entries)§3.1 / Appendix ACompliantFull RFC 9204 Appendix A table in h3/qpack/static_table.rs (indices 0–98); index-by-index regression test guards against the former truncated table that collided from index 30

HTTP caching and conditional requests — RFC 9111 / RFC 9110 §8.8, §13

Scope is origin-server correctness: producing validators and freshness information and honouring conditional requests. hopf-http does not store responses, so the cache-side rules of RFC 9111 (storing, freshness calculation, Age, invalidation, Vary secondary keys) do not apply and a shared-cache or reverse-proxy mode is a separate piece of work, not part of this section. Before this work hopf-http had no validator or precondition handling at all, and hopf-webdav compared a nanosecond file mtime against a whole-second If-Modified-Since, so its revalidations never matched.

RequirementSectionStatusNotes
Entity-tag syntax, strong and weak comparisonRFC 9110 §8.8.3Compliantcaching::EntityTag; strong_eq for If-Match, weak_eq for If-None-Match; tag lists are scanned quote-aware because a tag may contain a comma
Precondition evaluation orderRFC 9110 §13.2.2Compliantcaching::evaluate_preconditions: If-Match, then If-Unmodified-Since, then If-None-Match, then If-Modified-Since, each only when its predecessor is absent, as specified
Ignore malformed, repeated or inapplicable fieldsRFC 9110 §13.1.3-4CompliantInvalid HTTP-dates and multi-line date fields are ignored; If-Modified-Since only acts on GET/HEAD; obsolete RFC 850 and asctime dates are understood
Date comparison at one-second resolutionRFC 9110 §5.6.7CompliantA modification time is truncated to whole seconds before comparison; previously a file with a fractional mtime never revalidated
304 for a matching safe request; 412 for a failed oneRFC 9110 §13.1, §15.4.5CompliantConditionalServerFactory (on by default in HttpServer) for GET/HEAD; HTTP/3 listeners wrap it explicitly
304 repeats the fields a 200 would carryRFC 9110 §15.4.5CompliantCache-Control, Content-Location, Date, ETag, Expires, Vary and Last-Modified; layered outside content coding so the Vary and weakened ETag of a compressed 200 are what the 304 shows
Ignore preconditions unless the response is 2xx or 412RFC 9110 §13.2.1CompliantOnly a 200 is converted; every other status passes through
Preconditions on state-changing methodsRFC 9110 §13.1PartialA decorator cannot stop an action already taken, so handlers call evaluate_preconditions before acting. hopf-webdav does for PUT (before the file is truncated) and DELETE; MKCOL, COPY, MOVE, PROPPATCH and LOCK still rely on the WebDAV If header alone
WebDAV entity-tags usable with If-MatchRFC 9110 §13.1.1PartialWebDAV file ETags are weak (a hash of path, size and mtime), and If-Match needs a strong match, so If-Match on a WebDAV resource always fails closed with 412. If-None-Match, If-Unmodified-Since and If-Modified-Since work
Cache-Control generationRFC 9111 §5.2.2CompliantCacheControl builder (public, private, no-cache, no-store, no-transform, must-revalidate, max-age, s-maxage, immutable, and more). Handlers opt in; WebDAV takes it from WebDavConfig::cache_control for file GET/HEAD. Nothing is added by default
ExpiresRFC 9111 §5.3CompliantSet by the handler using format_http_date; ignored by caches when max-age is present
If-Range and range requestsRFC 9110 §13.1.5, §14Not implementedNo Range support anywhere in hopf-http, so there is nothing for If-Range to guard
Cache storage, freshness, Age, invalidationRFC 9111 §3-4N/ANot a cache. See the scope note above

HTTP Strict Transport Security — RFC 6797

RequirementSectionStatusNotes
Field syntax: max-age, includeSubDomains, preload§6.1CompliantHstsPolicy formats the value; max-age=0 clears a host (§6.1.1); preload is validated against the browser lists' one-year and includeSubDomains conditions when the server binds
Never send over insecure transport§7.2CompliantAdded only when the connection reports TLS, on HTTP/1.1 and HTTP/2; covered by an end-to-end test against a plaintext listener
Send in all secure responses, error statuses included§7.1CompliantHstsServerFactory is the outermost layer, so it covers 304/412 from the conditional layer and responses a handler writes later through the response handle. A value the handler set itself is kept
Send on HTTP/3§7.1PartialHttpServer::hsts covers TCP listeners; listen_h3 takes a factory directly, so it must be wrapped with HstsServerFactory by hand
Enabled by default—N/ADeliberately opt-in: it is a long-lived promise to browsers that is painful to retract
HTTP-to-HTTPS redirect for plaintext visitors§7.2, §8.3Not implementedhopf-http has no built-in redirect; HSTS does not replace one, because a browser only honours the field once it has been received over HTTPS. See the server page for the recommended pattern
User-agent processing (Known HSTS Hosts, URI rewriting)§8N/AClient-side browser behaviour; HttpClient is not a user agent and keeps no HSTS store

QUIC Transport — RFC 9000 / RFC 9001 (in-tree, hopf-quic)

hopf-quic is a full in-tree RFC 9000/9001/9002 implementation — transport state machine, loss recovery, congestion control, and packet protection are all Hopf-owned, with no external QUIC library dependency. Three real gaps are called out below rather than smoothed over: connection migration is wired up but structurally inert (nothing ever updates the address it compares against), NEW_TOKEN-based address validation doesn't exist despite config fields that look like it does, and the RFC 9000 §8.1 3× anti-amplification byte cap isn't enforced. Hybrid PQC key exchange is available on QUIC as an opt-in via QuicTlsOptions::with_pqc().

RequirementSectionStatusNotes
Connection ID generation, packet number spaces, loss detection§5,§13PartialPacket number spaces (Initial/Handshake/1-RTT, RFC 9000 §12.3) are real: Connection keeps independent per-space state (transport/connection.rs's spaces: [Space; 3]). Loss detection is a real RFC 9002 Appendix A implementation — transport/recovery/loss_detector.rs names its own constants after the RFC's (K_PACKET_THRESHOLD, K_TIME_THRESHOLD, K_GRANULARITY, K_PERSISTENT_CONGESTION_THRESHOLD). Connection IDs are only partially there: a random CID is picked once at connection creation (transport/endpoint.rs's ConnectionId::random) and used for demultiplexing inbound packets (transport/cid.rs's CidMap), but RFC 9000 §5.1.1's active CID lifecycle — NEW_CONNECTION_ID/RETIRE_CONNECTION_ID frames, issuing a pool of CIDs, retiring old ones — has no wire support at all: neither frame type exists in transport/frame/parser.rs's Frame enum. A connection has exactly one CID for its entire lifetime
Driver arms mio wait from the loss detector's real timer deadlineRFC 9002CompliantDriver::next_timeout() (driver.rs:969) takes the soonest of app-level one-shot timers and every live connection's Connection::poll_timeout() — backed by the real RFC 9002 loss-detection timer in loss_detector.rs; with no deadline the poll blocks until UDP or a wake instead of spinning (poll_wait, driver.rs:663, plus its own poll_wait_* unit tests). handle_timeouts (driver.rs:1238) fires Connection::handle_timeout() once that deadline passes
Server accept, client connect§7Compliantdriver.rs listen/connect paths; server incoming handling goes through handle_incoming (driver.rs:1199)
Version Negotiation (server)§5.2.2, §6, §17.2.1, RFC 8999, RFC 9368 §2.1CompliantEndpoint::handle (transport/endpoint.rs) answers a long-header datagram in an unsupported version with a Version Negotiation packet (transport/packet/version_negotiation.rs): connection IDs echoed swapped and verbatim (read from the RFC 8999 invariants as raw bytes, so IDs over v1's 20 bytes are not truncated), the configured versions (version 1 and 2 by default, QuicServerConfig::versions) listed plus one greased 0x?a?a?a?a entry, random unused bits. Datagrams under 1200 bytes, Version Negotiation packets themselves (never answered), packets for a connection we already hold, and client-only endpoints get no reply. Not rate-limited: the reply is far smaller than the 1200-byte trigger, so it cannot amplify. Proven by endpoint unit tests and a raw-UDP test against a real listener
Version Negotiation (client)§6.2, RFC 9368 §2.1/§4CompliantConnection::handle_version_negotiation: a valid packet offering a version the client speaks (QuicClientConfig::versions, default version 1 only) restarts the attempt in the first such version with a fresh first flight and destination CID; one offering none abandons it (ConnectionError::VersionMismatch). Discarded, per §6.2: a packet listing the version in use (already chosen), one that does not echo our connection IDs, any once another server packet (including Retry) has been processed, and any once this attempt is itself a restart after Version Negotiation. Proven by connection unit tests and a UDP test in which a forged reply leaves the client dialling and a genuine one stops it. Known gap: a failure before the handshake completes (this one, a TLS failure, a timeout) has no stream handler to report to yet, so the application sees the dial simply stop
Address validation via Retry (listen hardening)§8.1.2Compliant (high-security default)QuicListenHardening::high_security() is the default on QuicListenConfig/QuicListenHooksConfig: unvalidated Incoming get a real Retry packet (RFC 9001 §5.8 fixed integrity key/nonce, plus an AEAD-sealed opaque token bound to client IP with expiry — transport/packet/retry.rs) before any TLS handshake; a client that echoes a valid token is accepted. Also tightens max_incoming/Incoming buffers, shortens the Retry token lifetime, and disables migration. Opt out with QuicListenHardening::permissive() via with_hardening. Proven end-to-end by listen_hardening_retry_still_completes_handshake
Address validation via NEW_TOKEN (resumed validation on a later connection)§8.1.3Not implementedNo NEW_TOKEN frame type exists in transport/frame/parser.rs's Frame enum, and nothing issues, stores, or validates one. QuicListenHardening carries validation_token_lifetime/validation_tokens_sent fields, set by high_security()'s own defaults, that read as though this were wired up, but neither field has any consumer outside config.rs itself — vestigial, not connected to anything
3× anti-amplification limit on an unvalidated address§8.1Not implemented (moot under the high-security default)No byte-count cap anywhere in the codebase bounds how much an unvalidated address may be sent before its path is validated — the only amplification-adjacent code is loss_detector.rs's PTO-arming nuance (a server shouldn't arm a probe timer while amplification-limited), which doesn't stop data from actually being sent. Under QuicListenHardening::high_security() this is moot in practice: an unvalidated peer is sent only the (small) Retry packet, and the handshake itself never starts until the client echoes a valid token. Under permissive() (or any deployment that sets require_address_validation: false), it's a real gap — a full ServerHello/Certificate/Finished flight can go out to an address that was never confirmed reachable
Handshake completion detection§7.3CompliantEvent::Connected (transport/types.rs:187) → on_connected (driver.rs:1325)
TLS 1.3-only pinned for QUICRFC 9001Compliant, but see the key-exchange row belowEvery QUIC TLS config (hopf-quic/src/crypto/mod.rs's HopfTlsBuildParams::server/client_self_signed, config.rs's PEM/public-trust builders) constructs a hopf_core::tls::HandshakeConfig with HandshakeMode::Quic directly — the same in-tree TLS 1.3 engine TCP TLS uses. No TLS 1.2 exists on this path (the shared engine has no such mode for QUIC)
Hybrid PQC key exchange offered first (RFC 10024)—Compliant (opt-in)QuicTlsOptions::with_kx_policy() / with_pqc() (config.rs) select the KxPolicy for every *_with config builder; with_pqc() offers X25519MLKEM768, SecP256r1MLKEM768 and SecP384r1MLKEM1024 with X25519 as fallback. The default remains KxPolicy::classical_only() because the ~1.2 KiB ML-KEM key share pushes the ClientHello past one 1200-byte Initial datagram. Proven by tls_bridge handshake tests (hybrid negotiated when both peers opt in, X25519 fallback against a classical client) and a UDP loopback echo test
Idle timeout§10.1Compliant, configurable30s default (transport/packet/transport_params.rs's TransportParameters::default()) unless overridden via QuicTransportOptions::max_idle_timeout() + apply_server_transport_options()/apply_client_transport_options() (config.rs) — a real end-to-end test proves a shortened timeout actually tears the connection down via ProtocolHandler::error with ErrorKind::TimedOut (driver.rs, transport_options_shorten_the_idle_timeout), not just that the value is accepted
Keepalive§10.1.2CompliantOpt-in via QuicTransportOptions::keep_alive_interval() (disabled unless set); applied through the same apply_*_transport_options path as idle timeout. Proven end-to-end: a 50ms keep-alive keeps a connection alive across an 200ms idle timeout that otherwise tears it down (driver.rs, transport_options_keepalive_prevents_idle_timeout)
Immediate close / CONNECTION_CLOSE with app error code§10.2CompliantQuicStreamEndpoint::close_connection(error_code) (stream.rs) sends DriverCmd::ConnectionClose{conn, error_code}; the driver calls Connection::close(now, VarInt, Bytes::new()) (transport/connection.rs:473) on the right connection slot. Exposed at the hopf-core level as Endpoint::close_connection(), giving any stream on a connection a way to end the whole thing, not just itself. The peer receives the close as ProtocolHandler::error with QuicConnectionCloseError { application_error: true, error_code, .. } (see the connection-lost row below)
QUIC version 2RFC 9369Implemented (client and server)transport/version.rs (QuicVersion) holds everything that differs from version 1 - version number 0x6b3343cf, Initial salt, quicv2 HKDF labels, the permuted long-header type bits, Retry Integrity Tag key and nonce - and each connection carries one. A listener accepts version 1 and 2 by default; a client speaks version 1 unless QuicClientConfig::versions says otherwise, and falls back through Version Negotiation. Verified byte for byte against the RFC 9369 Appendix A vectors (Initial secrets, client and server Initial packets, Retry, the ChaCha20 short-header packet), a real-UDP handshake in each version pairing, dual-stack fallback, and HTTP/3 over version 2. Session tickets are bound to the version that issued them (§5): a client's cache is partitioned per version and a server derives a per-version ticket key. A Retry token is bound to its version (§4.1). Not implemented: compatible version negotiation (RFC 9368 §2.3), where a server switches a connection between versions mid-handshake; the client lists only its chosen version as available, which RFC 9368 §3 allows, so a server never tries to switch it
version_information transport parameterRFC 9368 §3-§4 (required by RFC 9369 §4)CompliantSent by both roles and validated by both: malformed (short, ragged, or a zero version) is TRANSPORT_PARAMETER_ERROR; a chosen version other than the one in use, a client chosen version missing from its own list, or (when the client restarted after Version Negotiation) a missing parameter or a server list that shows the negotiation was forced, is VERSION_NEGOTIATION_ERROR. The downgrade check follows RFC 9368 §4's worked example and is unit tested, including a forged downgrade
Initial packet key derivation and datagram sizeRFC 9001 §5.1, §5.2; RFC 9000 §14.1CompliantVerified against RFC 9001 Appendix A (Initial keys and the client and server Initial packets, byte for byte). Two defects found while adding version 2, both invisible to hopf-to-hopf traffic and fatal against any other stack, are fixed: packet protection keys were derived without HKDF-Expand-Label's tls13 prefix, and a client's first Initial datagram was about 1180 octets rather than the required 1200
QUIC-LB server-issued connection IDsdraft-ietf-quic-load-balancers-21Implemented (opt-in)QuicServerConfig::quic_lb / apply_server_quic_lb with a QuicLbConfig (transport/quic_lb.rs): plaintext, single-block AES-128-ECB and four-round Feistel encodings, config rotation bits, optional length self-description, random-start non-repeating nonces with the 0b111 unroutable fallback on exhaustion. Endpoint CID length and short-header demux follow the generator; random 8-octet IDs remain the default. Verified against the draft's own worked example (section 5.4.2.4) and a FIPS 197 known answer, round-trips of every server-ID/nonce split, endpoint tests including a simulated address change routed to the same backend, and real-UDP echo through Retry with four ID shapes. Limits: Initial and Retry source CIDs only (no NEW_CONNECTION_ID is issued by this stack yet), no extra server-owned CID octets, no load-balancer side beyond QuicLbConfig::decode_server_id
Connection migration§9Not implemented — structurally inertConnection::remote_address() (transport/connection.rs:352) returns a field that's set once at construction and never reassigned anywhere in the connection's own code. Driver::detect_migrations() (driver.rs:1570) compares this constant value against its own cached copy on every poll, so the comparison can never see a change — the whole mechanism can't fire, regardless of what's on the wire. No PATH_CHALLENGE/PATH_RESPONSE frames (§9.3 path validation) and no NEW_CONNECTION_ID issuance (§9.5 — needed so a migrating path doesn't reuse a linkable CID) exist anywhere in the Frame enum. QuicListenHardening.migration (set to false by high_security()) has no consumer either — the same dead-knob pattern as NEW_TOKEN above
Each bidi stream exposed as a hopf Endpoint§2.1Complianthopf-quic/src/stream.rs's QuicStreamEndpoint (struct at line 97, impl Endpoint at 184-332), verified end-to-end by the H3 and plain-echo integration tests
Unidirectional streams (locally-opened, hooks mode)§2.1PartialPeer-initiated uni streams handled the same as bi; locally-opened uni streams get a NopHandler (driver.rs:1463) — no inbound handling wired for streams hopf itself opens unidirectionally (documented)
Stream write backpressure / read pausing§4 (app-visible)CompliantDriver::drive_streams (driver.rs:1898-1987) requeues a partially-written or fully-blocked (WriteError::Blocked) chunk at the front of the queue instead of dropping it; read pausing via QuicStreamEndpoint::pause_read/resume_read (stream.rs)
Client open_bi retries when MAX_STREAMS credit returns§4.6CompliantExhausted streams().open(Dir::Bi) queues the factory on pending_open_bi (same deque as handshake-time opens, driver.rs:554) instead of returning WouldBlock; StreamEvent::Available { Dir::Bi } drains it. Peer FIN in read_stream also finishes the local send half so the stream fully closes and the peer can raise MAX_STREAMS. Proven with server max_concurrent_bidi_streams(1) (driver.rs, open_bi_queues_until_stream_credit_available)
Graceful stream close (FIN)§3.5CompliantQuicStreamEndpoint::close (stream.rs:206-217) marks the shared queue's finish_write; Driver::drive_streams (driver.rs:1964-1982) calls SendStream::finish() once the write queue drains
Abrupt stream reset (RESET_STREAM/STOP_SENDING)§3.5-3.6CompliantQuicStreamEndpoint::abort(error_code) (stream.rs:220-235) queues a reset request (StreamQueues::reset_error_code); Driver::drive_streams calls Connection::send_stream(id).reset() + recv_stream(id).stop() (transport/connection.rs:1652,1682) on its next pass, taking priority over any pending graceful write/finish for that stream. Inbound STOP_SENDING reaches the stream handler as ProtocolHandler::error with QuicStreamStoppedError (not disconnected)
max_idle_timeout / keep_alive_interval / stream+connection flow-control windows / max concurrent streams / DATAGRAM buffers configurable via hopf§18.2 / RFC 9221CompliantQuicTransportOptions fluent builder (config.rs) covers max_idle_timeout, keep_alive_interval, stream_receive_window, receive_window, send_window, max_concurrent_bidi_streams, max_concurrent_uni_streams, datagram_receive_buffer_size, datagram_send_buffer_size; applied via apply_server_transport_options()/apply_client_transport_options(). max_udp_payload_size is a real per-connection TransportParameters field (transport/packet/transport_params.rs, default 1452, sent/parsed on the wire) but has no builder method on QuicTransportOptions, so it stays fixed at the default — unconfigured via hopf
QUIC DATAGRAM frames (send/recv)RFC 9221CompliantEvent::DatagramReceived drains via Connection::datagrams().recv(); app routes with QuicConnection::decode_datagram (DatagramDecode). Outbound via QuicConnApi::send_datagram / Endpoint::send_datagram → DriverCmd::SendDatagram. Proven by quic_datagram_echo_round_trip
Congestion control selectable—Compliant (uses default; not selectable)In-tree NewReno (RFC 9002 §7/Appendix B) — transport/recovery/congestion.rs's CongestionController, using the RFC's own named constant (K_LOSS_REDUCTION_FACTOR) and initial-window floor. No API to choose an alternative algorithm
Negotiated ALPN/SNI/cipher suite surfaced accuratelyRFC 7301Compliantsecurity_info_from_conn() (driver.rs:40-45) reads Connection::security_info() (transport/connection.rs:362), populated directly from hopf_core::tls::HandshakeEngine's own SecurityInfo via TlsEventSink::handshake_complete (transport/tls_bridge.rs:66) — the identical in-tree TLS 1.3 engine TCP TLS uses. Protocol version stays the constant "TLSv1.3" (hopf-core's tls::engine::protocol_name()) — a genuine invariant, since hopf-quic's own TLS configs only ever build TLS 1.3. Cipher suite is populated (Some(aead.name().to_string()), set inside the shared engine's own handshake-completion path). A real two-peer test with divergent ALPN offer lists proves the negotiated value is surfaced, not a constant (driver.rs, security_info_reflects_real_negotiated_alpn_and_sni)
0-RTT / early data enabled at config layerRFC 9001 §4.6.1Compliant (off by default)PEM builders leave early data disabled; opt in with QuicTlsOptions::with_early_data via server_config_*_with / client_config_*_with (config.rs)
0-RTT handled correctly by the stream-open pathRFC 9001 §4.6.1Compliant (client stream path)When Connection::has_0rtt() is true after endpoint.connect(), the plain client driver opens the first bi stream (and drains queued open_bi) immediately and drives it before the first UDP flush, so handler writes can leave as early data — no longer gated on Event::Connected. Without a session ticket (cold dial) behaviour is unchanged. Proven by reusing one ClientConfig Arc across two dials with early data enabled (driver.rs, early_data_second_dial_opens_stream_before_connected). Hooks-mode (H3) still attaches the app connection only on Event::Connected
start_tls on a QUIC stream rejected (always-on TLS)—Compliantstream.rs:281-283 always returns Unsupported
Timer integration routed through the QUIC driver thread, not a TCP reactor—CompliantQuicStreamEndpoint::schedule_timer (stream.rs:309-320) sends a DriverCmd::ScheduleTimer rather than touching any TCP reactor; the driver's own timer queue (driver.rs's next_timeout/fire_timers, ~962-1002) fires it
Connection lost → stream teardown with close reason§10 / §19.19CompliantEvent::ConnectionLost { reason } is mapped through connection_lost_io_error() (hopf-quic/src/error.rs:182): clean local shutdown (LocallyClosed) still calls ProtocolHandler::disconnected; every other ConnectionError (transport/types.rs:215 — application/transport CONNECTION_CLOSE, idle timeout, reset, …) reaches ProtocolHandler::error with a typed QuicConnectionCloseError (or a plain TimedOut/ConnectionReset io::Error). Peer STOP_SENDING likewise delivers QuicStreamStoppedError instead of disconnected. Proven by peer_application_close_delivers_connection_close_error and the idle-timeout tests in driver.rs

MASQUE — RFC 9298 / RFC 9484 (hopf-masque)

hopf-masque layers RFC 9298 CONNECT-UDP and RFC 9484 CONNECT-IP on hopf-http's ProtocolUpgradeHandler and Capsule Protocol machinery. CONNECT-UDP is a complete server relay (DNS resolution, outbound UDP socket, bidirectional pump) plus a client; CONNECT-IP is protocol plumbing only — target/ipproto parsing, acceptance, and the RFC 9484 capsule types are implemented, but this crate does no IP packet forwarding, address allocation, or target resolution of its own by design (the application supplies a ConnectIpHandler). Both protocols interoperate end to end against this crate's own client and server (loopback), not against an external MASQUE implementation. See masque.html.

CONNECT-UDP — RFC 9298

RequirementSectionStatusNotes
Target URI template parsing§2Compliantparse_connect_udp_target strictly rejects a truncated/non-hex percent-escape, invalid UTF-8, a non-numeric or out-of-u16-range port, or a missing segment — no best-effort decode, since the result feeds a DNS lookup
Extended CONNECT (H2/H3) and HTTP/1.1 Upgrade acceptance§3Compliantcrate::accept is shared verbatim between CONNECT-UDP and CONNECT-IP; matches the :protocol/Upgrade token case-insensitively and requires the paired Connection: Upgrade token on H1
Context ID framing for the registered UDP payload§5Complianthopf_http::context_id (shared with CONNECT-IP); a nonzero, unrecognized Context ID is silently ignored per the RFC rather than treated as an error
Server relay: resolve, authorize, open outbound socket, pump bidirectionally§3, §7CompliantConnectUdpFactory/ConnectUdpRelay; resolves via the supplied DnsResolver and relays to only the first returned address — no Happy-Eyeballs-style racing and no re-resolution mid-tunnel
Relay idle timeout—CompliantSelf-rearming timer, DEFAULT_IDLE_TIMEOUT = 5 minutes, overridable via ConnectUdpFactory::with_idle_timeout
Target authorization policy—CompliantConnectUdpPolicy::is_target_allowed is checked against the resolved address, not the original hostname; no permissive default exists anywhere in the crate
Client dial + tunnel§3Compliant (feature h3)connect_udp negotiates transport via hopf_http::connect_auto (h3 first when a QuicClientConfig is supplied, else the given HttpFallback); requires the h3 feature even for h1/h2-only use, since the shared dial path pulls in hopf-quic regardless
Non-capsule HTTP Datagram fallback (native QUIC DATAGRAM frames)RFC 9297 §6Not implementedEvery payload — on H1, H2, and H3 alike — goes through Capsule Protocol DATAGRAM-capsule framing unconditionally; a peer lacking Capsule-Protocol: ?1 is rejected with a plain 400 before its target is even parsed

CONNECT-IP — RFC 9484

RequirementSectionStatusNotes
Target/ipproto URI template parsing, including wildcards§3Compliantparse_connect_ip_target — IpTarget::Wildcard/Named(String), IpProto::Wildcard/Number(u8), either literal *
Extended CONNECT / HTTP/1.1 Upgrade acceptance§3CompliantShares crate::accept with CONNECT-UDP
IP packet relaying / forwarding§5Not implemented (by design)This crate does no packet forwarding, address allocation, or target resolution of its own — no TUN device, no userspace router, no kernel network stack dependency anywhere in the workspace. The application supplies a ConnectIpHandler/ConnectIpHandlerFactory
ADDRESS_ASSIGN / ADDRESS_REQUEST capsules§4.2Compliantip_capsule::{encode,decode}_address_entries; round-trips single and multi-entry capsules, IPv4 and IPv6, and rejects a prefix length past the address width or a truncated trailing entry
ROUTE_ADVERTISEMENT capsule§4.3Compliantip_capsule::{encode,decode}_route_entries; RouteEntry::new rejects a mismatched address family or start > end. Server-to-client only, matching the RFC
Client dial + tunnel, incl. address requests / route advertisements§3-4Compliant (feature h3)connect_ip; RequestedAddress.request_id must be nonzero and not reused within a tunnel per §4.2, but this is a documented caller obligation, not enforced by the crate
Target/ipproto authorization policy—CompliantConnectIpPolicy::is_target_allowed; a separate trait from ConnectUdpPolicy since a scope here is never resolved and either half may be wildcarded. No permissive default

Capsule Protocol / HTTP Datagrams — RFC 9297

RequirementSectionStatusNotes
Capsule-Protocol header required on every accept and every request§3.1Complianthopf_http::capsule::capsule_protocol_enabled; a missing header is a 400 on the server side and an error() callback on the client side, before the target is parsed or the tunnel opens
DATAGRAM capsule + Context ID multiplexing§3.2, RFC 9298 §5CompliantShared hopf_http::capsule::Capsule::datagram + hopf_http::context_id codec, identical across H1/H2/H3 and across both CONNECT-UDP and CONNECT-IP
Non-DATAGRAM capsules dispatched to the upgrade handler§3.2CompliantProtocolUpgradeHandler::capsule_received; an unrecognized capsule type is ignored by default
Native QUIC DATAGRAM frame fast path bypassing Capsule Protocol on H3§6Not implementedEvery payload takes the capsule-framed path even over H3 — see the matching CONNECT-UDP row above

WebDAV — RFC 4918 (Class 1+2)

HTTP Methods

RequirementSectionStatusNotes
GET/HEAD/OPTIONS/PUTRFC 9110Complianthandler.rs:245-290,307-420
DELETE (files)RFC 9110 §9.3.5Complianthandler.rs:422-454
DELETE (collections)RFC 4918 §9.6.1CompliantRecursive member deletion continues after failures and returns 207 Multi-Status naming failed hrefs (424/403); full success remains 204
If-Modified-Since / 304RFC 9110 §13.1.3Complianthopf_http::parse_http_date accepts IMF-fixdate plus obsolete RFC 850 / asctime forms; GET/HEAD return 304 when Last-Modified ≤ If-Modified-Since
ETag generation, Content-Type detectionRFC 9110 §8.8.3,§8.3CompliantWeak MD5-based ETag (handler.rs:810-825)

WebDAV Methods

RequirementSectionStatusNotes
PROPFIND (allprop/propname/prop)§9.1CompliantDepth 0/1/infinity; infinity walks capped by max_tree_entries (default 10 000 → 507). PUT default max_put_body 16 MiB (aligned with HTTP)
PROPPATCH§9.2Compliant207 propstat lists each set/removed property by name
MKCOL§9.3Compliantcreate_dir only (409 when a parent is missing); non-empty request body → 415
COPY§9.8CompliantDepth: 0 copies a collection without members; infinity (default) remains recursive, capped by WebDavConfig::max_tree_entries (507 when exceeded)
MOVE§9.9Compliantfs::rename is inherently whole-subtree, matching required Depth-infinity semantics
LOCK (new + refresh), UNLOCK§9.10-9.11Complianthandler.rs:538-596, lock.rs:183-227
Locked empty resources (LOCK unmapped URL)RFC 4918 §7.3CompliantLOCK on an unmapped URL creates a normal empty non-collection file and returns 201 Created with Lock-Token; LOCK on an already-mapped URL stays 200 OK. The empty resource appears in PROPFIND, accepts PUT (with the lock token), and MUST NOT convert via MKCOL. UNLOCK releases the lock but leaves the file (recommended model). Deprecated RFC 2518 lock-null removal-on-UNLOCK is not implemented. Proven in integration.rs (lock_unmapped_url_creates_locked_empty_resource, lock_then_put_fills_empty_resource_and_relock_is_200, mkcol_on_locked_empty_resource_fails)
207 Multi-Status framework§13CompliantStreaming writer/parser, split-feed tested

Headers, Locking, Live Properties

RequirementSectionStatusNotes
DAV/Depth/Destination/If/Lock-Token/Overwrite/Timeout headers§10CompliantFull grammar including weak-ETag comparison (if_header.rs); COPY honours Depth 0/infinity
Exclusive/shared write lock, conflict detection, Depth-infinity coverage, expiry cleanup§6Compliantlock.rs — in-process by default; WebDavConfig::lock_root (issue #415) switches to file-backed records under lock_store.rs's FileLockStore, keyed by each resource's path relative to the content root and shared by every handler pointed at the same lock_root, for a filesystem with atomic create-new and immediately-visible writes (not NFS)
creationdate / getcontentlanguage / source live properties§15.1,§15.4,§15.10Compliantcreationdate from birth time (mtime fallback) as ISO-8601; getcontentlanguage from optional WebDavConfig::content_language; empty source
displayname/getcontentlength/getcontenttype/getetag/getlastmodified/lockdiscovery/resourcetype/supportedlock§15.2-15.11Complianthandler.rs:1018-1073
Dead properties (xattr/sidecar/none)§4Compliantdead_props.rs:71-333; sidecar files sit next to their resource by default, or under WebDavConfig::sidecar_root (issue #415) mirroring the content tree one-to-one instead, so nothing is written into the content tree and no .webdav_* name in it is mistaken for a sidecar — MOVE carries the sidecar key across, DELETE prunes emptied mirrored directories
Class 3 ordered collections, Auth/ACL—Deferred / N/AExplicit scope: Class 1+2 only, compose with HTTP auth

WebSocket — RFC 6455 / RFC 8441 / RFC 9220

RequirementSectionStatusNotes
Upgrade/Connection/Key/Version checks, Accept calculation, 101§4.2Complianthandshake.rs, RFC golden vector tested
Origin check (browser CSRF)§10.2Compliant (secure default)OriginPolicy on WebSocketConfig: default empty allowlist rejects any present Origin; missing Origin (non-browser) allowed; opt in with with_allowed_origins / allow_any_origin
Subprotocol negotiation§4.2.2Compliantnegotiate_subprotocol: echoes configured protocol only when present in the client's offer list
permessage-deflate / extensions§9.1Not implementedMatches docs Limitations (RSV must be zero)
Client: key generation§4.1Complianthandshake.rs
Client: 101 response validation§4.1 step 5Compliantvalidate_upgrade_response / WebSocketOpening — status 101, Upgrade/Connection tokens, Accept recomputed vs sent key, subprotocol echo rules; call before entering WsUpgradeHandler::client
FIN/RSV/opcode handling, 7/16/64-bit length§5.2Compliantframe.rs
Masking direction (server rejects unmasked in, never masks out; client reverse)§5.1/§5.3Compliantframe.rs, session.rs
Protocol-error handling forces a real close§7.1.7CompliantWrites Close (1002/1007/1009), sets dead, and wants_close() ends the H1 connection or H2/H3 stream
Text (UTF-8 validated) / binary frames§5.6Compliantupgrade.rs
Fragmentation reassembly§5.4CompliantBuffers non-FIN data + continuation up to max_payload; delivers on FIN
Standalone continuation frame (no fragment in progress)§5.4CompliantProtocol error → Close 1002
Max message size enforcement§7.4.1CompliantDefault 16 MiB; exceeding emits Close 1009
Control frame max 125 bytes + FIN; ping auto-pong; pong dispatch§5.5Compliantframe.rs, default trait method
Close frame parse/reply; abnormal closure synthesizes 1006§7.1Compliantupgrade.rs; empty peer Close is echoed with empty payload (1005 is local-only)
Close code validation (reject 1004/1005/1006/1015/out-of-range)§7.4Compliantis_valid_close_code; invalid → Close 1002
HTTP/2 and HTTP/3 Extended CONNECT detection + 200 responseRFC 8441 §4 / RFC 9220Complianthandshake.rs
WsEventHandlerFactory::create/opened take a ConnHandle—Compliant (recently fixed)factory.rs, upgrade.rs
framed_ws_conn_handle helper for async cross-connection delivery—Compliant (recently fixed)session.rs — closes a real correctness gap: a plain ConnHandle::send bypasses WS framing entirely; see MQTT's WS bridge for the motivating case

gRPC — over HTTP/2 (unary only)

No Gumdrop precedent (Gumdrop doesn't implement gRPC); checklist derived from the gRPC-over-HTTP2 wire protocol.

RequirementStatusNotes
5-byte length-prefixed framing, split-feed parsingCompliantframing.rs:104-282, tested at arbitrary split points
Compressed frames (flag ≠ 0) rejectedCompliant (matches declared scope)framing.rs:225-231
Max message size enforcement (4 MiB default, configurable)CompliantDEFAULT_MAX_MESSAGE_SIZE; set_max_message_size(0) clamps to the default (not unlimited); pass u64::MAX only for an explicit no-cap
application/grpc / application/grpc+proto / application/grpc+json content-typeCompliantparse_grpc_content_type — exact media type (optional ; params); case-insensitive; JSON via rjsonparser + proto3 JSON mapping
Unsupported subtypes (e.g. +thrift, grpc-web*) rejectedCompliantHTTP 415 — only +proto (default) and +json are decoded
POST /{service}/{method} routingCompliantserver.rs:330-333, proto_file.rs:42-54
grpc-status/grpc-message trailers (success) and Trailers-Only (error)Compliantserver.rs:250-271
Full gRPC status-code space representableCompliantAny caller-supplied code accepted; only UNIMPLEMENTED/INTERNAL auto-generated, no named enum for the other 15
One response message per unary call enforcedCompliantserver.rs:71-142
Client/server/bidirectional streamingDeferred (documented)Only start_unary_call exists on GrpcService
Runtime .proto parsing via rprotobuf, no codegenN/A — architectural choiceFunctionally equivalent to codegen-based gRPC, resolved per-request instead
H1 trailer supportDeferred (cites hopf-http's own gap)Inherits the H1-trailers-discarded limitation from HTTP/1.1 above; prefer H2/H3

DNS — RFC 1035 (stub resolver, caching forwarder, authoritative zone server)

hopf-dns is a stub resolver plus a DNS server shell whose stock handlers are a caching forwarder and an authoritative zone server (zone files, RFC 2136 UPDATE, RFC 1996 NOTIFY, AXFR/IXFR, TSIG, secondaries). It does not sign zones (DNSSEC signing is out of scope; an externally signed zone is served as data). The authoritative rows are in their own table below.

Message Format and Name Encoding (RFC 1035 §3-4)

RequirementSectionStatusNotes
Standard RR types (A/AAAA/CNAME/NS/PTR/MX/TXT/SOA + SRV/OPT/DNSSEC types)§3.2-3.4Compliantwire/type.rs:6-41, unknown types preserved raw (RFC 3597)
SOA / SRV RDATA decode accessors§3.3.13, RFC 2782CompliantNew DnsResourceRecord::soa() constructor + as_soa() -> Option<SoaData> (a named struct, not a tuple — SOA has seven fields, several same-typed, too easy to transpose positionally) and as_srv() -> Option<(u16, u16, u16, String)> mirroring the existing as_mx() shape. cache.rs's own ad-hoc SOA-MINIMUM byte-walking (used for negative-cache TTL derivation) was refactored to just call as_soa() instead of duplicating the RDATA layout
Label (63) / total name (255 octet) limits§2.3.4CompliantEnforced on encode; on decode, a single cumulative length accumulator is now threaded through the entire chain of compression-pointer jumps (wire/name.rs's decode_name_inner, previously reset to zero on each pointer jump, checking only one segment at a time). Proven with a real test chaining 4 pointer-linked ~64-octet segments (256 octets total, over the limit, but each segment alone well under 255 and only 3 jumps deep — comfortably inside the separate MAX_JUMPS=10 guard that used to be the only thing bounding this) and confirming it's now rejected, while a 3-segment (192-octet) chain still decodes (cumulative_length_across_pointer_jumps_is_enforced, cumulative_length_under_the_limit_still_decodes)
Case-insensitive name comparison§2.3.3Compliantwire/name.rs:14-20
Header/flags/OPCODE/RCODE, Z-bit masking§4.1.1Compliantwire/message.rs:16-131
Response echoes the query's OPCODE§4.1.1CompliantDnsMessage::response_template copies the OPCODE and RD bit; it previously built the flags from QR/RD/RCODE alone, so a reply to any non-QUERY opcode (including the default NOTIMP) claimed opcode 0
Non-QUERY opcodes (NOTIFY, UPDATE, others) are offered to the handler before being refusedRFC 1996, RFC 2136CompliantDnsQueryHandler::handle_non_query_opcode; with no handler, or one that Declines, the reply is NOTIMP. The authoritative handler implements NOTIFY and UPDATE (see Authoritative serving)
AD/CD flags§4.1.1, RFC 4035 §3.2CompliantSee the DNSSEC table below for details
Unknown QTYPE/QCLASS in the question section§4.1.2, RFC 3597CompliantDnsQuestion now mirrors DnsResourceRecord's raw-preservation shape: qtype/qclass are Option<DnsType>/Option<DnsClass> (None for an unrecognized value) alongside always-present raw_qtype/raw_qclass fields; parse_question builds via the new DnsQuestion::opaque() instead of rejecting the message (wire/question.rs, wire/message.rs). The RFC 5452 response/question verification added for issue #4 was updated to compare the raw values, since two different unrecognized types would otherwise both parse to None and wrongly compare equal
Name compression, pointer-loop guard§4.1.4Compliantwire/name.rs:52-134
Names inside RDATA of NS/CNAME/PTR/SOA/MX (and MD, MF, MB, MG, MR, MINFO, RP, AFSDB, RT) expanded on parseRFC 3597 §4Compliantwire/message.rs expand_rdata_names. Previously RDATA was stored exactly as received, so a compression pointer inside it pointed into a message that no longer existed once the record was cached, copied into a zone or re-serialised (proven by compressed_names_in_rdata_are_expanded_on_parse)
UDP transport, TCP 2-byte length prefix§4.2CompliantClient and DoT/DoQ server framing
TCP fallback on truncation — client§4.2.1Compliantclient/mod.rs:523-545
TCP fallback on truncation — forwarder§4.2.1CompliantThe forwarder's own upstream query already goes through DnsResolver::query(), whose TC-triggered TCP retry (client-side path above) was already unconditionally enabled (tcp_fallback has no public setter, only ever true) — this was working correctly but untested through the actual forwarder code path; now proven with a real test driving DnsService::process_query_sync against a stub upstream that answers truncated over UDP and complete over TCP on the same address (tests/resolver_stub.rs, forwarder_retries_truncated_upstream_answer_over_tcp)
Forwarder truncates/sets TC on oversized UDP responses§4.2.1CompliantDnsService::process_wire (server/mod.rs, called by the UDP listener) now compares the serialised response against the querying client's advertised UDP payload size (new DnsMessage::requested_udp_payload_size(): the inbound OPT record's CLASS field per RFC 6891 §6.2.3, or the legacy 512-octet limit with no EDNS at all) and, if oversized, clears all record sections and sets TC instead of sending an oversized datagram. Proven with a real UDP client sending both a plain (no-EDNS) and an EDNS(4096) query against a 40-answer response, confirming the first truncates to empty+TC and the second doesn't (forwarder_truncates_oversized_udp_response_for_the_clients_advertised_size)

Authoritative serving (RFC 1034/1035 §6, RFC 1996, 2136, 1995, 5936, 8945)

RequirementSectionStatusNotes
Master file format: $ORIGIN, $TTL, $INCLUDE, @, blank owner, ; comments, parenthesised groups, quoted strings and escapesRFC 1035 §5Compliantserver/zone/{lexer,parser,loader,rdata}.rs. Push parser: proven identical for every chunk split and one byte at a time. $GENERATE and BIND TTL units are BIND extensions. Structured types A, AAAA, NS, CNAME, PTR, MX, TXT/SPF, SOA, SRV, HINFO; every other type via the generic form. Owner names with backslash escapes are rejected
Unknown / unstructured RR typesRFC 3597 §5Compliant\# length hex read and written for any type
Zone lookup algorithm: exact match, CNAME, referral at a cut, wildcard, NXDOMAINRFC 1034 §4.3.2CompliantZone::lookup; AA set on answers, clear on referrals; in-zone CNAME chains followed (loop is SERVFAIL)
Wildcards at the closest encloser, owner rewritten to the query nameRFC 4592 §3.3CompliantNot applied to a name that exists (including an empty non-terminal)
Empty non-terminals are NODATA, not NXDOMAINRFC 8020 §2CompliantDescendant counts kept per name
Negative answers carry the SOA, TTL = min(SOA TTL, MINIMUM)RFC 2308 §2-3CompliantZone::negative_soa
Minimal ANY responseRFC 8482CompliantSingle HINFO "RFC8482" ""; switchable
Glue in the additional section for in-zone NS and MX targetsRFC 1034 §4.3.2, RFC 1035 §3.3.9CompliantIncludes glue below a zone cut
Exactly one SOA at the apex; owners within the zone; CNAME alone; RRset TTLs equal; duplicate RRs collapsedRFC 1034 §3.6.2, RFC 2181 §5CompliantEnforced at load, transfer and update
EDNS: OPT echoed with DO; version above 0 is BADVERSRFC 6891 §6.1.1, §6.1.3CompliantDNSSEC signing and NSEC/NSEC3 generation are not provided
NOTIFY: sent to configured peers, hosts (every resolved address) and in-zone NS glue except the primary; accepted only from a secondary's primaryRFC 1996CompliantZoneOptions::also_notify / also_notify_host; three UDP attempts per peer. NOTAUTH for an unserved zone, REFUSED from anyone else
Dynamic update: prerequisite checks (all five kinds, value-dependent by whole RRset), prescan, atomic apply, one serial bumpRFC 2136 §3Compliantserver/zone/update.rs; verified against BIND nsupdate. Apex SOA/NS protected; CNAME conflicts ignored; newer SOA accepted. Secondaries answer REFUSED rather than forward (§6 forwarding not implemented)
Update authorisationRFC 2136 §3.3CompliantDefault deny; Acl by source network and/or TSIG key. SIG(0) is not implemented
AXFR: SOA first and last, question in the first message, messages within a TCP frame, TC over UDPRFC 5936 §2, §4CompliantDefault deny (allow_transfer); verified against BIND dig
IXFR: current / differences / AXFR-style fallback; RFC 1982 serial arithmeticRFC 1995, RFC 1982CompliantIn-memory journal of 512 changes, empty after a restart
Secondary: refresh at start, on NOTIFY, on REFRESH; RETRY after failure; EXPIRE stops answering; SOA serial compared firstRFC 1034 §4.3.5, RFC 1035 §3.3.13Compliantserver/zone/maintain.rs, proven over real sockets without any NOTIFY (tests/authoritative.rs). Expired or never-loaded zones answer SERVFAIL
TSIG: HMAC-SHA256/384/512, request/response chaining, multi-message transfers, BADKEY/BADSIG/BADTIME, 300 s fudgeRFC 8945Complianttsig.rs; verified in both directions against BIND dig -y and nsupdate -y. HMAC-MD5 and HMAC-SHA1 are deliberately not offered. Not applied over DoQ
DNSSEC signing, NSEC/NSEC3 generation, automated key rolloverRFC 4034, 5155N/AOut of scope; a pre-signed zone loads and serves as data
Zone write-back and secondary persistence—CompliantAtomic temp-file-and-rename to the zone's own file; ZoneFileMode::ReadOnly never writes

Resolver Behaviour and Cache-Poisoning Resilience

RequirementSectionStatusNotes
RD set, cache used (TTL-based), CNAME chase (depth-limited)§7.1,§7.4Compliantclient/mod.rs:292-365, cache.rs:101-180, MAX_CNAME_DEPTH=8
Retry across multiple configured servers§7.2CompliantA new shared retry_or_fail() (client/mod.rs) fires on the query's timeout, advances PendingQuery.server_idx, and resends to the next configured server (re-arming its own timeout) before finally failing once every server has been tried — replacing the old timeout closure that just failed immediately. The same helper now also arms the CNAME-chase re-query's timeout, which previously had none at all (self-documented as skipped in the old code). Proven with a real test where servers[0] is a socket that never answers and servers[1] does (tests/resolver_stub.rs, retries_against_second_configured_server_when_first_is_dead)
Query ID unpredictabilityRFC 5452 §2.1/§9.1CompliantDnsQueryIdGenerator draws every id from the OS CSPRNG via getrandom (wire/query_id.rs), used identically by both the resolver and the forwarder (the forwarder's own now-removed ids field was dead code — its upstream queries always went through the resolver's own id allocation). A new alloc_id() also avoids handing out an id already in flight, which a random draw (unlike the old monotonic counter) can otherwise collide with.
Response verified against source address of the queryRFC 5452 §3CompliantPendingQuery now records the exact server: SocketAddr the query was sent to; ResolverUdpHandler::on_datagram checks the inbound datagram's source against it before removing the pending entry, so a mismatched packet doesn't discard a query that's still legitimately outstanding. Proven with a real two-socket test where a distinct "attacker" address sends a correctly-IDed forged reply before the real server answers (tests/resolver_stub.rs, spoofed_source_address_is_rejected_but_real_reply_still_accepted).
Response verified against query question sectionRFC 5452 §3CompliantThe same check compares the response's question (case-insensitively on the name) against the outstanding query's via a new questions_match() helper (client/mod.rs). Proven with a real test where the right server sends a correctly-IDed reply for the wrong question before the real answer (tests/resolver_stub.rs, mismatched_question_is_rejected_but_real_reply_still_accepted). Cookie data from a response is also now only trusted after both checks pass, closing a related gap where an unverified packet could otherwise poison the per-server cookie cache.
Bailiwick / off-bailiwick glue filtering—Compliantbailiwick.rs, applied to every accepted response before caching — now one layer among several, alongside the id/address/question checks above
DNS Cookies (client-side)RFC 7873 §4-5.1Compliantcookie.rs, client cookie + server secret from getrandom (fail closed; no time-based fallback)
Server cookie generationRFC 7873 §5.2CompliantDnsCookie::generate_server_cookie derives via HMAC-SHA256 (keyed by the server secret, over the client cookie + client IP, truncated to 8 octets); validate_server_cookie uses constant-time compare
Server-side cookie validation on inbound queriesRFC 7873 §5.2CompliantDnsService::process_query_sync parses an inbound query's COOKIE option (cookie::parse_client_cookie), validates a presented server cookie against a freshly recomputed one for the query's source address, and always attaches a response COOKIE option (client cookie + valid server cookie) — proven with real client/server-address round trips (server/mod.rs tests), including a forged-cookie-from-another-address case that must not validate. Note: per RFC 7873 §5.2's explicit leniency, an invalid/missing server cookie doesn't change the response (no BADCOOKIE) — enforcing that would need the EDNS extended-RCODE support tracked separately (see EDNS0 table below)

EDNS0, DoT, DoQ, DoH

RequirementSectionStatusNotes
OPT pseudo-RR, UDP payload size, DO bit, server strips DNSSEC RRs when DO absentRFC 6891 §6.1-6.2Compliantwire/rr.rs:158-178, server/mod.rs:143-151
Extended RCODE / EDNS VERSION field§6.1.3CompliantDnsResourceRecord::edns_extended_rcode/edns_version/edns_full_rcode (read) and with_edns_rcode_version (write), plus a new RCODE_BADVERS constant — wire/rr.rs. Not wired into any codepath that actively enforces/rejects an unsupported EDNS version (no caller currently sets a nonzero VERSION to reject in the first place)
DoT: TLS-wrapped listener, 2-byte length, client queryRFC 7858 §3Compliantserver/dot.rs, client/tcp.rs:56-79
DoT: ALPN "dot"—Needs verificationNo ALPN constant/negotiation in hopf-dns itself; delegated to the caller-supplied TLS acceptor/connector
DoT/TCP connection reuseRFC 7766 §6.2.1CompliantTcpDnsConnectionPool (client/tcp.rs) now keeps a live connection (plus, for DoT, its established TLS session) per destination server across queries, reusing it on the next call and only reconnecting (re-handshaking for DoT) when a reused connection turns out to be stale. Proven with real keep-alive TCP and TLS stub servers that count actual accepted connections — two queries to the same server produce exactly one connection in both cases (tests/resolver_stub.rs, tcp_connection_pool_reuses_a_connection_across_queries, dot_connection_pool_reuses_a_connection_across_queries) — plus a stale-connection-recovery test where the server closes after the first query and the second must still succeed by silently reconnecting
DoQ: one stream per query, 2-byte length prefixRFC 9250 §4.2Compliantclient/doq.rs, server/doq.rs
DoQ: message ID MUST be 0RFC 9250 §4.2.1CompliantBoth DoqClientTransport::send_query and DoqServerHandler::receive now zero the DNS message ID before sending, independently in each direction rather than trusting the peer to have complied
DoQ: error codes / RESET_STREAMRFC 9250 §4.3PartialDOQ_NO_ERROR/DOQ_INTERNAL_ERROR/DOQ_PROTOCOL_ERROR/DOQ_REQUEST_CANCELLED/DOQ_EXCESSIVE_LOAD constants added (client/doq.rs); the server now aborts the stream with DOQ_PROTOCOL_ERROR on a malformed query instead of silently dropping it, but the other codes aren't yet wired into any codepath
DoQ: 0-RTT / connection reuse across queriesRFC 9250 §4.5,§5.5.1Compliant (live-connection reuse)DoqConnectionPool (client/doq.rs) keeps one live QUIC connection per destination and opens a new client-initiated bidirectional stream per query via QuicDriverHandle::open_bi; a stale pooled connection is dropped and redialled. Proven with a real DoQ stub that counts QUIC handshakes — two queries produce exactly one connection (tests/resolver_stub.rs, doq_connection_pool_reuses_a_connection_across_queries). TLS early data is off by default in hopf-quic; ticket-based 0-RTT resume on a fresh dial after the pooled connection is gone is not wired (no session-ticket cache)
EDNS paddingRFC 9250 §5.4, RFC 7830Partialwire/rr.rs::encode_edns_padding/edns_padding_length is a real, tested codec, but nothing automatically applies it to DoQ/DoT/DoH queries — a caller has to compose it into their own OPT options
DoH: POST, correct content-type/accept, HTTPS transportRFC 8484 §4.1,§5.1Compliantclient/doh.rs
DoH: GET method supportRFC 8484 §4.1 (SHOULD support both)CompliantDohClientTransport::with_get(true) sends the DNS wire message base64url-encoded (unpadded, RFC 4648 §5) into the dns query parameter with no body, per RFC 8484 §4.1. Proven against a real TLS+HTTP stub server that decodes the parameter itself and checks it byte-for-byte matches what was sent, alongside the existing POST path against the same stub (tests/resolver_stub.rs, doh_get_and_post_round_trip_over_a_real_tls_http_stub)
DoH server—N/AExplicitly out of scope

Negative Caching and DNSSEC

RequirementSectionStatusNotes
NXDOMAIN negative caching, SOA-MINIMUM-derived TTL, expiryRFC 2308 §3,§5Compliantcache.rs:87-219
Serve-Stale: answer from expired data when a refresh failsRFC 8767CompliantForwarderHandler + DnsCache::lookup_stale. Expired positive answers are retained for a bounded window (default 1 day, DnsCache::with_max_stale) and served with a 30 s TTL (§4) when the upstream times out, errors, or returns any RCODE but NOERROR/NXDOMAIN (§4). Client response timer 1.8 s and failure recheck 30 s (§5); the upstream query continues so a late answer refreshes the cache. Policy hook StalePolicy (per-question window and TTL, or off); DnsServerMetrics::stale_served. Not done: the Extended DNS Error "Stale Answer" (RFC 8914) option, serving stale negative answers, and stale nameserver addresses (the forwarder does no iterative resolution)
NXDOMAIN cut: nothing exists beneath a non-existent nameRFC 8020CompliantDnsCache::has_nxdomain_ancestor, applied by ForwarderHandler before forwarding; label-boundary match on proper ancestors only; expired entries and NODATA do not count. Policy hook NxdomainCutPolicy. The synthesised NXDOMAIN carries no SOA, like the existing exact-name negative cache hit
Minimal ANY responses (forwarder)RFC 8482CompliantForwarderHandler: an existing name's ANY answer becomes one HINFO "RFC8482" "" (§4.2), cached; NXDOMAIN/NODATA unchanged; DO=1 gets the full answer because a synthesised record cannot be signed. Policy hook MinimalAnyPolicy, shared with the authoritative handler. The upstream still assembles the full RRset
NODATA (empty-answer NOERROR) negative cachingRFC 2308 §2CompliantDnsCache::put_response (cache.rs) now covers NOERROR-with-empty-answers using the same SOA-MINIMUM-derived TTL logic as NXDOMAIN, via a new CacheKey::nodata()/is_nodata_cached()/put_nodata() path kept deliberately distinct from NXDOMAIN's: NODATA is scoped per (name, qtype, qclass) rather than name-only, since NODATA for one qtype says nothing about another at the same name. Wired into both the resolver's query() and the forwarder's process_query_sync() cache checks, synthesizing a plain NOERROR/empty-answers response rather than NXDOMAIN. Proven with a real test where a stub upstream is only queried once across two identical lookups (tests/resolver_stub.rs, nodata_response_is_negatively_cached_so_a_repeat_query_skips_upstream)
Aggressive use of DNSSEC-validated cache (NSEC/NSEC3)RFC 8198Partialdnssec/aggressive.rs (DenialCache, on DnsCache::denials()), applied by ForwarderHandler with feature dnssec and a validating upstream resolver. A proof is cached only after validate_denial_of_existence returns Secure (never from an unvalidated response), filed under its signing zone, with lifetime = min(record TTL, SOA minimum, signature validity, 3 h) (§5.4). NXDOMAIN and NODATA from NSEC (§5.1) and NSEC3 (§5.2; RFC 5155 §8 closest-encloser proof, Opt-Out refused, iterations capped at 100 per RFC 9276); NXDOMAIN requires the wildcard at the closest encloser to be denied too; nothing synthesised at or below a delegation/DNAME. Policy hook AggressiveNsecPolicy; DnsServerMetrics::aggressive_nsec_hits. Gaps: wildcard-positive synthesis (§5.3, a SHOULD); one NSEC3 parameter set per zone at a time; the chain-of-trust walk itself does not yet authenticate a missing DS (see the DNSSEC rows)
Trust anchor management, validation statesRFC 4033 §5Compliantdnssec/trust_anchor.rs, IANA root KSK-2017/2024 preloaded, key-tag-verified by test
Off by default, feature-gated—Compliant (deferred by design)dnssec Cargo feature; resolver validation disabled unless explicitly enabled
DNSKEY/RRSIG/DS record types, key-tag computation, canonical RRset form, real signature verification (RSA/ECDSA/Ed25519 via aws-lc-rs)RFC 4034 §2-6Compliantdnssec/validator.rs:128-261, dnssec/crypto.rs — this validates actual cryptographic signatures, not just record parsing
Ed448 signature verificationRFC 8080Compliantdnssec/crypto.rs's verify_ed448 uses the pure-Rust ed448-goldilocks-plus crate (aws-lc-rs has no Ed448 support), verified against its own RFC 8032 test-vector suite
NSEC/NSEC3 record RDATA parsing, denial-of-existence verificationRFC 4034 §4, RFC 5155CompliantNSEC/NSEC3 RDATA accessors and constructors (wire/rr.rs), a type-bitmap codec (wire/bitmap.rs), a base32hex codec (wire/base32hex.rs), and RFC 5155 §5 iterated NSEC3 hashing (SHA-1, dnssec/crypto.rs::nsec3_hash — verified against RFC 5155 Appendix A's own worked example) back a real NSEC "is qname between owner and next" proof and an NSEC3 closest-encloser proof (dnssec/denial.rs::verify_denial), driven over the network via DnsResolver::validate_denial_of_existence. Limitation: wildcard non-existence proof (RFC 5155 §8.3's optional extra step) and Opt-Out (§3/§6) are not implemented
AD flag set on validated responses, CD flag honouredRFC 4035 §3.2CompliantDnsResolver's automatic per-query validation sets AD when a message validates Secure and, via query_with_cd/PendingQuery.cd, lets a CD=1 caller's query through even when validation comes back Bogus (with AD left unset); the forwarder relays a downstream client's own CD bit upstream. Limitation: a cache hit (resolver- or forwarder-side) doesn't currently re-assert AD
Full chain-of-trust walk (DS→DNSKEY across zone cuts to the root)RFC 4035 §5.3.1CompliantDnssecChainWalk (dnssec/validator.rs) is a real, tested, I/O-agnostic state machine descending from a configured trust anchor toward a name's own zone, resolving and verifying DS/DNSKEY at each cut (skipping a cut with no DS rather than failing, since that's also the shape of an attacker stripping it — full protection needs the NSEC/NSEC3 denial proof above); driven over the network by DnsResolver::validate_chain_of_trust/validate_denial_of_existence via the resolver's own query/cache/retry machinery

mDNS / DNS-SD — RFC 6762 / RFC 6763 (hopf-mdns)

hopf-mdns is a standalone crate providing both an RFC 6762 multicast DNS responder/querier and RFC 6763 DNS-SD service advertisement/browsing, reusing hopf-dns's wire types directly (no parallel codec) via thin QU-bit/cache-flush-bit helpers in its own bits module. See mdns.html.

Responder: Probing and Announcing (RFC 6762 §8)

RequirementSectionStatusNotes
Probing before claiming a name, proposed record in Authority section§8.1Compliantresponder.rs's begin_probing/send_next_probe; random initial delay, probe_count probes (default 3) spaced probe_interval apart
Conflict during probing: rename and restart§8.1CompliantAn incoming response asserting the candidate name restarts probing under a renamed candidate (label-2.local, ...)
Simultaneous-probe tie-break§8.2Compliant (simplified)Compares one representative record (the host's first A record) unsigned byte-wise rather than the full lexicographic RRset ordering the RFC technically specifies; loser waits probe_conflict_wait and retries the same name
Announcing: unsolicited multicast responses, cache-flush bit set§8.3Compliantannounce_count (default 2) announcements, announce_interval apart, carrying every published record
Known-answer suppression§7.1CompliantA record already listed in the query's own Answer section with more than half its TTL remaining is not repeated
Unicast response (QU bit)§5.4Compliantbits::unicast_response_requested; a query with any QU-flagged question gets a unicast, not multicast, reply
Goodbye (TTL-0 departure announcement)§10.1CompliantSent on MdnsService::goodbye(), on Drop (best-effort, if still announced), and per-service on ServiceHandle::drop

Querier and Cache (RFC 6762 §5, §10)

RequirementSectionStatusNotes
Active TTL-fraction cache refresh (80/85/90/95%)§5.2Compliantcache::REFRESH_FRACTIONS; generation-guarded against stale timers after a re-upsert
Cache-flush bit honoured on ingest§10.2CompliantRecords for a name/type not reasserted in a cache-flush-bearing answer group are scheduled for removal after a 1 s grace period (tolerates the flush answer arriving split across packets), not deleted immediately
Goodbye (TTL-0) removes cached record§10.1CompliantSame grace-period removal path as cache-flush
One-shot query resolves from cache after a fixed timeout—Compliant (simplified)MdnsService::query waits query_timeout (default 750 ms) rather than resolving on the first matching answer, by design
IPv6 (ff02::fb)§3N/AOut of scope; IPv4 only

DNS-SD (RFC 6763)

RequirementSectionStatusNotes
Service instance enumeration: PTR + SRV + TXT§4Compliantdnssd::build_records; PTR shared (not cache-flush, §10.1), SRV and TXT unique (cache-flush)
_services._dns-sd._udp meta-query PTR§9CompliantPublished alongside every registered service type
TXT record attribute encoding§6.1Compliantkey=value character-strings, capped at 255 bytes per entry (over-length attributes dropped, not truncated); a service with no attributes still emits the required zero-length string
Browse (discovery side)§4CompliantMdnsService::browse; fixed 10 s re-poll layered on the cache's own TTL-fraction refresh, resolves SRV/TXT from cache, emits Found/Lost events
Instance-name escaping§4.3Not implementedSpecial characters (., \) in an instance name are not escaped
SRV priority/weight§4.1.2N/AAlways published as 0/0; not configurable per registration
Central service registry (pull-based advertisement)—N/A — architectural choiceEvery service must be registered explicitly via register_service; there is no workspace-wide listener registry to draw from, since every Hopf protocol is an independent crate

LDAP — RFC 4511 (client only, hopf-ldap)

hopf-ldap is an async LDAPv3 client and LdapCredentialStore; no directory/server side. See ldap.html.

CapabilitySectionStatusNotes
BER codec (definite length)ITU-T X.690 (LDAP subset)Complianthopf_ldap::asn1; indefinite length rejected — LDAP-oriented BER, not full DER/CER
Bind (simple, anonymous), Search, UnbindRFC 4511 §4.2-4.5CompliantLdapSession::bind/bind_anonymous/search/unbind; RFC 4515 filter strings compiled to BER
LDAPS (implicit TLS)RFC 4513 §3CompliantLdapClientConfig::with_tls; session withheld until security_established
StartTLSRFC 4511 §4.14CompliantLdapSession::start_tls after an ExtendedResponse for OID 1.3.6.1.4.1.1466.20037; mutually exclusive with LDAPS on one dial
Referral chaseRFC 4511 §4.1.10, RFC 4516Compliant (opt-in)with_chase_referrals(true), hop cap max_referral_hops (default 5); empty-DN referral URLs keep the referring base DN; same bind credentials reused across hops rather than rewritten per URL
Content synchronization, client sideRFC 4533CompliantLdapSession::sync + SyncReplica: RefreshOnly and RefreshAndPersist modes, cookie resume from any source, present/delete phase convergence keyed by entryUUID, e-syncRefreshRequired handling, Abandon-based cancellation (§3.7). Server side of RFC 4533 not implemented
LDAP controls, IntermediateResponse, AbandonRFC 4511 §4.1.11-4.1.12CompliantMessage layer carries controls both ways (Control, encode_search_request_with_controls), built to layer further controls beyond Sync
LdapCredentialStore (search-then-bind)—CompliantPLAIN/LOGIN mechanisms only — digest/SCRAM/CRAM/plaintext/bearer unsupported; password_match blocks on a Condvar and must run off the reactor thread
Directory admin API (modify/add/delete/compare)RFC 4511 §4.6-4.10Not implementedDeferred — non-goal for a CredentialStore-focused client
SASL bind to the directoryRFC 4513 §5Not implementedStore uses simple bind only
Role / memberOf lookup API—Not implementedNo consumer yet; quota uses a separate lookup callback if needed

SOCKS — RFC 1928 (SOCKS5), historical SOCKS4/4a (in-tree, hopf-socks)

Server — Versions, Commands, Addressing

RequirementSectionStatusNotes
SOCKS4 CONNECT/BINDHistorical protocol descriptionCompliantwire.rs parse/encode; no IPv6 concept, USERID field carried but not validated
SOCKS4a hostname extension (magic IP 0.0.0.x, x≠0)Historical protocol descriptionCompliantResolved server-side via hopf-dns; connect.rs
SOCKS5 method negotiation, CONNECT/BIND/UDP ASSOCIATE§3–4Complianthandler.rs dispatch; IPv4/IPv6/domain-name addressing (§5)
UDP ASSOCIATE datagram relay§7Compliant (no fragment reassembly)Only standalone (FRAG=0x00) datagrams forwarded; non-standalone fragments silently dropped — matches near-universal real-world server practice, documented as a scope decision
Per-resolved-address destination policy check on hostname targets—CompliantEvery resolved address is checked, not just the first — a multi-answer DNS response can't bypass the policy by ordering

Server — Authentication and Policy

RequirementSectionStatusNotes
Username/password authenticationRFC 1929 §2CompliantSocksAuthenticator trait (auth.rs); configuring one withdraws no-auth from SOCKS5's offered methods and rejects SOCKS4/4a outright (no credential field to check)
GSSAPI authenticationRFC 1961Not implementedNo GSSAPI method byte, sub-negotiation, or per-message protection anywhere in wire.rs/handler.rs/client handlers; planned with optional GSSAPI/Kerberos SASL in hopf-auth
Destination authorization (no permissive default)—CompliantSocksPolicy trait (policy.rs) has no default implementation anywhere in the crate; checked for CONNECT targets, BIND's connecting peer, and every UDP ASSOCIATE datagram's target
Per-source-address listener ACL—CompliantSocksService::with_acl, enforced at the hopf-core listener level before any SOCKS byte is read — distinct from SocksPolicy's per-target check
SOCKS over TLS—CompliantSocksService::with_tls; transport-level only, no protocol state machine changes needed

Client

RequirementStatusNotes
CONNECT client (SOCKS4/4a/5)CompliantSocksConnectHandler/socks_connect_config (client.rs) — a transport decorator forwarding every callback to a caller-supplied inner ProtocolHandler once the tunnel is up
BIND clientCompliantSocksBindHandler/socks_bind_config (client_bind.rs) — beyond this crate's original CONNECT-only reference scope, added for symmetry with the server's own BIND support
UDP ASSOCIATE clientCompliantSocksUdpAssociateHandler/socks_udp_associate_config (client_udp_associate.rs) — same symmetry rationale as BIND; opens its own UDP socket once the association is confirmed
RFC 1929 username/password (client side)CompliantSocksClientConfig::with_credentials; silently unused under SOCKS4/4a
Handshake timeout with proper error deliveryCompliantA silent proxy force-fails via Endpoint::fail (reaches the inner handler's error()) rather than a bare close, which the inner handler — having never seen connected() — would have no way to observe

FTP — RFC 959

Server — Data Types, Structures, Access/Transfer Commands

RequirementSectionStatusNotes
TYPE A/I (E and L not implemented, by design)§3.1.1Compliantcontrol.rs TYPE dispatch
STRU F only (R/P not implemented)§3.1.2Compliant (declared scope)RFC says R SHOULD be accepted for text types, hopf doesn't
ASCII CRLF normalization — download (RETR)§3.1.1.1Compliantascii.rs + RETR path in data.rs
ASCII CRLF normalization — upload (STOR/APPE)§3.1.1.1CompliantAsciiNewlineDenormalizer on TYPE A STOR/APPE via StorTransfer::ascii
USER/PASS/ACCT/CWD/CDUP/SMNT/REIN/QUIT§4.1.1Compliant
PORT/PASV, active-mode bounce protection§4.1.2Compliant
Passive-mode same-IP verificationRFC 4217 §10 (security practice)CompliantFtpDataHandler carries an expected_peer: IpAddr (the control connection's remote IP for PASV/EPSV, the PORT/EPRT dial target for active mode) and arm() closes and refuses to arm any data connection whose remote_addr() doesn't match, independent of the pre-existing PORT/EPRT bounce-protection check
RETR/STOR/APPE§4.1.3Compliant
STOU§4.1.3Compliantgenerate_unique_name uses a per-process counter + nanos; 150 reply includes FILE: {path}
REST (STREAM)§4.1.3CompliantHonoured by RETR and STOR; APPE ignores restart (append semantics)
RNFR/RNTO, DELE, RMD/MKD, PWD, LIST/NLST§4.1.3Compliant
ABOR§4.1.3CompliantCloses the data connection; replies 426 then 226 when a transfer was in progress, else 226 only
STAT with a pathname argument§4.1.3CompliantListing sent over the control connection as multiline 213 (no data connection)

Server — TLS (RFC 4217), IPv6/NAT (RFC 2428), Extensions

RequirementSectionStatusNotes
AUTH TLS, explicit-mode cleartext-first, implicit FTPS, PBSZ/PROT C§4,§8-9Compliantcontrol.rs:836-873
PROT P — passive-mode data channel§9Compliantcontrol.rs:383-385,428-430; FtpDataHandler waits for security_established
PROT P — active-mode data channel§9CompliantFtpConfig.data_tls_connector/data_tls_server_name (service.rs:36-43), set via with_data_tls_connector(connector, server_name); prepare_data's active-mode arm (control.rs:516-547) dials out through the connector when PROT P is in effect, refusing with 425 rather than falling back to cleartext if no connector is configured
require_tls_for_data rejects non-protected transfers as documented—Compliantcheck_data_protection (control.rs:156-163) explicitly rejects with 522 "PROT P required for data connections" — called at the top of cmd_pasv, cmd_epsv, and prepare_data (covering both passive and active mode) whenever the config demands protection and PROT P isn't yet in effect
EPRT/EPSV command format and replyRFC 2428 §2-3Compliantnet-prt validated against address family; mismatch → 522
EPSV ALLRFC 2428 §4CompliantSession flag rejects subsequent PORT/PASV/EPRT with 501; cleared on REIN
FEAT / OPTS UTF8, only-advertise-what's-implementedRFC 2389Compliant
SIZERFC 3659 §4Compliant
MDTM (RFC 3659 timestamp format)RFC 3659 §2.3,§3CompliantYYYYMMDDHHMMSS UTC via format_ftp_mtime
MLST/MLSD fact setRFC 3659 §7.5CompliantEmits Type=/Size=/Modify=/Perm=; FEAT advertises the same
OPTS UTF8, non-ASCII rejection without it, charset substitutionRFC 2640 §4Compliantutf8.rs

Server — Filesystem Containment and Auth (security-critical)

RequirementStatusNotes
../symlink escape prevention for existing pathsCompliantfs.rs:131-151: component-wise walk + a second post-canonicalize re-check, which also defeats symlink escapes for anything already on disk
Symlink escape prevention for newly-created paths (STOR to a new file, MKD of a new dir)CompliantWhen the target doesn't yet exist, join_jail (fs.rs:134-163) canonicalizes the parent directory instead of falling back to the unresolved full path, then re-appends the final path component — an intermediate symlink pointing outside root is caught by the parent's canonicalized containment check, the same as for existing paths
is_authorized hook enforced for all mutating operationsCompliantCalled for RETR/LIST/STAT (Read), STOR/APPE/STOU (Write), DELE (Delete), MKD (CreateDir), RMD (DeleteDir), RNFR/RNTO (Rename), CWD/CDUP (Navigate)
TrustPolicy-based USER/PASS auth, correct reply-code mappingComplianthandler.rs:114-134, matches hopf's own docs example exactly

Client

RequirementStatusNotes
Control state machine, multiline reply parsing, RETR/STOR/LIST, arbitrary command + expected-code wait, PASV/EPSV, PORT/EPRT, QUIT, per-phase timeoutsCompliantFull coverage, client/handler.rs, client/pipeline.rs, client/data.rs; FtpClient::active_mode selects PORT/EPRT (with optional data_tls_acceptor for PROT P)
Active mode (PORT/EPRT) as a clientCompliantFtpClient::active_mode(true) binds a one-shot listener, advertises it via EPRT (default) or PORT, peer-ACL'd to the control remote; PROT P active data uses data_tls_acceptor
FTPS (AUTH TLS/PBSZ/PROT/implicit) as a clientCompliantFtpClient::auth_tls (explicit RFC 4217: welcome → AUTH TLS → start_client_tls → PBSZ 0/PROT P) and FtpClient::implicit_tls (TLS-from-dial, then PBSZ/PROT); data connections reuse the connector under PROT P; active-mode PROT P uses data_tls_acceptor. Covered by async_client_auth_tls_retr / async_client_implicit_tls_retr

SMTP — RFC 5321

Server — Core Commands and Pipelining

RequirementSectionStatusNotes
Greeting, HELO/EHLO, MAIL FROM/RCPT TO (+ ESMTP params), DATA + dot-unstuffing, RSET/QUIT/NOOP/HELP/VRFY/EXPN§4.1.1,§4.2Compliantcontrol.rs, delivery.rs, data.rs:44-171
Command sequencing, multi-line responses, enhanced status codes§4.5.1,§4.2, RFC 2034CompliantSmtpSessionState-gated dispatch
Max command line length enforcement§4.5.3CompliantOver-long verb/argument/SASL line sets took_line_too_long(); control replies 500 5.5.2 Line too long then resyncs
Pipelined commands accepted without deadlockRFC 2920Compliantreceive_inner drains and dispatches all ready commands per call; DATA/BDAT leftover bytes correctly re-fed

Server — Extensions

ExtensionRFCStatusNotes
SIZE (advertised + enforced), 8BITMIME, SMTPUTF8, PIPELINING, ENHANCEDSTATUSCODES1870,6152,6531,2920,2034CompliantAll wired end-to-end
CHUNKING/BDAT, BINARYMIME3030Compliantcontrol.rs:511-591,660-691, data.rs:193-222
DSN RET/ENVID (MAIL FROM), NOTIFY/ORCPT (RCPT TO)3461 §4CompliantParsed into DeliveryRequirements/DsnRecipientParams; stock handlers generate RFC 3461 multipart/report DSNs honouring NOTIFY/RET/ENVID/ORCPT (null reverse-path → no DSN)
DELIVERBY, MT-PRIORITY2852,6710CompliantAdvertised in EHLO; MT-PRIORITY −9..=9 validated at parse; past DELIVERBY deadline rejected at MAIL FROM and again at delivery in stock handlers
REQUIRETLS8689CompliantAdvertised; inbound MAIL FROM rejected without TLS (530 5.7.10); stock handlers refuse REQUIRETLS off-TLS; relay attempts verified STARTTLS + REQUIRETLS to next hop when a TLS connector is configured
FUTURERELEASE (HOLDFOR/HOLDUNTIL)4865Compliant (as rejection)Parsed then explicitly rejected with 553 by both stock handlers rather than honoured — documented
LIMITS9422CompliantEHLO advertises LIMITS RCPTMAX=… MAILMAX=…; session counters enforce both (SmtpConfig::max_recipients / max_mail_transactions)
STARTTLS3207CompliantSession/EHLO state correctly reset after upgrade
AUTH mechanisms4954CompliantFull hopf-auth mechanism set wired via SaslMechanism::from_name + create_server against a CredentialStore (SmtpConfig::store), matching hopf-pop3/hopf-imap's dispatcher pattern; EHLO's AUTH line lists each mechanism the store supports, filtered by TLS requirement, instead of a hardcoded AUTH PLAIN
AUTH requires TLS, AUTH abort (*)4954Compliantcontrol.rs:786-816
Implicit TLS (SMTPS)8314Compliantcontrol.rs:983-993
XCLIENTPostfix ext.CompliantACL-gated via SmtpConfig::with_xclient_allow (empty = disabled); EHLO advertises supported attrs; overrides peer/local/HELO/NAME/LOGIN; mid-transaction rejected with 503; LOGIN is informational only (not SASL AUTH)

Server — Handler SPI and Stock Services

ItemStatusNotes
Staged handler SPI (connect→hello→mail→rcpt→data)Complianthandler/mod.rs:40-111
SimpleRelayService — open MX relayDeferred (documented risk)Accepts every recipient with no domain check at all (relay/handler.rs:200-209) — a genuine open relay, explicitly scoped to dev/test/closed-networks by the crate's own doc comment; must not be exposed to the public Internet
SimpleRelayHandler multi-domain reply semanticsCompliantPost-DATA reply is still a single 250/450 (SMTP has no per-RCPT DATA reply); partial success returns 250 and failure DSNs cover undelivered domains so clients do not retry and duplicate
LocalDeliveryService — APPEND to local INBOX via hopf-mailboxCompliantmailbox/service.rs,handler.rs, case-insensitive domain check at RCPT TO
Local delivery multi-recipient failure semanticsCompliantEvery recipient is attempted; partial success returns 250 with failure DSNs for the rest (avoids duplicate delivery on client retry)

Client

RequirementSectionStatusNotes
Greeting, EHLO + HELO fallback, MAIL/RCPT/DATA + dot-stuffing, RSET/QUIT, STARTTLS, implicit TLS, 421-any-state, multi-line parsingvariousCompliantclient/endpoint.rs,pipeline.rs
AUTH — SmtpSend auto-pilot drives the full mechanism menu4954CompliantSmtpSendDriver::choose_mechanism picks the strongest mechanism the server advertises (SCRAM-SHA-256 > CRAM-MD5 > PLAIN > LOGIN) via hopf-auth::create_client; on_auth_challenge drives the full client-side exchange, including absorbing SCRAM's trailing v= verifier continuation — same pattern as hopf-pop3's Pop3Fetch
True command pipelining (client sends ahead of replies)RFC 2920CompliantWhen EHLO advertises PIPELINING, SmtpSend batches MAIL+RCPT×N+DATA (DATA last); replies drained via a pending-command FIFO
BDAT/CHUNKING (client send)3030CompliantWhen the server advertises CHUNKING, SmtpSend / start_data drives BDAT chunks (raw octets, no dot-stuffing) with per-chunk 250 acks; BODY=BINARYMIME requires CHUNKING
MAIL FROM/RCPT TO ESMTP parameters (SIZE/BODY/RET/ENVID/REQUIRETLS/etc.)variousCompliantSmtpSend::mail_from_params / MailFromParams and rcpt_to_with / DsnRecipientParams cover MAIL FROM and RCPT TO extensions; relay forwards per-recipient NOTIFY/ORCPT

Email Authentication — SPF / DKIM / DMARC (hopf_smtp::auth)

RequirementSectionStatusNotes
SPF check_host(): all/ip4/ip6/a/mx/include/exists/ptr mechanisms, redirect=/exp= modifiers, macro expansion, 10-lookup + 2-void-lookup limits7208 §4-7Compliantauth/spf.rs; null MAIL FROM falls back to the HELO domain per §4.3
SPF %{p} ("validated domain") macro7208 §7.3Deferred (documented)Always expands to "unknown" rather than performing the PTR-then-forward-confirm lookup — the RFC itself says publishers "SHOULD NOT" use it and resolvers merely "SHOULD" support it; the ptr mechanism, which records use far more often, is fully implemented
DKIM verify: signature-tag parsing, simple/relaxed header+body canonicalization, {s}._domainkey.{d} key fetch, rsa-sha256 and ed25519-sha256, bh=/l=/x=, bottom-up header selection for repeated h= names6376, 8463Compliantauth/dkim/{canon,verify}.rs; wired to rmimeparser::DkimMessageParser raw capture for wire-accurate body/header bytes; round-trip tested against real RSA-2048 and Ed25519 keys
DKIM sign: builder API (DkimSigner), rsa-sha256/ed25519-sha256, header/body canonicalization choice, custom signed-header list, t=/x=/i=6376 §5, 8463Compliantauth/dkim/sign.rs; not auto-wired into any stock relay/submission path (Gumdrop's SimpleRelay doesn't sign either) — library API only
DMARC: _dmarc record fetch with organizational-domain fallback, p/sp/adkim/aspf/pct/rua/ruf/fo/rf tags, strict/relaxed SPF+DKIM alignment, pct= sampling with policy downgrade7489 §6-7Compliantauth/dmarc/mod.rs; alignment checks every verified DKIM signature, not just the first, per §3.1.2
DMARCbis np= / psd=draft-ietf-dmarc-dmarcbisCompliantnp= applied when the org-domain record is used and an A lookup for the exact From: domain is NXDOMAIN; psd= parsed on DmarcRecord
Public Suffix List (organizational-domain resolution)7489 §3.2Compliantauth/psl.rs implements the publicsuffix.org "Formal Algorithm" (exception > wildcard > longest-match) over the bundled ICANN+PRIVATE list (auth/public_suffix_list.dat, MPL-2.0)
DMARC aggregate ("rua") report XML7489 Appx. CCompliantauth/dmarc/aggregate.rs renders the <feedback> document from app-supplied traffic records; sending/scheduling reports is app-driven, same as Gumdrop
DMARC forensic ("ruf") report6591, 5965 (ARF)Compliantauth/dmarc/forensic.rs renders the multipart/report; report-type=feedback-report MIME document; sending is app-driven
AuthPipeline — SPF/DKIM/DMARC wired into the transaction pipeline SPI—Compliantauth/pipeline.rs; SPF starts at MAIL FROM, DKIM+DMARC at end-of-DATA; results delivered via on_spf/on_dkim/on_dmarc callbacks and a pollable AuthVerdictHandle that MessageEndState::defer() / DeferredDelivery can wait on when DNS hasn't resolved yet — proven end-to-end in integration.rs::dmarc_pipeline against a real loopback DNS stub and the real control-handler state machine
ARC chain validation: group ARC-* headers by i= (1–50, one AAR/AMS/AS each, contiguous), cv= sequencing (none at i=1, pass after, no fail), newest ARC-Message-Signature against the body, every ARC-Seal newest-to-oldest8617 §4-5.2Compliantauth/arc/{mod,validate}.rs; reuses the DKIM key lookup, algorithms and canonicalization (rsa-sha256, ed25519-sha256). Transient DNS failures are reported as cv=fail
ARC sealing: AAR + AMS + AS for this hop, cv= from the incoming chain, cv=fail seals cover only their own set, refuses to extend a failed or malformed chain or exceed 50 sets8617 §5.1Compliantauth/arc/seal.rs (ArcSealer); wired into AuthPipelineBuilder::arc_sealer, which exposes the set via AuthPipeline::arc_seal() - the caller prepends it, as with Authentication-Results
ARC-aware DMARC: a validated chain can substitute the SPF/DKIM results DMARC evaluates8617 §1, §7Compliant (mechanism only)ArcDmarcPolicy trait via AuthPipelineBuilder::arc_dmarc_policy; which sealers to trust is the application's decision, no built-in trust list. ArcSet::recorded_results() extracts the SPF/DKIM verdicts an AAR recorded
arc= result in Authentication-Results8617 §7Partialarc=none|pass|fail is emitted; the header.oldest-pass / smtp.remote-ip properties are not
Auto-injected Authentication-Results on every inbound message, built-in quarantine mailbox, SMTP-service config toggles for auth—Not implemented (deliberate)Out of scope for v1, matching Gumdrop — apps build the pipeline and act on verdicts themselves rather than the framework enforcing policy implicitly

Auth / SASL — RFC 4422

RequirementSectionStatusNotes
PLAIN, LOGIN, CRAM-MD5, DIGEST-MD54616, draft-murchison, 2195, 2831Complianthopf-auth/src/{plain,login,cram_md5,digest_md5}.rs, round-trip tested
SCRAM-SHA-256 / SCRAM-SHA-256-PLUS5802/7677, RFC 5929 (channel binding)Compliant (with a documented scope limit)Full client-first→server-final exchange, OS-RNG nonce, configurable iteration count (default 4096). Real tls-server-end-point channel binding is now implemented (ScramSha256Client::new_plus / ScramSha256Server::with_channel_binding): the server independently recomputes the expected c= value from the client's GS2 header plus its own channel-binding data and rejects a mismatch. Not implemented: cross-mechanism downgrade detection, and no protocol crate in this workspace wires real TLS channel-binding data through yet (ScramSha256Plus is deliberately excluded from SaslMechanism::all() for that reason, so it isn't advertised anywhere by default)
OAUTHBEARER7628CompliantNo structured JSON error-challenge on failure per §3.2.2, but plain failure is spec-permitted
EXTERNAL4422 App. ACompliantRequires TLS layer to supply a peer certificate first
Proxy authorization (authzid)4422 §4.2CompliantUsed by PLAIN and EXTERNAL
GSSAPI4752Deferred (planned)Not implemented today. Gumdrop ships keytab-based GSSAPI/Kerberos SASL (RFC 4752) with KDC contact offloaded to worker threads. Planned as an optional hopf-auth feature once the AWS-LC security foundation in hopf-core is consolidated — same auth partition as SCRAM/EXTERNAL, not part of the TLS/DTLS wire stack
Base64, HMAC-MD5/SHA-256, MD5, SHA-256, PBKDF2-HMAC-SHA256, constant-time comparison, CSPRNG noncesvariousCompliantcrypto.rs; constant-time compares used consistently across CRAM-MD5/SCRAM/DIGEST-MD5/HTTP Digest
PasswordStore demo-grade / in-memory—Documented (demo); LDAP + PAM backends availablePasswordStore enrolls a password once then retains SCRAM-SHA-256 (+ optional Digest HA1); PLAIN/LOGIN verify via constant-time StoredKey compare — no plaintext retention. CRAM-MD5 / APOP unsupported here. Production password AUTH: hopf-ldap::LdapCredentialStore and optional pam → PamCredentialStore
HTTP Digest (RFC 7616) challenge/verification, Basic, Bearer factories7616,7617,6750Complianthttp_digest.rs, hopf-http/src/auth.rs
HTTP Digest server-side nonce replay protection7616Complianthopf-http's DigestAuthHandler::check_auth now consults (and consumes) the tracked-nonce map before verifying the credential hash — an untracked, already-used, or expired nonce is rejected outright, regardless of whether response is otherwise cryptographically correct. Nonces are single-use and expire after DigestAuthConfig::nonce_ttl (default 5 minutes, configurable via with_nonce_ttl) if never redeemed
OAuth 2.0 (RFC 6749/7662 token introspection)6749,7662Compliant (with a documented scope limit)hopf-auth::oauth_introspection implements RFC 7662 for real: IntrospectionRequest/IntrospectionResponse build the request and parse the response (a small purpose-built JSON reader, no new crate dependency), and IntrospectionCredentialStore wraps any CredentialStore to back validate_bearer with a real introspection round trip instead of a local map. Scope limit: the actual HTTP POST is supplied by the caller via IntrospectionTransport (hopf-auth has no network I/O of its own — hopf-http, which does, already depends on hopf-auth, so the dependency can't run the other way) — no protocol crate wires one up by default, so PasswordStore::validate_bearer (a local static map) remains what's actually used unless an app supplies its own transport

hopf-auth implements seven SASL mechanisms; hopf-pop3, hopf-imap, and hopf-smtp all wire the full set (PLAIN/LOGIN/CRAM-MD5/DIGEST-MD5/SCRAM-SHA-256/OAUTHBEARER/EXTERNAL, filtered by TLS requirement) onto their wire protocols, server and client alike. EXTERNAL's peer-certificate fingerprint is threaded through on all three servers via SecurityInfo::peer_certificate_fingerprint.

Telemetry — OpenTelemetry OTLP/HTTP (hopf-otel)

hopf-otel provides connection-level and HTTP Stream instrumentation plus OTLP/JSONL export; not a Gumdrop port. See telemetry.html.

CapabilityStatusNotes
OTLP/HTTP export (protobuf)Compliant/v1/logs, /v1/traces, /v1/metrics; validated URLs, errors if no sink is configured
JSONL file exportCompliantOptional sink alongside or instead of OTLP, per signal (logs/traces/metrics)
Off-hot-path batchingCompliantHot-path methods only enqueue; encoding and I/O run on a dedicated export worker, never on accept or reactor threads
Connection-level hooks (Accept/Dial/Close/Error)CompliantTelemetryHook, attached via Runtime::start_with_telemetry / Composition::new_with_telemetry; applies to every protocol
HTTP Stream instrumentation (SERVER spans, RED metrics)CompliantInstrumentedServerFactory, instruments at the Stream handler layer, not at TCP accept
W3C Trace Context propagationComplianttraceparent inject/extract; wired into SMTP, FTP, POP3, IMAP, and MQTT handler traceparent plus duration histograms via each service's with_telemetry
DNS trace propagation on the wireN/ANo IETF standard for it (experimental EDNS TRACEPARENT out of scope); client lookups can still be timed as local child spans, and DnsService::metrics() stays process-local rather than an OTLP instrument set
OTLP gRPC exporterNot implementedHTTP/protobuf only
Overflow handlingCompliant (documented)Bounded export queue drops on overflow rather than blocking hot-path callers

POP3 — RFC 1939

Server

RequirementSectionStatusNotes
Three-state model, greeting, 512-octet command cap, auto-logout timer, dot-stuffing§3-4Compliantsession.rs, control.rs, egress.rs
USER/PASS, APOP (MD5 digest, constant-time compare)§7Compliantauth.rs:22-37
AUTH (SASL, RFC 5034) — initial response, cancel with *5034 §4Compliantcontrol.rs:376-484
All 7 SASL mechanisms wired (PLAIN/LOGIN/CRAM-MD5/DIGEST-MD5/SCRAM-SHA-256/OAUTHBEARER/EXTERNAL, no GSSAPI)variousCompliantcontrol.rs:397-413 — the one protocol crate that connects the full hopf-auth mechanism set
STLS (pre-auth only)RFC 2595 §4Compliantcontrol.rs:283-299
UTF8RFC 6856 §3CompliantCAPA advertises UTF8 USER; after UTF8, LIST/TOP/RETR may return non-ASCII content; without UTF8 mode those commands fail with [UTF8] for non-ASCII messages; STLS after UTF8 is rejected
STAT/LIST/RETR/DELE/RSET/TOP/UIDL/NOOP§5,§7CompliantAll route through the Mailbox trait, never touch a path/File directly (verified for RETR/TOP)
DELE session-only marks, RSET clears, QUIT commits (expunge), abrupt disconnect discards without deleting§6CompliantExact match to spec, verified against hopf-mailbox's close(expunge: bool) contract in both Maildir and mbox backends
CAPA + all advertised extensions (SASL/STLS/UTF8/PIPELINING/IMPLEMENTATION/RESP-CODES/EXPIRE/LOGIN-DELAY)2449Compliantcontrol.rs:242-281
Implicit TLS (POP3S), SASL TLS-only mechanism gating, login delay after failed authRFC 8314,5034Compliant

Client

RequirementStatusNotes
Greeting parsing, APOP timestamp extraction, implicit TLS, STLS upgradeCompliantclient/endpoint.rs,facade.rs
USER/PASS, APOP, AUTH (SASL) with initial response + continuation/abort, CAPA discoveryCompliantclient/endpoint.rs:861-957
STAT/LIST/RETR/DELE/RSET/TOP/UIDL/NOOP/QUIT, streaming RETR/TOP via a cross-buffer-safe dot-unstufferCompliantclient/unstuff.rs, tested for split-across-buffer terminators
Pop3Fetch auto-pilot: CAPA negotiation, optional STLS, APOP-preferred then full-SASL-then-USER/PASS fallback, STAT+RETR-all, optional DELE, QUITCompliantclient/pipeline.rs:261-519
Full SASL exchange in the auto-pilot (beyond PLAIN)Compliantchoose_mechanism picks the strongest mechanism the server advertises (SCRAM-SHA-256 > CRAM-MD5 > PLAIN > LOGIN) via hopf-auth::create_client; on_auth_challenge drives the full exchange, including absorbing SCRAM's trailing v= verifier continuation rather than treating it as an abort-worthy unexpected challenge

IMAP — RFC 9051 (IMAP4rev2)

hopf correctly targets RFC 9051 rather than the older RFC 3501 — IMAP4rev2 is unconditionally advertised.

Server — Connection, Commands

RequirementSectionStatusNotes
Greeting + inline CAPABILITY, state machine, command pipelining (queued, answered in order)§3,§7.1Compliantserver/control/mod.rs:432-560
PREAUTH greeting§7.1CompliantImapConfig::preauth_username / with_preauth — opens the user's store then greets with * PREAUTH [CAPABILITY …]
CAPABILITY/NOOP/LOGOUT§6.1Compliant
STARTTLS§6.2.1Compliant
AUTHENTICATE (SASL mechanisms beyond PLAIN)§6.2.2CompliantAll 7 hopf-auth mechanisms wired via SaslMechanism::from_name + create_server (server/control/mod.rs's cmd_authenticate/sasl_step), matching hopf-pop3's dispatcher pattern; capability advertisement lists each mechanism the store supports (store.supported_mechanisms()), filtered by TLS requirement, instead of a hardcoded AUTH=PLAIN. EXTERNAL still requires a peer certificate the TLS layer doesn't plumb through yet (see hopf-pop3's own note above)
SASL-IR (inline initial response)RFC 4959Compliant
LOGIN, LOGINDISABLED/PRIVACYREQUIRED on cleartext§6.2.3Compliant
SELECT/EXAMINE (FLAGS/PERMANENTFLAGS/EXISTS/UIDVALIDITY/UIDNEXT), CREATE/DELETE/RENAME, SUBSCRIBE/UNSUBSCRIBE, LIST/LIST-EXTENDED, LSUB, STATUS, APPEND (+ literal streaming)§6.3CompliantUnsolicited RECENT omitted (IMAP4rev2); STATUS RECENT still answered when requested
NAMESPACERFC 2342CompliantPersonal namespace from the mailbox store; other-users / shared configurable via ImapConfig::other_users_namespaces / shared_namespaces (default NIL)
IDLERFC 2177CompliantContinuation/DONE loop; 1s poll refreshes Maildir and emits untagged EXISTS / EXPUNGE / FETCH FLAGS; auto-completes after idle_max_duration (default 29 minutes)
GETQUOTA/GETQUOTAROOT/SETQUOTARFC 9208 §3Compliant

Server — Selected-State Commands

RequirementSectionStatusNotes
CLOSE (implicit expunge), UNSELECT (no expunge)§6.4.1-2Compliant
EXPUNGE / UID EXPUNGE§6.4.3 / RFC 4315CompliantUID EXPUNGE correctly protects out-of-set \Deleted messages
SEARCH / UID SEARCH§6.4.4CompliantSee Mailbox section for criteria coverage
FETCH / UID FETCH — ENVELOPE and BODYSTRUCTURE§6.4.5Compliant (with documented scope limits)server/envelope.rs / server/bodystructure.rs: ENVELOPE parsed from RFC 5322 headers, BODYSTRUCTURE built by streaming the whole message through rmimeparser's push MIME parser (recursive multipart/* nesting, message/rfc822 nested envelope+structure via a bounded 8 MiB capture). Two known gaps, both documented in the IMAP page's Limitations: body size/lines reflect rmimeparser's decoded bytes for base64/quoted-printable parts rather than the RFC-mandated wire-encoded octet count (identity encodings are unaffected); Content-Language/Content-Location are always NIL and Content-Disposition parameters other than filename are omitted, since rmimeparser's public API doesn't expose either.
FETCH partial content BODY[section]<start.count>§6.4.5Compliantfetch_format.rs::parse_fetch_items now consumes a trailing <start.count> after the section's closing ] instead of leaving it for the next token; push_fetch_attrs streams only the requested byte window (clamped to an empty string, never an error, when start is beyond the end) without ever buffering the full section first
STORE / UID STORE, COPY / UID COPY (+ COPYUID), MOVE / UID MOVE (RFC 6851)§6.4.6-7, RFC 6851CompliantMOVE is an atomic copy+expunge, correctly emits VANISHED under QRESYNC or per-message EXPUNGE otherwise
SORT / UID SORTRFC 5256 §3Compliantserver/sort.rs: ARRIVAL/CC/DATE/FROM/SIZE/SUBJECT/TO keys, per-key REVERSE, sequence-number tie-break; charset restricted to UTF-8/US-ASCII (NO [BADCHARSET (UTF-8 US-ASCII)] otherwise)
THREAD / UID THREADRFC 5256 §2,§4Compliantserver/thread.rs: both ORDEREDSUBJECT and REFERENCES algorithms, including the base-subject algorithm (§2.1) shared with the SORT SUBJECT key. REFERENCES §2.2 step 5C (fabricating a new dummy parent to merge two same-subject, non-reply root messages) is skipped — no thread-list wire form exists to write a parent-less placeholder there — so those two threads stay separate instead of merged; every other step (dummy creation/pruning, cycle guard, sibling sort) is implemented

Server — Responses and Extension Capabilities

RequirementSectionStatusNotes
Untagged/tagged/continuation responses, response codes (CAPABILITY/TRYCREATE/APPENDUID/COPYUID/PRIVACYREQUIRED/READ-ONLY/READ-WRITE)§7Compliant
Unsolicited RECENT (IMAP4rev2)§7.4.1CompliantNot emitted on SELECT/EXAMINE or IDLE/NOOP; solicited STATUS … (RECENT) remains available
UIDPLUS (APPENDUID/COPYUID/UID EXPUNGE)RFC 4315Compliant
ENABLE, UNSELECT, ID, LIST-EXTENDED/LIST-STATUS5161,3691,2971,5258/5819Compliant
CONDSTORE / QRESYNCRFC 7162Compliant (with a documented scope limit)HIGHESTMODSEQ/MODSEQ/CHANGEDSINCE are now backed by real per-message mod-sequence tracking in both shipped backends — Maildir++'s .uidlist and mbox's .flags sidecar each gained a persisted, monotonic counter (maildir/uidlist.rs, mbox/flags.rs); Mailbox::modseq()/highest_modseq()/changed_since() return genuine values that survive a mailbox reopen. expunged_since() (VANISHED (EARLIER) history) is unchanged — still returns empty rather than fabricating history, a deliberate scope boundary, not an oversight.
LITERAL- (non-sync literals, 4096-byte cap)RFC 7888Compliant
COMPRESS=DEFLATERFC 4978Compliantcompress.rs: raw DEFLATE (RFC 1951, no zlib/gzip wrapper), one compressor/decompressor pair for the whole connection, each outbound write flushed with Z_SYNC_FLUSH so the peer can decode without waiting for stream end; advertised only once authenticated and only while not already compressed. Fixed two bugs surfaced while wiring this up: the LOGIN/AUTHENTICATE completion's embedded [CAPABILITY …] was built from pre-auth session state, and a pipelined second COMPRESS DEFLATE inside an already-compressed read could be wrongly granted (a re-entrancy gap while the compression state was transiently taken out of self)
UTF8=ACCEPTRFC 6855CompliantTracked via ENABLE; no wire-format change needed since IMAP4rev2 responses are already raw UTF-8, not modified UTF-7
STATUS=SIZERFC 8438Compliantserver/status_items.rs: SIZE status data item (message octets)
OBJECTIDRFC 8474Compliant (Maildir only)MAILBOXID generated once per mailbox and persisted in .uidlist (survives RENAME); EMAILID derived deterministically from MAILBOXID + UID, avoiding a reverse index for SEARCH EMAILID. The mbox backend doesn't assign either (Mailbox::mailbox_id()/email_id() trait defaults return None)
METADATARFC 5464Compliant (Maildir only)server/metadata.rs (parsing) / maildir/store.rs (storage): GETMETADATA/SETMETADATA with DEPTH/MAXSIZE and METADATA LONGENTRIES, backed by a directory tree per mailbox plus a separate .server-metadata tree for server-level (empty-mailbox) annotations — kept distinct from INBOX's own annotations, since Maildir++ resolves INBOX's directory to the user root itself
NOTIFYRFC 5465Compliant (documented subset)server/notify.rs: SET/NONE for the selected mailbox's MessageNew/MessageExpunge/FlagChange events, reusing IDLE's poll-and-diff machinery against the same baseline so an event is reported exactly once regardless of which mechanism observes it first. Cross-mailbox selectors and the annotation/mailbox-name/subscription event groups are rejected with a tagged BAD rather than silently ignored

Client

RequirementStatusNotes
Plaintext/implicit TLS, STARTTLS, greeting parsing (OK/PREAUTH/BYE), CAPABILITY from greetingCompliant
LOGIN, AUTHENTICATE PLAIN + SASL-IR, challenge/response/abortCompliant
Non-PLAIN SASL mechanisms (client)Compliant (with the same documented scope limit as hopf-pop3)The underlying protocol is mechanism-agnostic — authenticate(mechanism, initial) takes any mechanism name and ImapClientAuthExchange::respond/abort drive an arbitrary challenge/response exchange; ImapCapabilities retains every raw AUTH= token via .tokens/.has(), not just PLAIN. The ImapFetch/ImapIdle auto-pilots still only drive PLAIN themselves — same accepted scope limit as hopf-pop3's auto-pilot; a custom driver is required for other mechanisms
Full RFC 9051 §6.3-6.4 command surface (SELECT through MOVE, IDLE/DONE, GETQUOTA)CompliantImapFetch/ImapIdle auto-pilots exercise the whole surface
Tag-correlated pipelining with out-of-order completionCompliantPendingMap routes untagged replies to the oldest pending command of a compatible kind regardless of tag-completion order — directly unit-tested (out_of_order_completion, status_and_list_both_compatible_simultaneously)
SORT/THREAD, COMPRESS=DEFLATE, OBJECTID/METADATA/NOTIFY (client parity)Compliantclient/state.rs/client/endpoint.rs: ImapClientSelected::sort/uid_sort/thread/uid_thread with a SORT numeric-list event and a THREAD chain/nested response parser producing a client-side thread tree; compress_deflate activating the same raw-DEFLATE layer as the server; notify_set_selected/notify_none; get_metadata/set_metadata; mailbox_id/email_id surfaced on the relevant response data structs

Mailbox Storage — hopf-mailbox

No RFC of its own; audited against what RFC 9051 SEARCH needs and against mbox/Maildir++ format conventions.

RequirementStatusNotes
SEARCH criteria: system flags, FROM/TO/CC/BCC/SUBJECT, BODY/TEXT, date ranges, LARGER/SMALLER, KEYWORD, UID sets, AND/OR/NOTCompliantsearch.rs, matched against RFC 9051 §6.4.4's full criteria list
Generic HEADER <name> <string> searchCompliantsearch::HeaderExtractor now falls back to a raw per-message header scan — working for genuinely any header name, not just a fixed set — whenever the requested header isn't one of the six specifically-indexed fields; wired into both backends via MessageIndex::search's header_loader callback (mbox) and a direct file read (Maildir++), e.g. HEADER X-Spam-Flag ... now matches correctly
MODSEQ criterionCompliantNow resolves to the real per-message mod-sequence via the same tracking as the CONDSTORE/QRESYNC row above — SearchCriteria::ModSeq correctly matches against genuine, persisted values for both backends
Search execution model — indexed or linear?Linear scanEvery SEARCH is a linear scan over a BTreeMap<uid, IndexEntry> — there is no inverted/term index. The .gidx sidecar avoids re-parsing MIME per search for six cached header fields; body text search re-reads the raw file per candidate message per search unless body_indexing is explicitly enabled (off by default)
mbox: "From " line escaping confined to body, rewrite-on-expunge preserves unaffected messagesCompliantmbox/mailbox.rs:195-276,680-746
mbox: locking conventionCompliantmbox::lock::DotLock now acquires a classic <mbox>.lock sentinel file (atomic O_EXCL create, stale-lock reclaim after 5 minutes) alongside the existing flock, held for the mailbox's lifetime — interoperates with dotlock-only mbox tooling (procmail, mutt, etc.), not just other flock-aware processes
mbox: no hierarchy/COPY/MOVE (single-mailbox backend)Compliant (documented limitation)MboxStore::create_mailbox etc. all return Unsupported
Maildir++: tmp/new/cur layout, new→cur promotion on open, :2,<flags> suffix encoding, Maildir++ folder naming, UID assignment via .uidlistCompliantmaildir/mailbox.rs,filename.rs,uidlist.rs
Maildir++: unique filename generationCompliantMaildirFilename::generate now appends the local hostname (via gethostname(2), escaped per the classic / → \057 / : → \072 convention) as a fourth dot-separated component — {millis}.{pid}.{counter}.{host} — cross-host-safe over a maildir shared via NFS, matching the traditional time.delivery-id.hostname convention
APPEND: write-then-fsync-then-atomic-rename (both backends), UID assigned + index updated atomically with the writeCompliant
Body index (.gidx) on-disk integrityCompliantCRC32 header + per-entry checksums, atomic tmp-then-rename save

MQTT — OASIS MQTT Version 3.1.1 / Version 5.0 (useful core)

Gumdrop implements MQTT (broker + client) under org.bluezoo.gumdrop.mqtt (MQTTService, MQTTProtocolHandler, WebSocket bridge, staged Connect/Publish/Subscribe handlers). This section audits hopf-mqtt against the OASIS specification text the same way as the other protocols, using Gumdrop as a parity reference rather than as the requirements source. Gumdrop's own HTML docs overstate some v5 features relative to its code — hopf rows below reflect what each codebase actually enforces.

SpecStatus
OASIS MQTT Version 3.1.1Core — full semantics
OASIS MQTT Version 5.0Useful subset — see per-feature rows; QoS retry across broker restarts remains limited (in-flight is process-local)

Connect / Session / Disconnect

RequirementSectionStatusNotes
CONNECT parsing (flags, keep alive, client id, will, credentials), CONNACK (session-present, version-appropriate reason code)v3.1.1 §3.1-3.2, v5 §3.1-3.2Compliantcodec/decode.rs, server/control.rs; v3.1.1 uses the narrower reason::connack_v311 table, v5 the unified reason-code table — kept genuinely separate, not unified incorrectly
Zero-length Client Identifier: assigned when Clean Session set, rejected otherwisev3.1.1 §3.1.3.7Compliantassign_client_id() (TCP) / uuid_ish() (WS)
Session takeover (evicts a live prior connection)v3.1.1 §3.1.2.4CompliantBrokerState::register evicted-connection path, unit-tested (session_takeover_evicts_old_connection)
Session resume (orphaned, non-clean-start, Session Expiry)v5 §3.1.2.11.2CompliantEpoch-guarded orphan/resume on TCP and WS; offline QoS ≥ 1 drained via BrokerState::drain_offline
Keep Alive ×1.5 enforcementv3.1.1 §3.1.2.10CompliantTCP via Endpoint::schedule_timer; WS via ConnHandle::schedule_timer
CONNECT-timeout if no CONNECT arrivesv3.1.1 §3.1 (implied)CompliantTCP and WS (connect_timeout_timer)
DISCONNECT — client-initiated (graceful, no Will), server-initiated (v5 reason code, correctly absent for v3.1.1 which has no such packet)v3.1.1 §3.14, v5 §3.14Compliant

Publish / QoS / Retained / Will

RequirementSectionStatusNotes
QoS 0/1/2 full handshakes (PUBACK; PUBREC/PUBREL/PUBCOMP)v3.1.1 §3.3-3.7CompliantServer-inbound QoS 2 dedup via awaiting_pubrel, re-acks a duplicate PUBLISH without re-delivering to subscribers
Streaming PUBLISH payload (never buffered in full)—CompliantMqttFrameParser streams payload chunks directly to publish_data, mirroring hopf-http's H2Parser buffer-and-drain shape
Retransmission / redelivery timer for outbound QoS 1/2v3.1.1 §4.4Compliant (in-process)MqttMessageStore::track_inflight / due_retransmits; 5s retry timer on TCP/WS. Does not survive broker restarts
Topic Name validation on PUBLISH (no wildcards)v3.1.1 §4.7Compliantvalidate_topic_name
Retained messages: store, clear, deliver on new SUBSCRIBE respecting Retain Handlingv3.1.1 §3.3.1.3, v5 §3.8.3.1CompliantRetainedStore, deliver_retained
Will Message published on unclean disconnectv3.1.1 §3.1.2.5Compliant
Will Delay Interval, Message Expiry Interval enforcementv5 §3.1.3.2, §3.3.2.3.3Compliantserver/expiry.rs; Will Delay capped by Session Expiry; Message Expiry rewritten on offline dequeue / retained delivery

Subscribe / Wildcards / v5 Options

RequirementSectionStatusNotes
SUBSCRIBE/SUBACK, UNSUBSCRIBE/UNSUBACKv3.1.1 §3.8,§3.10Compliant
Wildcards (+, #), $-prefix exclusion from wildcard matchingv3.1.1 §4.7CompliantTopicTree
Subscription Options: No Local, Retain As Published, Retain Handling (0/1/2)v5 §3.8.3.1CompliantMatchOptions::from_filter; No Local checked in BrokerState::publish; unit-tested
Shared Subscriptions ($share/group/filter)v5 §4.8.2CompliantTopicTree round-robin; CONNACK advertises SHARED_SUBSCRIPTION_AVAILABLE = 1
Topic Aliasv5 §3.3.2.3.4CompliantInbound alias map on TCP/WS; CONNACK TOPIC_ALIAS_MAXIMUM
Offline QoS ≥ 1 queue (Session Expiry orphan window)v5 §4.1CompliantMqttMessageStore on BrokerState; default in-memory, optional file-backed

v5 Flow Control and Reason Codes

RequirementSectionStatusNotes
Receive Maximum (server caps unacked outbound QoS 1/2 per subscriber)v5 §3.3.4CompliantLock-free AtomicU16 in-flight counter + try_reserve_delivery, unit-tested including the credit-release-on-ack path
Unified v5 reason-code table (CONNACK/PUBACK/PUBREC/SUBACK/etc.)v5 §2.4Compliantcodec/packet.rs reason module
Enhanced AUTH (AUTH packet exchange, re-authentication)v5 §3.15,§4.12Compliant (TCP server + client callback)Server: server/auth.rs via hopf-auth SASL; client: MqttClientDriver::on_auth / MqttClientControl::auth

Async Client

RequirementStatusNotes
MqttClient facade (DNS resolve, MQTTS, per-phase timeouts), publish/subscribe/unsubscribe, keepalive PINGREQ + PINGRESP deadlineCompliant
Automatic PUBACK / PUBREC-then-PUBREL for incoming QoS 1/2CompliantHandled by the endpoint automatically — the driver only sees the final on_message_complete/on_publish_acked outcome

MQTT-over-WebSocket Bridge

RequirementStatusNotes
Same codec + BrokerState shared with the TCP listenerCompliantMqttWsFactory/MqttWsHandler drive the identical MqttFrameParser
Cross-transport, cross-connection pub/sub fan-out (WS subscriber receives a TCP publisher's PUBLISH)Compliant — required a real fix this sessionOriginally broken: a plain ConnHandle::send writes straight to the raw transport, bypassing WS framing entirely and corrupting the stream. Fixed by adding ConnHandle::framed() to hopf-core and framed_ws_conn_handle() to hopf-websocket; proven by a real cross-transport integration test
Session Expiry over WSCompliantOrphan + expire via ConnHandle::schedule_timer
Keepalive / CONNECT-timeout enforcement over WSCompliantSame timer path as TCP

Remaining gap: QoS retransmission bookkeeping does not survive broker process restarts (in-flight entries stay in memory even when offline queues use FileBackedMessageStore). Staged Connect / Publish / Subscribe SPI matches Gumdrop.

AMQP 0-9-1 — client only (RabbitMQ)

hopf-amqp is a new client-only crate (not a Gumdrop port). It dials an AMQP 0-9-1 broker, publishes opaque message bodies with basic properties, and consumes via push basic.consume deliveries. See amqp.html.

CapabilityStatusNotes
Connection handshake + PLAIN / AMQPLAINCompliant
Tune / heartbeat / multi-channelCompliant
Exchange / queue topology methodsCompliantdeclare / bind / unbind / purge / delete
basic.publish + content streaming / frame_maxCompliant
Publisher confirms + basic.returnCompliant
basic.consume push deliveries + ack / nack / reject / qosCompliantbasic.get deferred
AMQPS (implicit TLS)Compliant
Broker / transactionsOut of scopeClient-only by design

AMQP 1.0 — ISO/IEC 19464 (client only, hopf-amqp1)

hopf-amqp1 is a separate crate from hopf-amqp — AMQP 1.0 shares only the four-byte AMQP magic with AMQP 0-9-1; framing, the type system, and the connection/session/link model are unrelated. Client-only, no broker. See amqp.html.

CapabilityStatusNotes
Frame layer, type system, described typesComplianthopf_amqp1::codec: protocol header exchange, frame header (size/DOFF/type/channel), compact-encoded type system with depth-guarded recursive decode
SASL PLAIN / ANONYMOUS, auto-negotiatedCompliantPLAIN when credentials are configured and advertised, else ANONYMOUS
Connection open, sessions (begin/end), links (attach/detach)Compliant
Session-level flow control, link creditCompliantIncoming/outgoing window; RFC 1982 serial arithmetic for delivery-ids and window comparisons
Sending: header / properties / application-properties / dataCompliantAutomatically split across multiple transfer frames for messages larger than the negotiated max frame size
Receiving, streamed incrementallyCompliantDelivered to the application as transfer frames arrive rather than buffered whole (genuinely incremental MessageParser)
Delivery settlement (accept/reject/release/modify), outcomesCompliant
AMQPS (implicit TLS)Compliant
Automatic reconnectionNot implementedUnlike hopf-amqp's AmqpRecoveringClient
Heartbeat send / peer idle-time-out enforcementNot implementedAdvertises no requirement of its own; a long-idle connection against a broker with a strict idle timeout could be dropped
Sending delivery-annotations / message-annotations / footerPartialReceived as raw maps; not yet offered on the send side
Broker / transactionsOut of scopeClient-only by design

Planned work

Items below are not audited as shipped behaviour. Partial crates are listed with their current gap; unstarted items are Gumdrop parity targets or later protocol work.

Protocol crates

ProtocolGumdrop (reference)hopf status
Redis clientRESP clientNot started
CoAP / CoAPS—Not started; CoAPS depends on planned DTLS
SSH / SFTP / SCP—Not started; SSH KEX + channels (parallel transport family, not TLS)
DNSCrypt—Not started; UDP object encryption on shared primitives
ARC (mail)RFC 8617Sealing, validation and ARC-aware DMARC hook shipped in hopf_smtp::auth::arc

Documented gaps vs Gumdrop (implemented crates)