Skip to content

Commit 5e0260a

Browse files
MagicalTuxclaude
andcommitted
feat(qpack): dynamic-table encoder driving the encoder stream
QPACK previously had a full decoder but a static-only encoder. Add a dynamic-table encoder so the send path is fully bidirectional, matching HPACK. QpackEncoder::with_dynamic_table + encode() return an Encoded pair: the encoder-stream instructions (Set Dynamic Table Capacity, Insert with Name Reference against static/dynamic names, Insert with Literal Name) and a field section with dynamic indexed / name-reference representations and a non-zero Required Insert Count (Base = RIC, no post-base forms). Entries referenced by a field section are never evicted by inserts in the same batch (the section is decoded as a unit after the whole encoder-stream chunk); sensitive fields stay out of the table and are coded never-indexed. The static-only encode_field_section path is unchanged (byte-identical). Adds DynamicTable::find / evict_floor for the encoder mirror, 11 round-trip tests through the existing decoder, and updates README/CHANGELOG/module docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d94348c commit 5e0260a

5 files changed

Lines changed: 593 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- *(qpack)* dynamic-table encoder: `QpackEncoder::with_dynamic_table` + `encode`
13+
drive the encoder stream (Set Dynamic Table Capacity, Insert with
14+
Name Reference against static/dynamic names, Insert with Literal Name) and
15+
emit field sections with dynamic indexed / name-reference representations and
16+
a non-zero Required Insert Count. Entries referenced by a field section are
17+
never evicted by inserts in the same batch; sensitive fields stay out of the
18+
table (never-indexed). The previous static-only `encode_field_section` path is
19+
unchanged. QPACK is now fully bidirectional (encode + decode, static +
20+
dynamic), matching HPACK.
21+
1022
## [0.6.5](https://github.com/KarpelesLab/compcol/compare/v0.6.4...v0.6.5) - 2026-06-15
1123

1224
### Fixed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ flag, and a `compcol` binary turns the library into a Unix-style filter.
7777
| RAR 3.x | `rar3` | `.rar` | `Unsupported` (license) | full LZ77+Huffman + E8 filter; PPMd & VM filters refused | libarchive RAR3 fixtures |
7878
| RAR 5.x | `rar5` | `.rar` | `Unsupported` (license) | full LZ77+Huffman + x86 filter; Delta/ARM refused | RARLAB-CLI fixtures |
7979
| 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 |
80-
| 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 |
80+
| 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 |
8181
| Canonical Huffman (standalone) | `huffman` | `.huff` | full (length-limited, self-delimiting) | full | own round-trip |
8282
| Range coder (adaptive order-0) | `rangecoder` | `.range` | full | full | own round-trip |
8383
| Move-To-Front transform | `mtf` | `mtf` | full (reversible filter) | full | round-trip identity |

src/qpack/dynamic_table.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,49 @@ impl DynamicTable {
120120
Some(abs)
121121
}
122122

123+
/// Find the best live entry for `(name, value)`, used by the encoder mirror
124+
/// to reference existing insertions. Prefers a full name+value match
125+
/// (returning `value_matched = true`), else the most recent name-only match.
126+
/// Returns the **absolute** index. Newest matches are preferred so the
127+
/// reference is least likely to be evicted soon.
128+
pub(crate) fn find(&self, name: &[u8], value: &[u8]) -> Option<(usize, bool)> {
129+
let mut name_only: Option<usize> = None;
130+
for (i, (n, v)) in self.entries.iter().enumerate().rev() {
131+
if n.as_slice() == name {
132+
let abs = self.dropped + i;
133+
if v.as_slice() == value {
134+
return Some((abs, true));
135+
}
136+
if name_only.is_none() {
137+
name_only = Some(abs);
138+
}
139+
}
140+
}
141+
name_only.map(|abs| (abs, false))
142+
}
143+
144+
/// The absolute index of the oldest entry that would survive inserting an
145+
/// entry of `incoming` bytes (i.e. the post-insert `dropped`). Every live
146+
/// entry with absolute index `< evict_floor(incoming)` would be evicted to
147+
/// make room. Pure — does not mutate. The encoder uses this to avoid
148+
/// evicting entries it still references in the field section being built.
149+
pub(crate) fn evict_floor(&self, incoming: usize) -> usize {
150+
let mut size = self.size;
151+
let mut dropped = self.dropped;
152+
let mut i = 0;
153+
while size + incoming > self.capacity {
154+
match self.entries.get(i) {
155+
Some((n, v)) => {
156+
size -= Self::entry_size(n, v);
157+
dropped += 1;
158+
i += 1;
159+
}
160+
None => break,
161+
}
162+
}
163+
dropped
164+
}
165+
123166
/// Look up an entry by **absolute** index. Returns `None` if the index has
124167
/// been evicted or never inserted.
125168
pub(crate) fn get_absolute(&self, abs: usize) -> Option<(&[u8], &[u8])> {

0 commit comments

Comments
 (0)