Skip to main content

asupersync_conformance/
hpack_encoder_conformance.rs

1//! HPACK encoder conformance testing.
2//!
3//! This harness tests the `asupersync` HPACK encoder against the `h2` reference
4//! implementation to ensure byte-identical wire output given the same HeaderMap
5//! and dynamic table state.
6
7use asupersync::bytes::Bytes;
8use asupersync::bytes::BytesMut;
9use asupersync::http::h2::{Header, HpackDecoder, HpackEncoder as AsupersyncEncoder};
10use serde::{Deserialize, Serialize};
11use std::fmt;
12
13/// Test verdict for individual encoder conformance cases.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub enum EncoderTestVerdict {
16    Pass,
17    Fail,
18    ExpectedFailure, // Known divergence
19    Skipped,
20}
21
22impl fmt::Display for EncoderTestVerdict {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Self::Pass => write!(f, "PASS"),
26            Self::Fail => write!(f, "FAIL"),
27            Self::ExpectedFailure => write!(f, "XFAIL"),
28            Self::Skipped => write!(f, "SKIP"),
29        }
30    }
31}
32
33/// Single HPACK encoder conformance test case.
34#[derive(Debug, Clone)]
35pub struct HpackEncoderConformanceCase {
36    pub id: String,
37    pub description: String,
38    /// Initial dynamic table size to construct the encoder/decoder with.
39    ///
40    /// This is distinct from `max_table_size`: h2's encoder tests can start
41    /// with a non-default table size without emitting an on-wire table-size
42    /// update first.
43    pub initial_max_table_size: Option<usize>,
44    /// Header blocks encoded before `headers` to establish dynamic table state.
45    pub prelude_blocks: Vec<Vec<Header>>,
46    pub headers: Vec<Header>,
47    pub max_table_size: Option<usize>,
48    pub use_huffman: bool,
49    pub expected_identical: bool, // Whether outputs should be byte-identical
50    /// Golden bytes captured from hyperium/h2 for the final `headers` block.
51    pub h2_golden_output: Option<Vec<u8>>,
52    /// Golden dynamic table byte size after the final h2 encode.
53    pub h2_golden_table_size: Option<usize>,
54}
55
56/// Result of running a single encoder test case.
57#[derive(Debug, Clone, Serialize)]
58pub struct HpackEncoderTestResult {
59    pub case_id: String,
60    pub verdict: EncoderTestVerdict,
61    pub error: Option<String>,
62    pub asupersync_output: Vec<u8>,
63    pub h2_output: Vec<u8>,
64    pub bytes_match: bool,
65    pub table_size_match: bool,
66    pub asupersync_table_size: usize,
67    pub h2_table_size: usize,
68}
69
70/// Summary statistics for an encoder conformance run.
71#[derive(Debug, Clone, Serialize)]
72pub struct HpackEncoderComplianceSummary {
73    pub passed: usize,
74    pub failed: usize,
75    pub expected_failures: usize,
76    pub skipped: usize,
77    pub total: usize,
78    pub compliance_score: f64, // (passed / (passed + failed))
79}
80
81/// Complete report for an HPACK encoder conformance run.
82#[derive(Debug, Clone, Serialize)]
83pub struct HpackEncoderComplianceReport {
84    pub test_run_id: String,
85    pub timestamp: String,
86    pub total_cases: usize,
87    pub results: Vec<HpackEncoderTestResult>,
88    pub summary: HpackEncoderComplianceSummary,
89}
90
91/// HPACK encoder conformance tester.
92pub struct HpackEncoderConformanceTester {
93    pub test_cases: Vec<HpackEncoderConformanceCase>,
94}
95
96impl HpackEncoderConformanceTester {
97    /// Create a new encoder conformance tester with standard test cases.
98    pub fn new() -> Self {
99        Self {
100            test_cases: Self::create_test_cases(),
101        }
102    }
103
104    /// Create the standard set of encoder conformance test cases.
105    fn create_test_cases() -> Vec<HpackEncoderConformanceCase> {
106        vec![
107            HpackEncoderConformanceCase {
108                id: "ENC-001".to_string(),
109                description: "Simple header without indexing".to_string(),
110                initial_max_table_size: None,
111                prelude_blocks: Vec::new(),
112                headers: vec![Header {
113                    name: "custom-header".to_string(),
114                    value: "custom-value".to_string(),
115                }],
116                max_table_size: None,
117                use_huffman: false,
118                expected_identical: true,
119                h2_golden_output: None,
120                h2_golden_table_size: None,
121            },
122            HpackEncoderConformanceCase {
123                id: "ENC-002".to_string(),
124                description: "Common headers using static table".to_string(),
125                initial_max_table_size: None,
126                prelude_blocks: Vec::new(),
127                headers: vec![
128                    Header {
129                        name: ":method".to_string(),
130                        value: "GET".to_string(),
131                    },
132                    Header {
133                        name: ":path".to_string(),
134                        value: "/".to_string(),
135                    },
136                    Header {
137                        name: ":scheme".to_string(),
138                        value: "https".to_string(),
139                    },
140                    Header {
141                        name: ":authority".to_string(),
142                        value: "example.com".to_string(),
143                    },
144                ],
145                max_table_size: None,
146                use_huffman: false,
147                expected_identical: true,
148                h2_golden_output: None,
149                h2_golden_table_size: None,
150            },
151            HpackEncoderConformanceCase {
152                id: "ENC-003".to_string(),
153                description: "Headers with Huffman encoding".to_string(),
154                initial_max_table_size: None,
155                prelude_blocks: Vec::new(),
156                headers: vec![
157                    Header {
158                        name: "user-agent".to_string(),
159                        value: "Mozilla/5.0".to_string(),
160                    },
161                    Header {
162                        name: "accept-encoding".to_string(),
163                        value: "gzip, deflate".to_string(),
164                    },
165                ],
166                max_table_size: None,
167                use_huffman: true,
168                expected_identical: true,
169                h2_golden_output: None,
170                h2_golden_table_size: None,
171            },
172            HpackEncoderConformanceCase {
173                id: "ENC-004".to_string(),
174                description: "Dynamic table indexing".to_string(),
175                initial_max_table_size: None,
176                prelude_blocks: Vec::new(),
177                headers: vec![
178                    Header {
179                        name: "x-custom-header".to_string(),
180                        value: "first-value".to_string(),
181                    },
182                    Header {
183                        name: "x-custom-header".to_string(),
184                        value: "second-value".to_string(),
185                    },
186                    Header {
187                        name: "x-another-header".to_string(),
188                        value: "another-value".to_string(),
189                    },
190                ],
191                max_table_size: Some(4096),
192                use_huffman: false,
193                expected_identical: true,
194                h2_golden_output: None,
195                h2_golden_table_size: None,
196            },
197            HpackEncoderConformanceCase {
198                id: "ENC-005".to_string(),
199                description: "Small dynamic table eviction".to_string(),
200                initial_max_table_size: None,
201                prelude_blocks: Vec::new(),
202                headers: vec![
203                    Header {
204                        name: "large-header-name-that-exceeds".to_string(),
205                        value: "large-header-value-that-also-exceeds-small-table".to_string(),
206                    },
207                    Header {
208                        name: "another-large-header-name".to_string(),
209                        value: "another-large-value".to_string(),
210                    },
211                ],
212                max_table_size: Some(128), // Small table to force eviction
213                use_huffman: false,
214                expected_identical: true,
215                h2_golden_output: None,
216                h2_golden_table_size: None,
217            },
218            HpackEncoderConformanceCase {
219                id: "ENC-006".to_string(),
220                description: "Empty headers list".to_string(),
221                initial_max_table_size: None,
222                prelude_blocks: Vec::new(),
223                headers: vec![],
224                max_table_size: None,
225                use_huffman: false,
226                expected_identical: true,
227                h2_golden_output: None,
228                h2_golden_table_size: None,
229            },
230            HpackEncoderConformanceCase {
231                id: "ENC-007".to_string(),
232                description: "Headers with empty values".to_string(),
233                initial_max_table_size: None,
234                prelude_blocks: Vec::new(),
235                headers: vec![
236                    Header {
237                        name: "empty-value".to_string(),
238                        value: "".to_string(),
239                    },
240                    Header {
241                        name: "x-trace-id".to_string(),
242                        value: "".to_string(),
243                    },
244                ],
245                max_table_size: None,
246                use_huffman: false,
247                expected_identical: true,
248                h2_golden_output: None,
249                h2_golden_table_size: None,
250            },
251            HpackEncoderConformanceCase {
252                id: "ENC-008".to_string(),
253                description: "Duplicate header names".to_string(),
254                initial_max_table_size: None,
255                prelude_blocks: Vec::new(),
256                headers: vec![
257                    Header {
258                        name: "cookie".to_string(),
259                        value: "session=abc123".to_string(),
260                    },
261                    Header {
262                        name: "cookie".to_string(),
263                        value: "preference=dark".to_string(),
264                    },
265                    Header {
266                        name: "cookie".to_string(),
267                        value: "lang=en".to_string(),
268                    },
269                ],
270                max_table_size: None,
271                use_huffman: false,
272                expected_identical: true,
273                h2_golden_output: None,
274                h2_golden_table_size: None,
275            },
276            HpackEncoderConformanceCase {
277                id: "ENC-009".to_string(),
278                description: "hyperium/h2 dynamic table eviction preserves evicted name reference"
279                    .to_string(),
280                initial_max_table_size: Some(76),
281                prelude_blocks: vec![
282                    vec![Header {
283                        name: "foo".to_string(),
284                        value: "bar".to_string(),
285                    }],
286                    vec![Header {
287                        name: "bar".to_string(),
288                        value: "foo".to_string(),
289                    }],
290                ],
291                headers: vec![Header {
292                    name: "foo".to_string(),
293                    value: "baz".to_string(),
294                }],
295                max_table_size: None,
296                use_huffman: true,
297                expected_identical: true,
298                // hyperium/h2 0.4.13 hpack::encoder::test_evicting_headers_when_multiple_of_same_name_are_in_table
299                // emits: dynamic name index 63, extended integer zero, Huffman "baz".
300                h2_golden_output: Some(vec![0x7f, 0x00, 0x83, 0x8c, 0x7e, 0xff]),
301                h2_golden_table_size: Some(76),
302            },
303        ]
304    }
305
306    /// Run all conformance test cases.
307    pub async fn run_all_tests(&mut self) -> HpackEncoderComplianceReport {
308        let test_run_id = uuid::Uuid::new_v4().to_string();
309        let timestamp = chrono::Utc::now().to_rfc3339();
310        let total_cases = self.test_cases.len();
311        let mut results = Vec::new();
312
313        for test_case in &self.test_cases {
314            let result = self.run_single_test(test_case).await;
315            results.push(result);
316        }
317
318        let summary = self.compute_summary(&results);
319
320        HpackEncoderComplianceReport {
321            test_run_id,
322            timestamp,
323            total_cases,
324            results,
325            summary,
326        }
327    }
328
329    /// Run a single encoder conformance test case.
330    async fn run_single_test(&self, case: &HpackEncoderConformanceCase) -> HpackEncoderTestResult {
331        // Test our encoder
332        let mut asupersync_encoder = match case.initial_max_table_size {
333            Some(size) => AsupersyncEncoder::with_max_size(size),
334            None => AsupersyncEncoder::new(),
335        };
336        if let Some(size) = case.max_table_size {
337            asupersync_encoder.set_max_table_size(size);
338        }
339        asupersync_encoder.set_use_huffman(case.use_huffman);
340
341        let mut prelude_outputs = Vec::with_capacity(case.prelude_blocks.len());
342        for headers in &case.prelude_blocks {
343            let mut prelude_buf = BytesMut::new();
344            asupersync_encoder.encode(headers, &mut prelude_buf);
345            prelude_outputs.push(prelude_buf.to_vec());
346        }
347
348        let mut asupersync_buf = BytesMut::new();
349        asupersync_encoder.encode(&case.headers, &mut asupersync_buf);
350        let asupersync_output = asupersync_buf.to_vec();
351        let asupersync_table_size = asupersync_encoder.dynamic_table_size();
352
353        let roundtrip_error =
354            decode_asupersync_sequence(case, &prelude_outputs, &asupersync_output).err();
355        let h2_output = case.h2_golden_output.clone().unwrap_or_default();
356        let h2_table_size = case.h2_golden_table_size.unwrap_or(0);
357        let bytes_match = case
358            .h2_golden_output
359            .as_ref()
360            .is_some_and(|expected| expected == &asupersync_output);
361        let table_size_match = case
362            .h2_golden_table_size
363            .is_none_or(|expected| expected == asupersync_table_size);
364
365        let (verdict, error) = match (&case.h2_golden_output, roundtrip_error) {
366            (_, Some(error)) => (EncoderTestVerdict::Fail, Some(error)),
367            (Some(_), None) if bytes_match && table_size_match => {
368                (EncoderTestVerdict::Pass, None)
369            }
370            (Some(expected), None) => (
371                EncoderTestVerdict::Fail,
372                Some(format!(
373                    "asupersync HPACK output diverged from h2 golden: actual={asupersync_output:?}, expected={expected:?}, actual_table_size={asupersync_table_size}, expected_table_size={h2_table_size}"
374                )),
375            ),
376            (None, None) => (
377                EncoderTestVerdict::Skipped,
378                Some(
379                    "h2 crate HPACK encoder internals are private; byte-differential reference output is unavailable"
380                        .to_string(),
381                ),
382            ),
383        };
384
385        HpackEncoderTestResult {
386            case_id: case.id.clone(),
387            verdict,
388            error,
389            asupersync_output,
390            h2_output,
391            bytes_match,
392            table_size_match,
393            asupersync_table_size,
394            h2_table_size,
395        }
396    }
397
398    /// Compute summary statistics from test results.
399    fn compute_summary(&self, results: &[HpackEncoderTestResult]) -> HpackEncoderComplianceSummary {
400        let total = results.len();
401        let passed = results
402            .iter()
403            .filter(|r| r.verdict == EncoderTestVerdict::Pass)
404            .count();
405        let failed = results
406            .iter()
407            .filter(|r| r.verdict == EncoderTestVerdict::Fail)
408            .count();
409        let expected_failures = results
410            .iter()
411            .filter(|r| r.verdict == EncoderTestVerdict::ExpectedFailure)
412            .count();
413        let skipped = results
414            .iter()
415            .filter(|r| r.verdict == EncoderTestVerdict::Skipped)
416            .count();
417
418        let compliance_score = if passed + failed > 0 {
419            passed as f64 / (passed + failed) as f64
420        } else {
421            1.0
422        };
423
424        HpackEncoderComplianceSummary {
425            passed,
426            failed,
427            expected_failures,
428            skipped,
429            total,
430            compliance_score,
431        }
432    }
433
434    /// Generate a markdown report from the compliance results.
435    pub fn generate_markdown_report(&self, report: &HpackEncoderComplianceReport) -> String {
436        let mut output = String::new();
437
438        output.push_str("# HPACK Encoder Conformance Report\n\n");
439        output.push_str(&format!("**Test Run ID:** {}\n", report.test_run_id));
440        output.push_str(&format!("**Timestamp:** {}\n", report.timestamp));
441        output.push_str(&format!("**Total Test Cases:** {}\n\n", report.total_cases));
442
443        output.push_str("## Summary\n\n");
444        output.push_str(&format!(
445            "- ✅ **Passed:** {} tests\n",
446            report.summary.passed
447        ));
448        output.push_str(&format!(
449            "- ❌ **Failed:** {} tests\n",
450            report.summary.failed
451        ));
452        output.push_str(&format!(
453            "- ⚠️  **Expected Failures:** {} tests\n",
454            report.summary.expected_failures
455        ));
456        output.push_str(&format!(
457            "- ⏭️  **Skipped:** {} tests\n",
458            report.summary.skipped
459        ));
460        output.push_str(&format!(
461            "- 🎯 **Compliance Score:** {:.1}%\n\n",
462            report.summary.compliance_score * 100.0
463        ));
464
465        if report.summary.failed > 0 {
466            output.push_str("## Failed Test Cases\n\n");
467            for result in &report.results {
468                if result.verdict == EncoderTestVerdict::Fail {
469                    output.push_str(&format!("### {}\n", result.case_id));
470                    if let Some(error) = &result.error {
471                        output.push_str(&format!("**Error:** {}\n", error));
472                    }
473                    output.push_str(&format!("**Bytes match:** {}\n", result.bytes_match));
474                    output.push_str(&format!(
475                        "**Table size match:** {}\n",
476                        result.table_size_match
477                    ));
478                    output.push_str(&format!(
479                        "**Asupersync output:** {} bytes\n",
480                        result.asupersync_output.len()
481                    ));
482                    output.push_str(&format!(
483                        "**H2 output:** {} bytes\n\n",
484                        result.h2_output.len()
485                    ));
486                }
487            }
488        }
489
490        output.push_str("## All Test Results\n\n");
491        output.push_str("| Case ID | Verdict | Bytes Match | Table Size Match | Error |\n");
492        output.push_str("|---------|---------|-------------|------------------|-------|\n");
493
494        for result in &report.results {
495            let error_str = result.error.as_deref().unwrap_or("-");
496            output.push_str(&format!(
497                "| {} | {} | {} | {} | {} |\n",
498                result.case_id,
499                result.verdict,
500                result.bytes_match,
501                result.table_size_match,
502                error_str
503            ));
504        }
505
506        output
507    }
508}
509
510fn decode_asupersync_sequence(
511    case: &HpackEncoderConformanceCase,
512    prelude_outputs: &[Vec<u8>],
513    encoded: &[u8],
514) -> Result<(), String> {
515    let mut decoder = match case.initial_max_table_size {
516        Some(size) => HpackDecoder::with_max_size(size),
517        None => HpackDecoder::new(),
518    };
519
520    if let Some(size) = case.max_table_size {
521        decoder.set_allowed_table_size(size);
522    }
523
524    for (index, (prelude, expected)) in prelude_outputs.iter().zip(&case.prelude_blocks).enumerate()
525    {
526        let mut src = Bytes::copy_from_slice(prelude);
527        let decoded = decoder.decode(&mut src).map_err(|err| err.to_string())?;
528        if decoded != *expected {
529            return Err(format!(
530                "asupersync HPACK prelude block {index} round trip differed: decoded={decoded:?}, expected={expected:?}"
531            ));
532        }
533    }
534
535    let mut src = Bytes::copy_from_slice(encoded);
536    let decoded = decoder.decode(&mut src).map_err(|err| err.to_string())?;
537    if decoded == case.headers {
538        Ok(())
539    } else {
540        Err(format!(
541            "asupersync HPACK encode/decode round trip differed: decoded={decoded:?}, expected={:?}",
542            case.headers
543        ))
544    }
545}
546
547impl Default for HpackEncoderConformanceTester {
548    fn default() -> Self {
549        Self::new()
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::{EncoderTestVerdict, HpackEncoderConformanceTester};
556
557    #[tokio::test]
558    async fn h2_dynamic_eviction_golden_case_passes() {
559        let tester = HpackEncoderConformanceTester::new();
560        let case = tester
561            .test_cases
562            .iter()
563            .find(|case| case.id == "ENC-009")
564            .expect("ENC-009 golden case present");
565
566        let result = tester.run_single_test(case).await;
567
568        assert_eq!(result.verdict, EncoderTestVerdict::Pass);
569        assert_eq!(
570            result.asupersync_output,
571            vec![0x7f, 0x00, 0x83, 0x8c, 0x7e, 0xff]
572        );
573        assert_eq!(result.h2_output, result.asupersync_output);
574        assert!(result.bytes_match);
575        assert!(result.table_size_match);
576        assert_eq!(result.asupersync_table_size, 76);
577    }
578}