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).
Contents
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:
- Compliant — implemented and verified against the code.
- Partial — implemented but with a specific, named gap (see Notes).
- Not implemented — the requirement is not met; a genuine gap.
- Deferred — explicitly documented as deferred or planned in hopf's docs, not a silent gap.
- N/A — delegated to another library, or structurally inapplicable to hopf's stated scope (e.g. authoritative DNS serving for a stub resolver).
Security substrate (hopf-core foundation)
Implemented today
| Area | Implementation | Notes |
|---|---|---|
| Native crypto library | AWS-LC via aws-lc-rs / aws-lc-sys | BoringSSL-lineage C library; the only native crypto dependency in production anywhere in this workspace |
| TCP TLS 1.2 / 1.3 + STARTTLS | In-tree, hopf-core::tls | Hybrid-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.3 | In-tree, hopf-core::dtls/dtls12 | Same 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-quic | Full 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-quic | Same 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/3 | In-tree in hopf-http (feature h3) | QPACK and H3 framing are Hopf-owned |
| Encrypted Client Hello | In-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 verify | Direct aws-lc-rs in hopf-smtp / hopf-dns | RSA / Ed25519 / ECDSA; Ed448 via ed448-goldilocks-plus; ML-DSA (FIPS 204) certificate verification also shipped in hopf-core::crypto::x509 |
| UDP datagram I/O | hopf-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 Compression | hopf-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::x509 | FIPS 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
| Area | Target | Notes |
|---|---|---|
| GSSAPI / Kerberos SASL | Optional gssapi feature on hopf-auth | Gumdrop parity (RFC 4752); KDC/keytab work on StorageExecutor; acceptable once AWS-LC is the accepted native foundation |
| Other transport families | Separate stacks on same primitives | SSH/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).
| Requirement | Section | Status | Notes |
|---|---|---|---|
| TLS 1.3 handshake state machine, record layer | RFC 8446 | Compliant | In-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 layer | RFC 5246 | Compliant | In-tree tls12::Tls12Engine/Tls12RecordEngine (hopf-core/src/tls/tls12/); AEAD-only (GCM + ChaCha20-Poly1305), CBC permanently excluded |
| TLS / DTLS version selection on one port | RFC 8446 §4.2.1 / RFC 9147 §5.3 | Compliant | TcpTlsVersionPolicy (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 selection | RFC 8446 §B.4 / RFC 5289 / RFC 7905 | Compliant | AES_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 PQC | RFC 10024 | Compliant | crypto::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 5077 | Compliant | TLS 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.4 | Compliant | All 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 8449 | Compliant | hopf-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 8449 | Not implemented | The 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 mode | RFC 9180 | Compliant | hopf-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 9849 | Partial | hopf-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 DNS | RFC 9849 §1, RFC 9460 §9 | Compliant | hopf-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 extension | RFC 6066 §4, RFC 8449 §5 | N/A | Not 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 types | RFC 8422 §5 | Partial | RSA (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 negotiation | RFC 7301 | Compliant | TLS 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.4 | Compliant | ClientAuthPolicy (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 side | RFC 6066 §3 | Compliant | Sent in every ClientHello when HandshakeConfig::server_name is set (handshake/messages.rs) |
| SNI — server side (per-name cert selection) | RFC 6066 §3 | Compliant | HandshakeConfig::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 | — | Compliant | public_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 | — | Compliant | insecure_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) | — | Compliant | TcpListenerConfig::with_tls → immediate handshake on connect (hopf-core/src/connection.rs) |
| STARTTLS-style deferred upgrade (server) | — | Compliant | Endpoint::start_tls(); used by SMTP/POP3/IMAP/FTP control handlers |
| STARTTLS-style deferred upgrade (client) | — | Compliant | Endpoint::start_client_tls(connector, server_name); used by SMTP/IMAP/POP3 clients |
Close/shutdown via close_notify | RFC 8446 §6.1 | Compliant | TlsRecordEngine::send_close_notify()/Tls12RecordEngine::send_close_notify(), invoked before flush on close |
| Handshake/decode error mapping | — | Compliant | Protocol 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) | — | Compliant | PEM 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 driver | RFC 6347, RFC 9147 | Compliant | hopf-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 cookie | RFC 6347 §4.2.1 | Compliant | Dtls12Config::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 interop | RFC 9147 | Deferred | Engine-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)
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Request-line parsing, CTL rejection in method | §3 | Compliant | h1/server_codec.rs:321-421,332-335 |
| 400 for malformed request-line | §3 | Compliant | server_codec.rs:395-420 |
| 414/431 for oversized URI/header line | §3, §5 | Compliant | token_too_long(), server_codec.rs:809-817, driven by HttpLimits::max_line_length |
| 501 for unrecognized method | RFC 9110 §15.6.2 | Compliant | is_default_method(), server_codec.rs:340-343 |
| 505 HTTP-Version Not Supported | §3 | Compliant | Only HTTP/1.0 and 1.1 accepted (server_codec.rs:380-387) |
| HTTP/2 prior-knowledge preface detected/rejected at H1 layer | §3 | Compliant | server_codec.rs:381-383 → 505 (routing to H2 handled one layer up) |
Server — Field Syntax (RFC 9112 §5)
| Requirement | Section | Status | Notes |
|---|---|---|---|
| field-line = field-name ":" OWS field-value OWS | §5.1 | Compliant | server_codec.rs:423-502,552-558 |
| obs-fold continuation handling | §5.2 | Compliant | h1/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.2 | Compliant | is_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.5 | Compliant | is_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 | §5 | Compliant | server_codec.rs:429-435 (count), token_too_long() (line length) |
| Host header required exactly once (HTTP/1.1) | §3.2 | Compliant | end_headers(), server_codec.rs:563-582, 400 if count != 1 |
| Case-insensitive header lookup | §5.1 | Compliant | Headers::get(), headers.rs:74-79 |
Server — Message Body Length and Transfer Coding (RFC 9112 §6-7)
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Transfer-Encoding overrides Content-Length | §6.3 | Compliant | end_headers(), server_codec.rs:593-612 |
| Reject invalid/multi-coding Transfer-Encoding | §6.3 | Compliant | Only exact single chunked token accepted (utils.rs:36-54), else 400 |
| Multiple/conflicting Content-Length header fields → error | §6.3 | Compliant | end_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.1 | Compliant | server_codec.rs:504-550,735-765 |
| Chunk extensions parsed/ignored; quoted chunk-ext rejected | §7.1.1 | Compliant | server_codec.rs:513-519 |
| Chunk-size / aggregate body limits enforced | §7.1 | Compliant | HttpLimits::max_chunk_size (10 MiB) / max_request_body (default 16 MiB) |
| Trailer section after last chunk | §7.1.2 | Partial | Parsed 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.3 | Compliant | server_codec.rs:663-666 |
| Read-until-close body (HTTP/1.0 only) | §6.3 | Compliant | server_codec.rs:659-662,774-788 |
Server — Methods, Upgrade, Status Codes (RFC 9110)
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Method = token, validated | §9.1 | Compliant | utils.rs:6-14 |
| HEAD: no response body written | §9.3.2 | Compliant | h1/response.rs:335-364 |
| CONNECT tunnel | §9.3.6 | Compliant | Via ServerWriter::upgrade() (h1/response.rs:378-393), no built-in framing beyond the hook |
OPTIONS * auto-responder | §9.3.7 | N/A | Target accepted; no built-in 200+Allow auto-responder — fully delegated to the app |
TRACE echo (message/http) | §9.3.8 | Rejected by default | TRACE 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.1 | Compliant | server_codec.rs:624-633 |
| 101 Switching Protocols / Upgrade | §7.8 | Compliant | h1/response.rs:378-393; with Capsule-Protocol: ?1, post-upgrade bytes are Capsule Protocol (capsule.rs, RFC 9297 §3) |
| h2c cleartext Upgrade | RFC 9113 §3.2 legacy | Compliant | Handled in CleartextHttpEndpoint, see HTTP/2 table |
| 1xx informational responses (server send) | §15.2 | Compliant | h1/response.rs:301-317 |
| Status codes / reason phrases | §15 | Partial | reason_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.1 | Compliant | http_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.4 | Compliant | Defaults to hopf (h1/response.rs:272-273) |
| Auto-chunk response when length unknown (1.1) | §8.6 | Compliant | h1/response.rs:275-285 |
Client (RFC 9112 / RFC 9110)
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Request-line format, Host always emitted | §3.1-3.2 | Compliant | h1/client_codec.rs:593-628 |
| Chunked request body encoding | §7.1 | Compliant | client_codec.rs:558-590 |
| Status-line / header parsing, obs-fold | §4-5 | Compliant | client_codec.rs:255-340 |
| Content-Length / read-until-close body framing | §6.3 | Compliant | client_codec.rs:410-413,462-470 |
| Multiple/conflicting Content-Length in response | §6.3 | Compliant | Same duplicate-field handling as the server side (client_codec.rs:260-277) |
| Chunked response body + trailers | §7.1-7.1.2 | Partial | Chunk 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.2 | Compliant | end_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.2 | Compliant | client_codec.rs:383-389 |
Connection: close handling, persistent by default | §9.3,§9.6 | Compliant | client_codec.rs:118-121,353-360 |
| Automatic redirect following | §15.4 (client-optional) | N/A | No 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.1 | Not implemented | No retry loop; server-side auth factories have no client-side counterpart |
HTTP/2 — RFC 9113 / HPACK RFC 7541
Server — Connection Startup, Frames, Streams
| Requirement | Section | Status | Notes |
|---|---|---|---|
ALPN h2, cleartext prior-knowledge, h2c Upgrade | §3.2-3.4 | Compliant | dispatch.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.4 | Compliant | h2/endpoint.rs:1272-1289,497-530 |
| 9-octet frame header, unknown types ignored | §4.1 | Compliant | h2/frame.rs:115-139, h2/parser.rs:196-198 |
| DATA/HEADERS (+PADDED/+PRIORITY strip) | §6.1-6.2 | Compliant | h2/frame.rs:227-269 |
| HEADERS/CONTINUATION reassembly locked to one stream | §4.3,§6.10 | Compliant | h2/parser.rs:125-135, h2/endpoint.rs:734-767 |
| PRIORITY tolerated/ignored (deprecated) | §5.3.2 | N/A | Ignored; hopf advertises SETTINGS_NO_RFC7540_PRIORITIES=1 (RFC 9218 §2.1) and schedules via Extensible Prioritization instead |
| Extensible Prioritization (Priority header + PRIORITY_UPDATE) | RFC 9218 | Compliant | priority.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.4 | Compliant | h2/endpoint.rs:1014-1022 |
| SETTINGS parse + ACK (HEADER_TABLE_SIZE/MAX_FRAME_SIZE/INITIAL_WINDOW_SIZE/ENABLE_PUSH/MAX_CONCURRENT_STREAMS) | §6.5 | Compliant | h2/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.3 | Compliant | Closes 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.6 | N/A (by design) | Client sending PUSH_PROMISE to the server → GOAWAY PROTOCOL_ERROR (h2/endpoint.rs:983-996), matches docs |
| PING + ACK | §6.7 | Compliant | h2/endpoint.rs:1002-1012 |
| Server-initiated keepalive PING | practice | Not implemented | No periodic PING logic |
| GOAWAY send/receive | §6.8 | Compliant | Immediate 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.9 | Compliant | h2/endpoint.rs:1028-1040 |
| Stream IDs odd/strictly-increasing, MAX_CONCURRENT_STREAMS enforced | §5.1.1-5.1.2 | Compliant | HttpLimits::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.1 | Compliant | StreamState (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.9 | Compliant | h2/flow.rs:57-82 |
| Default initial window 65535, SETTINGS-driven adjustment | §6.9.2 | Compliant | h2/flow.rs:12,116-121 |
| Flow control respected on send (server → client DATA) | §5.2,§6.9 | Compliant | write_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.9 | Compliant | start_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
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Static table (61 entries) | Appendix A | Compliant | h2/hpack/static_table.rs:15 |
| Dynamic table insert/evict, 32-byte overhead rule | §4.1,§4.3 | Compliant | h2/hpack/dynamic.rs:67-77,114-122 |
| Oversized entry clears table; dynamic table size update | §4.4,§6.3 | Compliant | dynamic.rs:67-73, decode.rs:66-70 |
| Integer representation, multi-byte prefix | §5.1 | Compliant | decode.rs:127-160, encode.rs:91-107; guards runaway continuation |
| String literal + Huffman (encode picks shorter) | §5.2 | Compliant | decode.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.3 | Compliant | decode.rs:41-65 |
| Decompression failure → COMPRESSION_ERROR | RFC 9113 §6.5.2 | Compliant | h2/endpoint.rs:797-803,848-854 |
| SETTINGS_MAX_HEADER_LIST_SIZE enforced against decoded lists | RFC 9113 §6.5.2 | Compliant | header_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)
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Pseudo-header presence/ordering/uniqueness validation | §8.3.1 | Compliant | validate_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.2 | Compliant | Same 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 8441 | Compliant | SETTINGS_ENABLE_CONNECT_PROTOCOL advertised; h2/response.rs:191-206 |
| Capsule Protocol on upgraded streams | RFC 9297 §3 | Compliant | When 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.2 | Compliant | h2/endpoint.rs:536-542,1247-1261 |
| Client: h2c Upgrade dial (HTTP/1.1 → H2) | RFC 7540 §3.2 | Compliant | H2cUpgradeClientEndpoint / 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.1 | Compliant | h2/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.1 | Compliant | h2/endpoint.rs:847-882 |
| Client: GOAWAY reception fails in-flight streams | §6.8 | Compliant | on_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.2 | Compliant | process_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.2 | Compliant | h2/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
| Requirement | Section | Status | Notes |
|---|---|---|---|
ALPN h3, TLS 1.3 only | §3,§3.1 | Compliant | hopf-quic/src/lib.rs:26, config.rs:104,117 |
| Control stream (type 0x00) + SETTINGS on connect | §6.2.1,§7.2.4 | Compliant | h3/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.4 | Compliant | H3UniStream::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.1 | Compliant | Advertised 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 opened | RFC 9204 §4.2 | Compliant | h3/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.2 | Compliant | H3UniStream::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.2 | Compliant | H3UniStream::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.1 | Compliant | h3/endpoint.rs:336-386 |
| Mandatory request pseudo-header validation | §4.3.1 | Compliant | H3RequestStream::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.2 | Compliant | h3/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.6 | Compliant (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.2 | Compliant | h3/endpoint.rs:119-142 |
| Request trailers (second HEADERS received) | §4.1 | Compliant | H3RequestStream::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-H3 | RFC 9220 | Compliant | Server 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 Prioritization | RFC 9218 | Compliant | Priority 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.1 | Compliant | SETTINGS_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 capsule | RFC 9297 §3 | Compliant | capsule.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 §6 | Compliant | Full 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.1 | Compliant | H3UniStream'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.2 | Compliant | New 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.2 | Compliant | H3UniStream::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) | §8 | Compliant | New 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 table | RFC 9204 §3.2,§4.3,§4.4 | Compliant | Real 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 coding | RFC 9204 §4.1.2/Appendix A | Compliant | Field-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/A | Early 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
| Requirement | Section | Status | Notes |
|---|---|---|---|
| ALPN/TLS1.3, control+QPACK streams, request bi-stream, pseudo-headers | §3,§6.1-6.2,§4.3.1 | Compliant | h3/client.rs:35-99 |
| HEADERS+DATA request framing, FIN to complete | §4.1 | Compliant | h3/client.rs:167-176 |
:status extraction from response | §4.3.2 | Compliant | validate_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.2 | Compliant | H3ClientStream::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.1 | Compliant | h3/client.rs:193-252 |
| Request trailers (client-sent) | §4.1 | Compliant | ClientWriter::trailers (default no-op for H1/H2); H3 client encodes a second HEADERS frame before stream FIN (h3/client.rs) |
| GOAWAY reception | §5.2 | Compliant | Shared 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 9220 | Compliant | H3ClientStream::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 | — | Compliant | h3/endpoint.rs:487-536 integration test, real client GET through connect_h3/listen_h3 |
QPACK — RFC 9204
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Required Insert Count / Base encoding | §4.5.1.1-2 | Compliant | Real 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-6 | Compliant | All 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.3 | Compliant | Write + 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.4 | Compliant | Write + 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-3 | Compliant | Per-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 A | Compliant | Full 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.
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Entity-tag syntax, strong and weak comparison | RFC 9110 §8.8.3 | Compliant | caching::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 order | RFC 9110 §13.2.2 | Compliant | caching::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 fields | RFC 9110 §13.1.3-4 | Compliant | Invalid 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 resolution | RFC 9110 §5.6.7 | Compliant | A 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 one | RFC 9110 §13.1, §15.4.5 | Compliant | ConditionalServerFactory (on by default in HttpServer) for GET/HEAD; HTTP/3 listeners wrap it explicitly |
| 304 repeats the fields a 200 would carry | RFC 9110 §15.4.5 | Compliant | Cache-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 412 | RFC 9110 §13.2.1 | Compliant | Only a 200 is converted; every other status passes through |
| Preconditions on state-changing methods | RFC 9110 §13.1 | Partial | A 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-Match | RFC 9110 §13.1.1 | Partial | WebDAV 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 generation | RFC 9111 §5.2.2 | Compliant | CacheControl 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 |
Expires | RFC 9111 §5.3 | Compliant | Set by the handler using format_http_date; ignored by caches when max-age is present |
If-Range and range requests | RFC 9110 §13.1.5, §14 | Not implemented | No Range support anywhere in hopf-http, so there is nothing for If-Range to guard |
Cache storage, freshness, Age, invalidation | RFC 9111 §3-4 | N/A | Not a cache. See the scope note above |
HTTP Strict Transport Security — RFC 6797
| Requirement | Section | Status | Notes |
|---|---|---|---|
Field syntax: max-age, includeSubDomains, preload | §6.1 | Compliant | HstsPolicy 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.2 | Compliant | Added 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.1 | Compliant | HstsServerFactory 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.1 | Partial | HttpServer::hsts covers TCP listeners; listen_h3 takes a factory directly, so it must be wrapped with HstsServerFactory by hand |
| Enabled by default | — | N/A | Deliberately 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.3 | Not implemented | hopf-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) | §8 | N/A | Client-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().
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Connection ID generation, packet number spaces, loss detection | §5,§13 | Partial | Packet 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 deadline | RFC 9002 | Compliant | Driver::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 | §7 | Compliant | driver.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.1 | Compliant | Endpoint::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/§4 | Compliant | Connection::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.2 | Compliant (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.3 | Not implemented | No 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.1 | Not 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.3 | Compliant | Event::Connected (transport/types.rs:187) → on_connected (driver.rs:1325) |
| TLS 1.3-only pinned for QUIC | RFC 9001 | Compliant, but see the key-exchange row below | Every 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.1 | Compliant, configurable | 30s 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.2 | Compliant | Opt-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.2 | Compliant | QuicStreamEndpoint::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 2 | RFC 9369 | Implemented (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 parameter | RFC 9368 §3-§4 (required by RFC 9369 §4) | Compliant | Sent 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 size | RFC 9001 §5.1, §5.2; RFC 9000 §14.1 | Compliant | Verified 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 IDs | draft-ietf-quic-load-balancers-21 | Implemented (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 | §9 | Not implemented — structurally inert | Connection::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.1 | Compliant | hopf-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.1 | Partial | Peer-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) | Compliant | Driver::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.6 | Compliant | Exhausted 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.5 | Compliant | QuicStreamEndpoint::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.6 | Compliant | QuicStreamEndpoint::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 9221 | Compliant | QuicTransportOptions 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 9221 | Compliant | Event::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 accurately | RFC 7301 | Compliant | security_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 layer | RFC 9001 §4.6.1 | Compliant (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 path | RFC 9001 §4.6.1 | Compliant (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) | — | Compliant | stream.rs:281-283 always returns Unsupported |
| Timer integration routed through the QUIC driver thread, not a TCP reactor | — | Compliant | QuicStreamEndpoint::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.19 | Compliant | Event::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
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Target URI template parsing | §2 | Compliant | parse_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 | §3 | Compliant | crate::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 | §5 | Compliant | hopf_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, §7 | Compliant | ConnectUdpFactory/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 | — | Compliant | Self-rearming timer, DEFAULT_IDLE_TIMEOUT = 5 minutes, overridable via ConnectUdpFactory::with_idle_timeout |
| Target authorization policy | — | Compliant | ConnectUdpPolicy::is_target_allowed is checked against the resolved address, not the original hostname; no permissive default exists anywhere in the crate |
| Client dial + tunnel | §3 | Compliant (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 §6 | Not implemented | Every 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
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Target/ipproto URI template parsing, including wildcards | §3 | Compliant | parse_connect_ip_target — IpTarget::Wildcard/Named(String), IpProto::Wildcard/Number(u8), either literal * |
| Extended CONNECT / HTTP/1.1 Upgrade acceptance | §3 | Compliant | Shares crate::accept with CONNECT-UDP |
| IP packet relaying / forwarding | §5 | Not 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.2 | Compliant | ip_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.3 | Compliant | ip_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-4 | Compliant (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 | — | Compliant | ConnectIpPolicy::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
| Requirement | Section | Status | Notes |
|---|---|---|---|
Capsule-Protocol header required on every accept and every request | §3.1 | Compliant | hopf_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 §5 | Compliant | Shared 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.2 | Compliant | ProtocolUpgradeHandler::capsule_received; an unrecognized capsule type is ignored by default |
| Native QUIC DATAGRAM frame fast path bypassing Capsule Protocol on H3 | §6 | Not implemented | Every payload takes the capsule-framed path even over H3 — see the matching CONNECT-UDP row above |
WebDAV — RFC 4918 (Class 1+2)
HTTP Methods
| Requirement | Section | Status | Notes |
|---|---|---|---|
| GET/HEAD/OPTIONS/PUT | RFC 9110 | Compliant | handler.rs:245-290,307-420 |
| DELETE (files) | RFC 9110 §9.3.5 | Compliant | handler.rs:422-454 |
| DELETE (collections) | RFC 4918 §9.6.1 | Compliant | Recursive member deletion continues after failures and returns 207 Multi-Status naming failed hrefs (424/403); full success remains 204 |
| If-Modified-Since / 304 | RFC 9110 §13.1.3 | Compliant | hopf_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 detection | RFC 9110 §8.8.3,§8.3 | Compliant | Weak MD5-based ETag (handler.rs:810-825) |
WebDAV Methods
| Requirement | Section | Status | Notes |
|---|---|---|---|
| PROPFIND (allprop/propname/prop) | §9.1 | Compliant | Depth 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.2 | Compliant | 207 propstat lists each set/removed property by name |
| MKCOL | §9.3 | Compliant | create_dir only (409 when a parent is missing); non-empty request body → 415 |
| COPY | §9.8 | Compliant | Depth: 0 copies a collection without members; infinity (default) remains recursive, capped by WebDavConfig::max_tree_entries (507 when exceeded) |
| MOVE | §9.9 | Compliant | fs::rename is inherently whole-subtree, matching required Depth-infinity semantics |
| LOCK (new + refresh), UNLOCK | §9.10-9.11 | Compliant | handler.rs:538-596, lock.rs:183-227 |
| Locked empty resources (LOCK unmapped URL) | RFC 4918 §7.3 | Compliant | LOCK 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 | §13 | Compliant | Streaming writer/parser, split-feed tested |
Headers, Locking, Live Properties
| Requirement | Section | Status | Notes |
|---|---|---|---|
| DAV/Depth/Destination/If/Lock-Token/Overwrite/Timeout headers | §10 | Compliant | Full grammar including weak-ETag comparison (if_header.rs); COPY honours Depth 0/infinity |
| Exclusive/shared write lock, conflict detection, Depth-infinity coverage, expiry cleanup | §6 | Compliant | lock.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.10 | Compliant | creationdate 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.11 | Compliant | handler.rs:1018-1073 |
| Dead properties (xattr/sidecar/none) | §4 | Compliant | dead_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/A | Explicit scope: Class 1+2 only, compose with HTTP auth |
WebSocket — RFC 6455 / RFC 8441 / RFC 9220
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Upgrade/Connection/Key/Version checks, Accept calculation, 101 | §4.2 | Compliant | handshake.rs, RFC golden vector tested |
| Origin check (browser CSRF) | §10.2 | Compliant (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.2 | Compliant | negotiate_subprotocol: echoes configured protocol only when present in the client's offer list |
| permessage-deflate / extensions | §9.1 | Not implemented | Matches docs Limitations (RSV must be zero) |
| Client: key generation | §4.1 | Compliant | handshake.rs |
| Client: 101 response validation | §4.1 step 5 | Compliant | validate_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.2 | Compliant | frame.rs |
| Masking direction (server rejects unmasked in, never masks out; client reverse) | §5.1/§5.3 | Compliant | frame.rs, session.rs |
| Protocol-error handling forces a real close | §7.1.7 | Compliant | Writes Close (1002/1007/1009), sets dead, and wants_close() ends the H1 connection or H2/H3 stream |
| Text (UTF-8 validated) / binary frames | §5.6 | Compliant | upgrade.rs |
| Fragmentation reassembly | §5.4 | Compliant | Buffers non-FIN data + continuation up to max_payload; delivers on FIN |
| Standalone continuation frame (no fragment in progress) | §5.4 | Compliant | Protocol error → Close 1002 |
| Max message size enforcement | §7.4.1 | Compliant | Default 16 MiB; exceeding emits Close 1009 |
| Control frame max 125 bytes + FIN; ping auto-pong; pong dispatch | §5.5 | Compliant | frame.rs, default trait method |
| Close frame parse/reply; abnormal closure synthesizes 1006 | §7.1 | Compliant | upgrade.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.4 | Compliant | is_valid_close_code; invalid → Close 1002 |
| HTTP/2 and HTTP/3 Extended CONNECT detection + 200 response | RFC 8441 §4 / RFC 9220 | Compliant | handshake.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.
| Requirement | Status | Notes |
|---|---|---|
| 5-byte length-prefixed framing, split-feed parsing | Compliant | framing.rs:104-282, tested at arbitrary split points |
| Compressed frames (flag ≠ 0) rejected | Compliant (matches declared scope) | framing.rs:225-231 |
| Max message size enforcement (4 MiB default, configurable) | Compliant | DEFAULT_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-type | Compliant | parse_grpc_content_type — exact media type (optional ; params); case-insensitive; JSON via rjsonparser + proto3 JSON mapping |
Unsupported subtypes (e.g. +thrift, grpc-web*) rejected | Compliant | HTTP 415 — only +proto (default) and +json are decoded |
POST /{service}/{method} routing | Compliant | server.rs:330-333, proto_file.rs:42-54 |
grpc-status/grpc-message trailers (success) and Trailers-Only (error) | Compliant | server.rs:250-271 |
| Full gRPC status-code space representable | Compliant | Any caller-supplied code accepted; only UNIMPLEMENTED/INTERNAL auto-generated, no named enum for the other 15 |
| One response message per unary call enforced | Compliant | server.rs:71-142 |
| Client/server/bidirectional streaming | Deferred (documented) | Only start_unary_call exists on GrpcService |
Runtime .proto parsing via rprotobuf, no codegen | N/A — architectural choice | Functionally equivalent to codegen-based gRPC, resolved per-request instead |
| H1 trailer support | Deferred (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)
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Standard RR types (A/AAAA/CNAME/NS/PTR/MX/TXT/SOA + SRV/OPT/DNSSEC types) | §3.2-3.4 | Compliant | wire/type.rs:6-41, unknown types preserved raw (RFC 3597) |
| SOA / SRV RDATA decode accessors | §3.3.13, RFC 2782 | Compliant | New 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.4 | Compliant | Enforced 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.3 | Compliant | wire/name.rs:14-20 |
| Header/flags/OPCODE/RCODE, Z-bit masking | §4.1.1 | Compliant | wire/message.rs:16-131 |
| Response echoes the query's OPCODE | §4.1.1 | Compliant | DnsMessage::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 refused | RFC 1996, RFC 2136 | Compliant | DnsQueryHandler::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.2 | Compliant | See the DNSSEC table below for details |
| Unknown QTYPE/QCLASS in the question section | §4.1.2, RFC 3597 | Compliant | DnsQuestion 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.4 | Compliant | wire/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 parse | RFC 3597 §4 | Compliant | wire/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.2 | Compliant | Client and DoT/DoQ server framing |
| TCP fallback on truncation — client | §4.2.1 | Compliant | client/mod.rs:523-545 |
| TCP fallback on truncation — forwarder | §4.2.1 | Compliant | The 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.1 | Compliant | DnsService::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)
| Requirement | Section | Status | Notes |
|---|---|---|---|
Master file format: $ORIGIN, $TTL, $INCLUDE, @, blank owner, ; comments, parenthesised groups, quoted strings and escapes | RFC 1035 §5 | Compliant | server/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 types | RFC 3597 §5 | Compliant | \# length hex read and written for any type |
| Zone lookup algorithm: exact match, CNAME, referral at a cut, wildcard, NXDOMAIN | RFC 1034 §4.3.2 | Compliant | Zone::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 name | RFC 4592 §3.3 | Compliant | Not applied to a name that exists (including an empty non-terminal) |
| Empty non-terminals are NODATA, not NXDOMAIN | RFC 8020 §2 | Compliant | Descendant counts kept per name |
| Negative answers carry the SOA, TTL = min(SOA TTL, MINIMUM) | RFC 2308 §2-3 | Compliant | Zone::negative_soa |
Minimal ANY response | RFC 8482 | Compliant | Single HINFO "RFC8482" ""; switchable |
| Glue in the additional section for in-zone NS and MX targets | RFC 1034 §4.3.2, RFC 1035 §3.3.9 | Compliant | Includes glue below a zone cut |
| Exactly one SOA at the apex; owners within the zone; CNAME alone; RRset TTLs equal; duplicate RRs collapsed | RFC 1034 §3.6.2, RFC 2181 §5 | Compliant | Enforced at load, transfer and update |
| EDNS: OPT echoed with DO; version above 0 is BADVERS | RFC 6891 §6.1.1, §6.1.3 | Compliant | DNSSEC 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 primary | RFC 1996 | Compliant | ZoneOptions::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 bump | RFC 2136 §3 | Compliant | server/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 authorisation | RFC 2136 §3.3 | Compliant | Default 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 UDP | RFC 5936 §2, §4 | Compliant | Default deny (allow_transfer); verified against BIND dig |
| IXFR: current / differences / AXFR-style fallback; RFC 1982 serial arithmetic | RFC 1995, RFC 1982 | Compliant | In-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 first | RFC 1034 §4.3.5, RFC 1035 §3.3.13 | Compliant | server/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 fudge | RFC 8945 | Compliant | tsig.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 rollover | RFC 4034, 5155 | N/A | Out of scope; a pre-signed zone loads and serves as data |
| Zone write-back and secondary persistence | — | Compliant | Atomic temp-file-and-rename to the zone's own file; ZoneFileMode::ReadOnly never writes |
Resolver Behaviour and Cache-Poisoning Resilience
| Requirement | Section | Status | Notes |
|---|---|---|---|
| RD set, cache used (TTL-based), CNAME chase (depth-limited) | §7.1,§7.4 | Compliant | client/mod.rs:292-365, cache.rs:101-180, MAX_CNAME_DEPTH=8 |
| Retry across multiple configured servers | §7.2 | Compliant | A 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 unpredictability | RFC 5452 §2.1/§9.1 | Compliant | DnsQueryIdGenerator 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 query | RFC 5452 §3 | Compliant | PendingQuery 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 section | RFC 5452 §3 | Compliant | The 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 | — | Compliant | bailiwick.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.1 | Compliant | cookie.rs, client cookie + server secret from getrandom (fail closed; no time-based fallback) |
| Server cookie generation | RFC 7873 §5.2 | Compliant | DnsCookie::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 queries | RFC 7873 §5.2 | Compliant | DnsService::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
| Requirement | Section | Status | Notes |
|---|---|---|---|
| OPT pseudo-RR, UDP payload size, DO bit, server strips DNSSEC RRs when DO absent | RFC 6891 §6.1-6.2 | Compliant | wire/rr.rs:158-178, server/mod.rs:143-151 |
| Extended RCODE / EDNS VERSION field | §6.1.3 | Compliant | DnsResourceRecord::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 query | RFC 7858 §3 | Compliant | server/dot.rs, client/tcp.rs:56-79 |
| DoT: ALPN "dot" | — | Needs verification | No ALPN constant/negotiation in hopf-dns itself; delegated to the caller-supplied TLS acceptor/connector |
| DoT/TCP connection reuse | RFC 7766 §6.2.1 | Compliant | TcpDnsConnectionPool (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 prefix | RFC 9250 §4.2 | Compliant | client/doq.rs, server/doq.rs |
| DoQ: message ID MUST be 0 | RFC 9250 §4.2.1 | Compliant | Both 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_STREAM | RFC 9250 §4.3 | Partial | DOQ_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 queries | RFC 9250 §4.5,§5.5.1 | Compliant (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 padding | RFC 9250 §5.4, RFC 7830 | Partial | wire/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 transport | RFC 8484 §4.1,§5.1 | Compliant | client/doh.rs |
| DoH: GET method support | RFC 8484 §4.1 (SHOULD support both) | Compliant | DohClientTransport::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/A | Explicitly out of scope |
Negative Caching and DNSSEC
| Requirement | Section | Status | Notes |
|---|---|---|---|
| NXDOMAIN negative caching, SOA-MINIMUM-derived TTL, expiry | RFC 2308 §3,§5 | Compliant | cache.rs:87-219 |
| Serve-Stale: answer from expired data when a refresh fails | RFC 8767 | Compliant | ForwarderHandler + 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 name | RFC 8020 | Compliant | DnsCache::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 8482 | Compliant | ForwarderHandler: 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 caching | RFC 2308 §2 | Compliant | DnsCache::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 8198 | Partial | dnssec/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 states | RFC 4033 §5 | Compliant | dnssec/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-6 | Compliant | dnssec/validator.rs:128-261, dnssec/crypto.rs — this validates actual cryptographic signatures, not just record parsing |
| Ed448 signature verification | RFC 8080 | Compliant | dnssec/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 verification | RFC 4034 §4, RFC 5155 | Compliant | NSEC/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 honoured | RFC 4035 §3.2 | Compliant | DnsResolver'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.1 | Compliant | DnssecChainWalk (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)
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Probing before claiming a name, proposed record in Authority section | §8.1 | Compliant | responder.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.1 | Compliant | An incoming response asserting the candidate name restarts probing under a renamed candidate (label-2.local, ...) |
| Simultaneous-probe tie-break | §8.2 | Compliant (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.3 | Compliant | announce_count (default 2) announcements, announce_interval apart, carrying every published record |
| Known-answer suppression | §7.1 | Compliant | A 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.4 | Compliant | bits::unicast_response_requested; a query with any QU-flagged question gets a unicast, not multicast, reply |
| Goodbye (TTL-0 departure announcement) | §10.1 | Compliant | Sent on MdnsService::goodbye(), on Drop (best-effort, if still announced), and per-service on ServiceHandle::drop |
Querier and Cache (RFC 6762 §5, §10)
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Active TTL-fraction cache refresh (80/85/90/95%) | §5.2 | Compliant | cache::REFRESH_FRACTIONS; generation-guarded against stale timers after a re-upsert |
| Cache-flush bit honoured on ingest | §10.2 | Compliant | Records 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.1 | Compliant | Same 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) | §3 | N/A | Out of scope; IPv4 only |
DNS-SD (RFC 6763)
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Service instance enumeration: PTR + SRV + TXT | §4 | Compliant | dnssd::build_records; PTR shared (not cache-flush, §10.1), SRV and TXT unique (cache-flush) |
_services._dns-sd._udp meta-query PTR | §9 | Compliant | Published alongside every registered service type |
| TXT record attribute encoding | §6.1 | Compliant | key=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) | §4 | Compliant | MdnsService::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.3 | Not implemented | Special characters (., \) in an instance name are not escaped |
| SRV priority/weight | §4.1.2 | N/A | Always published as 0/0; not configurable per registration |
| Central service registry (pull-based advertisement) | — | N/A — architectural choice | Every 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.
| Capability | Section | Status | Notes |
|---|---|---|---|
| BER codec (definite length) | ITU-T X.690 (LDAP subset) | Compliant | hopf_ldap::asn1; indefinite length rejected — LDAP-oriented BER, not full DER/CER |
| Bind (simple, anonymous), Search, Unbind | RFC 4511 §4.2-4.5 | Compliant | LdapSession::bind/bind_anonymous/search/unbind; RFC 4515 filter strings compiled to BER |
| LDAPS (implicit TLS) | RFC 4513 §3 | Compliant | LdapClientConfig::with_tls; session withheld until security_established |
| StartTLS | RFC 4511 §4.14 | Compliant | LdapSession::start_tls after an ExtendedResponse for OID 1.3.6.1.4.1.1466.20037; mutually exclusive with LDAPS on one dial |
| Referral chase | RFC 4511 §4.1.10, RFC 4516 | Compliant (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 side | RFC 4533 | Compliant | LdapSession::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, Abandon | RFC 4511 §4.1.11-4.1.12 | Compliant | Message layer carries controls both ways (Control, encode_search_request_with_controls), built to layer further controls beyond Sync |
LdapCredentialStore (search-then-bind) | — | Compliant | PLAIN/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.10 | Not implemented | Deferred — non-goal for a CredentialStore-focused client |
| SASL bind to the directory | RFC 4513 §5 | Not implemented | Store uses simple bind only |
Role / memberOf lookup API | — | Not implemented | No 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
| Requirement | Section | Status | Notes |
|---|---|---|---|
| SOCKS4 CONNECT/BIND | Historical protocol description | Compliant | wire.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 description | Compliant | Resolved server-side via hopf-dns; connect.rs |
| SOCKS5 method negotiation, CONNECT/BIND/UDP ASSOCIATE | §3–4 | Compliant | handler.rs dispatch; IPv4/IPv6/domain-name addressing (§5) |
| UDP ASSOCIATE datagram relay | §7 | Compliant (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 | — | Compliant | Every resolved address is checked, not just the first — a multi-answer DNS response can't bypass the policy by ordering |
Server — Authentication and Policy
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Username/password authentication | RFC 1929 §2 | Compliant | SocksAuthenticator trait (auth.rs); configuring one withdraws no-auth from SOCKS5's offered methods and rejects SOCKS4/4a outright (no credential field to check) |
| GSSAPI authentication | RFC 1961 | Not implemented | No 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) | — | Compliant | SocksPolicy 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 | — | Compliant | SocksService::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 | — | Compliant | SocksService::with_tls; transport-level only, no protocol state machine changes needed |
Client
| Requirement | Status | Notes |
|---|---|---|
| CONNECT client (SOCKS4/4a/5) | Compliant | SocksConnectHandler/socks_connect_config (client.rs) — a transport decorator forwarding every callback to a caller-supplied inner ProtocolHandler once the tunnel is up |
| BIND client | Compliant | SocksBindHandler/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 client | Compliant | SocksUdpAssociateHandler/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) | Compliant | SocksClientConfig::with_credentials; silently unused under SOCKS4/4a |
| Handshake timeout with proper error delivery | Compliant | A 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
| Requirement | Section | Status | Notes |
|---|---|---|---|
| TYPE A/I (E and L not implemented, by design) | §3.1.1 | Compliant | control.rs TYPE dispatch |
| STRU F only (R/P not implemented) | §3.1.2 | Compliant (declared scope) | RFC says R SHOULD be accepted for text types, hopf doesn't |
| ASCII CRLF normalization — download (RETR) | §3.1.1.1 | Compliant | ascii.rs + RETR path in data.rs |
| ASCII CRLF normalization — upload (STOR/APPE) | §3.1.1.1 | Compliant | AsciiNewlineDenormalizer on TYPE A STOR/APPE via StorTransfer::ascii |
| USER/PASS/ACCT/CWD/CDUP/SMNT/REIN/QUIT | §4.1.1 | Compliant | |
| PORT/PASV, active-mode bounce protection | §4.1.2 | Compliant | |
| Passive-mode same-IP verification | RFC 4217 §10 (security practice) | Compliant | FtpDataHandler 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.3 | Compliant | |
| STOU | §4.1.3 | Compliant | generate_unique_name uses a per-process counter + nanos; 150 reply includes FILE: {path} |
| REST (STREAM) | §4.1.3 | Compliant | Honoured by RETR and STOR; APPE ignores restart (append semantics) |
| RNFR/RNTO, DELE, RMD/MKD, PWD, LIST/NLST | §4.1.3 | Compliant | |
| ABOR | §4.1.3 | Compliant | Closes the data connection; replies 426 then 226 when a transfer was in progress, else 226 only |
| STAT with a pathname argument | §4.1.3 | Compliant | Listing sent over the control connection as multiline 213 (no data connection) |
Server — TLS (RFC 4217), IPv6/NAT (RFC 2428), Extensions
| Requirement | Section | Status | Notes |
|---|---|---|---|
| AUTH TLS, explicit-mode cleartext-first, implicit FTPS, PBSZ/PROT C | §4,§8-9 | Compliant | control.rs:836-873 |
| PROT P — passive-mode data channel | §9 | Compliant | control.rs:383-385,428-430; FtpDataHandler waits for security_established |
| PROT P — active-mode data channel | §9 | Compliant | FtpConfig.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 | — | Compliant | check_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 reply | RFC 2428 §2-3 | Compliant | net-prt validated against address family; mismatch → 522 |
| EPSV ALL | RFC 2428 §4 | Compliant | Session flag rejects subsequent PORT/PASV/EPRT with 501; cleared on REIN |
| FEAT / OPTS UTF8, only-advertise-what's-implemented | RFC 2389 | Compliant | |
| SIZE | RFC 3659 §4 | Compliant | |
| MDTM (RFC 3659 timestamp format) | RFC 3659 §2.3,§3 | Compliant | YYYYMMDDHHMMSS UTC via format_ftp_mtime |
| MLST/MLSD fact set | RFC 3659 §7.5 | Compliant | Emits Type=/Size=/Modify=/Perm=; FEAT advertises the same |
| OPTS UTF8, non-ASCII rejection without it, charset substitution | RFC 2640 §4 | Compliant | utf8.rs |
Server — Filesystem Containment and Auth (security-critical)
| Requirement | Status | Notes |
|---|---|---|
../symlink escape prevention for existing paths | Compliant | fs.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) | Compliant | When 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 operations | Compliant | Called 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 mapping | Compliant | handler.rs:114-134, matches hopf's own docs example exactly |
Client
| Requirement | Status | Notes |
|---|---|---|
| Control state machine, multiline reply parsing, RETR/STOR/LIST, arbitrary command + expected-code wait, PASV/EPSV, PORT/EPRT, QUIT, per-phase timeouts | Compliant | Full 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 client | Compliant | FtpClient::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 client | Compliant | FtpClient::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
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Greeting, HELO/EHLO, MAIL FROM/RCPT TO (+ ESMTP params), DATA + dot-unstuffing, RSET/QUIT/NOOP/HELP/VRFY/EXPN | §4.1.1,§4.2 | Compliant | control.rs, delivery.rs, data.rs:44-171 |
| Command sequencing, multi-line responses, enhanced status codes | §4.5.1,§4.2, RFC 2034 | Compliant | SmtpSessionState-gated dispatch |
| Max command line length enforcement | §4.5.3 | Compliant | Over-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 deadlock | RFC 2920 | Compliant | receive_inner drains and dispatches all ready commands per call; DATA/BDAT leftover bytes correctly re-fed |
Server — Extensions
| Extension | RFC | Status | Notes |
|---|---|---|---|
| SIZE (advertised + enforced), 8BITMIME, SMTPUTF8, PIPELINING, ENHANCEDSTATUSCODES | 1870,6152,6531,2920,2034 | Compliant | All wired end-to-end |
| CHUNKING/BDAT, BINARYMIME | 3030 | Compliant | control.rs:511-591,660-691, data.rs:193-222 |
| DSN RET/ENVID (MAIL FROM), NOTIFY/ORCPT (RCPT TO) | 3461 §4 | Compliant | Parsed into DeliveryRequirements/DsnRecipientParams; stock handlers generate RFC 3461 multipart/report DSNs honouring NOTIFY/RET/ENVID/ORCPT (null reverse-path → no DSN) |
| DELIVERBY, MT-PRIORITY | 2852,6710 | Compliant | Advertised in EHLO; MT-PRIORITY −9..=9 validated at parse; past DELIVERBY deadline rejected at MAIL FROM and again at delivery in stock handlers |
| REQUIRETLS | 8689 | Compliant | Advertised; 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) | 4865 | Compliant (as rejection) | Parsed then explicitly rejected with 553 by both stock handlers rather than honoured — documented |
| LIMITS | 9422 | Compliant | EHLO advertises LIMITS RCPTMAX=… MAILMAX=…; session counters enforce both (SmtpConfig::max_recipients / max_mail_transactions) |
| STARTTLS | 3207 | Compliant | Session/EHLO state correctly reset after upgrade |
| AUTH mechanisms | 4954 | Compliant | Full 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 (*) | 4954 | Compliant | control.rs:786-816 |
| Implicit TLS (SMTPS) | 8314 | Compliant | control.rs:983-993 |
| XCLIENT | Postfix ext. | Compliant | ACL-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
| Item | Status | Notes |
|---|---|---|
| Staged handler SPI (connect→hello→mail→rcpt→data) | Compliant | handler/mod.rs:40-111 |
SimpleRelayService — open MX relay | Deferred (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 semantics | Compliant | Post-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-mailbox | Compliant | mailbox/service.rs,handler.rs, case-insensitive domain check at RCPT TO |
| Local delivery multi-recipient failure semantics | Compliant | Every recipient is attempted; partial success returns 250 with failure DSNs for the rest (avoids duplicate delivery on client retry) |
Client
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Greeting, EHLO + HELO fallback, MAIL/RCPT/DATA + dot-stuffing, RSET/QUIT, STARTTLS, implicit TLS, 421-any-state, multi-line parsing | various | Compliant | client/endpoint.rs,pipeline.rs |
AUTH — SmtpSend auto-pilot drives the full mechanism menu | 4954 | Compliant | SmtpSendDriver::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 2920 | Compliant | When EHLO advertises PIPELINING, SmtpSend batches MAIL+RCPT×N+DATA (DATA last); replies drained via a pending-command FIFO |
| BDAT/CHUNKING (client send) | 3030 | Compliant | When 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.) | various | Compliant | SmtpSend::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)
| Requirement | Section | Status | Notes |
|---|---|---|---|
SPF check_host(): all/ip4/ip6/a/mx/include/exists/ptr mechanisms, redirect=/exp= modifiers, macro expansion, 10-lookup + 2-void-lookup limits | 7208 §4-7 | Compliant | auth/spf.rs; null MAIL FROM falls back to the HELO domain per §4.3 |
SPF %{p} ("validated domain") macro | 7208 §7.3 | Deferred (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= names | 6376, 8463 | Compliant | auth/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, 8463 | Compliant | auth/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 downgrade | 7489 §6-7 | Compliant | auth/dmarc/mod.rs; alignment checks every verified DKIM signature, not just the first, per §3.1.2 |
DMARCbis np= / psd= | draft-ietf-dmarc-dmarcbis | Compliant | np= 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.2 | Compliant | auth/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 XML | 7489 Appx. C | Compliant | auth/dmarc/aggregate.rs renders the <feedback> document from app-supplied traffic records; sending/scheduling reports is app-driven, same as Gumdrop |
| DMARC forensic ("ruf") report | 6591, 5965 (ARF) | Compliant | auth/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 | — | Compliant | auth/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-oldest | 8617 §4-5.2 | Compliant | auth/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 sets | 8617 §5.1 | Compliant | auth/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 evaluates | 8617 §1, §7 | Compliant (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-Results | 8617 §7 | Partial | arc=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
| Requirement | Section | Status | Notes |
|---|---|---|---|
| PLAIN, LOGIN, CRAM-MD5, DIGEST-MD5 | 4616, draft-murchison, 2195, 2831 | Compliant | hopf-auth/src/{plain,login,cram_md5,digest_md5}.rs, round-trip tested |
| SCRAM-SHA-256 / SCRAM-SHA-256-PLUS | 5802/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) |
| OAUTHBEARER | 7628 | Compliant | No structured JSON error-challenge on failure per §3.2.2, but plain failure is spec-permitted |
| EXTERNAL | 4422 App. A | Compliant | Requires TLS layer to supply a peer certificate first |
| Proxy authorization (authzid) | 4422 §4.2 | Compliant | Used by PLAIN and EXTERNAL |
| GSSAPI | 4752 | Deferred (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 nonces | various | Compliant | crypto.rs; constant-time compares used consistently across CRAM-MD5/SCRAM/DIGEST-MD5/HTTP Digest |
PasswordStore demo-grade / in-memory | — | Documented (demo); LDAP + PAM backends available | PasswordStore 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 factories | 7616,7617,6750 | Compliant | http_digest.rs, hopf-http/src/auth.rs |
| HTTP Digest server-side nonce replay protection | 7616 | Compliant | hopf-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,7662 | Compliant (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.
| Capability | Status | Notes |
|---|---|---|
| OTLP/HTTP export (protobuf) | Compliant | /v1/logs, /v1/traces, /v1/metrics; validated URLs, errors if no sink is configured |
| JSONL file export | Compliant | Optional sink alongside or instead of OTLP, per signal (logs/traces/metrics) |
| Off-hot-path batching | Compliant | Hot-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) | Compliant | TelemetryHook, attached via Runtime::start_with_telemetry / Composition::new_with_telemetry; applies to every protocol |
| HTTP Stream instrumentation (SERVER spans, RED metrics) | Compliant | InstrumentedServerFactory, instruments at the Stream handler layer, not at TCP accept |
| W3C Trace Context propagation | Compliant | traceparent 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 wire | N/A | No 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 exporter | Not implemented | HTTP/protobuf only |
| Overflow handling | Compliant (documented) | Bounded export queue drops on overflow rather than blocking hot-path callers |
POP3 — RFC 1939
Server
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Three-state model, greeting, 512-octet command cap, auto-logout timer, dot-stuffing | §3-4 | Compliant | session.rs, control.rs, egress.rs |
| USER/PASS, APOP (MD5 digest, constant-time compare) | §7 | Compliant | auth.rs:22-37 |
AUTH (SASL, RFC 5034) — initial response, cancel with * | 5034 §4 | Compliant | control.rs:376-484 |
| All 7 SASL mechanisms wired (PLAIN/LOGIN/CRAM-MD5/DIGEST-MD5/SCRAM-SHA-256/OAUTHBEARER/EXTERNAL, no GSSAPI) | various | Compliant | control.rs:397-413 — the one protocol crate that connects the full hopf-auth mechanism set |
| STLS (pre-auth only) | RFC 2595 §4 | Compliant | control.rs:283-299 |
| UTF8 | RFC 6856 §3 | Compliant | CAPA 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,§7 | Compliant | All 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 | §6 | Compliant | Exact 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) | 2449 | Compliant | control.rs:242-281 |
| Implicit TLS (POP3S), SASL TLS-only mechanism gating, login delay after failed auth | RFC 8314,5034 | Compliant |
Client
| Requirement | Status | Notes |
|---|---|---|
| Greeting parsing, APOP timestamp extraction, implicit TLS, STLS upgrade | Compliant | client/endpoint.rs,facade.rs |
| USER/PASS, APOP, AUTH (SASL) with initial response + continuation/abort, CAPA discovery | Compliant | client/endpoint.rs:861-957 |
| STAT/LIST/RETR/DELE/RSET/TOP/UIDL/NOOP/QUIT, streaming RETR/TOP via a cross-buffer-safe dot-unstuffer | Compliant | client/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, QUIT | Compliant | client/pipeline.rs:261-519 |
| Full SASL exchange in the auto-pilot (beyond PLAIN) | Compliant | 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 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
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Greeting + inline CAPABILITY, state machine, command pipelining (queued, answered in order) | §3,§7.1 | Compliant | server/control/mod.rs:432-560 |
| PREAUTH greeting | §7.1 | Compliant | ImapConfig::preauth_username / with_preauth — opens the user's store then greets with * PREAUTH [CAPABILITY …] |
| CAPABILITY/NOOP/LOGOUT | §6.1 | Compliant | |
| STARTTLS | §6.2.1 | Compliant | |
| AUTHENTICATE (SASL mechanisms beyond PLAIN) | §6.2.2 | Compliant | All 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 4959 | Compliant | |
| LOGIN, LOGINDISABLED/PRIVACYREQUIRED on cleartext | §6.2.3 | Compliant | |
| SELECT/EXAMINE (FLAGS/PERMANENTFLAGS/EXISTS/UIDVALIDITY/UIDNEXT), CREATE/DELETE/RENAME, SUBSCRIBE/UNSUBSCRIBE, LIST/LIST-EXTENDED, LSUB, STATUS, APPEND (+ literal streaming) | §6.3 | Compliant | Unsolicited RECENT omitted (IMAP4rev2); STATUS RECENT still answered when requested |
| NAMESPACE | RFC 2342 | Compliant | Personal namespace from the mailbox store; other-users / shared configurable via ImapConfig::other_users_namespaces / shared_namespaces (default NIL) |
| IDLE | RFC 2177 | Compliant | Continuation/DONE loop; 1s poll refreshes Maildir and emits untagged EXISTS / EXPUNGE / FETCH FLAGS; auto-completes after idle_max_duration (default 29 minutes) |
| GETQUOTA/GETQUOTAROOT/SETQUOTA | RFC 9208 §3 | Compliant |
Server — Selected-State Commands
| Requirement | Section | Status | Notes |
|---|---|---|---|
| CLOSE (implicit expunge), UNSELECT (no expunge) | §6.4.1-2 | Compliant | |
| EXPUNGE / UID EXPUNGE | §6.4.3 / RFC 4315 | Compliant | UID EXPUNGE correctly protects out-of-set \Deleted messages |
| SEARCH / UID SEARCH | §6.4.4 | Compliant | See Mailbox section for criteria coverage |
FETCH / UID FETCH — ENVELOPE and BODYSTRUCTURE | §6.4.5 | Compliant (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.5 | Compliant | fetch_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 6851 | Compliant | MOVE is an atomic copy+expunge, correctly emits VANISHED under QRESYNC or per-message EXPUNGE otherwise |
| SORT / UID SORT | RFC 5256 §3 | Compliant | server/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 THREAD | RFC 5256 §2,§4 | Compliant | server/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
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Untagged/tagged/continuation responses, response codes (CAPABILITY/TRYCREATE/APPENDUID/COPYUID/PRIVACYREQUIRED/READ-ONLY/READ-WRITE) | §7 | Compliant | |
| Unsolicited RECENT (IMAP4rev2) | §7.4.1 | Compliant | Not emitted on SELECT/EXAMINE or IDLE/NOOP; solicited STATUS … (RECENT) remains available |
| UIDPLUS (APPENDUID/COPYUID/UID EXPUNGE) | RFC 4315 | Compliant | |
| ENABLE, UNSELECT, ID, LIST-EXTENDED/LIST-STATUS | 5161,3691,2971,5258/5819 | Compliant | |
| CONDSTORE / QRESYNC | RFC 7162 | Compliant (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 7888 | Compliant | |
| COMPRESS=DEFLATE | RFC 4978 | Compliant | compress.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=ACCEPT | RFC 6855 | Compliant | Tracked via ENABLE; no wire-format change needed since IMAP4rev2 responses are already raw UTF-8, not modified UTF-7 |
| STATUS=SIZE | RFC 8438 | Compliant | server/status_items.rs: SIZE status data item (message octets) |
| OBJECTID | RFC 8474 | Compliant (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) |
| METADATA | RFC 5464 | Compliant (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 |
| NOTIFY | RFC 5465 | Compliant (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
| Requirement | Status | Notes |
|---|---|---|
| Plaintext/implicit TLS, STARTTLS, greeting parsing (OK/PREAUTH/BYE), CAPABILITY from greeting | Compliant | |
| LOGIN, AUTHENTICATE PLAIN + SASL-IR, challenge/response/abort | Compliant | |
| 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) | Compliant | ImapFetch/ImapIdle auto-pilots exercise the whole surface |
| Tag-correlated pipelining with out-of-order completion | Compliant | PendingMap 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) | Compliant | client/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.
| Requirement | Status | Notes |
|---|---|---|
| SEARCH criteria: system flags, FROM/TO/CC/BCC/SUBJECT, BODY/TEXT, date ranges, LARGER/SMALLER, KEYWORD, UID sets, AND/OR/NOT | Compliant | search.rs, matched against RFC 9051 §6.4.4's full criteria list |
Generic HEADER <name> <string> search | Compliant | search::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 criterion | Compliant | Now 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 scan | Every 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 messages | Compliant | mbox/mailbox.rs:195-276,680-746 |
| mbox: locking convention | Compliant | mbox::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 .uidlist | Compliant | maildir/mailbox.rs,filename.rs,uidlist.rs |
| Maildir++: unique filename generation | Compliant | MaildirFilename::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 write | Compliant | |
Body index (.gidx) on-disk integrity | Compliant | CRC32 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.
| Spec | Status |
|---|---|
| OASIS MQTT Version 3.1.1 | Core — full semantics |
| OASIS MQTT Version 5.0 | Useful subset — see per-feature rows; QoS retry across broker restarts remains limited (in-flight is process-local) |
Connect / Session / Disconnect
| Requirement | Section | Status | Notes |
|---|---|---|---|
| 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.2 | Compliant | codec/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 otherwise | v3.1.1 §3.1.3.7 | Compliant | assign_client_id() (TCP) / uuid_ish() (WS) |
| Session takeover (evicts a live prior connection) | v3.1.1 §3.1.2.4 | Compliant | BrokerState::register evicted-connection path, unit-tested (session_takeover_evicts_old_connection) |
| Session resume (orphaned, non-clean-start, Session Expiry) | v5 §3.1.2.11.2 | Compliant | Epoch-guarded orphan/resume on TCP and WS; offline QoS ≥ 1 drained via BrokerState::drain_offline |
| Keep Alive ×1.5 enforcement | v3.1.1 §3.1.2.10 | Compliant | TCP via Endpoint::schedule_timer; WS via ConnHandle::schedule_timer |
| CONNECT-timeout if no CONNECT arrives | v3.1.1 §3.1 (implied) | Compliant | TCP 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.14 | Compliant |
Publish / QoS / Retained / Will
| Requirement | Section | Status | Notes |
|---|---|---|---|
| QoS 0/1/2 full handshakes (PUBACK; PUBREC/PUBREL/PUBCOMP) | v3.1.1 §3.3-3.7 | Compliant | Server-inbound QoS 2 dedup via awaiting_pubrel, re-acks a duplicate PUBLISH without re-delivering to subscribers |
| Streaming PUBLISH payload (never buffered in full) | — | Compliant | MqttFrameParser streams payload chunks directly to publish_data, mirroring hopf-http's H2Parser buffer-and-drain shape |
| Retransmission / redelivery timer for outbound QoS 1/2 | v3.1.1 §4.4 | Compliant (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.7 | Compliant | validate_topic_name |
| Retained messages: store, clear, deliver on new SUBSCRIBE respecting Retain Handling | v3.1.1 §3.3.1.3, v5 §3.8.3.1 | Compliant | RetainedStore, deliver_retained |
| Will Message published on unclean disconnect | v3.1.1 §3.1.2.5 | Compliant | |
| Will Delay Interval, Message Expiry Interval enforcement | v5 §3.1.3.2, §3.3.2.3.3 | Compliant | server/expiry.rs; Will Delay capped by Session Expiry; Message Expiry rewritten on offline dequeue / retained delivery |
Subscribe / Wildcards / v5 Options
| Requirement | Section | Status | Notes |
|---|---|---|---|
| SUBSCRIBE/SUBACK, UNSUBSCRIBE/UNSUBACK | v3.1.1 §3.8,§3.10 | Compliant | |
Wildcards (+, #), $-prefix exclusion from wildcard matching | v3.1.1 §4.7 | Compliant | TopicTree |
| Subscription Options: No Local, Retain As Published, Retain Handling (0/1/2) | v5 §3.8.3.1 | Compliant | MatchOptions::from_filter; No Local checked in BrokerState::publish; unit-tested |
Shared Subscriptions ($share/group/filter) | v5 §4.8.2 | Compliant | TopicTree round-robin; CONNACK advertises SHARED_SUBSCRIPTION_AVAILABLE = 1 |
| Topic Alias | v5 §3.3.2.3.4 | Compliant | Inbound alias map on TCP/WS; CONNACK TOPIC_ALIAS_MAXIMUM |
| Offline QoS ≥ 1 queue (Session Expiry orphan window) | v5 §4.1 | Compliant | MqttMessageStore on BrokerState; default in-memory, optional file-backed |
v5 Flow Control and Reason Codes
| Requirement | Section | Status | Notes |
|---|---|---|---|
| Receive Maximum (server caps unacked outbound QoS 1/2 per subscriber) | v5 §3.3.4 | Compliant | Lock-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.4 | Compliant | codec/packet.rs reason module |
| Enhanced AUTH (AUTH packet exchange, re-authentication) | v5 §3.15,§4.12 | Compliant (TCP server + client callback) | Server: server/auth.rs via hopf-auth SASL; client: MqttClientDriver::on_auth / MqttClientControl::auth |
Async Client
| Requirement | Status | Notes |
|---|---|---|
MqttClient facade (DNS resolve, MQTTS, per-phase timeouts), publish/subscribe/unsubscribe, keepalive PINGREQ + PINGRESP deadline | Compliant | |
| Automatic PUBACK / PUBREC-then-PUBREL for incoming QoS 1/2 | Compliant | Handled by the endpoint automatically — the driver only sees the final on_message_complete/on_publish_acked outcome |
MQTT-over-WebSocket Bridge
| Requirement | Status | Notes |
|---|---|---|
Same codec + BrokerState shared with the TCP listener | Compliant | MqttWsFactory/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 session | Originally 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 WS | Compliant | Orphan + expire via ConnHandle::schedule_timer |
| Keepalive / CONNECT-timeout enforcement over WS | Compliant | Same 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.
| Capability | Status | Notes |
|---|---|---|
| Connection handshake + PLAIN / AMQPLAIN | Compliant | |
| Tune / heartbeat / multi-channel | Compliant | |
| Exchange / queue topology methods | Compliant | declare / bind / unbind / purge / delete |
basic.publish + content streaming / frame_max | Compliant | |
Publisher confirms + basic.return | Compliant | |
basic.consume push deliveries + ack / nack / reject / qos | Compliant | basic.get deferred |
| AMQPS (implicit TLS) | Compliant | |
| Broker / transactions | Out of scope | Client-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.
| Capability | Status | Notes |
|---|---|---|
| Frame layer, type system, described types | Compliant | hopf_amqp1::codec: protocol header exchange, frame header (size/DOFF/type/channel), compact-encoded type system with depth-guarded recursive decode |
| SASL PLAIN / ANONYMOUS, auto-negotiated | Compliant | PLAIN when credentials are configured and advertised, else ANONYMOUS |
| Connection open, sessions (begin/end), links (attach/detach) | Compliant | |
| Session-level flow control, link credit | Compliant | Incoming/outgoing window; RFC 1982 serial arithmetic for delivery-ids and window comparisons |
| Sending: header / properties / application-properties / data | Compliant | Automatically split across multiple transfer frames for messages larger than the negotiated max frame size |
| Receiving, streamed incrementally | Compliant | Delivered to the application as transfer frames arrive rather than buffered whole (genuinely incremental MessageParser) |
| Delivery settlement (accept/reject/release/modify), outcomes | Compliant | |
| AMQPS (implicit TLS) | Compliant | |
| Automatic reconnection | Not implemented | Unlike hopf-amqp's AmqpRecoveringClient |
| Heartbeat send / peer idle-time-out enforcement | Not implemented | Advertises 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 / footer | Partial | Received as raw maps; not yet offered on the send side |
| Broker / transactions | Out of scope | Client-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
| Protocol | Gumdrop (reference) | hopf status |
|---|---|---|
| Redis client | RESP client | Not 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 8617 | Sealing, validation and ARC-aware DMARC hook shipped in hopf_smtp::auth::arc |
Documented gaps vs Gumdrop (implemented crates)
- DNS over DTLS — DTLS 1.2/1.3 have landed in
hopf-core, but no protocol crate (includinghopf-dns) is wired up to dial/listen over them yet; this needs DTLS's genericEndpointintegration first (see Security substrate → Remaining). - Auto-injected Authentication-Results, built-in quarantine mailbox — deliberate v1 omissions on SMTP; see SMTP auth table.