Skip to main content

cellos_supervisor/sni_proxy/h2/
mod.rs

1//! HTTP/2 cleartext (h2c) frame parser — pure logic, no I/O.
2//!
3//! ## Phases
4//!
5//! - **Phase 3a (P3c)** — h2c preface detection + first-HEADERS-frame
6//!   `:authority` extraction with **static-table-only HPACK**.
7//!   CONTINUATION-fragmented HEADERS, dynamic-table refs, and Huffman
8//!   literals all rejected.
9//!
10//! - **Phase 3g** — full HPACK dynamic-table state, Huffman literal
11//!   decoding (RFC 7541 §5.2 + Appendix B), and CONTINUATION-fragmented
12//!   HEADERS reassembly. Closes the v0.4.0 honest residual *"full HPACK
13//!   dynamic-table state + Huffman literal decoding (currently rejected
14//!   as `l7_h2_unparseable_headers`)."*
15//!
16//!   The stateless [`extract_h2_authority`] entry point is preserved with
17//!   identical behaviour for backward compatibility — it constructs a
18//!   one-shot [`HpackDecoder`] internally. Callers that need the dynamic
19//!   table to evolve across multiple frames on the same connection use
20//!   [`HpackDecoder::decode_block`] + [`reassemble_header_block`] directly
21//!   (see the proxy wiring in `sni_proxy::handle_connection`).
22//!
23//! ## Honest scope (Phase 3g)
24//!
25//! - **h2c only.** TLS termination is ADR-0004 territory.
26//! - **First-stream allow/deny only.** Connection-wide allow/deny is
27//!   locked to the first `:authority` extracted; multi-stream per-request
28//!   enforcement (different `:authority` per stream on the same connection)
29//!   is future Phase 3g.1.
30//! - **No HTTP/3 / QUIC.** Phase 4 separate.
31//! - **Frame size cap.** RFC 7540 §6.5.2 default `SETTINGS_MAX_FRAME_SIZE`
32//!   is 16 KiB. Frames whose declared length exceeds that are rejected as
33//!   [`H2ParseError::OversizedFrame`].
34//! - **Header block cap.** Aggregate HEADERS + CONTINUATION payload cannot
35//!   exceed [`MAX_HEADER_BLOCK_SIZE`] (64 KiB) — defence against memory
36//!   amplification via long fragmentation chains.
37//! - **HPACK table size cap.** `SETTINGS_HEADER_TABLE_SIZE` updates are
38//!   bounded at 64 KiB (see [`hpack::dynamic_table::MAX_TABLE_SIZE`]).
39//!
40//! [RFC 7540]: https://www.rfc-editor.org/rfc/rfc7540
41//! [RFC 7541]: https://www.rfc-editor.org/rfc/rfc7541
42
43pub mod error;
44pub mod frame;
45pub mod hpack;
46
47pub use error::{H2ParseError, ReassemblerOverflowKind};
48#[allow(unused_imports)]
49pub use frame::{FrameHeader, DEFAULT_MAX_FRAME_SIZE};
50#[allow(unused_imports)]
51pub use hpack::{AuthorityProvenance, DecodedAuthority, HpackDecoder};
52
53use frame::{
54    parse_one_frame, strip_headers_padding_and_priority, FLAG_END_HEADERS, FRAME_TYPE_CONTINUATION,
55    FRAME_TYPE_HEADERS, FRAME_TYPE_SETTINGS,
56};
57use std::collections::HashMap;
58
59/// HTTP/2 connection preface (RFC 7540 §3.5).
60pub const HTTP2_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
61
62/// Defensive cap on aggregate HEADERS + CONTINUATION payload. SETTINGS
63/// `MAX_HEADER_LIST_SIZE` (RFC 7540 §6.5.2) governs this on the wire but
64/// the proxy doesn't see SETTINGS that haven't reached the client; pick a
65/// generous bound that admits realistic browsers (~16 KiB cookies + the
66/// rest of the request line) while bounding adversarial fragmentation.
67pub const MAX_HEADER_BLOCK_SIZE: usize = 64 * 1024;
68
69/// Returns `true` when `bytes` starts with the canonical 24-byte HTTP/2
70/// cleartext connection preface.
71pub fn is_h2c_preface(bytes: &[u8]) -> bool {
72    bytes.len() >= HTTP2_PREFACE.len() && &bytes[..HTTP2_PREFACE.len()] == HTTP2_PREFACE
73}
74
75/// Output of [`reassemble_header_block`]: the concatenated header-block
76/// payload + the slice of input remaining after the END_HEADERS-bearing
77/// frame.
78pub type ReassembledBlock<'a> = (Vec<u8>, &'a [u8]);
79
80/// Reassemble a header block from a stream of bytes that begins with a
81/// HEADERS frame and may continue with one or more CONTINUATION frames
82/// (RFC 7540 §4.3 + §6.10). Returns the concatenated header-block fragment
83/// (after stripping padding + priority from the initial HEADERS) and the
84/// slice of `buf` after the last CONTINUATION.
85///
86/// Returns `Ok(None)` if the buffer ends mid-frame (caller may wait for
87/// more bytes). Returns `Err(InterleavedFrame)` if a non-CONTINUATION
88/// frame appears between the initial HEADERS and the END_HEADERS-bearing
89/// CONTINUATION. Returns `Err(HpackOversizedHeaderBlock)` if the
90/// concatenated payloads exceed [`MAX_HEADER_BLOCK_SIZE`].
91pub fn reassemble_header_block(buf: &[u8]) -> Result<Option<ReassembledBlock<'_>>, H2ParseError> {
92    let mut cursor = buf;
93    let mut accumulated: Vec<u8> = Vec::new();
94    let mut first_frame = true;
95
96    loop {
97        let (header, payload, rest) = match parse_one_frame(cursor)? {
98            Some(p) => p,
99            None => return Ok(None),
100        };
101
102        if first_frame {
103            if header.frame_type != FRAME_TYPE_HEADERS {
104                return Err(H2ParseError::UnexpectedFirstFrame {
105                    frame_type: header.frame_type,
106                });
107            }
108            let block = strip_headers_padding_and_priority(payload, header.flags)?;
109            if accumulated.len() + block.len() > MAX_HEADER_BLOCK_SIZE {
110                return Err(H2ParseError::HpackOversizedHeaderBlock {
111                    total: accumulated.len() + block.len(),
112                    max: MAX_HEADER_BLOCK_SIZE,
113                });
114            }
115            accumulated.extend_from_slice(block);
116            cursor = rest;
117            if header.flags & FLAG_END_HEADERS != 0 {
118                return Ok(Some((accumulated, cursor)));
119            }
120            first_frame = false;
121        } else {
122            if header.frame_type != FRAME_TYPE_CONTINUATION {
123                return Err(H2ParseError::InterleavedFrame {
124                    frame_type: header.frame_type,
125                });
126            }
127            // CONTINUATION carries no padding / priority.
128            if accumulated.len() + payload.len() > MAX_HEADER_BLOCK_SIZE {
129                return Err(H2ParseError::HpackOversizedHeaderBlock {
130                    total: accumulated.len() + payload.len(),
131                    max: MAX_HEADER_BLOCK_SIZE,
132                });
133            }
134            accumulated.extend_from_slice(payload);
135            cursor = rest;
136            if header.flags & FLAG_END_HEADERS != 0 {
137                return Ok(Some((accumulated, cursor)));
138            }
139        }
140    }
141}
142
143/// Stateless one-shot extraction of `:authority` from h2c bytes that
144/// begin **after** the 24-byte preface. This API is preserved from P3c
145/// for backward compat; under the hood it constructs a one-shot
146/// [`HpackDecoder`] and runs it against the reassembled header block.
147///
148/// Returns:
149/// - `Ok(Some(authority))` — first HEADERS frame's `:authority`,
150///   normalised (lowercased + port-stripped).
151/// - `Ok(None)` — buffer too short, or no `:authority` in the block.
152/// - `Err(...)` — see [`H2ParseError`].
153pub fn extract_h2_authority(after_preface: &[u8]) -> Result<Option<String>, H2ParseError> {
154    extract_h2_authority_with(after_preface, &mut HpackDecoder::new())
155        .map(|opt| opt.map(|d| d.value))
156}
157
158/// Like [`extract_h2_authority`] but parameterised over a caller-owned
159/// [`HpackDecoder`] so the dynamic table can persist across multiple
160/// invocations on the same h2 connection. Returns the [`DecodedAuthority`]
161/// (value + provenance) so the caller can pick the right reason code.
162pub fn extract_h2_authority_with(
163    after_preface: &[u8],
164    decoder: &mut HpackDecoder,
165) -> Result<Option<DecodedAuthority>, H2ParseError> {
166    let mut cursor = after_preface;
167
168    // Step 1: skip a single optional non-ACK SETTINGS frame.
169    if let Some((header, _payload, rest)) = parse_one_frame(cursor)? {
170        if header.frame_type == FRAME_TYPE_SETTINGS {
171            cursor = rest;
172        }
173    } else {
174        return Ok(None);
175    }
176
177    // Step 2: reassemble HEADERS + 0..n CONTINUATION.
178    let (block, _rest_after) = match reassemble_header_block(cursor)? {
179        Some(p) => p,
180        None => return Ok(None),
181    };
182
183    // Step 3: HPACK-decode the assembled block.
184    decoder.decode_block(&block)
185}
186
187// ── Phase 3g.1 — per-stream HEADERS+CONTINUATION reassembly ──────────────
188//
189// The reassembler is keyed on stream id (RFC 7540 §5.1.1) so HEADERS on
190// stream 1 and HEADERS on stream 3 multiplexed on the same connection
191// remain isolated. CONTINUATION frames (§6.10) are valid ONLY for the
192// stream that has an open (non-END_HEADERS) HEADERS frame; any other
193// frame type or any frame on a different stream while a reassembly is
194// open is a PROTOCOL_ERROR (§6.10) — the reassembler returns
195// `InterleavedFrame` for that case.
196//
197// Defensive memory bounds:
198//
199//   - Per-stream block ≤ [`MAX_HEADER_BLOCK_SIZE`] (64 KiB).
200//   - Aggregate in-flight bytes ≤ [`REASSEMBLER_TOTAL_IN_FLIGHT_MAX`]
201//     (256 KiB).
202//   - Concurrent in-flight reassemblies ≤
203//     [`REASSEMBLER_MAX_CONCURRENT_STREAMS`] (64).
204//
205// On overflow the reassembler returns
206// [`H2ParseError::ReassemblerOverflow`] for the OFFENDING frame's stream;
207// the caller emits `l7_h2_unparseable_headers` for that stream and
208// RST_STREAMs only that stream — other in-flight streams continue.
209
210/// Defensive aggregate bound on bytes the per-stream reassembler will
211/// hold for streams that have started HEADERS but not yet sent
212/// END_HEADERS. Sized at 4× per-stream cap so a small fleet of in-flight
213/// streams can still complete; an adversary opening many streams and
214/// hanging each at 64 KiB-1 trips this bound first (cheaper than waiting
215/// for per-stream).
216pub const REASSEMBLER_TOTAL_IN_FLIGHT_MAX: usize = 256 * 1024;
217
218/// Defensive bound on concurrent in-flight HEADERS+CONTINUATION
219/// reassemblies. RFC 7540 §6.5.2 `SETTINGS_MAX_CONCURRENT_STREAMS` is
220/// usually 100 — 64 is below that floor, which is fine because a stream
221/// only counts against this bound while its HEADERS block is unfinished.
222/// Production traffic finishes HEADERS in one frame; only adversarial
223/// fragmentation chains accumulate.
224pub const REASSEMBLER_MAX_CONCURRENT_STREAMS: usize = 64;
225
226/// Per-stream HEADERS+CONTINUATION accumulator (Phase 3g.1).
227///
228/// One instance per h2 connection, fed every HEADERS / CONTINUATION
229/// frame the proxy parses. Returns the completed (stream-id, block)
230/// pair when END_HEADERS is observed; otherwise tracks the pending
231/// reassembly internally.
232///
233/// Non-HEADERS / non-CONTINUATION frames pass through with `Ok(None)`.
234/// A non-CONTINUATION frame from any stream while a reassembly is open
235/// on another stream is rejected as
236/// [`H2ParseError::InterleavedFrame`] (RFC 7540 §6.10).
237pub struct H2StreamReassembler {
238    /// Accumulator per stream id. Bounded by the size + count caps below.
239    blocks: HashMap<u32, Vec<u8>>,
240    /// Stream id currently mid-reassembly. RFC 7540 §6.10: only one
241    /// stream at a time may have an open HEADERS-without-END_HEADERS
242    /// because CONTINUATION must immediately follow on the same stream.
243    active_stream: Option<u32>,
244    /// Cumulative size of all in-flight blocks; defends against memory
245    /// abuse via many simultaneously-open streams.
246    total_in_flight: usize,
247}
248
249impl Default for H2StreamReassembler {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255impl H2StreamReassembler {
256    /// Construct a fresh reassembler with no pending streams.
257    pub fn new() -> Self {
258        Self {
259            blocks: HashMap::new(),
260            active_stream: None,
261            total_in_flight: 0,
262        }
263    }
264
265    /// Feed a single frame's `(header, payload)` pair.
266    ///
267    /// Returns:
268    ///
269    /// - `Ok(Some((stream_id, block)))` — the frame completed a
270    ///   HEADERS+CONTINUATION block. The block has had padding +
271    ///   priority stripped (frame layer) and is the concatenated header
272    ///   block fragment ready for `HpackDecoder::decode_block`.
273    /// - `Ok(None)` — the frame either was not HEADERS/CONTINUATION
274    ///   (passed through; pure flow-control or DATA), or was HEADERS
275    ///   without END_HEADERS (more bytes needed), or was a CONTINUATION
276    ///   without END_HEADERS (more bytes needed).
277    /// - `Err(InterleavedFrame)` — RFC 7540 §6.10 violation: a frame
278    ///   appeared between HEADERS and the END_HEADERS-bearing
279    ///   CONTINUATION on the same stream; or a CONTINUATION arrived
280    ///   for a stream that does NOT have an open reassembly.
281    /// - `Err(ReassemblerOverflow)` — one of the defensive bounds
282    ///   tripped; caller RST_STREAMs the offending stream id.
283    pub fn ingest(
284        &mut self,
285        frame: &FrameHeader,
286        payload: &[u8],
287    ) -> Result<Option<(u32, Vec<u8>)>, H2ParseError> {
288        // Reject CONTINUATION on a stream that isn't the active one,
289        // and reject any non-CONTINUATION on any stream while an
290        // active reassembly is open. This is the §6.10 PROTOCOL_ERROR
291        // contract.
292        if let Some(active) = self.active_stream {
293            if frame.frame_type == FRAME_TYPE_CONTINUATION {
294                if frame.stream_id != active {
295                    return Err(H2ParseError::InterleavedFrame {
296                        frame_type: frame.frame_type,
297                    });
298                }
299                // Append; check per-stream + aggregate bounds.
300                let entry = self
301                    .blocks
302                    .get_mut(&active)
303                    .expect("active_stream invariant: blocks contains active");
304                if entry.len() + payload.len() > MAX_HEADER_BLOCK_SIZE {
305                    return Err(H2ParseError::ReassemblerOverflow {
306                        kind: ReassemblerOverflowKind::PerStreamBlock,
307                    });
308                }
309                if self.total_in_flight + payload.len() > REASSEMBLER_TOTAL_IN_FLIGHT_MAX {
310                    return Err(H2ParseError::ReassemblerOverflow {
311                        kind: ReassemblerOverflowKind::TotalInFlight,
312                    });
313                }
314                entry.extend_from_slice(payload);
315                self.total_in_flight += payload.len();
316                if frame.flags & FLAG_END_HEADERS != 0 {
317                    let block = self
318                        .blocks
319                        .remove(&active)
320                        .expect("active reassembly entry");
321                    self.total_in_flight = self.total_in_flight.saturating_sub(block.len());
322                    self.active_stream = None;
323                    return Ok(Some((active, block)));
324                }
325                return Ok(None);
326            }
327            // Anything else mid-reassembly is §6.10 PROTOCOL_ERROR.
328            return Err(H2ParseError::InterleavedFrame {
329                frame_type: frame.frame_type,
330            });
331        }
332
333        // No active reassembly. Only HEADERS opens one.
334        if frame.frame_type == FRAME_TYPE_HEADERS {
335            if self.blocks.len() >= REASSEMBLER_MAX_CONCURRENT_STREAMS {
336                return Err(H2ParseError::ReassemblerOverflow {
337                    kind: ReassemblerOverflowKind::ConcurrentStreams,
338                });
339            }
340            let inner = strip_headers_padding_and_priority(payload, frame.flags)?;
341            if inner.len() > MAX_HEADER_BLOCK_SIZE {
342                return Err(H2ParseError::ReassemblerOverflow {
343                    kind: ReassemblerOverflowKind::PerStreamBlock,
344                });
345            }
346            if self.total_in_flight + inner.len() > REASSEMBLER_TOTAL_IN_FLIGHT_MAX {
347                return Err(H2ParseError::ReassemblerOverflow {
348                    kind: ReassemblerOverflowKind::TotalInFlight,
349                });
350            }
351            if frame.flags & FLAG_END_HEADERS != 0 {
352                // Single-frame HEADERS; complete immediately. No need to
353                // touch blocks/active.
354                return Ok(Some((frame.stream_id, inner.to_vec())));
355            }
356            self.blocks.insert(frame.stream_id, inner.to_vec());
357            self.total_in_flight += inner.len();
358            self.active_stream = Some(frame.stream_id);
359            return Ok(None);
360        }
361
362        // CONTINUATION without active reassembly is §6.10 PROTOCOL_ERROR.
363        if frame.frame_type == FRAME_TYPE_CONTINUATION {
364            return Err(H2ParseError::InterleavedFrame {
365                frame_type: frame.frame_type,
366            });
367        }
368
369        // Any other frame type (DATA, SETTINGS, WINDOW_UPDATE, PING,
370        // GOAWAY, PRIORITY, RST_STREAM, PUSH_PROMISE) passes through.
371        Ok(None)
372    }
373
374    /// Number of streams currently mid-reassembly (HEADERS open without
375    /// END_HEADERS). Test-only.
376    #[cfg(test)]
377    pub fn pending_count(&self) -> usize {
378        self.blocks.len()
379    }
380}
381
382/// Output of [`H2ConnectionDecoder::feed_frame`] when a HEADERS+
383/// CONTINUATION block completes for a particular stream.
384#[derive(Debug, Clone)]
385pub struct H2HeadersDecoded {
386    /// Stream id the HEADERS belonged to (RFC 7540 §4.1).
387    pub stream_id: u32,
388    /// Extracted `:authority`. `None` when the HEADERS block parsed
389    /// cleanly but contained no `:authority` pseudo-header — caller
390    /// emits `l7_h2_authority_missing` and RST_STREAMs.
391    pub authority: Option<String>,
392    /// True when HEADERS were transported via dynamic-table indexing.
393    pub via_dynamic_table: bool,
394    /// True when the `:authority` value used Huffman-coded literal.
395    pub via_huffman: bool,
396}
397
398/// Per-connection h2c decoder stacking the per-stream
399/// [`H2StreamReassembler`] over the per-connection [`HpackDecoder`].
400///
401/// The reassembler isolates HEADERS+CONTINUATION sequences per stream;
402/// the HPACK decoder is per-connection because RFC 7541 §2.3.1 says the
403/// dynamic table is a single per-connection compression context shared
404/// across all streams.
405pub struct H2ConnectionDecoder {
406    hpack: HpackDecoder,
407    reassembler: H2StreamReassembler,
408}
409
410impl Default for H2ConnectionDecoder {
411    fn default() -> Self {
412        Self::new()
413    }
414}
415
416impl H2ConnectionDecoder {
417    /// Fresh decoder with empty dynamic table and no in-flight reassemblies.
418    pub fn new() -> Self {
419        Self {
420            hpack: HpackDecoder::new(),
421            reassembler: H2StreamReassembler::new(),
422        }
423    }
424
425    /// Feed a single frame.
426    ///
427    /// Returns `Some(decoded)` when a HEADERS+CONTINUATION block
428    /// completes; `None` when more bytes are needed or the frame was
429    /// not HEADERS/CONTINUATION. Errors are stream-scoped — the caller
430    /// decides whether to RST_STREAM the offender or close the
431    /// whole connection.
432    pub fn feed_frame(
433        &mut self,
434        frame: &FrameHeader,
435        payload: &[u8],
436    ) -> Result<Option<H2HeadersDecoded>, H2ParseError> {
437        let (stream_id, block) = match self.reassembler.ingest(frame, payload)? {
438            Some(p) => p,
439            None => return Ok(None),
440        };
441        let decoded = self.hpack.decode_block(&block)?;
442        let (authority, via_dynamic_table, via_huffman) = match decoded {
443            Some(d) => {
444                let dyn_indexed = matches!(d.provenance, AuthorityProvenance::DynamicIndexed);
445                let huff = matches!(d.provenance, AuthorityProvenance::Huffman);
446                (Some(d.value), dyn_indexed, huff)
447            }
448            None => (None, false, false),
449        };
450        Ok(Some(H2HeadersDecoded {
451            stream_id,
452            authority,
453            via_dynamic_table,
454            via_huffman,
455        }))
456    }
457}
458
459#[cfg(test)]
460pub(crate) mod test_helpers {
461    //! Hand-crafted h2 byte sequences for unit tests. Pure constructors —
462    //! no I/O, no `h2`-crate dependency. The shapes mirror what real h2c
463    //! clients (curl --http2-prior-knowledge, h2load, nghttp) emit on the
464    //! wire.
465
466    use super::frame::{FRAME_TYPE_CONTINUATION, FRAME_TYPE_HEADERS, FRAME_TYPE_SETTINGS};
467    use super::hpack::huffman;
468
469    /// Build a 9-byte h2 frame header.
470    pub fn frame_header(length: u32, frame_type: u8, flags: u8, stream_id: u32) -> Vec<u8> {
471        let mut out = Vec::with_capacity(9);
472        out.push(((length >> 16) & 0xFF) as u8);
473        out.push(((length >> 8) & 0xFF) as u8);
474        out.push((length & 0xFF) as u8);
475        out.push(frame_type);
476        out.push(flags);
477        out.extend_from_slice(&(stream_id & 0x7FFF_FFFF).to_be_bytes());
478        out
479    }
480
481    /// Empty SETTINGS frame on stream 0 (no payload, no ACK).
482    pub fn empty_settings_frame() -> Vec<u8> {
483        frame_header(0, FRAME_TYPE_SETTINGS, 0, 0)
484    }
485
486    /// Build a HEADERS frame on stream 1 with `END_HEADERS | END_STREAM`,
487    /// payload = the given header-block fragment.
488    pub fn headers_frame(header_block: &[u8]) -> Vec<u8> {
489        let length = header_block.len() as u32;
490        let flags = super::frame::FLAG_END_HEADERS | 0x1; // END_STREAM
491        let mut out = frame_header(length, FRAME_TYPE_HEADERS, flags, 1);
492        out.extend_from_slice(header_block);
493        out
494    }
495
496    /// Build a HEADERS frame missing END_HEADERS (i.e. CONTINUATION
497    /// fragmentation). `flags` already excludes END_HEADERS.
498    pub fn continuation_fragmented_headers(header_block: &[u8]) -> Vec<u8> {
499        let length = header_block.len() as u32;
500        let flags = 0x0;
501        let mut out = frame_header(length, FRAME_TYPE_HEADERS, flags, 1);
502        out.extend_from_slice(header_block);
503        out
504    }
505
506    /// Build a CONTINUATION frame on stream 1.
507    pub fn continuation_frame(header_block: &[u8], end_headers: bool) -> Vec<u8> {
508        let length = header_block.len() as u32;
509        let flags = if end_headers {
510            super::frame::FLAG_END_HEADERS
511        } else {
512            0x0
513        };
514        let mut out = frame_header(length, FRAME_TYPE_CONTINUATION, flags, 1);
515        out.extend_from_slice(header_block);
516        out
517    }
518
519    /// HPACK-encode a literal-with-incremental-indexing entry whose name
520    /// is an indexed reference (`name_index`) and whose value is a
521    /// non-Huffman literal.
522    pub fn hpack_literal_indexed_name(name_index: u8, value: &str) -> Vec<u8> {
523        let mut out = Vec::new();
524        out.push(0x40 | (name_index & 0x3F));
525        encode_literal_string(&mut out, value);
526        out
527    }
528
529    /// HPACK-encode a literal-with-incremental-indexing entry whose name
530    /// AND value are both non-Huffman literals.
531    pub fn hpack_literal_indexed_with_name(name: &str, value: &str) -> Vec<u8> {
532        let mut out = Vec::new();
533        out.push(0x40);
534        encode_literal_string(&mut out, name);
535        encode_literal_string(&mut out, value);
536        out
537    }
538
539    /// HPACK-encode a literal-without-indexing entry whose name is an
540    /// indexed reference. Uses the `0000xxxx` representation (RFC 7541
541    /// §6.2.2); the four-bit prefix carries the name index.
542    pub fn hpack_literal_no_indexing(name_index: u8, value: &str) -> Vec<u8> {
543        let mut out = Vec::new();
544        out.push(name_index & 0x0F);
545        encode_literal_string(&mut out, value);
546        out
547    }
548
549    /// HPACK-encode an indexed-header-field reference (8-bit form).
550    pub fn hpack_indexed(index: u8) -> Vec<u8> {
551        vec![0x80 | (index & 0x7F)]
552    }
553
554    /// HPACK-encode a literal-with-incremental-indexing entry whose name
555    /// is indexed and whose value is *Huffman-coded*.
556    pub fn hpack_literal_indexed_name_huffman(name_index: u8, value: &str) -> Vec<u8> {
557        let mut out = Vec::new();
558        out.push(0x40 | (name_index & 0x3F));
559        let payload = huffman::encode(value);
560        // Length octet: top bit = 1 (Huffman).
561        if payload.len() < 0x7F {
562            out.push(0x80 | payload.len() as u8);
563        } else {
564            out.push(0xFF);
565            let mut v = payload.len() as u64 - 0x7F;
566            while v >= 128 {
567                out.push(((v & 0x7F) as u8) | 0x80);
568                v >>= 7;
569            }
570            out.push(v as u8);
571        }
572        out.extend_from_slice(&payload);
573        out
574    }
575
576    /// Encode an HPACK non-Huffman string literal (length prefix + raw
577    /// octets) into `out`.
578    pub fn encode_literal_string(out: &mut Vec<u8>, s: &str) {
579        let len = s.len() as u64;
580        if len < 0x7F {
581            out.push(len as u8);
582        } else {
583            out.push(0x7F);
584            let mut v = len - 0x7F;
585            while v >= 128 {
586                out.push(((v & 0x7F) as u8) | 0x80);
587                v >>= 7;
588            }
589            out.push(v as u8);
590        }
591        out.extend_from_slice(s.as_bytes());
592    }
593
594    /// Build a Huffman-flagged literal whose payload is the raw bytes
595    /// given (NOT actually Huffman-coded). Used to assert that broken
596    /// Huffman streams reject.
597    pub fn hpack_literal_huffman_indexed_name_with_raw(
598        name_index: u8,
599        raw_value: &[u8],
600    ) -> Vec<u8> {
601        let mut out = Vec::new();
602        out.push(0x40 | (name_index & 0x3F));
603        out.push(0x80 | (raw_value.len() as u8));
604        out.extend_from_slice(raw_value);
605        out
606    }
607
608    /// Build a complete h2c stream prefix: SETTINGS + HEADERS frame
609    /// carrying the given header-block fragment. Does NOT include the
610    /// 24-byte preface.
611    pub fn settings_then_headers(header_block: &[u8]) -> Vec<u8> {
612        let mut out = empty_settings_frame();
613        out.extend_from_slice(&headers_frame(header_block));
614        out
615    }
616
617    /// Build a HEADERS frame on `stream_id` with `END_HEADERS | END_STREAM`
618    /// and the given header-block fragment. (Helper for Phase 3g.1
619    /// per-stream reassembler tests.)
620    pub fn headers_frame_on_stream(header_block: &[u8], stream_id: u32) -> Vec<u8> {
621        let length = header_block.len() as u32;
622        let flags = super::frame::FLAG_END_HEADERS | 0x1; // END_STREAM
623        let mut out = frame_header(length, FRAME_TYPE_HEADERS, flags, stream_id);
624        out.extend_from_slice(header_block);
625        out
626    }
627
628    /// Build a HEADERS frame on `stream_id` WITHOUT END_HEADERS (i.e.
629    /// CONTINUATION fragmentation expected).
630    pub fn headers_frame_on_stream_no_end(header_block: &[u8], stream_id: u32) -> Vec<u8> {
631        let length = header_block.len() as u32;
632        let mut out = frame_header(length, FRAME_TYPE_HEADERS, 0x0, stream_id);
633        out.extend_from_slice(header_block);
634        out
635    }
636
637    /// Build a CONTINUATION frame on `stream_id`.
638    pub fn continuation_frame_on_stream(
639        header_block: &[u8],
640        stream_id: u32,
641        end_headers: bool,
642    ) -> Vec<u8> {
643        let length = header_block.len() as u32;
644        let flags = if end_headers {
645            super::frame::FLAG_END_HEADERS
646        } else {
647            0x0
648        };
649        let mut out = frame_header(length, FRAME_TYPE_CONTINUATION, flags, stream_id);
650        out.extend_from_slice(header_block);
651        out
652    }
653
654    /// Build a DATA frame on `stream_id` with arbitrary payload.
655    pub fn data_frame_on_stream(payload: &[u8], stream_id: u32) -> Vec<u8> {
656        let length = payload.len() as u32;
657        let mut out = frame_header(length, super::frame::FRAME_TYPE_DATA, 0x0, stream_id);
658        out.extend_from_slice(payload);
659        out
660    }
661}
662
663#[cfg(test)]
664mod tests {
665    use super::test_helpers::*;
666    use super::*;
667
668    // ── 1. Static-table indexed authority (no value carried) ────────────
669    #[test]
670    fn extracts_authority_from_static_table_index_1() {
671        let block = hpack_indexed(1);
672        let bytes = settings_then_headers(&block);
673        let result = extract_h2_authority(&bytes).unwrap();
674        assert_eq!(
675            result, None,
676            "indexed-only :authority has no value; parser returns None"
677        );
678    }
679
680    // ── 2. Literal-with-indexing, name from static table index 1 ────────
681    #[test]
682    fn extracts_authority_from_literal_indexed() {
683        let block = hpack_literal_indexed_name(1, "api.example.com");
684        let bytes = settings_then_headers(&block);
685        let result = extract_h2_authority(&bytes).unwrap();
686        assert_eq!(result.as_deref(), Some("api.example.com"));
687    }
688
689    // ── 3. Literal with name AND value as literals (no static index) ────
690    #[test]
691    fn extracts_authority_from_literal_with_incremental_indexing() {
692        let block = hpack_literal_indexed_with_name(":authority", "api.example.com");
693        let bytes = settings_then_headers(&block);
694        let result = extract_h2_authority(&bytes).unwrap();
695        assert_eq!(result.as_deref(), Some("api.example.com"));
696    }
697
698    // ── 4. Oversized frame → reject ─────────────────────────────────────
699    #[test]
700    fn rejects_oversized_frame() {
701        let mut bytes = empty_settings_frame();
702        let bogus_header = frame_header(
703            16_385,
704            frame::FRAME_TYPE_HEADERS,
705            frame::FLAG_END_HEADERS,
706            1,
707        );
708        bytes.extend_from_slice(&bogus_header);
709        let err = extract_h2_authority(&bytes).unwrap_err();
710        assert!(matches!(
711            err,
712            H2ParseError::OversizedFrame {
713                declared: 16_385,
714                max: 16_384
715            }
716        ));
717    }
718
719    // ── 5. CONTINUATION fragmentation now reassembles ────────────────────
720    // scope: a HEADERS payload is treated as the first chunk and we wait
721    // for CONTINUATION; the test guards against regression to the older
722    // "reject CONTINUATION-fragmented HEADERS" behaviour.
723    #[test]
724    fn reassembles_headers_with_one_continuation() {
725        let block_full = hpack_literal_indexed_name(1, "api.example.com");
726        // Split the block in half and stuff the second half in a CONTINUATION
727        // with END_HEADERS.
728        let mid = block_full.len() / 2;
729        let (first_half, second_half) = block_full.split_at(mid);
730        let mut bytes = empty_settings_frame();
731        bytes.extend_from_slice(&continuation_fragmented_headers(first_half));
732        bytes.extend_from_slice(&continuation_frame(second_half, true));
733        let result = extract_h2_authority(&bytes).unwrap();
734        assert_eq!(result.as_deref(), Some("api.example.com"));
735    }
736
737    // ── 6. Non-HEADERS first frame after SETTINGS → reject ──────────────
738    #[test]
739    fn rejects_non_headers_first_frame() {
740        let mut bytes = empty_settings_frame();
741        bytes.extend_from_slice(&frame_header(0, frame::FRAME_TYPE_DATA, 0, 1));
742        let err = extract_h2_authority(&bytes).unwrap_err();
743        assert!(matches!(
744            err,
745            H2ParseError::UnexpectedFirstFrame { frame_type: 0 }
746        ));
747    }
748
749    // ── 7. Mixed-case authority is lowercased ───────────────────────────
750    #[test]
751    fn extracts_lowercased_authority() {
752        let block = hpack_literal_indexed_name(1, "API.Example.COM");
753        let bytes = settings_then_headers(&block);
754        let result = extract_h2_authority(&bytes).unwrap();
755        assert_eq!(result.as_deref(), Some("api.example.com"));
756    }
757
758    // ── 8. Port stripped from authority ─────────────────────────────────
759    #[test]
760    fn strips_port_from_authority() {
761        let block = hpack_literal_indexed_name(1, "api.example.com:8443");
762        let bytes = settings_then_headers(&block);
763        let result = extract_h2_authority(&bytes).unwrap();
764        assert_eq!(result.as_deref(), Some("api.example.com"));
765    }
766
767    // ── 9. Buffer too short → Ok(None) ──────────────────────────────────
768    #[test]
769    fn returns_none_when_buffer_short() {
770        let bytes = empty_settings_frame();
771        let result = extract_h2_authority(&bytes).unwrap();
772        assert_eq!(result, None);
773
774        let truncated = &bytes[..5];
775        let result = extract_h2_authority(truncated).unwrap();
776        assert_eq!(result, None);
777    }
778
779    // ── 10. h2c preface recognition ─────────────────────────────────────
780    #[test]
781    fn is_h2c_preface_recognises_canonical_preface() {
782        let mut bytes = HTTP2_PREFACE.to_vec();
783        bytes.extend_from_slice(&[0x00, 0x00, 0x00]);
784        assert!(is_h2c_preface(&bytes));
785        assert!(is_h2c_preface(HTTP2_PREFACE));
786    }
787
788    #[test]
789    fn is_h2c_preface_rejects_malformed() {
790        assert!(!is_h2c_preface(&HTTP2_PREFACE[..23]));
791        let mut bad = HTTP2_PREFACE.to_vec();
792        bad[5] = b'x';
793        assert!(!is_h2c_preface(&bad));
794        assert!(!is_h2c_preface(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n"));
795        assert!(!is_h2c_preface(&[0x16, 0x03, 0x01]));
796    }
797
798    // ── 11. Padded HEADERS frame ────────────────────────────────────────
799    #[test]
800    fn extracts_authority_from_padded_headers_frame() {
801        let block = hpack_literal_indexed_name(1, "api.example.com");
802        let pad_len: u8 = 4;
803        let mut padded_payload = Vec::new();
804        padded_payload.push(pad_len);
805        padded_payload.extend_from_slice(&block);
806        padded_payload.extend(std::iter::repeat_n(0u8, pad_len as usize));
807        let length = padded_payload.len() as u32;
808        let flags = frame::FLAG_END_HEADERS | frame::FLAG_PADDED;
809        let mut bytes = empty_settings_frame();
810        bytes.extend_from_slice(&frame_header(length, frame::FRAME_TYPE_HEADERS, flags, 1));
811        bytes.extend_from_slice(&padded_payload);
812        let result = extract_h2_authority(&bytes).unwrap();
813        assert_eq!(result.as_deref(), Some("api.example.com"));
814    }
815
816    // ── 12. Padding length larger than payload → reject ────────────────
817    #[test]
818    fn rejects_padding_overflow() {
819        let block = hpack_literal_indexed_name(1, "api.example.com");
820        let mut padded_payload = Vec::new();
821        padded_payload.push(255);
822        padded_payload.extend_from_slice(&block);
823        let length = padded_payload.len() as u32;
824        let flags = frame::FLAG_END_HEADERS | frame::FLAG_PADDED;
825        let mut bytes = empty_settings_frame();
826        bytes.extend_from_slice(&frame_header(length, frame::FRAME_TYPE_HEADERS, flags, 1));
827        bytes.extend_from_slice(&padded_payload);
828        let err = extract_h2_authority(&bytes).unwrap_err();
829        assert_eq!(err, H2ParseError::PaddingOverflow);
830    }
831
832    // ── 13. Huffman-coded literal NOW DECODES (Phase 3g) ───────────────
833    // Previously this rejected as HpackUnsupported. Now it succeeds.
834    #[test]
835    fn decodes_huffman_coded_literal() {
836        let block = hpack_literal_indexed_name_huffman(1, "api.example.com");
837        let bytes = settings_then_headers(&block);
838        let result = extract_h2_authority(&bytes).unwrap();
839        assert_eq!(result.as_deref(), Some("api.example.com"));
840    }
841
842    // ── 14. Dynamic-table reference NOW DECODES (Phase 3g) ─────────────
843    // Requires a stateful decoder — the stateless one-shot path returns
844    // an error since the dynamic table is empty. Use the stateful API.
845    #[test]
846    fn decodes_dynamic_table_reference_with_stateful_decoder() {
847        let mut decoder = HpackDecoder::new();
848        // Connection 1: incremental-index :authority = "api.example.com".
849        let block1 = hpack_literal_indexed_name(1, "api.example.com");
850        let bytes1 = settings_then_headers(&block1);
851        let r1 = extract_h2_authority_with(&bytes1, &mut decoder)
852            .unwrap()
853            .unwrap();
854        assert_eq!(r1.value, "api.example.com");
855        // Connection 2 (same decoder): indexed-header-field at dynamic
856        // index 62. Build a SETTINGS+HEADERS sequence.
857        let block2 = vec![0x80 | 62];
858        let bytes2 = settings_then_headers(&block2);
859        let r2 = extract_h2_authority_with(&bytes2, &mut decoder)
860            .unwrap()
861            .unwrap();
862        assert_eq!(r2.value, "api.example.com");
863        assert_eq!(r2.provenance, AuthorityProvenance::DynamicIndexed);
864    }
865
866    // ── 15. PRIORITY-bearing HEADERS frame ──────────────────────────────
867    #[test]
868    fn extracts_authority_with_priority_flag() {
869        let block = hpack_literal_indexed_name(1, "api.example.com");
870        let priority_section = [0u8; 5];
871        let mut payload = Vec::new();
872        payload.extend_from_slice(&priority_section);
873        payload.extend_from_slice(&block);
874        let length = payload.len() as u32;
875        let flags = frame::FLAG_END_HEADERS | frame::FLAG_PRIORITY;
876        let mut bytes = empty_settings_frame();
877        bytes.extend_from_slice(&frame_header(length, frame::FRAME_TYPE_HEADERS, flags, 1));
878        bytes.extend_from_slice(&payload);
879        let result = extract_h2_authority(&bytes).unwrap();
880        assert_eq!(result.as_deref(), Some("api.example.com"));
881    }
882
883    // ── 16. Authority with trailing dot is stripped ─────────────────────
884    #[test]
885    fn strips_trailing_dot_from_authority() {
886        let block = hpack_literal_indexed_name(1, "api.example.com.");
887        let bytes = settings_then_headers(&block);
888        let result = extract_h2_authority(&bytes).unwrap();
889        assert_eq!(result.as_deref(), Some("api.example.com"));
890    }
891
892    // ── 17. IPv6 literal authority keeps brackets, drops port ───────────
893    #[test]
894    fn ipv6_literal_authority_keeps_brackets() {
895        let block = hpack_literal_indexed_name(1, "[::1]:443");
896        let bytes = settings_then_headers(&block);
897        let result = extract_h2_authority(&bytes).unwrap();
898        assert_eq!(result.as_deref(), Some("[::1]"));
899    }
900
901    // ── 18. Literal-without-indexing path ───────────────────────────────
902    #[test]
903    fn extracts_authority_from_literal_without_indexing() {
904        let block = hpack_literal_no_indexing(1, "api.example.com");
905        let bytes = settings_then_headers(&block);
906        let result = extract_h2_authority(&bytes).unwrap();
907        assert_eq!(result.as_deref(), Some("api.example.com"));
908    }
909
910    // ── 19. Broken Huffman bitstream → reject ──────────────────────────
911    #[test]
912    fn rejects_broken_huffman_literal() {
913        // Raw bytes 0x00,0x00,0x00 inside a Huffman-flagged length —
914        // decoder will fail to decode and return HuffmanInvalid (or
915        // padding-violates-EOS rule).
916        let block = hpack_literal_huffman_indexed_name_with_raw(1, &[0x00, 0x00, 0x00]);
917        let bytes = settings_then_headers(&block);
918        let err = extract_h2_authority(&bytes).unwrap_err();
919        assert!(matches!(
920            err,
921            H2ParseError::HuffmanInvalid | H2ParseError::HuffmanOversized { .. }
922        ));
923    }
924
925    // ── 20. Reassembly rejects interleaved DATA frame ──────────────────
926    #[test]
927    fn reassembly_rejects_interleaved_data_frame() {
928        let block_full = hpack_literal_indexed_name(1, "api.example.com");
929        let mid = block_full.len() / 2;
930        let (first_half, second_half) = block_full.split_at(mid);
931        let mut bytes = empty_settings_frame();
932        bytes.extend_from_slice(&continuation_fragmented_headers(first_half));
933        // Insert a DATA frame between HEADERS and the END_HEADERS continuation.
934        bytes.extend_from_slice(&frame_header(0, frame::FRAME_TYPE_DATA, 0, 1));
935        bytes.extend_from_slice(&continuation_frame(second_half, true));
936        let err = extract_h2_authority(&bytes).unwrap_err();
937        assert!(matches!(
938            err,
939            H2ParseError::InterleavedFrame { frame_type: 0 }
940        ));
941    }
942
943    // ── 21. Three-fragment reassembly with priority + padding ──────────
944    #[test]
945    fn reassembles_three_fragments_with_priority_and_padding() {
946        let block_full = hpack_literal_indexed_name(1, "api.example.com");
947        let third = block_full.len() / 3;
948        let (a, rest) = block_full.split_at(third);
949        let (b, c) = rest.split_at(third);
950
951        // First HEADERS frame: priority + padded, no END_HEADERS.
952        let pad_len: u8 = 2;
953        let priority = [0u8; 5];
954        let mut headers_payload = Vec::new();
955        headers_payload.push(pad_len);
956        headers_payload.extend_from_slice(&priority);
957        headers_payload.extend_from_slice(a);
958        headers_payload.extend(std::iter::repeat_n(0u8, pad_len as usize));
959        let headers_frame_bytes = {
960            let length = headers_payload.len() as u32;
961            let flags = frame::FLAG_PADDED | frame::FLAG_PRIORITY;
962            let mut out = frame_header(length, frame::FRAME_TYPE_HEADERS, flags, 1);
963            out.extend_from_slice(&headers_payload);
964            out
965        };
966
967        let mut bytes = empty_settings_frame();
968        bytes.extend_from_slice(&headers_frame_bytes);
969        bytes.extend_from_slice(&continuation_frame(b, false));
970        bytes.extend_from_slice(&continuation_frame(c, true));
971
972        let result = extract_h2_authority(&bytes).unwrap();
973        assert_eq!(result.as_deref(), Some("api.example.com"));
974    }
975
976    // ── 22. Reassembly oversized aggregate → reject ────────────────────
977    #[test]
978    fn reassembly_rejects_oversized_aggregate_block() {
979        // Build > 64 KiB of header-block bytes split across CONTINUATION
980        // frames. We use literal-with-indexing + a giant value that, on
981        // its own, exceeds MAX_HEADER_BLOCK_SIZE.
982        // Instead of a single oversized value (which would exceed
983        // MAX_FRAME_SIZE = 16 KiB), chain enough CONTINUATION frames
984        // each carrying ~16 KiB of arbitrary HPACK octets.
985        let mut bytes = empty_settings_frame();
986        // First HEADERS frame: 16383 bytes of payload, no END_HEADERS.
987        // Use literal-with-indexing name=:authority value=<garbage>; the
988        // decoder will likely error on garbage but reassembly checks the
989        // size cap first.
990        let chunk = vec![0xAAu8; 16_383];
991        bytes.extend_from_slice(&continuation_fragmented_headers(&chunk));
992        bytes.extend_from_slice(&continuation_frame(&chunk, false));
993        bytes.extend_from_slice(&continuation_frame(&chunk, false));
994        bytes.extend_from_slice(&continuation_frame(&chunk, false));
995        bytes.extend_from_slice(&continuation_frame(&chunk, true));
996        let err = extract_h2_authority(&bytes).unwrap_err();
997        assert!(matches!(
998            err,
999            H2ParseError::HpackOversizedHeaderBlock { .. }
1000        ));
1001    }
1002
1003    // ── 23. Single HEADERS with END_HEADERS works via reassembly path ──
1004    #[test]
1005    fn single_headers_with_end_headers_works_unchanged_via_reassembly_path() {
1006        // Same as test #2 but verifies reassembly is transparent.
1007        let block = hpack_literal_indexed_name(1, "api.example.com");
1008        let bytes = settings_then_headers(&block);
1009        let result = extract_h2_authority(&bytes).unwrap();
1010        assert_eq!(result.as_deref(), Some("api.example.com"));
1011    }
1012
1013    // ─────────────────────────────────────────────────────────────────────
1014    // scope: H2StreamReassembler unit tests
1015    // ─────────────────────────────────────────────────────────────────────
1016
1017    fn parse_first_frame(bytes: &[u8]) -> (frame::FrameHeader, Vec<u8>) {
1018        let (header, payload, _rest) = parse_one_frame(bytes).unwrap().unwrap();
1019        (header, payload.to_vec())
1020    }
1021
1022    #[test]
1023    fn ingest_single_headers_with_end_headers_returns_block() {
1024        let mut r = H2StreamReassembler::new();
1025        let block = hpack_literal_indexed_name(1, "api.example.com");
1026        let frame_bytes = headers_frame(&block);
1027        let (h, p) = parse_first_frame(&frame_bytes);
1028        let out = r.ingest(&h, &p).unwrap();
1029        let (sid, returned) = out.expect("expected immediate completion");
1030        assert_eq!(sid, 1);
1031        assert_eq!(returned, block);
1032        assert_eq!(r.pending_count(), 0);
1033    }
1034
1035    #[test]
1036    fn ingest_two_headers_on_different_streams_returns_two_blocks() {
1037        let mut r = H2StreamReassembler::new();
1038        let block_a = hpack_literal_indexed_name(1, "api.example.com");
1039        let block_b = hpack_literal_indexed_name(1, "api.other.com");
1040        let f_a = headers_frame_on_stream(&block_a, 1);
1041        let f_b = headers_frame_on_stream(&block_b, 3);
1042        let (ha, pa) = parse_first_frame(&f_a);
1043        let (hb, pb) = parse_first_frame(&f_b);
1044        let out_a = r.ingest(&ha, &pa).unwrap().unwrap();
1045        let out_b = r.ingest(&hb, &pb).unwrap().unwrap();
1046        assert_eq!(out_a.0, 1);
1047        assert_eq!(out_b.0, 3);
1048        assert_eq!(out_a.1, block_a);
1049        assert_eq!(out_b.1, block_b);
1050    }
1051
1052    #[test]
1053    fn ingest_continuation_on_correct_active_stream_reassembles() {
1054        let mut r = H2StreamReassembler::new();
1055        let block_full = hpack_literal_indexed_name(1, "api.example.com");
1056        let mid = block_full.len() / 2;
1057        let (a, b) = block_full.split_at(mid);
1058        let f1 = headers_frame_on_stream_no_end(a, 1);
1059        let f2 = continuation_frame_on_stream(b, 1, true);
1060        let (h1, p1) = parse_first_frame(&f1);
1061        let (h2, p2) = parse_first_frame(&f2);
1062        assert!(r.ingest(&h1, &p1).unwrap().is_none());
1063        assert_eq!(r.pending_count(), 1);
1064        let (sid, block) = r.ingest(&h2, &p2).unwrap().unwrap();
1065        assert_eq!(sid, 1);
1066        assert_eq!(block, block_full);
1067        assert_eq!(r.pending_count(), 0);
1068    }
1069
1070    #[test]
1071    fn ingest_continuation_on_wrong_stream_returns_interleaved() {
1072        let mut r = H2StreamReassembler::new();
1073        let block_full = hpack_literal_indexed_name(1, "api.example.com");
1074        let mid = block_full.len() / 2;
1075        let (a, b) = block_full.split_at(mid);
1076        let f1 = headers_frame_on_stream_no_end(a, 1);
1077        let f2 = continuation_frame_on_stream(b, 3, true);
1078        let (h1, p1) = parse_first_frame(&f1);
1079        let (h2, p2) = parse_first_frame(&f2);
1080        r.ingest(&h1, &p1).unwrap();
1081        let err = r.ingest(&h2, &p2).unwrap_err();
1082        assert!(matches!(err, H2ParseError::InterleavedFrame { .. }));
1083    }
1084
1085    #[test]
1086    fn ingest_data_frame_passes_through_with_no_block_returned() {
1087        let mut r = H2StreamReassembler::new();
1088        let f = data_frame_on_stream(b"hello", 1);
1089        let (h, p) = parse_first_frame(&f);
1090        assert!(r.ingest(&h, &p).unwrap().is_none());
1091        assert_eq!(r.pending_count(), 0);
1092    }
1093
1094    #[test]
1095    fn ingest_oversized_per_stream_block_returns_overflow() {
1096        let mut r = H2StreamReassembler::new();
1097        // Open a HEADERS that's already at MAX_HEADER_BLOCK_SIZE - 1 then
1098        // append a CONTINUATION that pushes over the per-stream cap.
1099        // Frames must obey MAX_FRAME_SIZE = 16 KiB so we chain many
1100        // CONTINUATIONs of ~16 KiB until we trip the per-stream cap.
1101        let chunk = vec![0u8; 16_383];
1102        let f1 = headers_frame_on_stream_no_end(&chunk, 1);
1103        let (h1, p1) = parse_first_frame(&f1);
1104        r.ingest(&h1, &p1).unwrap();
1105        // After 1 HEADERS + 3 CONTINUATIONs we're at 4*16383 = 65532 ≤
1106        // 65536 — borderline. Add a 4th CONTINUATION of 16383 → 81915 >
1107        // 65536 → PerStreamBlock.
1108        for _ in 0..3 {
1109            let f = continuation_frame_on_stream(&chunk, 1, false);
1110            let (h, p) = parse_first_frame(&f);
1111            r.ingest(&h, &p).unwrap();
1112        }
1113        let f = continuation_frame_on_stream(&chunk, 1, false);
1114        let (h, p) = parse_first_frame(&f);
1115        let err = r.ingest(&h, &p).unwrap_err();
1116        assert!(matches!(
1117            err,
1118            H2ParseError::ReassemblerOverflow {
1119                kind: ReassemblerOverflowKind::PerStreamBlock
1120            }
1121        ));
1122    }
1123
1124    #[test]
1125    fn ingest_oversized_aggregate_returns_overflow() {
1126        // Open 5 streams each at 60 KiB → 300 KiB > 256 KiB cap.
1127        // Use ascending odd stream ids (RFC 7540 §5.1.1: client streams
1128        // are odd). Each stream just opens HEADERS without END_HEADERS.
1129        // The first 4 streams accumulate fine; the 5th (or its
1130        // continuation) trips the aggregate cap.
1131        //
1132        // Strategy: build streams that have just-under-MAX_HEADER_BLOCK_SIZE
1133        // pending. We can't have multiple streams "active" at once
1134        // because §6.10 forbids interleaving — so this test exercises
1135        // the aggregate-cap math by opening one stream at a time and
1136        // closing it, then reusing the bytes. That doesn't accumulate.
1137        //
1138        // Instead: each open HEADERS-with-END_HEADERS returns immediately
1139        // and DOESN'T linger. To exercise the aggregate cap we need
1140        // to accumulate via the active reassembly. So use a single
1141        // stream that pushes past 256 KiB — but per-stream cap is
1142        // 64 KiB, which trips first. Therefore the aggregate cap is
1143        // primarily a defence against fragmented HEADERS pending across
1144        // many active reassemblies — but only ONE reassembly may be
1145        // active per §6.10.
1146        //
1147        // The aggregate cap is exercised by the reassembler keeping
1148        // pending blocks in the map even after the active stream
1149        // changes (which §6.10 forbids — so this is purely defence in
1150        // depth). To trigger it deterministically we directly inject
1151        // entries via the test-only API (no such API exists).
1152        //
1153        // Simplest deterministic test: feed one stream up to the
1154        // per-stream cap and verify the cap trips with kind =
1155        // PerStreamBlock; then construct a smaller cap scenario via a
1156        // second reassembler whose total_in_flight is artificially
1157        // pushed by feeding many small CONTINUATIONs.
1158        //
1159        // Since per-stream cap < aggregate cap, the per-stream cap
1160        // always trips first for a single stream. The aggregate cap is
1161        // therefore unreachable in normal protocol; we can still assert
1162        // it triggers if total_in_flight would exceed via direct path:
1163        // we build an *accept* path that fills up to 250 KiB across
1164        // multiple completed streams (each is removed on END_HEADERS
1165        // so total_in_flight returns to 0). To force it nonzero we
1166        // need pending streams. RFC §6.10 says only one open at once
1167        // — but the reassembler enforces that via InterleavedFrame.
1168        //
1169        // Conclusion: aggregate cap is checked but only triggers in
1170        // anomalous cases (e.g. a future PUSH_PROMISE path that doesn't
1171        // close a stream cleanly). For Phase 3g.1 unit-test coverage we
1172        // assert that the bound is checked at ingest (any fresh
1173        // single-stream block past the bound trips PerStreamBlock first
1174        // because per-stream is the smaller of the two). The aggregate
1175        // path is therefore tested by the synthetic case below where
1176        // we manually stuff the reassembler's internal state.
1177        let mut r = H2StreamReassembler::new();
1178        // Manually pre-fill an entry to simulate a pending block from a
1179        // prior frame (this can occur if a future code path leaves
1180        // residual reassembly state).
1181        r.blocks.insert(1, vec![0u8; 60 * 1024]);
1182        r.total_in_flight = 60 * 1024;
1183        r.active_stream = Some(1);
1184        // Append another 200 KiB worth via a CONTINUATION that nominally
1185        // would only add per-frame bytes. Use four CONTINUATIONs each
1186        // 16 KiB on stream 1; the per-stream cap will trip on the 1st
1187        // because 60+16 = 76 > 64.
1188        let chunk = vec![0u8; 16_383];
1189        let f = continuation_frame_on_stream(&chunk, 1, false);
1190        let (h, p) = parse_first_frame(&f);
1191        let err = r.ingest(&h, &p).unwrap_err();
1192        // Per-stream cap trips first by construction; that is the
1193        // reachable bound for a single-stream chain. Verify bound is
1194        // checked.
1195        assert!(matches!(
1196            err,
1197            H2ParseError::ReassemblerOverflow {
1198                kind: ReassemblerOverflowKind::PerStreamBlock
1199            }
1200        ));
1201
1202        // Now verify the aggregate-cap path with synthetic state: set
1203        // total_in_flight just under cap, attempt a fresh HEADERS that
1204        // would push over. (Per-stream cap doesn't trip because the
1205        // new stream has 0 bytes pending.)
1206        let mut r2 = H2StreamReassembler::new();
1207        r2.total_in_flight = REASSEMBLER_TOTAL_IN_FLIGHT_MAX - 100;
1208        let block = vec![0u8; 200];
1209        let f = headers_frame_on_stream_no_end(&block, 5);
1210        let (h, p) = parse_first_frame(&f);
1211        let err = r2.ingest(&h, &p).unwrap_err();
1212        assert!(matches!(
1213            err,
1214            H2ParseError::ReassemblerOverflow {
1215                kind: ReassemblerOverflowKind::TotalInFlight
1216            }
1217        ));
1218    }
1219
1220    #[test]
1221    fn ingest_too_many_concurrent_streams_returns_overflow() {
1222        let mut r = H2StreamReassembler::new();
1223        // Synthetic fill: jam the blocks map to the cap directly,
1224        // then attempt to open a fresh HEADERS. (Real protocol can't
1225        // sustain >1 open HEADERS reassembly at once per §6.10, so
1226        // this path is purely defence in depth.)
1227        for i in 1..=REASSEMBLER_MAX_CONCURRENT_STREAMS as u32 {
1228            r.blocks.insert(i * 2 + 1, vec![]);
1229        }
1230        // active_stream stays None so the new-HEADERS path runs.
1231        let block = hpack_literal_indexed_name(1, "api.example.com");
1232        let f = headers_frame_on_stream(&block, 999);
1233        let (h, p) = parse_first_frame(&f);
1234        let err = r.ingest(&h, &p).unwrap_err();
1235        assert!(matches!(
1236            err,
1237            H2ParseError::ReassemblerOverflow {
1238                kind: ReassemblerOverflowKind::ConcurrentStreams
1239            }
1240        ));
1241    }
1242
1243    // ─────────────────────────────────────────────────────────────────────
1244    // scope: H2ConnectionDecoder unit tests
1245    // ─────────────────────────────────────────────────────────────────────
1246
1247    #[test]
1248    fn feed_returns_decoded_authority_for_complete_headers_on_stream_1() {
1249        let mut d = H2ConnectionDecoder::new();
1250        let block = hpack_literal_indexed_name(1, "api.example.com");
1251        let f = headers_frame(&block);
1252        let (h, p) = parse_first_frame(&f);
1253        let out = d.feed_frame(&h, &p).unwrap().unwrap();
1254        assert_eq!(out.stream_id, 1);
1255        assert_eq!(out.authority.as_deref(), Some("api.example.com"));
1256        assert!(!out.via_dynamic_table);
1257        assert!(!out.via_huffman);
1258    }
1259
1260    #[test]
1261    fn feed_returns_two_decoded_blocks_for_two_streams_interleaved_via_continuation() {
1262        let mut d = H2ConnectionDecoder::new();
1263        let block_a = hpack_literal_indexed_name(1, "api.example.com");
1264        let block_b = hpack_literal_indexed_name(1, "api.other.com");
1265        // Stream 1 single-frame HEADERS.
1266        let fa = headers_frame_on_stream(&block_a, 1);
1267        let (ha, pa) = parse_first_frame(&fa);
1268        let out_a = d.feed_frame(&ha, &pa).unwrap().unwrap();
1269        assert_eq!(out_a.stream_id, 1);
1270        assert_eq!(out_a.authority.as_deref(), Some("api.example.com"));
1271        // Stream 3 fragmented across HEADERS + CONTINUATION.
1272        let mid = block_b.len() / 2;
1273        let (b1, b2) = block_b.split_at(mid);
1274        let f1 = headers_frame_on_stream_no_end(b1, 3);
1275        let f2 = continuation_frame_on_stream(b2, 3, true);
1276        let (h1, p1) = parse_first_frame(&f1);
1277        let (h2, p2) = parse_first_frame(&f2);
1278        assert!(d.feed_frame(&h1, &p1).unwrap().is_none());
1279        let out_b = d.feed_frame(&h2, &p2).unwrap().unwrap();
1280        assert_eq!(out_b.stream_id, 3);
1281        assert_eq!(out_b.authority.as_deref(), Some("api.other.com"));
1282    }
1283
1284    #[test]
1285    fn feed_returns_provenance_when_huffman_used() {
1286        let mut d = H2ConnectionDecoder::new();
1287        let block = hpack_literal_indexed_name_huffman(1, "api.example.com");
1288        let f = headers_frame(&block);
1289        let (h, p) = parse_first_frame(&f);
1290        let out = d.feed_frame(&h, &p).unwrap().unwrap();
1291        assert!(out.via_huffman);
1292    }
1293
1294    #[test]
1295    fn feed_returns_provenance_when_dynamic_table_used() {
1296        let mut d = H2ConnectionDecoder::new();
1297        // Block 1: literal-with-incremental-indexing :authority =
1298        // "api.example.com" — populates dynamic index 62.
1299        let block1 = hpack_literal_indexed_name(1, "api.example.com");
1300        let f1 = headers_frame_on_stream(&block1, 1);
1301        let (h1, p1) = parse_first_frame(&f1);
1302        let _ = d.feed_frame(&h1, &p1).unwrap();
1303        // Block 2: indexed-header at dynamic index 62 — provenance
1304        // should reflect dynamic-table reuse.
1305        let block2 = vec![0x80 | 62];
1306        let f2 = headers_frame_on_stream(&block2, 3);
1307        let (h2, p2) = parse_first_frame(&f2);
1308        let out = d.feed_frame(&h2, &p2).unwrap().unwrap();
1309        assert!(out.via_dynamic_table);
1310        assert_eq!(out.authority.as_deref(), Some("api.example.com"));
1311    }
1312
1313    #[test]
1314    fn feed_returns_none_for_data_frame() {
1315        let mut d = H2ConnectionDecoder::new();
1316        let f = data_frame_on_stream(b"payload", 1);
1317        let (h, p) = parse_first_frame(&f);
1318        assert!(d.feed_frame(&h, &p).unwrap().is_none());
1319    }
1320
1321    #[test]
1322    fn feed_propagates_hpack_decoder_state_across_streams() {
1323        // Same as feed_returns_provenance_when_dynamic_table_used but
1324        // explicitly asserts on the decoded value matching what the
1325        // first stream pushed into the dynamic table.
1326        let mut d = H2ConnectionDecoder::new();
1327        let block1 = hpack_literal_indexed_name(1, "shared.example.com");
1328        let f1 = headers_frame_on_stream(&block1, 1);
1329        let (h1, p1) = parse_first_frame(&f1);
1330        let r1 = d.feed_frame(&h1, &p1).unwrap().unwrap();
1331        assert_eq!(r1.authority.as_deref(), Some("shared.example.com"));
1332        let block2 = vec![0x80 | 62];
1333        let f2 = headers_frame_on_stream(&block2, 5);
1334        let (h2, p2) = parse_first_frame(&f2);
1335        let r2 = d.feed_frame(&h2, &p2).unwrap().unwrap();
1336        assert_eq!(r2.authority.as_deref(), Some("shared.example.com"));
1337        assert_eq!(r2.stream_id, 5);
1338    }
1339
1340    #[test]
1341    fn feed_propagates_reassembler_overflow_per_stream() {
1342        let mut d = H2ConnectionDecoder::new();
1343        let chunk = vec![0u8; 16_383];
1344        let f1 = headers_frame_on_stream_no_end(&chunk, 1);
1345        let (h1, p1) = parse_first_frame(&f1);
1346        d.feed_frame(&h1, &p1).unwrap();
1347        for _ in 0..3 {
1348            let f = continuation_frame_on_stream(&chunk, 1, false);
1349            let (h, p) = parse_first_frame(&f);
1350            d.feed_frame(&h, &p).unwrap();
1351        }
1352        let f = continuation_frame_on_stream(&chunk, 1, false);
1353        let (h, p) = parse_first_frame(&f);
1354        let err = d.feed_frame(&h, &p).unwrap_err();
1355        assert!(matches!(
1356            err,
1357            H2ParseError::ReassemblerOverflow {
1358                kind: ReassemblerOverflowKind::PerStreamBlock
1359            }
1360        ));
1361    }
1362
1363    #[test]
1364    fn feed_handles_settings_frame_size_update_to_dynamic_table() {
1365        // SETTINGS frames pass through the reassembler (Ok(None)) and
1366        // never reach the HPACK decoder; the dynamic-table SIZE update
1367        // is an HPACK §6.3 representation that lives INSIDE a HEADERS
1368        // block, not a SETTINGS frame. So a SETTINGS frame should be a
1369        // no-op for the decoder, and a HEADERS block carrying a §6.3
1370        // size update should successfully update the dynamic table.
1371        let mut d = H2ConnectionDecoder::new();
1372        // Empty SETTINGS: pass-through.
1373        let settings = empty_settings_frame();
1374        let (sh, sp) = parse_first_frame(&settings);
1375        assert!(d.feed_frame(&sh, &sp).unwrap().is_none());
1376        // HEADERS carrying a §6.3 size update (set to 0) followed by
1377        // an indexed header (no :authority) — should succeed without
1378        // surfacing an authority.
1379        let block: Vec<u8> = vec![
1380            0x20,     // 001 00000 — size update, new max = 0
1381            0x80 | 2, // indexed :method GET
1382        ];
1383        let f = headers_frame_on_stream(&block, 1);
1384        let (h, p) = parse_first_frame(&f);
1385        let out = d.feed_frame(&h, &p).unwrap().unwrap();
1386        assert_eq!(out.stream_id, 1);
1387        assert!(out.authority.is_none());
1388    }
1389
1390    #[test]
1391    fn feed_returns_none_authority_when_headers_block_lacks_pseudo_header() {
1392        let mut d = H2ConnectionDecoder::new();
1393        // Indexed-header at static index 2 (`:method GET`) — no :authority.
1394        let block = vec![0x80 | 2];
1395        let f = headers_frame_on_stream(&block, 1);
1396        let (h, p) = parse_first_frame(&f);
1397        let out = d.feed_frame(&h, &p).unwrap().unwrap();
1398        assert_eq!(out.stream_id, 1);
1399        assert!(out.authority.is_none());
1400    }
1401
1402    #[test]
1403    fn feed_returns_block_for_continuation_after_headers_without_end_headers() {
1404        // Same as feed_returns_two_decoded_blocks but on stream 1 with
1405        // a single fragmented stream — verifies the CONTINUATION-only
1406        // completion path returns Some(decoded).
1407        let mut d = H2ConnectionDecoder::new();
1408        let block_full = hpack_literal_indexed_name(1, "api.example.com");
1409        let mid = block_full.len() / 2;
1410        let (a, b) = block_full.split_at(mid);
1411        let f1 = headers_frame_on_stream_no_end(a, 1);
1412        let f2 = continuation_frame_on_stream(b, 1, true);
1413        let (h1, p1) = parse_first_frame(&f1);
1414        let (h2, p2) = parse_first_frame(&f2);
1415        assert!(d.feed_frame(&h1, &p1).unwrap().is_none());
1416        let out = d.feed_frame(&h2, &p2).unwrap().unwrap();
1417        assert_eq!(out.stream_id, 1);
1418        assert_eq!(out.authority.as_deref(), Some("api.example.com"));
1419    }
1420}