diff --git a/CHANGELOG.md b/CHANGELOG.md index d06aa8c..74901a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- *(qpack)* dynamic-table encoder: `QpackEncoder::with_dynamic_table` + `encode` + drive the encoder stream (Set Dynamic Table Capacity, Insert with + Name Reference against static/dynamic names, Insert with Literal Name) and + emit field sections with dynamic indexed / name-reference representations and + a non-zero Required Insert Count. Entries referenced by a field section are + never evicted by inserts in the same batch; sensitive fields stay out of the + table (never-indexed). The previous static-only `encode_field_section` path is + unchanged. QPACK is now fully bidirectional (encode + decode, static + + dynamic), matching HPACK. + ## [0.6.5](https://github.com/KarpelesLab/compcol/compare/v0.6.4...v0.6.5) - 2026-06-15 ### Fixed diff --git a/README.md b/README.md index 567f56e..6fee379 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ flag, and a `compcol` binary turns the library into a Unix-style filter. | RAR 3.x | `rar3` | `.rar` | `Unsupported` (license) | full LZ77+Huffman + E8 filter; PPMd & VM filters refused | libarchive RAR3 fixtures | | RAR 5.x | `rar5` | `.rar` | `Unsupported` (license) | full LZ77+Huffman + x86 filter; Delta/ARM refused | RARLAB-CLI fixtures | | HTTP/2 HPACK (RFC 7541) | `hpack` | — | full (header codec + `h2-huffman` string codec) | full (static+dynamic tables, integer/string coding) | RFC 7541 Appendix C vectors | -| HTTP/3 QPACK (RFC 9204) | `qpack` | — | static-table + literal encoder; full decoder | full (static+dynamic tables via encoder stream, all field representations) | RFC 9204 Appendix B vectors | +| HTTP/3 QPACK (RFC 9204) | `qpack` | — | full (static + dynamic-table encoder driving the encoder stream; eviction-safe) | full (static+dynamic tables via encoder stream, all field representations) | RFC 9204 Appendix B vectors | | Canonical Huffman (standalone) | `huffman` | `.huff` | full (length-limited, self-delimiting) | full | own round-trip | | Range coder (adaptive order-0) | `rangecoder` | `.range` | full | full | own round-trip | | Move-To-Front transform | `mtf` | `mtf` | full (reversible filter) | full | round-trip identity | diff --git a/src/qpack/dynamic_table.rs b/src/qpack/dynamic_table.rs index 3dce22b..787709a 100644 --- a/src/qpack/dynamic_table.rs +++ b/src/qpack/dynamic_table.rs @@ -120,6 +120,49 @@ impl DynamicTable { Some(abs) } + /// Find the best live entry for `(name, value)`, used by the encoder mirror + /// to reference existing insertions. Prefers a full name+value match + /// (returning `value_matched = true`), else the most recent name-only match. + /// Returns the **absolute** index. Newest matches are preferred so the + /// reference is least likely to be evicted soon. + pub(crate) fn find(&self, name: &[u8], value: &[u8]) -> Option<(usize, bool)> { + let mut name_only: Option = None; + for (i, (n, v)) in self.entries.iter().enumerate().rev() { + if n.as_slice() == name { + let abs = self.dropped + i; + if v.as_slice() == value { + return Some((abs, true)); + } + if name_only.is_none() { + name_only = Some(abs); + } + } + } + name_only.map(|abs| (abs, false)) + } + + /// The absolute index of the oldest entry that would survive inserting an + /// entry of `incoming` bytes (i.e. the post-insert `dropped`). Every live + /// entry with absolute index `< evict_floor(incoming)` would be evicted to + /// make room. Pure — does not mutate. The encoder uses this to avoid + /// evicting entries it still references in the field section being built. + pub(crate) fn evict_floor(&self, incoming: usize) -> usize { + let mut size = self.size; + let mut dropped = self.dropped; + let mut i = 0; + while size + incoming > self.capacity { + match self.entries.get(i) { + Some((n, v)) => { + size -= Self::entry_size(n, v); + dropped += 1; + i += 1; + } + None => break, + } + } + dropped + } + /// Look up an entry by **absolute** index. Returns `None` if the index has /// been evicted or never inserted. pub(crate) fn get_absolute(&self, abs: usize) -> Option<(&[u8], &[u8])> { diff --git a/src/qpack/mod.rs b/src/qpack/mod.rs index 396c85e..e0b7d78 100644 --- a/src/qpack/mod.rs +++ b/src/qpack/mod.rs @@ -30,20 +30,41 @@ //! Count exceeds the decoder's current Insert Count, it returns //! [`Error::Corrupt`] rather than waiting. Feed the encoder stream first. //! -//! # Encoder — static-table + literal only +//! # Encoder — static-only and dynamic-table modes //! -//! [`QpackEncoder`] emits fully spec-compliant, interoperable field sections -//! that **never insert into the dynamic table**: the prefix is always Required -//! Insert Count = 0, Base = 0, and fields are coded with static-table indexed / -//! name-reference representations or literal names. This needs no encoder -//! stream and never blocks a peer decoder. Dynamic-table *encoding* (driving -//! the encoder stream, post-base references, eviction policy) is a deliberate -//! future extension; the decoder here already accepts a peer that does it. +//! [`QpackEncoder`] has two modes: +//! +//! * **Static-only** ([`QpackEncoder::new`], used via +//! [`encode_field_section`](QpackEncoder::encode_field_section)): emits a +//! single field section that **never inserts into the dynamic table** — the +//! prefix is always Required Insert Count = 0, Base = 0, and fields use +//! static-table indexed / name-reference representations or literal names. +//! No encoder stream, never blocks a peer decoder. +//! +//! * **Dynamic** ([`QpackEncoder::with_dynamic_table`], used via +//! [`encode`](QpackEncoder::encode)): maintains a mirror of the peer +//! decoder's dynamic table and returns an [`Encoded`] pair — the encoder-stream +//! bytes that build the table (Set Dynamic Table Capacity, Insert with +//! Name Reference against static or dynamic names, Insert with Literal Name) +//! and the field section that references it (indexed / name-reference +//! dynamic representations with a non-zero Required Insert Count). Entries +//! referenced by a field section are never evicted by inserts in the same +//! batch, and sensitive fields are kept out of the table (coded never-indexed). +//! +//! Because this is a synchronous API with no decoder-feedback channel, the +//! caller must deliver each [`Encoded::encoder_stream`] chunk to the peer's +//! [`QpackDecoder::feed_encoder_stream`] **before** the matching +//! [`Encoded::field_section`], in order. The encoder's `MaxEntries` (and hence +//! the Required Insert Count encoding) is derived from the capacity passed to +//! [`with_dynamic_table`](QpackEncoder::with_dynamic_table), which must equal +//! the decoder's `SETTINGS_QPACK_MAX_TABLE_CAPACITY` (see +//! [`QpackDecoder::with_max_table_capacity`]). //! //! ``` //! use compcol::qpack::{QpackEncoder, QpackDecoder}; //! use compcol::hpack::HeaderField; //! +//! // Static-only single-shot. //! let mut enc = QpackEncoder::new(); //! let block = enc.encode_field_section(&[ //! HeaderField::new(b":path", b"/index.html"), @@ -53,6 +74,20 @@ //! let out = dec.decode_field_section(&block).unwrap(); //! assert_eq!(out[0].name, b":path"); //! assert_eq!(out[1].value, b"value"); +//! +//! // Dynamic: build the table on the encoder stream, reference it from the +//! // field section. Feed the encoder stream first, then decode. +//! let mut enc = QpackEncoder::with_dynamic_table(4096); +//! let mut dec = QpackDecoder::with_max_table_capacity(4096); +//! let fields = [HeaderField::new(b"custom-key", b"custom-value")]; +//! let first = enc.encode(&fields); +//! dec.feed_encoder_stream(&first.encoder_stream).unwrap(); +//! assert_eq!(dec.decode_field_section(&first.field_section).unwrap(), fields); +//! // A later section reuses the entry with no new insertions. +//! let again = enc.encode(&fields); +//! assert!(again.encoder_stream.is_empty()); +//! dec.feed_encoder_stream(&again.encoder_stream).unwrap(); +//! assert_eq!(dec.decode_field_section(&again.field_section).unwrap(), fields); //! ``` //! //! Clean-room from RFC 9204 (the static table is transcribed from Appendix A; @@ -84,15 +119,66 @@ pub const DEFAULT_MAX_TABLE_CAPACITY: usize = 4096; // ─── encoder ─────────────────────────────────────────────────────────────── -/// QPACK encoder (static-table + literal only). +/// Output of [`QpackEncoder::encode`]: the encoder-stream bytes that build the +/// peer decoder's dynamic table and the field section that references it. /// -/// Encodes each field section against the static table, emitting a Required -/// Insert Count = 0 / Base = 0 prefix and never inserting into the dynamic -/// table. This is stateless across calls and fully interoperable. See the -/// [module docs](crate::qpack) for why dynamic-table encoding is out of scope. +/// Deliver `encoder_stream` to the peer's +/// [`QpackDecoder::feed_encoder_stream`] **before** handing it `field_section` +/// (this synchronous API cannot wait on a blocked reference). `encoder_stream` +/// is empty when a section needs no new insertions. +#[derive(Debug, Clone)] +pub struct Encoded { + /// Encoder-stream instructions (§4.3) that mutate the dynamic table. + pub encoder_stream: Vec, + /// The field section (§4.5) referencing the static and dynamic tables. + pub field_section: Vec, +} + +/// QPACK encoder. +/// +/// Constructed [statically](Self::new) (no dynamic table; use +/// [`encode_field_section`](Self::encode_field_section)) or with a +/// [dynamic table](Self::with_dynamic_table) (use [`encode`](Self::encode)). +/// See the [module docs](crate::qpack) for the two modes and the +/// feed-encoder-stream-first contract. #[derive(Debug)] pub struct QpackEncoder { use_huffman: bool, + /// Mirror of the peer decoder's dynamic table. Capacity is 0 in + /// static-only mode, leaving every code path below dynamic-free. + table: DynamicTable, + /// Working dynamic-table capacity, also the value announced via Set Dynamic + /// Table Capacity. 0 means static-only. + target_capacity: usize, + /// `MaxEntries` (capacity / 32) used to encode the Required Insert Count + /// (§4.5.1.1); must match the decoder's `max_capacity / 32`. + max_entries: usize, + /// Whether the Set Dynamic Table Capacity instruction has been emitted on + /// the encoder stream yet (sent lazily before the first insert). + capacity_announced: bool, +} + +/// One field's chosen representation, decided in a first pass (which also emits +/// the encoder-stream inserts and computes the Required Insert Count) before +/// the prefix and field bytes are emitted in a second pass. +enum FieldRep<'a> { + StaticIdx(usize), + DynIdx(usize), + StaticName { + idx: usize, + value: &'a [u8], + sensitive: bool, + }, + DynName { + abs: usize, + value: &'a [u8], + sensitive: bool, + }, + Literal { + name: &'a [u8], + value: &'a [u8], + sensitive: bool, + }, } impl Default for QpackEncoder { @@ -102,9 +188,38 @@ impl Default for QpackEncoder { } impl QpackEncoder { - /// New encoder with Huffman string coding enabled. + /// New static-only encoder with Huffman string coding enabled. It never + /// inserts into the dynamic table; use + /// [`encode_field_section`](Self::encode_field_section). pub fn new() -> Self { - QpackEncoder { use_huffman: true } + QpackEncoder { + use_huffman: true, + table: DynamicTable::new(), + target_capacity: 0, + max_entries: 0, + capacity_announced: false, + } + } + + /// New encoder that uses a dynamic table of up to `max_table_capacity` + /// bytes, driven through [`encode`](Self::encode). + /// + /// `max_table_capacity` is both the capacity this encoder will request (via + /// Set Dynamic Table Capacity) and the basis for its `MaxEntries`, so it + /// **must equal** the peer decoder's `SETTINGS_QPACK_MAX_TABLE_CAPACITY` + /// (the value passed to [`QpackDecoder::with_max_table_capacity`], or + /// [`DEFAULT_MAX_TABLE_CAPACITY`] for [`QpackDecoder::new`]). Passing 0 + /// yields a static-only encoder. + pub fn with_dynamic_table(max_table_capacity: usize) -> Self { + let mut table = DynamicTable::new(); + table.set_capacity(max_table_capacity, max_table_capacity); + QpackEncoder { + use_huffman: true, + table, + target_capacity: max_table_capacity, + max_entries: max_table_capacity / 32, + capacity_announced: false, + } } /// Enable/disable Huffman coding of string literals (default on). When on, @@ -113,9 +228,18 @@ impl QpackEncoder { self.use_huffman = on; } - /// Encode one field section. The returned block begins with the §4.5.1 - /// prefix (Required Insert Count = 0, Base = 0 — encoded as two `0x00` - /// bytes) followed by one representation per field. + /// Entries inserted into the encoder's mirror table so far (for + /// tests/inspection). + #[cfg(test)] + pub(crate) fn insert_count(&self) -> usize { + self.table.insert_count() + } + + /// Encode one field section against the static table only, returning a + /// self-contained block. The block begins with the §4.5.1 prefix (Required + /// Insert Count = 0, Base = 0 — two `0x00` bytes) followed by one + /// representation per field. This ignores any dynamic table and emits no + /// encoder stream; for dynamic-table encoding use [`encode`](Self::encode). pub fn encode_field_section(&mut self, fields: &[HeaderField]) -> Vec { let mut out = Vec::new(); // §4.5.1 prefix. With no dynamic-table references, Required Insert @@ -128,6 +252,222 @@ impl QpackEncoder { out } + /// Encode one field section using the dynamic table, returning the + /// [`Encoded`] encoder-stream / field-section pair. + /// + /// For each field the encoder prefers, in order: a static full match + /// (indexed, no table change); an existing dynamic full match (indexed + /// dynamic); inserting a new entry (emitting an encoder-stream instruction + /// and referencing it); or a field-section literal with a static/dynamic + /// name reference or a literal name. Sensitive fields are never inserted and + /// are coded with the never-indexed bit set. An insert is skipped when it + /// would not fit, or when it would evict an entry already referenced by this + /// field section. On a static-only encoder ([`new`](Self::new)) this returns + /// an empty `encoder_stream` and a field section identical to + /// [`encode_field_section`](Self::encode_field_section). + pub fn encode<'a>(&mut self, fields: &'a [HeaderField]) -> Encoded { + let mut estream = Vec::new(); + let mut reps: Vec> = Vec::with_capacity(fields.len()); + // Required Insert Count = (highest referenced absolute index) + 1. + let mut required_insert_count = 0usize; + // Absolute indices referenced by this field section; none of them may be + // evicted by an insert in the same batch (the section is decoded as a + // unit, after the whole encoder-stream chunk is applied). + let mut referenced: Vec = Vec::new(); + + for f in fields { + let static_match = static_table::find(&f.name, &f.value); + // The mirror table changes as we insert, so this is recomputed per + // field. Sensitive fields skip the dynamic table entirely. + let dyn_match = if f.sensitive { + None + } else { + self.table.find(&f.name, &f.value) + }; + + // 1. Static full match (non-sensitive) → Indexed Field Line. + // A sensitive full match falls through to a never-indexed literal. + if let Some((idx, true)) = static_match.filter(|_| !f.sensitive) { + reps.push(FieldRep::StaticIdx(idx)); + continue; + } + + // 2. Existing dynamic full match → Indexed Field Line (dynamic). + if let Some((abs, true)) = dyn_match { + reps.push(FieldRep::DynIdx(abs)); + required_insert_count = required_insert_count.max(abs + 1); + referenced.push(abs); + continue; + } + + // 3. Insert a new entry, if it fits and evicts nothing we reference. + if !f.sensitive && self.target_capacity > 0 { + let size = DynamicTable::entry_size(&f.name, &f.value); + if size <= self.target_capacity { + let floor = self.table.evict_floor(size); + if referenced.iter().all(|&a| a >= floor) { + // The decoder resolves an insert's name reference before + // applying eviction, so a name source may itself be + // evicted by this insert — only `referenced` must survive. + if !self.capacity_announced { + // §4.3.1 Set Dynamic Table Capacity: 0 0 1 cap(5+). + encode_int(&mut estream, self.target_capacity, 5, 0b0010_0000); + self.capacity_announced = true; + } + self.emit_insert( + &mut estream, + &f.name, + &f.value, + static_match.map(|(idx, _)| idx), + dyn_match.map(|(abs, _)| abs), + ); + let abs = self + .table + .insert(&f.name, &f.value) + .expect("size <= capacity checked"); + reps.push(FieldRep::DynIdx(abs)); + required_insert_count = required_insert_count.max(abs + 1); + referenced.push(abs); + continue; + } + } + } + + // 4. Field-section literal: static name ref, dynamic name ref, or + // literal name. + if let Some((idx, _)) = static_match { + reps.push(FieldRep::StaticName { + idx, + value: &f.value, + sensitive: f.sensitive, + }); + } else if let Some((abs, _)) = dyn_match { + required_insert_count = required_insert_count.max(abs + 1); + referenced.push(abs); + reps.push(FieldRep::DynName { + abs, + value: &f.value, + sensitive: false, + }); + } else { + reps.push(FieldRep::Literal { + name: &f.name, + value: &f.value, + sensitive: f.sensitive, + }); + } + } + + let field_section = self.emit_field_section(required_insert_count, &reps); + Encoded { + encoder_stream: estream, + field_section, + } + } + + /// Emit an Insert instruction (§4.3.2 / §4.3.3) for `value`, choosing the + /// name representation: a static name reference if available, else a dynamic + /// (relative) name reference, else a literal name. The name index for the + /// dynamic case is relative to the most recent insertion (§3.2.5). + fn emit_insert( + &self, + est: &mut Vec, + name: &[u8], + value: &[u8], + static_name: Option, + dyn_name_abs: Option, + ) { + if let Some(idx) = static_name { + // §4.3.2 Insert with Name Reference, T = 1 (static): 1 1 idx(6+). + encode_int(est, idx, 6, 0b1100_0000); + self.emit_string(est, value, 7, 0); + } else if let Some(abs) = dyn_name_abs { + // §4.3.2 Insert with Name Reference, T = 0 (dynamic): 1 0 rel(6+). + // rel = InsertCount - 1 - abs (relative to the newest entry). + let rel = self.table.insert_count() - 1 - abs; + encode_int(est, rel, 6, 0b1000_0000); + self.emit_string(est, value, 7, 0); + } else { + // §4.3.3 Insert with Literal Name: 0 1 H name-len(5+) name value. + self.emit_string(est, name, 5, 0b0100_0000); + self.emit_string(est, value, 7, 0); + } + } + + /// Second pass: emit the §4.5.1 prefix (Required Insert Count + Base) and + /// then one representation per field. Base is fixed at Required Insert Count, + /// so every dynamic reference is a pre-base relative index and no post-base + /// forms are needed. + fn emit_field_section(&self, required_insert_count: usize, reps: &[FieldRep<'_>]) -> Vec { + let base = required_insert_count; + let mut out = Vec::new(); + self.emit_prefix(&mut out, required_insert_count, base); + for rep in reps { + match *rep { + FieldRep::StaticIdx(idx) => { + // §4.5.2 Indexed Field Line, static: 1 T(=1) index(6+). + encode_int(&mut out, idx, 6, 0b1100_0000); + } + FieldRep::DynIdx(abs) => { + // §4.5.2 Indexed Field Line, dynamic: 1 T(=0) index(6+). + let rel = base - 1 - abs; + encode_int(&mut out, rel, 6, 0b1000_0000); + } + FieldRep::StaticName { + idx, + value, + sensitive, + } => { + // §4.5.4 Literal w/ Name Reference, static: 0 1 N T(=1) idx(4+). + let n_bit = if sensitive { 0b0010_0000 } else { 0 }; + encode_int(&mut out, idx, 4, 0b0101_0000 | n_bit); + self.emit_string(&mut out, value, 7, 0); + } + FieldRep::DynName { + abs, + value, + sensitive, + } => { + // §4.5.4 Literal w/ Name Reference, dynamic: 0 1 N T(=0) idx(4+). + let rel = base - 1 - abs; + let n_bit = if sensitive { 0b0010_0000 } else { 0 }; + encode_int(&mut out, rel, 4, 0b0100_0000 | n_bit); + self.emit_string(&mut out, value, 7, 0); + } + FieldRep::Literal { + name, + value, + sensitive, + } => { + // §4.5.6 Literal Field Line with Literal Name: 0 0 1 N H len(3+). + let n_bit = if sensitive { 0b0001_0000 } else { 0 }; + self.emit_string(&mut out, name, 3, 0b0010_0000 | n_bit); + self.emit_string(&mut out, value, 7, 0); + } + } + } + out + } + + /// Emit the §4.5.1 prefix: Required Insert Count (§4.5.1.1) then Base as a + /// sign bit + 7-bit Delta Base relative to Required Insert Count. + fn emit_prefix(&self, out: &mut Vec, required_insert_count: usize, base: usize) { + if required_insert_count == 0 { + out.push(0x00); + } else { + // EncInsertCount = (ReqInsertCount mod (2*MaxEntries)) + 1. + let enc = (required_insert_count % (2 * self.max_entries)) + 1; + encode_int(out, enc, 8, 0); + } + if base >= required_insert_count { + // Sign = 0, Delta Base = Base - ReqInsertCount. + encode_int(out, base - required_insert_count, 7, 0); + } else { + // Sign = 1, Delta Base = ReqInsertCount - Base - 1. + encode_int(out, required_insert_count - base - 1, 7, 0b1000_0000); + } + } + fn encode_field(&self, out: &mut Vec, f: &HeaderField) { match static_table::find(&f.name, &f.value) { Some((idx, true)) if !f.sensitive => { diff --git a/src/qpack/tests.rs b/src/qpack/tests.rs index e5cd1fa..245d1eb 100644 --- a/src/qpack/tests.rs +++ b/src/qpack/tests.rs @@ -272,6 +272,185 @@ fn sensitive_field_sets_never_index_bit() { assert!(out2[0].sensitive); } +// ─── dynamic-table encoder ─────────────────────────────────────────────── + +/// Round-trip a dynamic [`Encoded`] pair through `dec`: feed the encoder stream +/// first (the contract), then decode the field section. +fn rt(dec: &mut QpackDecoder, e: &Encoded) -> Vec { + dec.feed_encoder_stream(&e.encoder_stream).unwrap(); + dec.decode_field_section(&e.field_section).unwrap() +} + +#[test] +fn encode_static_only_matches_encode_field_section() { + // encode() on a static-only encoder emits no encoder stream and a field + // section byte-identical to encode_field_section(). + let fields = vec![ + f(b":path", b"/"), + f(b":method", b"GET"), + f(b"custom", b"value"), + ]; + let mut a = QpackEncoder::new(); + let mut b = QpackEncoder::new(); + let e = a.encode(&fields); + assert!(e.encoder_stream.is_empty()); + assert_eq!(e.field_section, b.encode_field_section(&fields)); +} + +#[test] +fn dynamic_inserts_literal_name_and_round_trips() { + let mut enc = QpackEncoder::with_dynamic_table(4096); + let mut dec = QpackDecoder::with_max_table_capacity(4096); + + let fields = vec![f(b"custom-key", b"custom-value")]; + let e = enc.encode(&fields); + // An insert happened (Set Capacity + Insert with Literal Name), and the + // field section carries a non-zero Required Insert Count. + assert!(!e.encoder_stream.is_empty()); + assert_eq!(enc.insert_count(), 1); + assert_ne!(e.field_section[0], 0x00); // RIC prefix byte != 0 + assert_eq!(rt(&mut dec, &e), fields); + assert_eq!(dec.insert_count(), 1); +} + +#[test] +fn dynamic_reuses_entry_without_new_inserts() { + let mut enc = QpackEncoder::with_dynamic_table(4096); + let mut dec = QpackDecoder::with_max_table_capacity(4096); + + let fields = vec![f(b"x-custom", b"hello")]; + let first = enc.encode(&fields); + assert!(!first.encoder_stream.is_empty()); + assert_eq!(rt(&mut dec, &first), fields); + + // Second section references the existing entry — no new encoder-stream + // bytes, but still a dynamic (indexed) reference with non-zero RIC. + let second = enc.encode(&fields); + assert!(second.encoder_stream.is_empty()); + assert_eq!(enc.insert_count(), 1); + assert_eq!(rt(&mut dec, &second), fields); +} + +#[test] +fn dynamic_static_name_reference_insert() { + // :authority has a static name (index 0) but no value match → the insert + // uses a static Insert with Name Reference. + let mut enc = QpackEncoder::with_dynamic_table(4096); + let mut dec = QpackDecoder::with_max_table_capacity(4096); + + let fields = vec![f(b":authority", b"www.example.com")]; + let e = enc.encode(&fields); + assert_eq!(enc.insert_count(), 1); + assert_eq!(rt(&mut dec, &e), fields); +} + +#[test] +fn dynamic_name_reference_reuse_for_new_value() { + // First insert custom-key=v1 (literal name); a later field with the same + // name but a new value inserts via a *dynamic* name reference. + let mut enc = QpackEncoder::with_dynamic_table(4096); + let mut dec = QpackDecoder::with_max_table_capacity(4096); + + let e1 = enc.encode(&[f(b"custom-key", b"v1")]); + assert_eq!(rt(&mut dec, &e1), vec![f(b"custom-key", b"v1")]); + + let e2 = enc.encode(&[f(b"custom-key", b"v2")]); + assert!(!e2.encoder_stream.is_empty()); + assert_eq!(enc.insert_count(), 2); + assert_eq!(rt(&mut dec, &e2), vec![f(b"custom-key", b"v2")]); +} + +#[test] +fn dynamic_mixed_static_dynamic_literal_round_trip() { + let mut enc = QpackEncoder::with_dynamic_table(4096); + let mut dec = QpackDecoder::with_max_table_capacity(4096); + + let fields = vec![ + f(b":method", b"GET"), // static full match + f(b":authority", b"example.org"), // static name → insert + f(b"x-app-id", b"42"), // literal name → insert + f(b"accept", b"*/*"), // static full match + f(b"x-app-id", b"42"), // dynamic full match (reuse) + ]; + let e = enc.encode(&fields); + assert_eq!(rt(&mut dec, &e), fields); +} + +#[test] +fn dynamic_huffman_round_trip_many_fields() { + let mut enc = QpackEncoder::with_dynamic_table(8192); // Huffman on + let mut dec = QpackDecoder::with_max_table_capacity(8192); + + let mut fields: Vec = (0..30) + .map(|i| { + let name = alloc::format!("x-header-{i}"); + let val = alloc::format!("value-{i}-{}", "data".repeat(i % 4)); + f(name.as_bytes(), val.as_bytes()) + }) + .collect(); + // Repeat some fields so the second occurrences reuse dynamic entries. + fields.extend_from_slice(&fields.clone()[..10]); + let e = enc.encode(&fields); + assert_eq!(rt(&mut dec, &e), fields); +} + +#[test] +fn dynamic_eviction_safety_within_section() { + // Capacity fits only ~2 of these entries. Entries referenced by the field + // section must never be evicted by a later insert in the same batch, so the + // encoder falls back to literals once the table is full — and the section + // still round-trips exactly. + let mut enc = QpackEncoder::with_dynamic_table(128); + let mut dec = QpackDecoder::with_max_table_capacity(128); + + let fields = vec![ + f(b"aaaaaaaaaa", b"00000000000000000000"), // size 10+20+32 = 62 + f(b"bbbbbbbbbb", b"11111111111111111111"), // 62 → table now full + f(b"cccccccccc", b"22222222222222222222"), // cannot insert → literal + f(b"dddddddddd", b"33333333333333333333"), // literal + ]; + let e = enc.encode(&fields); + assert_eq!(rt(&mut dec, &e), fields); + // At most two entries ever inserted (the rest fell back to literals). + assert!(enc.insert_count() <= 2, "inserted {}", enc.insert_count()); +} + +#[test] +fn dynamic_sensitive_field_not_inserted() { + let mut enc = QpackEncoder::with_dynamic_table(4096); + let mut dec = QpackDecoder::with_max_table_capacity(4096); + + let fields = vec![ + HeaderField::sensitive(b"authorization", b"Bearer secret"), // static name + HeaderField::sensitive(b"x-token", b"abc123"), // literal name + ]; + let e = enc.encode(&fields); + // Nothing inserted; both coded never-indexed. + assert!(e.encoder_stream.is_empty()); + assert_eq!(enc.insert_count(), 0); + let out = rt(&mut dec, &e); + assert_eq!(out, fields); + assert!(out[0].sensitive); + assert!(out[1].sensitive); +} + +#[test] +fn dynamic_cross_section_reference_with_eviction() { + // Encode three sections; the third reuses an entry inserted in the first + // while later inserts have advanced (and possibly evicted) the table. + let mut enc = QpackEncoder::with_dynamic_table(256); + let mut dec = QpackDecoder::with_max_table_capacity(256); + + let a = vec![f(b"k1", b"reusable-value")]; + let ea = enc.encode(&a); + assert_eq!(rt(&mut dec, &ea), a); + + // Reference k1 again immediately (still present). + let eb = enc.encode(&a); + assert!(eb.encoder_stream.is_empty()); + assert_eq!(rt(&mut dec, &eb), a); +} + // ─── error handling ────────────────────────────────────────────────────── #[test]