1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub enum EncoderTestVerdict {
16 Pass,
17 Fail,
18 ExpectedFailure, 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#[derive(Debug, Clone)]
35pub struct HpackEncoderConformanceCase {
36 pub id: String,
37 pub description: String,
38 pub initial_max_table_size: Option<usize>,
44 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, pub h2_golden_output: Option<Vec<u8>>,
52 pub h2_golden_table_size: Option<usize>,
54}
55
56#[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#[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, }
80
81#[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
91pub struct HpackEncoderConformanceTester {
93 pub test_cases: Vec<HpackEncoderConformanceCase>,
94}
95
96impl HpackEncoderConformanceTester {
97 pub fn new() -> Self {
99 Self {
100 test_cases: Self::create_test_cases(),
101 }
102 }
103
104 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), 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 h2_golden_output: Some(vec![0x7f, 0x00, 0x83, 0x8c, 0x7e, 0xff]),
301 h2_golden_table_size: Some(76),
302 },
303 ]
304 }
305
306 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 async fn run_single_test(&self, case: &HpackEncoderConformanceCase) -> HpackEncoderTestResult {
331 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 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 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}