-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathh2_20260615.html
More file actions
761 lines (704 loc) · 115 KB
/
Copy pathh2_20260615.html
File metadata and controls
761 lines (704 loc) · 115 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Code Audit — Interactive</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;font-size:14px;line-height:1.5}
.app{max-width:1100px;margin:0 auto;padding:1.5rem 1rem}
.top{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;margin-bottom:1rem;flex-wrap:wrap}
h1{font-size:1.5rem;font-weight:700;color:#f1f5f9}
.meta{font-size:.8rem;color:#64748b;margin-top:.2rem}
.top-right{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}
.project-info{margin-bottom:1rem;padding:.75rem .9rem;background:#1e293b;border:1px solid #334155;border-radius:8px}
.project-info .pi-name{font-size:.95rem;font-weight:700;color:#f1f5f9}
.project-info .pi-desc{font-size:.82rem;color:#cbd5e1;margin-top:.25rem}
.project-info .pi-row{display:flex;flex-wrap:wrap;gap:.4rem .9rem;margin-top:.45rem;font-size:.78rem;color:#94a3b8}
.project-info .pi-row .pi-k{color:#64748b}
.project-info a{color:#60a5fa;text-decoration:none}
.project-info a:hover{text-decoration:underline}
.project-info code{font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-size:.74rem;color:#93c5fd}
.pi-repo-btn{display:inline-flex;align-items:center;gap:.3rem;padding:.18rem .5rem;background:#0f172a;border:1px solid #3b82f6;border-radius:6px;color:#60a5fa !important;font-weight:600}
.pi-repo-btn:hover{background:#1d283a;text-decoration:none !important}
#search{background:#1e293b;border:1px solid #334155;color:#e2e8f0;padding:.4rem .75rem;border-radius:6px;font-size:.875rem;width:220px;outline:none}
#search:focus{border-color:#3b82f6}
.group-label{font-size:.8rem;color:#94a3b8;display:flex;align-items:center;gap:.4rem}
select{background:#1e293b;border:1px solid #334155;color:#e2e8f0;padding:.35rem .6rem;border-radius:6px;font-size:.8rem;outline:none;cursor:pointer}
select:focus{border-color:#3b82f6}
.filterbar{display:flex;flex-direction:column;gap:.4rem;margin-bottom:.6rem;padding:.6rem .75rem;background:#1e293b;border:1px solid #334155;border-radius:8px}
.filter-row{display:flex;flex-wrap:wrap;gap:.3rem;align-items:center}
.filter-lbl{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#475569;white-space:nowrap;margin-right:.25rem}
.filter-toggle{font-size:.65rem;font-weight:600;padding:.18rem .5rem;border-radius:4px;border:1px solid #334155;background:#0f172a;color:#94a3b8;cursor:pointer;margin-left:auto;white-space:nowrap;letter-spacing:.04em}
.filter-toggle:hover{background:#1e293b;color:#e2e8f0;border-color:#475569}
.badge{font-size:.68rem;font-weight:700;padding:.18rem .5rem;border-radius:4px;border:1px solid transparent;cursor:pointer;letter-spacing:.03em;transition:opacity .12s,filter .12s;user-select:none}
.badge.off{opacity:.28;filter:grayscale(.5)}
.sev-critical{background:#450a0a;color:#fca5a5;border-color:#991b1b}
.sev-high {background:#431407;color:#fdba74;border-color:#9a3412}
.sev-medium {background:#422006;color:#fcd34d;border-color:#854d0e}
.sev-low {background:#0c1a3a;color:#93c5fd;border-color:#1d4ed8}
.sev-negligible{background:#1e293b;color:#64748b;border-color:#334155}
.type-badge{background:#0c1e33;color:#7dd3fc;border-color:#0369a1}
.easy-badge{background:#052e16;color:#86efac;border-color:#16a34a}
.st-open{background:#1e293b;color:#475569;border-color:#334155}
.st-fixed{background:#052e16;color:#4ade80;border-color:#16a34a}
.st-wont{background:#431407;color:#fdba74;border-color:#9a3412}
.st-fp{background:#2e1065;color:#c4b5fd;border-color:#7c3aed}
#progress-wrap{display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem;flex-wrap:wrap}
#progress-bar{flex:1;min-width:120px;height:7px;background:#1e293b;border-radius:4px;overflow:hidden;border:1px solid #334155}
#progress-fill{height:100%;background:#22c55e;border-radius:4px;transition:width .35s ease;width:0}
#progress-text{font-size:.75rem;color:#64748b;white-space:nowrap}
.action-btn{font-size:.72rem;padding:.25rem .6rem;border-radius:5px;border:1px solid #334155;background:#1e293b;color:#94a3b8;cursor:pointer;white-space:nowrap}
.action-btn:hover{background:#334155;color:#e2e8f0}
#hidden-bar{margin-bottom:.6rem}
.hidden-inner{display:flex;flex-wrap:wrap;gap:.35rem;align-items:center}
.hidden-lbl{font-size:.72rem;color:#475569}
.show-btn{font-size:.7rem;padding:.15rem .45rem;border-radius:4px;background:#1e293b;color:#94a3b8;border:1px solid #334155;cursor:pointer}
.show-btn:hover{background:#334155;color:#e2e8f0}
.show-all-btn{font-size:.7rem;padding:.15rem .45rem;border-radius:4px;background:#2563eb;color:#fff;border:none;cursor:pointer}
.show-all-btn:hover{background:#1d4ed8}
.group-section{margin-bottom:1.5rem}
.group-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;padding-bottom:.35rem;border-bottom:1px solid #334155}
.group-title{font-size:.95rem;font-weight:700;color:#cbd5e1}
.group-title.all-done{color:#4ade80}
.group-count{font-size:.72rem;color:#475569;background:#1e293b;padding:.1rem .4rem;border-radius:10px;white-space:nowrap}
.icon-btn{background:transparent;border:none;color:#334155;cursor:pointer;font-size:.85rem;padding:.05rem .2rem;line-height:1}
.icon-btn:hover{color:#64748b}
.hide-btn{margin-left:auto;font-size:.68rem;padding:.12rem .45rem;border-radius:4px;background:transparent;color:#334155;border:1px solid #1e293b;cursor:pointer}
.hide-btn:hover{background:#1e293b;color:#64748b}
.card{background:#1e293b;border:1px solid #334155;border-radius:8px;margin-bottom:.6rem;overflow:hidden;transition:opacity .15s}
.card.resolved{opacity:.55;border-left:3px solid #16a34a}
.card.resolved:hover{opacity:.85}
.card-header{display:flex;align-items:center;gap:.5rem;padding:.45rem .85rem;background:#162032;border-bottom:1px solid #334155;flex-wrap:wrap;cursor:pointer;user-select:none}
.card-header:hover{background:#1a2840}
.card-arrow{font-size:.65rem;color:#334155;flex-shrink:0}
.card-id{font-size:.75rem;font-weight:700;color:#64748b;font-family:monospace;letter-spacing:.04em}
.card-loc{font-size:.72rem;color:#64748b;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.status-btn{margin-left:auto;font-size:.68rem;font-weight:600;padding:.15rem .5rem;border-radius:4px;border:1px solid transparent;cursor:pointer;white-space:nowrap;transition:filter .1s}
.status-btn:hover{filter:brightness(1.25)}
.copy-btn{font-size:.68rem;font-weight:600;padding:.15rem .5rem;border-radius:4px;border:1px solid #334155;background:#0f172a;color:#94a3b8;cursor:pointer;white-space:nowrap;transition:background .12s,color .12s}
.copy-btn:hover{background:#334155;color:#e2e8f0}
.card-body{padding:.85rem .9rem}
.field-lbl{font-size:.85rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#7dd3fc;margin:1rem 0 .35rem;padding-bottom:.18rem;border-bottom:1px solid #1e293b}
.field-lbl:first-child{margin-top:0}
.desc,.fix-txt{font-size:.875rem;color:#cbd5e1;line-height:1.55}
.desc p,.fix-txt p{margin:.4rem 0}
.desc p:first-child,.fix-txt p:first-child{margin-top:0}
.desc p:last-child,.fix-txt p:last-child{margin-bottom:0}
.loc-block{margin:.3rem 0 .6rem}
.loc-ref{font-size:.8rem;color:#38bdf8;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-weight:600;margin-bottom:.22rem}
.loc-lines{color:#475569;font-weight:400}
pre{background:#0f172a;color:#cdd6f4;border-radius:5px;padding:.6rem .8rem;overflow-x:auto;white-space:pre;font-size:.8rem;line-height:1.55;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;border:1px solid #1e293b;max-height:320px}
.desc pre.md-code,.fix-txt pre.md-code{margin:.5rem 0;font-size:.82rem;color:#e2e8f0;background:#0b1224;border:1px solid #1e293b;max-height:none}
code.md-inline{font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-size:.86em;background:#0b1224;color:#fca5a5;border:1px solid #1e293b;border-radius:3px;padding:.04rem .32rem;white-space:pre-wrap;word-break:break-word}
.not-found{font-size:.8rem;color:#334155;font-style:italic}
mark{background:#854d0e;color:#fef9c3;border-radius:2px;padding:0 .1rem}
.no-results{text-align:center;color:#334155;padding:3rem 1rem;font-size:.9rem}
</style>
</head>
<body>
<script type="application/json" id="D">[{"id":"PANIC_1","type":"panic","type_label":"Panic","severity":"high","easy":true,"description":"Frame::Priority(_) arm in Encoder::buffer() calls unimplemented!(), causing an unconditional panic if any code path sends a Priority frame through the codec. The implementation is commented out and the method has no encode() function either. Any caller that puts Frame::Priority into the codec will crash the process.","fix":"Either implement Priority frame encoding or, since the library never sends Priority frames internally, return Ok(()) silently (Priority is deprecated in RFC 9113) or return a UserError variant.","locations":[{"ref":"src/codec/framed_write.rs:282-288","code":" 282 | Frame::Priority(_) => {\n 283 | /*\n 284 | v.encode(self.buf.get_mut());\n 285 | tracing::trace!(\"encoded priority; rem={:?}\", self.buf.remaining());\n 286 | */\n 287 | unimplemented!();\n 288 | }","lines":7}]},{"id":"PANIC_2","type":"panic","type_label":"Panic","severity":"high","easy":true,"description":"get_static() is declared pub and panics via unreachable!() on any index outside 1..=61. The calling site in Table::get() guards the range, but because the function is public any other caller (inside or outside the crate) can trigger the panic without triggering a bounds-check error first.","fix":"Either make get_static private (it is only called from Table::get) or change the wildcard arm to return an Err / Option instead of unreachable!().","locations":[{"ref":"src/hpack/decoder.rs:628-835","code":" 628 | pub fn get_static(idx: usize) -> Header {\n 629 | use http::header::HeaderValue;\n 630 | \n 631 | match idx {\n 632 | 1 => Header::Authority(BytesStr::from_static(\"\")),\n 633 | 2 => Header::Method(Method::GET),\n 634 | 3 => Header::Method(Method::POST),\n 635 | 4 => Header::Path(BytesStr::from_static(\"/\")),\n 636 | 5 => Header::Path(BytesStr::from_static(\"/index.html\")),\n 637 | 6 => Header::Scheme(BytesStr::from_static(\"http\")),\n 638 | 7 => Header::Scheme(BytesStr::from_static(\"https\")),\n 639 | 8 => Header::Status(StatusCode::OK),\n 640 | 9 => Header::Status(StatusCode::NO_CONTENT),\n 641 | 10 => Header::Status(StatusCode::PARTIAL_CONTENT),\n 642 | 11 => Header::Status(StatusCode::NOT_MODIFIED),\n 643 | 12 => Header::Status(StatusCode::BAD_REQUEST),\n 644 | 13 => Header::Status(StatusCode::NOT_FOUND),\n 645 | 14 => Header::Status(StatusCode::INTERNAL_SERVER_ERROR),\n 646 | 15 => Header::Field {\n 647 | name: header::ACCEPT_CHARSET,\n 648 | value: HeaderValue::from_static(\"\"),\n 649 | },\n 650 | 16 => Header::Field {\n 651 | name: header::ACCEPT_ENCODING,\n 652 | value: HeaderValue::from_static(\"gzip, deflate\"),\n 653 | },\n 654 | 17 => Header::Field {\n 655 | name: header::ACCEPT_LANGUAGE,\n 656 | value: HeaderValue::from_static(\"\"),\n 657 | },\n 658 | 18 => Header::Field {\n 659 | name: header::ACCEPT_RANGES,\n 660 | value: HeaderValue::from_static(\"\"),\n 661 | },\n 662 | 19 => Header::Field {\n 663 | name: header::ACCEPT,\n 664 | value: HeaderValue::from_static(\"\"),\n 665 | },\n 666 | 20 => Header::Field {\n 667 | name: header::ACCESS_CONTROL_ALLOW_ORIGIN,\n 668 | value: HeaderValue::from_static(\"\"),\n 669 | },\n 670 | 21 => Header::Field {\n 671 | name: header::AGE,\n 672 | value: HeaderValue::from_static(\"\"),\n 673 | },\n 674 | 22 => Header::Field {\n 675 | name: header::ALLOW,\n 676 | value: HeaderValue::from_static(\"\"),\n 677 | },\n 678 | 23 => Header::Field {\n 679 | name: header::AUTHORIZATION,\n 680 | value: HeaderValue::from_static(\"\"),\n 681 | },\n 682 | 24 => Header::Field {\n 683 | name: header::CACHE_CONTROL,\n 684 | value: HeaderValue::from_static(\"\"),\n 685 | },\n 686 | 25 => Header::Field {\n 687 | name: header::CONTENT_DISPOSITION,\n 688 | value: HeaderValue::from_static(\"\"),\n 689 | },\n 690 | 26 => Header::Field {\n 691 | name: header::CONTENT_ENCODING,\n 692 | value: HeaderValue::from_static(\"\"),\n 693 | },\n 694 | 27 => Header::Field {\n 695 | name: header::CONTENT_LANGUAGE,\n 696 | value: HeaderValue::from_static(\"\"),\n 697 | },\n 698 | 28 => Header::Field {\n 699 | name: header::CONTENT_LENGTH,\n 700 | value: HeaderValue::from_static(\"\"),\n 701 | },\n 702 | 29 => Header::Field {\n 703 | name: header::CONTENT_LOCATION,\n 704 | value: HeaderValue::from_static(\"\"),\n 705 | },\n 706 | 30 => Header::Field {\n 707 | name: header::CONTENT_RANGE,\n 708 | value: HeaderValue::from_static(\"\"),\n 709 | },\n 710 | 31 => Header::Field {\n 711 | name: header::CONTENT_TYPE,\n 712 | value: HeaderValue::from_static(\"\"),\n 713 | },\n 714 | 32 => Header::Field {\n 715 | name: header::COOKIE,\n 716 | value: HeaderValue::from_static(\"\"),\n 717 | },\n 718 | 33 => Header::Field {\n 719 | name: header::DATE,\n 720 | value: HeaderValue::from_static(\"\"),\n 721 | },\n 722 | 34 => Header::Field {\n 723 | name: header::ETAG,\n 724 | value: HeaderValue::from_static(\"\"),\n 725 | },\n 726 | 35 => Header::Field {\n 727 | name: header::EXPECT,\n 728 | value: HeaderValue::from_static(\"\"),\n 729 | },\n 730 | 36 => Header::Field {\n 731 | name: header::EXPIRES,\n 732 | value: HeaderValue::from_static(\"\"),\n 733 | },\n 734 | 37 => Header::Field {\n 735 | name: header::FROM,\n 736 | value: HeaderValue::from_static(\"\"),\n 737 | },\n 738 | 38 => Header::Field {\n 739 | name: header::HOST,\n 740 | value: HeaderValue::from_static(\"\"),\n 741 | },\n 742 | 39 => Header::Field {\n 743 | name: header::IF_MATCH,\n 744 | value: HeaderValue::from_static(\"\"),\n 745 | },\n 746 | 40 => Header::Field {\n 747 | name: header::IF_MODIFIED_SINCE,\n 748 | value: HeaderValue::from_static(\"\"),\n 749 | },\n 750 | 41 => Header::Field {\n 751 | name: header::IF_NONE_MATCH,\n 752 | value: HeaderValue::from_static(\"\"),\n 753 | },\n 754 | 42 => Header::Field {\n 755 | name: header::IF_RANGE,\n 756 | value: HeaderValue::from_static(\"\"),\n 757 | },\n 758 | 43 => Header::Field {\n 759 | name: header::IF_UNMODIFIED_SINCE,\n 760 | value: HeaderValue::from_static(\"\"),\n 761 | },\n 762 | 44 => Header::Field {\n 763 | name: header::LAST_MODIFIED,\n 764 | value: HeaderValue::from_static(\"\"),\n 765 | },\n 766 | 45 => Header::Field {\n 767 | name: header::LINK,\n 768 | value: HeaderValue::from_static(\"\"),\n 769 | },\n 770 | 46 => Header::Field {\n 771 | name: header::LOCATION,\n 772 | value: HeaderValue::from_static(\"\"),\n 773 | },\n 774 | 47 => Header::Field {\n 775 | name: header::MAX_FORWARDS,\n 776 | value: HeaderValue::from_static(\"\"),\n 777 | },\n 778 | 48 => Header::Field {\n 779 | name: header::PROXY_AUTHENTICATE,\n 780 | value: HeaderValue::from_static(\"\"),\n 781 | },\n 782 | 49 => Header::Field {\n 783 | name: header::PROXY_AUTHORIZATION,\n 784 | value: HeaderValue::from_static(\"\"),\n 785 | },\n 786 | 50 => Header::Field {\n 787 | name: header::RANGE,\n 788 | value: HeaderValue::from_static(\"\"),\n 789 | },\n 790 | 51 => Header::Field {\n 791 | name: header::REFERER,\n 792 | value: HeaderValue::from_static(\"\"),\n 793 | },\n 794 | 52 => Header::Field {\n 795 | name: header::REFRESH,\n 796 | value: HeaderValue::from_static(\"\"),\n 797 | },\n 798 | 53 => Header::Field {\n 799 | name: header::RETRY_AFTER,\n 800 | value: HeaderValue::from_static(\"\"),\n 801 | },\n 802 | 54 => Header::Field {\n 803 | name: header::SERVER,\n 804 | value: HeaderValue::from_static(\"\"),\n 805 | },\n 806 | 55 => Header::Field {\n 807 | name: header::SET_COOKIE,\n 808 | value: HeaderValue::from_static(\"\"),\n 809 | },\n 810 | 56 => Header::Field {\n 811 | name: header::STRICT_TRANSPORT_SECURITY,\n 812 | value: HeaderValue::from_static(\"\"),\n 813 | },\n 814 | 57 => Header::Field {\n 815 | name: header::TRANSFER_ENCODING,\n 816 | value: HeaderValue::from_static(\"\"),\n 817 | },\n 818 | 58 => Header::Field {\n 819 | name: header::USER_AGENT,\n 820 | value: HeaderValue::from_static(\"\"),\n 821 | },\n 822 | 59 => Header::Field {\n 823 | name: header::VARY,\n 824 | value: HeaderValue::from_static(\"\"),\n 825 | },\n 826 | 60 => Header::Field {\n 827 | name: header::VIA,\n 828 | value: HeaderValue::from_static(\"\"),\n 829 | },\n 830 | 61 => Header::Field {\n 831 | name: header::WWW_AUTHENTICATE,\n 832 | value: HeaderValue::from_static(\"\"),\n 833 | },\n 834 | _ => unreachable!(),\n 835 | }","lines":208}]},{"id":"PANIC_3","type":"panic","type_label":"Panic","severity":"high","easy":true,"description":"`go_away_from_user` (called by `server::Connection::abrupt_shutdown`) records the\n`last_processed_id` snapshot at call time and stores it as\n`going_away.last_processed_id`. It does NOT call `streams.send_go_away()`, so\n`recv.max_stream_id` stays at `StreamId::MAX` and the remote can still open new\nstreams (their IDs will increment `last_processed_id`). If the user calls\n`abrupt_shutdown` a second time after more frames have arrived - a realistic\nsequence like: call once, poll, call again with a different reason - the internal\n`GoAway::go_away()` assertion fires:\n\n assert!(f.last_stream_id() <= going_away.last_processed_id, ...)\n\nbecause the new snapshot (`last_processed_id`) now exceeds the stored value. This\nis a user-triggerable panic on the server side.","fix":"In `go_away_from_user` (connection.rs:421), add a call to\n`self.streams.send_go_away(last_processed_id)` right after computing\n`last_processed_id`, so that `recv.max_stream_id` is capped and no new streams\ncan raise the counter between the two shutdown calls. Alternatively, mirror\n`go_away_gracefully` and return early when `self.go_away.is_going_away()` is\nalready true, updating only the error/reason for streams.","locations":[{"ref":"src/proto/connection.rs:421-428","code":" 421 | fn go_away_from_user(&mut self, e: Reason) {\n 422 | let last_processed_id = self.streams.last_processed_id();\n 423 | let frame = frame::GoAway::new(last_processed_id, e);\n 424 | self.go_away.go_away_from_user(frame);\n 425 | \n 426 | // Notify all streams of reason we're abruptly closing.\n 427 | self.streams.handle_error(Error::user_go_away(e));\n 428 | }","lines":8},{"ref":"src/proto/go_away.rs:53-68","code":" 53 | pub fn go_away(&mut self, f: frame::GoAway) {\n 54 | if let Some(ref going_away) = self.going_away {\n 55 | assert!(\n 56 | f.last_stream_id() <= going_away.last_processed_id,\n 57 | \"GOAWAY stream IDs shouldn't be higher; \\\n 58 | last_processed_id = {:?}, f.last_stream_id() = {:?}\",\n 59 | going_away.last_processed_id,\n 60 | f.last_stream_id(),\n 61 | );\n 62 | }\n 63 | \n 64 | self.going_away = Some(GoingAway {\n 65 | last_processed_id: f.last_stream_id(),\n 66 | reason: f.reason(),\n 67 | });\n 68 | self.pending = Some(f);","lines":16}]},{"id":"PANIC_4","type":"panic","type_label":"Panic","severity":"high","easy":false,"description":"A malicious or buggy server can cause a client to panic via the push-promise\npath. When a server sends a PUSH_PROMISE, the promised stream is created in\n`ReservedRemote` state. If the server subsequently sends an informational (1xx)\nHEADERS frame on that pushed stream, `recv_open` in `state.rs` (line 157-165)\nsets `is_initial = true` but keeps the state as `ReservedRemote`. This causes\n`recv.rs:recv_headers` (line 165-172) to call `counts.inc_num_recv_streams`,\nwhich sets `stream.is_counted = true`. When the actual (non-informational)\nHEADERS frame arrives on the same pushed stream, `recv_open` again enters the\n`ReservedRemote` branch, again sets `is_initial = true`, and again calls\n`inc_num_recv_streams`. That function asserts `!stream.is_counted` (counts.rs\nline 111), which is now true - the assertion panics.\n\nRFC 9113 §8.4 forbids 1xx on pushed streams, but the h2 library must not panic\non protocol violations from the peer.","fix":"In `state.rs:recv_open`, when the current state is `ReservedRemote` and the\nincoming frame is informational, do NOT set `initial = true`; informational\nframes on a reserved stream do not constitute opening it. Alternatively, guard\nthe `inc_num_recv_streams` call with `!stream.is_counted` so that a double-open\nis silently ignored rather than panicking.","locations":[{"ref":"src/proto/streams/state.rs:157-168","code":" 157 | ReservedRemote => {\n 158 | initial = true;\n 159 | \n 160 | if eos {\n 161 | Closed(Cause::EndStream)\n 162 | } else if frame.is_informational() {\n 163 | tracing::trace!(\"skipping 1xx response headers\");\n 164 | ReservedRemote\n 165 | } else {\n 166 | HalfClosedLocal(Streaming)\n 167 | }\n 168 | }","lines":12},{"ref":"src/proto/streams/recv.rs:163-172","code":" 163 | let is_initial = stream.state.recv_open(&frame)?;\n 164 | \n 165 | if is_initial {\n 166 | // TODO: be smarter about this logic\n 167 | if frame.stream_id() > self.last_processed_id {\n 168 | self.last_processed_id = frame.stream_id();\n 169 | }\n 170 | \n 171 | // Increment the number of concurrent streams\n 172 | counts.inc_num_recv_streams(stream);","lines":10},{"ref":"src/proto/streams/counts.rs:109-116","code":" 109 | pub fn inc_num_recv_streams(&mut self, stream: &mut store::Ptr) {\n 110 | assert!(self.can_inc_num_recv_streams());\n 111 | assert!(!stream.is_counted);\n 112 | \n 113 | // Increment the number of remote initiated streams\n 114 | self.num_recv_streams += 1;\n 115 | stream.is_counted = true;\n 116 | }","lines":8}]},{"id":"PANIC_5","type":"panic","type_label":"Panic","severity":"medium","easy":true,"description":"Table::resolve_idx() panics with `panic!(\"cannot resolve index\")` when called with Index::NotIndexed. This is called from Encoder::encode_header_without_name which can legitimately receive an Index::NotIndexed from a previous header. The code happens to avoid this path today only because encode_header_without_name wraps NotIndexed separately, but the panic is not gated by any type-system guarantee and will fire if call sites diverge.","fix":"Return an Option<usize> or a Result, or make Index::NotIndexed unreachable by construction at this call site. At minimum add a doc comment that callers must never pass NotIndexed.","locations":[{"ref":"src/hpack/table.rs:129","code":" 129 | NotIndexed(_) => panic!(\"cannot resolve index\"),","lines":1}]},{"id":"PANIC_6","type":"panic","type_label":"Panic","severity":"medium","easy":true,"description":"`PingPong::ping_shutdown` (ping_pong.rs:87-94) asserts `self.pending_ping.is_none()`\nunconditionally. `ping_shutdown` is called from `go_away_gracefully` (connection.rs:628),\nwhich is itself guarded by `self.inner.go_away.is_going_away()` (connection.rs:608).\nHowever, a user PING sent via `UserPings::send_ping` is also represented in\n`pending_ping` (because `send_pending_ping` reads and sets it). If a user sends a\nping, and before the pong arrives the server calls `go_away_gracefully`, the assert\nfires.\n\nMore precisely: `pending_ping` holds the shutdown ping struct (`PendingPing`), but\nuser pings are tracked separately via the atomic state machine (`user_pings`). The\n`ping_shutdown` assert is therefore vacuously safe for user pings but the comment\nand guard do not make this clear, and a future refactor that merges them could\nintroduce the panic.\n\nIn the current code the assertion is safe: the shutdown ping and user pings use\ndistinct storage (`pending_ping` vs. `user_pings.0.state`). But if `pending_ping`\nis not `None` when `ping_shutdown` is called (e.g. from a hypothetical second call\npath), it panics.","fix":"Add an early-return guard in `ping_shutdown`: if `self.pending_ping.is_some()`,\neither silently skip (if the semantics allow) or return an error/log a warning.\nReplace the hard `assert!` with a defensive check so a contract violation from a\ncaller does not crash the process.","locations":[{"ref":"src/proto/ping_pong.rs:87-94","code":" 87 | pub(crate) fn ping_shutdown(&mut self) {\n 88 | assert!(self.pending_ping.is_none());\n 89 | \n 90 | self.pending_ping = Some(PendingPing {\n 91 | payload: Ping::SHUTDOWN,\n 92 | sent: false,\n 93 | });\n 94 | }","lines":8},{"ref":"src/proto/connection.rs:606-629","code":" 606 | // Graceful shutdown only makes sense for server peers.\n 607 | pub fn go_away_gracefully(&mut self) {\n 608 | if self.inner.go_away.is_going_away() {\n 609 | // No reason to start a new one.\n 610 | return;\n 611 | }\n 612 | \n 613 | // According to http://httpwg.org/specs/rfc7540.html#GOAWAY:\n 614 | //\n 615 | // > A server that is attempting to gracefully shut down a connection\n 616 | // > SHOULD send an initial GOAWAY frame with the last stream\n 617 | // > identifier set to 2^31-1 and a NO_ERROR code. This signals to the\n 618 | // > client that a shutdown is imminent and that initiating further\n 619 | // > requests is prohibited. After allowing time for any in-flight\n 620 | // > stream creation (at least one round-trip time), the server can\n 621 | // > send another GOAWAY frame with an updated last stream identifier.\n 622 | // > This ensures that a connection can be cleanly shut down without\n 623 | // > losing requests.\n 624 | self.inner.as_dyn().go_away(StreamId::MAX, Reason::NO_ERROR);\n 625 | \n 626 | // We take the advice of waiting 1 RTT literally, and wait\n 627 | // for a pong before proceeding.\n 628 | self.inner.ping_pong.ping_shutdown();\n 629 | }","lines":24}]},{"id":"PANIC_7","type":"panic","type_label":"Panic","severity":"medium","easy":true,"description":"In `Prioritize::pop_frame`, when a `PushPromise` frame is dequeued, the code looks up the\npromised stream with `stream.store_mut().find_mut(&pp.promised_id()).unwrap()` (line 841).\nIf the promised stream has already been removed from the store (e.g., it was reset and\ncleaned up between when the PUSH_PROMISE was enqueued and when pop_frame runs), this\n`unwrap()` will panic. Because this runs on the connection poll path, it brings down the\nwhole connection task rather than returning a graceful error.","fix":"Replace the unwrap with a match and treat a missing promised stream as a cancelled push:\n let mut pushed = match stream.store_mut().find_mut(&pp.promised_id()) {\n Some(s) => s,\n None => continue,\n };","locations":[{"ref":"src/proto/streams/prioritize.rs:839-853","code":" 839 | Some(Frame::PushPromise(pp)) => {\n 840 | let mut pushed =\n 841 | stream.store_mut().find_mut(&pp.promised_id()).unwrap();\n 842 | pushed.is_pending_push = false;\n 843 | // Transition stream from pending_push to pending_open\n 844 | // if possible\n 845 | if !pushed.pending_send.is_empty() {\n 846 | if counts.can_inc_num_send_streams() {\n 847 | counts.inc_num_send_streams(&mut pushed);\n 848 | self.pending_send.push(&mut pushed);\n 849 | } else {\n 850 | self.queue_open(&mut pushed);\n 851 | }\n 852 | }\n 853 | Frame::PushPromise(pp)","lines":15}]},{"id":"PANIC_8","type":"panic","type_label":"Panic","severity":"medium","easy":true,"description":"`state.rs:send_close` (line 320-332) panics with an unformatted message on any\nstream state other than `Open` or `HalfClosedRemote`:\n\n ref state => panic!(\"send_close: unexpected state {:?}\", state),\n\n`send_close` is called from `Send::send_trailers` (send.rs:325) after the guard\n`stream.state.is_send_streaming()` returns `true`. Because the state machine is\nnon-atomic, a race is theoretically impossible inside a single-threaded event\nloop. Nevertheless, the guard and the match cover different state sets:\n`is_send_streaming` matches `Open{local: Streaming}` and `HalfClosedRemote(Streaming)`,\nwhile `send_close` additionally accepts `HalfClosedRemote(..)` generally.\nA future code change that widens the guard without updating the match body would\nsilently introduce a new panic.","fix":"Replace the bare `panic!` with `Err(UserError::UnexpectedFrameType)` and change\n`send_close` to return `Result<(), UserError>`, propagating the error up. This\neliminates the panic and makes the contract explicit.","locations":[{"ref":"src/proto/streams/state.rs:320-332","code":" 320 | pub fn send_close(&mut self) {\n 321 | match self.inner {\n 322 | Open { remote, .. } => {\n 323 | // The remote side will continue to receive data.\n 324 | tracing::trace!(\"send_close: Open => HalfClosedLocal({:?})\", remote);\n 325 | self.inner = HalfClosedLocal(remote);\n 326 | }\n 327 | HalfClosedRemote(..) => {\n 328 | tracing::trace!(\"send_close: HalfClosedRemote => Closed\");\n 329 | self.inner = Closed(Cause::EndStream);\n 330 | }\n 331 | ref state => panic!(\"send_close: unexpected state {:?}\", state),\n 332 | }","lines":13},{"ref":"src/proto/streams/send.rs:321-325","code":" 321 | if !stream.state.is_send_streaming() {\n 322 | return Err(UserError::UnexpectedFrameType);\n 323 | }\n 324 | \n 325 | stream.state.send_close();","lines":5}]},{"id":"PANIC_9","type":"panic","type_label":"Panic","severity":"low","easy":true,"description":"Settings::set_max_frame_size() panics with assert!() if the provided value is outside [DEFAULT_MAX_FRAME_SIZE, MAX_MAX_FRAME_SIZE]. While this is a programming-error guard, it is reachable via the public Codec::set_max_send_frame_size() path, meaning a caller passing an out-of-range value (e.g. 0 from a misconfigured integration) will crash the process rather than receiving an error.","fix":"Return a Result or silently clamp the value; alternatively document precisely that callers must pre-validate the range. At minimum, add the same validation in FramedWrite::set_max_frame_size() which currently only checks the upper bound (src/codec/framed_write.rs:325-328).","locations":[{"ref":"src/frame/settings.rs:88-93","code":" 88 | \n 89 | pub fn set_max_frame_size(&mut self, size: Option<u32>) {\n 90 | if let Some(val) = size {\n 91 | assert!(DEFAULT_MAX_FRAME_SIZE <= val && val <= MAX_MAX_FRAME_SIZE);\n 92 | }\n 93 | self.max_frame_size = size;","lines":6}]},{"id":"PANIC_10","type":"panic","type_label":"Panic","severity":"low","easy":true,"description":"`Store::try_for_each` documents and asserts (debug only) that the callback removes at most\none stream entry per invocation (`debug_assert!(new_len == len - 1)`, line 167). If any\ncallback ever removes two entries - for instance via push-promise chain cancellation inside\na future refactoring - the `len` tracking goes wrong and the loop either skips a stream or\nreads a stale index, causing logic errors or a store index OOB access. The assumption is\nentirely implicit and the debug_assert fires only in debug builds.","fix":"Either strengthen the API contract (document it firmly and add a test), or rewrite the loop\nto recompute the current-index stream ID after each call:\n let ids_snapshot: Vec<_> = self.ids.keys().copied().collect();\n for id in ids_snapshot {\n if let Some(index) = self.ids.get(&id).copied() { ... }\n }\nAlternatively accept arbitrary removal counts by adjusting `len` by `(len - new_len)` rather\nthan hard-coding 1.","locations":[{"ref":"src/proto/streams/store.rs:144-175","code":" 144 | pub fn try_for_each<F, E>(&mut self, mut f: F) -> Result<(), E>\n 145 | where\n 146 | F: FnMut(Ptr) -> Result<(), E>,\n 147 | {\n 148 | let mut len = self.ids.len();\n 149 | let mut i = 0;\n 150 | \n 151 | while i < len {\n 152 | // Get the key by index, this makes the borrow checker happy\n 153 | let (stream_id, index) = {\n 154 | let entry = self.ids.get_index(i).unwrap();\n 155 | (*entry.0, *entry.1)\n 156 | };\n 157 | \n 158 | f(Ptr {\n 159 | key: Key { index, stream_id },\n 160 | store: self,\n 161 | })?;\n 162 | \n 163 | // TODO: This logic probably could be better...\n 164 | let new_len = self.ids.len();\n 165 | \n 166 | if new_len < len {\n 167 | debug_assert!(new_len == len - 1);\n 168 | len -= 1;\n 169 | } else {\n 170 | i += 1;\n 171 | }\n 172 | }\n 173 | \n 174 | Ok(())\n 175 | }","lines":32}]},{"id":"PANIC_11","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"In `recv_data` (streams.rs:566,579) two `assert!` (not `debug_assert!`) calls verify that a\nreceived DATA frame's `flow_controlled_len()` is <= `MAX_WINDOW_SIZE`. While the codec's\nFramedRead layer should prevent individual frames from exceeding `MAX_MAX_FRAME_SIZE`\n(16,777,215 bytes) - well below `MAX_WINDOW_SIZE` (2,147,483,647) - these assertions panic\nunconditionally rather than returning a connection error. If the codec layer's enforcement\never regresses, the runtime will panic instead of gracefully closing the connection with\nGOAWAY.","fix":"Replace the `assert!` with an error return:\n if sz > super::MAX_WINDOW_SIZE as usize {\n return Err(Error::library_go_away(Reason::FLOW_CONTROL_ERROR));\n }","locations":[{"ref":"src/proto/streams/streams.rs:564-570","code":" 564 | // We still need to account for connection-level flow control.\n 565 | let sz = frame.flow_controlled_len();\n 566 | assert!(sz <= super::MAX_WINDOW_SIZE as usize);\n 567 | let sz = sz as WindowSize;\n 568 | self.actions.recv.ignore_data(sz)?;\n 569 | \n 570 | return Ok(());","lines":7},{"ref":"src/proto/streams/streams.rs:573-583","code":" 573 | if self.actions.may_have_forgotten_stream(peer, id) {\n 574 | tracing::debug!(\"recv_data for old stream={:?}, sending STREAM_CLOSED\", id,);\n 575 | \n 576 | let sz = frame.flow_controlled_len();\n 577 | // This should have been enforced at the codec::FramedRead layer, so\n 578 | // this is just a sanity check.\n 579 | assert!(sz <= super::MAX_WINDOW_SIZE as usize);\n 580 | let sz = sz as WindowSize;\n 581 | self.actions.recv.ignore_data(sz)?;\n 582 | \n 583 | return Err(Error::library_reset(id, Reason::STREAM_CLOSED));","lines":11}]},{"id":"PANIC_12","type":"panic","type_label":"Panic","severity":"low","easy":true,"description":"`recv_reset` in streams.rs asserts `assert!(stream.state.is_closed())` (line 659) after\ncalling `actions.recv.recv_reset` and `actions.send.handle_error`. This is a hard panic in\nproduction if, for any reason, those two calls do not close the stream. This converts a\npotential bug in the state machine into a process crash rather than a logged error or a\nconnection teardown.","fix":"Change to `debug_assert!` or, better, log a warning and return a GOAWAY error if the\nstream is not closed after recv_reset:\n debug_assert!(stream.state.is_closed(), \"stream not closed after recv_reset\");","locations":[{"ref":"src/proto/streams/streams.rs:656-662","code":" 656 | self.counts.transition(stream, |counts, stream| {\n 657 | actions.recv.recv_reset(frame, stream, counts)?;\n 658 | actions.send.handle_error(send_buffer, stream, counts);\n 659 | assert!(stream.state.is_closed());\n 660 | Ok(())\n 661 | })\n 662 | }","lines":7}]},{"id":"OVF_1","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"medium","easy":true,"description":"In Table::evict(), the arithmetic on line 384 executes before the debug_assert that guards against an empty slots VecDeque. In release builds, if slots is somehow empty (size invariant broken), `self.slots.len() - 1` wraps to usize::MAX, producing a completely invalid pos_idx. The subsequent probe_loop searching for a match on that sentinel value spins forever (infinite loop in release) instead of panicking cleanly.","fix":"Move the `debug_assert!(!self.slots.is_empty())` to line 383 (before the arithmetic). Better yet, make it a real runtime check: `if self.slots.is_empty() { return; }` or assert with a meaningful message in both debug and release.","locations":[{"ref":"src/hpack/table.rs:383-385","code":" 383 | fn evict(&mut self, prev_idx: Option<usize>) {\n 384 | let pos_idx = (self.slots.len() - 1).wrapping_sub(self.inserted);\n 385 | ","lines":3}]},{"id":"OVF_2","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"medium","easy":true,"description":"`SendStream::reserve_capacity` accepts a `usize` argument and silently truncates it to `u32`\n(WindowSize) without any bounds check. On a 64-bit system the caller can pass a value larger\nthan `u32::MAX` (e.g. 4_294_967_296) and the cast wraps to 0, so the stream effectively\nrequests no capacity at all. The code even has a `// TODO: Check for overflow` comment\nacknowledging the gap. Sending large data without reserving capacity defeats backpressure.","fix":"Add an explicit clamp before the cast:\n let capacity = capacity.min(super::proto::MAX_WINDOW_SIZE as usize) as WindowSize;\nor return an error if capacity > MAX_WINDOW_SIZE, matching the validation done in\n`FlowControl::release_capacity` (share.rs:533-536).","locations":[{"ref":"src/share.rs:285-288","code":" 285 | pub fn reserve_capacity(&mut self, capacity: usize) {\n 286 | // TODO: Check for overflow\n 287 | self.inner.reserve_capacity(capacity as WindowSize)\n 288 | }","lines":4}]},{"id":"OVF_3","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":true,"description":"In take() (decoder.rs line 468), buf.position() returns u64 and is cast to usize with `as usize`. On a 32-bit target this silently truncates if the cursor position exceeds 2^32-1. The same truncation occurs on line 334 in try_decode_string where `(buf.position() - old_pos) as usize` is computed. Since BytesMut is bounded by the platform's address space in practice these values will never exceed usize::MAX, but the casts are not checked and could silently produce wrong offsets if the assumption is violated.","fix":"Use `usize::try_from(buf.position()).expect(\"cursor position fits in usize\")` or add a debug_assert. On 64-bit systems the cast is fine; on 32-bit a saturating/panicking check is safer.","locations":[{"ref":"src/hpack/decoder.rs:334","code":" 334 | let offset = (buf.position() - old_pos) as usize;","lines":1},{"ref":"src/hpack/decoder.rs:468","code":" 468 | let pos = buf.position() as usize;","lines":1}]},{"id":"OVF_4","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"In `Stream::send_data` (stream.rs:313) `self.requested_send_capacity -= len` is an\nunchecked subtraction of two `u32` values. In debug mode it panics on underflow; in\nrelease mode it wraps. The preceding line has `debug_assert!(self.buffered_send_data >= len\nas usize)` but there is no corresponding guard for `requested_send_capacity`. Although the\ninvariant `requested_send_capacity >= len` should hold, it is not enforced in release\nbuilds, and any state inconsistency (e.g., from a concurrent reset clearing\n`requested_send_capacity` to 0 before pop_frame sends the data) would silently wrap\n`requested_send_capacity` to a very large value, causing the stream to permanently\nover-request capacity from the connection.","fix":"Add a saturating subtraction:\n self.requested_send_capacity = self.requested_send_capacity.saturating_sub(len);\nor promote the existing debug_assert to a proper check:\n debug_assert!(self.requested_send_capacity >= len, \"capacity underflow\");","locations":[{"ref":"src/proto/streams/stream.rs:303-313","code":" 303 | pub fn send_data(&mut self, len: WindowSize, max_buffer_size: usize) {\n 304 | let prev_capacity = self.capacity(max_buffer_size);\n 305 | \n 306 | // TODO: proper error handling\n 307 | let _res = self.send_flow.send_data(len);\n 308 | debug_assert!(_res.is_ok());\n 309 | \n 310 | // Decrement the stream's buffered data counter\n 311 | debug_assert!(self.buffered_send_data >= len as usize);\n 312 | self.buffered_send_data -= len as usize;\n 313 | self.requested_send_capacity -= len;","lines":11}]},{"id":"LOGIC_1","type":"logic_error","type_label":"Logic Error","severity":"high","easy":true,"description":"`SendResponse::send_informational` (server.rs) only rejects responses where\n`!response.status().is_informational()`. The `is_informational()` predicate in the `http`\ncrate returns `true` for all 1xx codes, including `101 Switching Protocols`. RFC 9113\nSection 8.4 explicitly forbids sending `101 Switching Protocols` over HTTP/2:\n\"The 101 (Switching Protocols) status code... is not defined for HTTP/2.\" A server using\nthis API can send `101` to a remote, which may cause the peer to treat the connection as\nupgraded and speak a different protocol, breaking both sides.","fix":"After the is_informational() check, additionally reject status 101:\n if response.status() == StatusCode::SWITCHING_PROTOCOLS {\n return Err(UserError::UnexpectedFrameType.into());\n }","locations":[{"ref":"src/server.rs:1162-1218","code":" 1162 | pub fn send_informational(&mut self, response: Response<()>) -> Result<(), crate::Error> {\n 1163 | let stream_id = self.inner.stream_id();\n 1164 | let status = response.status();\n 1165 | \n 1166 | tracing::trace!(\n 1167 | \"send_informational called with status: {} on stream: {:?}\",\n 1168 | status,\n 1169 | stream_id\n 1170 | );\n 1171 | \n 1172 | // Validate that this is an informational response (1xx status code)\n 1173 | if !response.status().is_informational() {\n 1174 | tracing::trace!(\n 1175 | \"invalid informational status code: {} on stream: {:?}\",\n 1176 | status,\n 1177 | stream_id\n 1178 | );\n 1179 | return Err(crate::Error::from(\n 1180 | UserError::InvalidInformationalStatusCode,\n 1181 | ));\n 1182 | }\n 1183 | \n 1184 | tracing::trace!(\n 1185 | \"converting informational response to HEADERS frame without END_STREAM flag for stream: {:?}\",\n 1186 | stream_id\n 1187 | );\n 1188 | \n 1189 | let frame = Peer::convert_send_message(\n 1190 | stream_id, response, false, // NOT end_of_stream for informational responses\n 1191 | );\n 1192 | \n 1193 | tracing::trace!(\n 1194 | \"sending interim informational headers frame for stream: {:?}\",\n 1195 | stream_id\n 1196 | );\n 1197 | \n 1198 | // Use the proper H2 streams API for sending interim informational headers\n 1199 | // This bypasses the normal response flow and allows multiple informational responses\n 1200 | let result = self\n 1201 | .inner\n 1202 | .send_informational_headers(frame)\n 1203 | .map_err(Into::into);\n 1204 | \n 1205 | match &result {\n 1206 | Ok(()) => tracing::trace!(\n 1207 | \"Successfully sent informational headers for stream: {:?}\",\n 1208 | stream_id\n 1209 | ),\n 1210 | Err(e) => tracing::trace!(\n 1211 | \"Failed to send informational headers for stream: {:?}: {:?}\",\n 1212 | stream_id,\n 1213 | e\n 1214 | ),\n 1215 | }\n 1216 | \n 1217 | result\n 1218 | }","lines":57}]},{"id":"LOGIC_2","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":true,"description":"`client::Peer::convert_send_message` silently drops the missing-scheme error when an\nHTTP/2 request has no `:scheme` pseudo-header, has an `:authority`, and uses a method\nother than CONNECT (lines 1652-1654). The code reaches `// TODO: Error` and falls through,\nproducing a HEADERS frame without `:scheme`, which violates RFC 9113 Section 8.3.1\n(\"All HTTP/2 requests MUST include exactly one valid value for the :method, :scheme, and\n:path pseudo-header fields\"). The remote server is required to reset such a stream with\nPROTOCOL_ERROR, but the library silently allows the user to send a malformed request.","fix":"Replace the `// TODO: Error` comment with an actual error return:\n } else if !is_connect {\n return Err(UserError::MissingUriSchemeAndAuthority.into());\n }","locations":[{"ref":"src/client.rs:1641-1655","code":" 1641 | //\n 1642 | if pseudo.authority.is_none() {\n 1643 | if version == Version::HTTP_2 {\n 1644 | return Err(UserError::MissingUriSchemeAndAuthority.into());\n 1645 | } else {\n 1646 | // This is acceptable as per the above comment. However,\n 1647 | // HTTP/2 requires that a scheme is set. Since we are\n 1648 | // forwarding an HTTP 1.1 request, the scheme is set to\n 1649 | // \"http\".\n 1650 | pseudo.set_scheme(uri::Scheme::HTTP);\n 1651 | }\n 1652 | } else if !is_connect {\n 1653 | // TODO: Error\n 1654 | }\n 1655 | }","lines":15}]},{"id":"LOGIC_3","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":true,"description":"encode_chunk()'s assertion only checks that dst has enough room for the payload data, but the function also writes a 9-byte frame header before the data. If dst has exactly `len` bytes remaining, head.encode() will panic (or silently corrupt in BufMut impls that don't panic) because 9 additional bytes are written. The assertion should require dst.remaining_mut() >= len + frame::HEADER_LEN.","fix":"Change `assert!(dst.remaining_mut() >= len)` to `assert!(dst.remaining_mut() >= len + crate::frame::HEADER_LEN)` to correctly cover both the header and payload bytes.","locations":[{"ref":"src/frame/data.rs:201-207","code":" 201 | pub(crate) fn encode_chunk<U: BufMut>(&mut self, dst: &mut U) {\n 202 | let len = self.data.remaining();\n 203 | \n 204 | assert!(dst.remaining_mut() >= len);\n 205 | \n 206 | self.head().encode(len, dst);\n 207 | dst.put(&mut self.data);","lines":7}]},{"id":"LOGIC_4","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"Table::index_dynamic() calls reserve_one() (which may allocate a new hash-map bucket) whenever the header is NOT sensitive, regardless of whether there is actually room for the header. The intended condition (per the comment \"Only grow internal storage if needed\") should be: grow only when the header will actually be stored. A sensitive header is never stored by index_vacant/index_occupied, yet reserve_one() is still invoked for it when size + header.len() >= max_size. The condition `header.len() + self.size < self.max_size || !header.is_sensitive()` allocates extra capacity for headers that will ultimately be discarded.","fix":"Change the condition to `!header.is_sensitive() && (header.len() + self.size < self.max_size || self.slots.len() < self.capacity())` or, more simply, always call reserve_one() for non-sensitive headers only: `if !header.is_sensitive() { self.reserve_one(); }`","locations":[{"ref":"src/hpack/table.rs:163-166","code":" 163 | if header.len() + self.size < self.max_size || !header.is_sensitive() {\n 164 | // Only grow internal storage if needed\n 165 | self.reserve_one();\n 166 | }","lines":4}]},{"id":"LOGIC_5","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"`go_away_from_user` (connection.rs:421-428) does not update `recv.max_stream_id`.\nThis means that between the moment the user calls `abrupt_shutdown` and the\nmoment the GOAWAY frame is actually flushed to the wire, the remote peer can\ncontinue opening new streams and they will be processed as normal (their headers\ndecoded, counted, etc.), even though the connection is logically shutting down.\nThe companion code path `go_away()` (connection.rs:403-407) correctly calls\n`self.streams.send_go_away(id)`, which sets `recv.max_stream_id`. This same call\nis missing from `go_away_from_user`, so the abrupt-shutdown invariant is weaker\nthan expected.","fix":"In `go_away_from_user`, add `self.streams.send_go_away(last_processed_id)` after\ncomputing `last_processed_id`, before calling `self.go_away.go_away_from_user(frame)`.\nThis mirrors the behaviour of `go_away()` and caps the stream ID the connection\nwill accept.","locations":[{"ref":"src/proto/connection.rs:403-407","code":" 403 | fn go_away(&mut self, id: StreamId, e: Reason) {\n 404 | let frame = frame::GoAway::new(id, e);\n 405 | self.streams.send_go_away(id);\n 406 | self.go_away.go_away(frame);\n 407 | }","lines":5},{"ref":"src/proto/connection.rs:421-428","code":" 421 | fn go_away_from_user(&mut self, e: Reason) {\n 422 | let last_processed_id = self.streams.last_processed_id();\n 423 | let frame = frame::GoAway::new(last_processed_id, e);\n 424 | self.go_away.go_away_from_user(frame);\n 425 | \n 426 | // Notify all streams of reason we're abruptly closing.\n 427 | self.streams.handle_error(Error::user_go_away(e));\n 428 | }","lines":8}]},{"id":"LOGIC_6","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"Neither GoAway::load() nor decode_frame() validates that the frame's stream identifier is 0x0. RFC 7540 Section 6.8 requires that a GOAWAY frame be associated with stream 0x0, and that receiving one on any other stream MUST be treated as a connection error of type PROTOCOL_ERROR. A peer that sends GOAWAY on a non-zero stream ID will have it silently accepted.","fix":"Add a stream_id check inside GoAway::load() (or in decode_frame's Kind::GoAway arm): if head.stream_id() is not zero, return Err(Error::InvalidStreamId).","locations":[{"ref":"src/frame/go_away.rs:43-57","code":" 43 | pub fn load(payload: &[u8]) -> Result<GoAway, Error> {\n 44 | if payload.len() < 8 {\n 45 | return Err(Error::BadFrameSize);\n 46 | }\n 47 | \n 48 | let (last_stream_id, _) = StreamId::parse(&payload[..4]);\n 49 | let error_code = unpack_octets_4!(payload, 4, u32);\n 50 | let debug_data = Bytes::copy_from_slice(&payload[8..]);\n 51 | \n 52 | Ok(GoAway {\n 53 | last_stream_id,\n 54 | error_code: error_code.into(),\n 55 | debug_data,\n 56 | })\n 57 | }","lines":15},{"ref":"src/codec/framed_read.rs:255-261","code":" 255 | Kind::GoAway => {\n 256 | let res = frame::GoAway::load(&bytes[frame::HEADER_LEN..]);\n 257 | res.map_err(|e| {\n 258 | proto_err!(conn: \"failed to load GO_AWAY frame; err={:?}\", e);\n 259 | Error::library_go_away(Reason::PROTOCOL_ERROR)\n 260 | })?\n 261 | .into()","lines":7}]},{"id":"LOGIC_7","type":"logic_error","type_label":"Logic Error","severity":"low","easy":true,"description":"PushPromise::load() checks src.len() < 5 after optionally consuming the 1-byte pad-length field. This rejects a valid PUSH_PROMISE that carries only the mandatory 4-byte promised stream ID with an empty header block fragment (src.len() == 4). RFC 7540 Section 6.6 requires only the 4-byte Promised Stream ID; the header block may be zero bytes when END_HEADERS will follow via CONTINUATION frames. A value of < 4 is the correct threshold.","fix":"Change `if src.len() < 5` to `if src.len() < 4` so that a PUSH_PROMISE carrying only the promised stream ID and no header bytes is accepted.","locations":[{"ref":"src/frame/headers.rs:438-440","code":" 438 | if src.len() < 5 {\n 439 | return Err(Error::MalformedMessage);\n 440 | }","lines":3}]},{"id":"LOGIC_8","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"When encoding HEADERS/PUSH_PROMISE and a continuation is needed, the code clears the END_HEADERS flag using subtraction (dst.get_mut()[head_pos + 4] -= END_HEADERS) instead of a bitwise clear. In release builds the debug_assert guarding this is disabled; if the flag byte somehow lacks the END_HEADERS bit (e.g. after a bug elsewhere), the subtraction wraps and corrupts the flags byte with an arbitrary value, producing a malformed frame.","fix":"Replace `dst.get_mut()[head_pos + 4] -= END_HEADERS;` with `dst.get_mut()[head_pos + 4] &= !END_HEADERS;` to safely clear the bit regardless of prior state.","locations":[{"ref":"src/frame/headers.rs:687-692","code":" 687 | if continuation.is_some() {\n 688 | // There will be continuation frames, so the `is_end_headers` flag\n 689 | // must be unset\n 690 | debug_assert!(dst.get_ref()[head_pos + 4] & END_HEADERS == END_HEADERS);\n 691 | \n 692 | dst.get_mut()[head_pos + 4] -= END_HEADERS;","lines":6}]},{"id":"LOGIC_9","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"In `settings.rs:poll_send` (line 136-137), `mark_remote_initial_settings_as_received`\nflips a one-way boolean flag before `streams.apply_remote_settings` is called. If\n`apply_remote_settings` returns `Err`, the function returns early without clearing\n`self.remote` (line 148 is not reached). On the next poll, `self.remote` is still\n`Some`, `mark_remote_initial_settings_as_received` now returns `false` (the flag\nwas already flipped), and `apply_remote_settings` is called again with\n`is_initial = false`. This changes the semantics - the initial `SETTINGS_MAX_CONCURRENT_STREAMS`\nvalue is treated as an update rather than the initial limit (counts.rs:182-184).\nIn practice the connection is being torn down when an error is returned, so the\nimpact is limited, but the ordering is fragile.","fix":"Clear `self.remote` (set to `None`) before calling `apply_remote_settings`, or\nuse `self.remote.take()` at the top of the `if let` block so that any early\nreturn leaves `remote` in a consistent state.","locations":[{"ref":"src/proto/settings.rs:123-148","code":" 123 | if let Some(settings) = self.remote.clone() {\n 124 | if !dst.poll_ready(cx)?.is_ready() {\n 125 | return Poll::Pending;\n 126 | }\n 127 | \n 128 | // Create an ACK settings frame\n 129 | let frame = frame::Settings::ack();\n 130 | \n 131 | // Buffer the settings frame\n 132 | dst.buffer(frame.into()).expect(\"invalid settings frame\");\n 133 | \n 134 | tracing::trace!(\"ACK sent; applying settings\");\n 135 | \n 136 | let is_initial = self.mark_remote_initial_settings_as_received();\n 137 | streams.apply_remote_settings(&settings, is_initial)?;\n 138 | \n 139 | if let Some(val) = settings.header_table_size() {\n 140 | dst.set_send_header_table_size(val as usize);\n 141 | }\n 142 | \n 143 | if let Some(val) = settings.max_frame_size() {\n 144 | dst.set_max_send_frame_size(val as usize);\n 145 | }\n 146 | }\n 147 | \n 148 | self.remote = None;","lines":26},{"ref":"src/proto/streams/counts.rs:180-185","code":" 180 | pub fn apply_remote_settings(&mut self, settings: &frame::Settings, is_initial: bool) {\n 181 | match settings.max_concurrent_streams() {\n 182 | Some(val) => self.max_send_streams = val as usize,\n 183 | None if is_initial => self.max_send_streams = usize::MAX,\n 184 | None => {}\n 185 | }","lines":6}]},{"id":"LOGIC_10","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"In `send_informational_headers` (streams.rs:1176-1211), the stream state is not validated\nbefore queuing the informational HEADERS frame. There is only a `debug_assert!` that the\nframe is informational (line 1191, release-mode ineffective) and a check that the frame\nhas no end-stream flag (line 1197). Nothing prevents the caller from invoking\n`SendResponse::send_informational()` after `send_response()` has already transitioned the\nstream to a half-closed or closed send state. The resulting HEADERS frame on a closed\nstream violates RFC 9113 Section 8.1 and will be treated as a stream error by the peer.","fix":"Add a state check before queuing the frame:\n if !stream.state.is_recv_headers() {\n return Err(UserError::UnexpectedFrameType);\n }\nThis mirrors the check already present in `recv_headers` for the normal response path.","locations":[{"ref":"src/proto/streams/streams.rs:1185-1210","code":" 1185 | me.counts.transition(stream, |counts, stream| {\n 1186 | // For informational responses (1xx), we need to send headers without\n 1187 | // changing the stream state. This allows multiple informational responses\n 1188 | // to be sent before the final response.\n 1189 | \n 1190 | // Validate that this is actually an informational response\n 1191 | debug_assert!(\n 1192 | frame.is_informational(),\n 1193 | \"Frame must be informational after conversion from informational response\"\n 1194 | );\n 1195 | \n 1196 | // Ensure the frame is not marked as end_stream for informational responses\n 1197 | if frame.is_end_stream() {\n 1198 | return Err(UserError::UnexpectedFrameType);\n 1199 | }\n 1200 | \n 1201 | // Send the interim informational headers directly to the buffer without state changes\n 1202 | // This bypasses the normal send_headers flow that would transition the stream state\n 1203 | actions.send.send_interim_informational_headers(\n 1204 | frame,\n 1205 | send_buffer,\n 1206 | stream,\n 1207 | counts,\n 1208 | &mut actions.task,\n 1209 | )\n 1210 | })","lines":26},{"ref":"src/proto/streams/send.rs:172-200","code":" 172 | pub fn send_interim_informational_headers<B>(\n 173 | &mut self,\n 174 | frame: frame::Headers,\n 175 | buffer: &mut Buffer<Frame<B>>,\n 176 | stream: &mut store::Ptr,\n 177 | _counts: &mut Counts,\n 178 | task: &mut Option<Waker>,\n 179 | ) -> Result<(), UserError> {\n 180 | tracing::trace!(\n 181 | \"send_interim_informational_headers; frame={:?}; stream_id={:?}\",\n 182 | frame,\n 183 | frame.stream_id()\n 184 | );\n 185 | \n 186 | // Validate headers\n 187 | Self::check_headers(frame.fields())?;\n 188 | \n 189 | debug_assert!(frame.is_informational(),\n 190 | \"Frame must be informational (1xx status code) at this point. Validation should happen at the public API boundary.\");\n 191 | debug_assert!(!frame.is_end_stream(),\n 192 | \"Informational frames must not have end_stream flag set. Validation should happen at the internal send informational header streams.\");\n 193 | \n 194 | // Queue the frame for sending WITHOUT changing stream state\n 195 | // This is the key difference from send_headers - we don't call stream.state.send_open()\n 196 | self.prioritize\n 197 | .queue_frame(frame.into(), buffer, stream, task);\n 198 | \n 199 | Ok(())\n 200 | }","lines":29}]},{"id":"ERR_1","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","easy":true,"description":"Settings::load() returns Error::InvalidPayloadAckSettings when a non-ACK SETTINGS frame has a payload length that is not a multiple of 6. The variant is named and documented 'Received a payload with an ACK settings frame', which is the opposite condition. The actual case of 'ACK + non-empty payload' is handled above and returns InvalidPayloadLength. The uses of the two error variants are swapped from their documented meanings.","fix":"For the non-ACK, non-multiple-of-6 case at line 153, return Err(Error::InvalidPayloadLength). Update the doc comment on InvalidPayloadAckSettings to correctly describe when it is emitted, or remove it and consolidate into InvalidPayloadLength.","locations":[{"ref":"src/frame/settings.rs:150-153","code":" 150 | // Ensure the payload length is correct, each setting is 6 bytes long.\n 151 | if payload.len() % 6 != 0 {\n 152 | tracing::debug!(\"invalid settings payload length; len={:?}\", payload.len());\n 153 | return Err(Error::InvalidPayloadAckSettings);","lines":4},{"ref":"src/frame/mod.rs:147-152","code":" 147 | /// The payload length specified by the frame header was not the\n 148 | /// value necessary for the specific frame type.\n 149 | InvalidPayloadLength,\n 150 | \n 151 | /// Received a payload with an ACK settings frame\n 152 | InvalidPayloadAckSettings,","lines":6}]},{"id":"ERR_2","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","easy":false,"description":"All From implementations that convert parsing errors into DecoderError map to DecoderError::InvalidUtf8 regardless of the actual error type. InvalidHeaderName, InvalidMethod, and InvalidStatusCode all map to the same DecoderError::InvalidUtf8 variant, making it impossible for callers to distinguish a malformed header name from an invalid method or a bad status code. The comments \"// TODO: Better error\" appear five times, confirming this is known but unfixed.","fix":"Use the appropriate DecoderError variant for each conversion: InvalidHeaderName -> a new DecoderError::InvalidHeaderName or InvalidUtf8 with a clear doc; InvalidMethod -> DecoderError::InvalidPseudoheader; InvalidStatusCode -> DecoderError::InvalidStatusCode (variant already exists). This makes protocol-level error reporting useful to callers.","locations":[{"ref":"src/hpack/decoder.rs:586-619","code":" 586 | impl From<Utf8Error> for DecoderError {\n 587 | fn from(_: Utf8Error) -> DecoderError {\n 588 | // TODO: Better error?\n 589 | DecoderError::InvalidUtf8\n 590 | }\n 591 | }\n 592 | \n 593 | impl From<header::InvalidHeaderValue> for DecoderError {\n 594 | fn from(_: header::InvalidHeaderValue) -> DecoderError {\n 595 | // TODO: Better error?\n 596 | DecoderError::InvalidUtf8\n 597 | }\n 598 | }\n 599 | \n 600 | impl From<header::InvalidHeaderName> for DecoderError {\n 601 | fn from(_: header::InvalidHeaderName) -> DecoderError {\n 602 | // TODO: Better error\n 603 | DecoderError::InvalidUtf8\n 604 | }\n 605 | }\n 606 | \n 607 | impl From<method::InvalidMethod> for DecoderError {\n 608 | fn from(_: method::InvalidMethod) -> DecoderError {\n 609 | // TODO: Better error\n 610 | DecoderError::InvalidUtf8\n 611 | }\n 612 | }\n 613 | \n 614 | impl From<status::InvalidStatusCode> for DecoderError {\n 615 | fn from(_: status::InvalidStatusCode) -> DecoderError {\n 616 | // TODO: Better error\n 617 | DecoderError::InvalidUtf8\n 618 | }\n 619 | }","lines":34},{"ref":"src/hpack/header.rs:244-247","code":" 244 | // TODO: better error handling\n 245 | Err(_) => Err(DecoderError::InvalidStatusCode),\n 246 | }\n 247 | }","lines":4}]},{"id":"PERF_1","type":"performance","type_label":"Performance","severity":"low","easy":false,"description":"In Decoder::decode_literal (table_idx == 0 branch), the function decodes both strings (name and value) twice: once via try_decode_string to build StringMarkers (which advances the cursor and for Huffman-encoded strings, performs the full Huffman decode), then resets the cursor position and calls StringMarker::consume which re-reads raw bytes via take(). For plain (non-Huffman) strings this is just a double-pass over the length header; for Huffman strings the decode result is cached in StringMarker::string so no re-decode occurs. The pattern is more convoluted than necessary.","fix":"Consider redesigning try_decode_string to directly return the decoded Bytes without the intermediate StringMarker pattern and cursor rewinding, simplifying the call sites. The current two-pass approach exists to handle partial frames but adds complexity.","locations":[{"ref":"src/hpack/decoder.rs:285-311","code":" 285 | fn decode_literal(\n 286 | &mut self,\n 287 | buf: &mut Cursor<&mut BytesMut>,\n 288 | index: bool,\n 289 | ) -> Result<Header, DecoderError> {\n 290 | let prefix = if index { 6 } else { 4 };\n 291 | \n 292 | // Extract the table index for the name, or 0 if not indexed\n 293 | let table_idx = decode_int(buf, prefix)?;\n 294 | \n 295 | // First, read the header name\n 296 | if table_idx == 0 {\n 297 | let old_pos = buf.position();\n 298 | let name_marker = self.try_decode_string(buf)?;\n 299 | let value_marker = self.try_decode_string(buf)?;\n 300 | buf.set_position(old_pos);\n 301 | // Read the name as a literal\n 302 | let name = name_marker.consume(buf);\n 303 | let value = value_marker.consume(buf);\n 304 | Header::new(name, value)\n 305 | } else {\n 306 | let e = self.table.get(table_idx)?;\n 307 | let value = self.decode_string(buf)?;\n 308 | \n 309 | e.name().into_entry(value)\n 310 | }\n 311 | }","lines":27}]},{"id":"PERF_2","type":"performance","type_label":"Performance","severity":"low","easy":true,"description":"In `settings.rs:poll_send` (line 123), `self.remote.clone()` is called on every\ninvocation of `poll_send`, even when the codec is not ready to accept new frames.\nIf `dst.poll_ready(cx)` returns `Pending`, the entire clone is wasted since we\nimmediately return `Poll::Pending` and retry on the next wakeup. `frame::Settings`\nis a struct of several `Option<u32>` fields - the clone is cheap but unnecessary.\n\nAdditionally, in the `Local::ToSend` branch (lines 157-161), `settings` is\ncloned twice: once to buffer the frame and once to store it in `WaitingAck`. A\nsingle clone would suffice by reusing one for buffering.","fix":"Replace the clone at line 123 with `if self.remote.is_some()` to check before\ncloning. Move the clone inside the block after `poll_ready` succeeds, using\n`self.remote.take().unwrap()` instead. For the double-clone at lines 157-161,\ntake ownership of `settings` from the `Local::ToSend` variant with\n`if let Local::ToSend(settings) = std::mem::replace(&mut self.local, Local::Synced)`\nand then clone only once.","locations":[{"ref":"src/proto/settings.rs:123-125","code":" 123 | if let Some(settings) = self.remote.clone() {\n 124 | if !dst.poll_ready(cx)?.is_ready() {\n 125 | return Poll::Pending;","lines":3},{"ref":"src/proto/settings.rs:157-161","code":" 157 | dst.buffer(settings.clone().into())\n 158 | .expect(\"invalid settings frame\");\n 159 | tracing::trace!(\"local settings sent; waiting for ack: {:?}\", settings);\n 160 | \n 161 | self.local = Local::WaitingAck(settings.clone());","lines":5}]},{"id":"DEAD_1","type":"dead_code","type_label":"Dead Code","severity":"low","easy":true,"description":"The Priority frame encoding path in Encoder::buffer() contains commented-out code alongside unimplemented!(). There is no Priority::encode() method, so the commented code never compiled. The unimplemented!() makes the entire Priority variant permanently broken. The dead commented code adds confusion about intent.","fix":"Remove the commented code block. Either implement Priority frame sending or silently ignore it with a note that Priority is deprecated in RFC 9113.","locations":[{"ref":"src/codec/framed_write.rs:282-288","code":" 282 | Frame::Priority(_) => {\n 283 | /*\n 284 | v.encode(self.buf.get_mut());\n 285 | tracing::trace!(\"encoded priority; rem={:?}\", self.buf.remaining());\n 286 | */\n 287 | unimplemented!();\n 288 | }","lines":7}]},{"id":"DEAD_2","type":"dead_code","type_label":"Dead Code","severity":"low","easy":true,"description":"Decoder::queue_size_update is annotated #[allow(dead_code)] but the function is actively called from src/codec/framed_read.rs:106 and from the hpack test modules (fixture.rs and fuzz.rs). The attribute suppresses a lint that should not fire for a live function, and misleads readers into thinking the method is unused.","fix":"Remove the #[allow(dead_code)] attribute.","locations":[{"ref":"src/hpack/decoder.rs:166-174","code":" 166 | #[allow(dead_code)]\n 167 | pub fn queue_size_update(&mut self, size: usize) {\n 168 | let size = match self.max_size_update {\n 169 | Some(v) => cmp::max(v, size),\n 170 | None => size,\n 171 | };\n 172 | \n 173 | self.max_size_update = Some(size);\n 174 | }","lines":9}]},{"id":"DEAD_3","type":"dead_code","type_label":"Dead Code","severity":"negligible","easy":true,"description":"In decode_frame()'s Kind::Continuation arm (line 305-306), when is_end_headers is true the partial.continuation_frames_count is reset to 0. This write is immediately followed by consuming the partial via into() and returning the frame - the updated value is never observed. The reset is harmless dead work.","fix":"Remove the assignment `partial.continuation_frames_count = 0;` in the is_end_headers branch since the partial is dropped immediately after.","locations":[{"ref":"src/codec/framed_read.rs:305-306","code":" 305 | if is_end_headers {\n 306 | partial.continuation_frames_count = 0;","lines":2}]},{"id":"DEAD_4","type":"dead_code","type_label":"Dead Code","severity":"negligible","easy":true,"description":"`peer.rs` lines 25-31 contain a block of dead code: a function `is_local_init`\ninside the `Peer` trait definition is commented out with `/* ... */`. The same\nfunction already exists and is active on `Dyn` (line 55-58). The commented-out\nblock adds noise without value.","fix":"Remove the commented-out `is_local_init` function block.","locations":[{"ref":"src/proto/peer.rs:25-31","code":" 25 | /*\n 26 | fn is_local_init(id: StreamId) -> bool {\n 27 | assert!(!id.is_zero());\n 28 | Self::is_server() == id.is_server_initiated()\n 29 | }\n 30 | */\n 31 | }","lines":7}]},{"id":"CMT_1","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","easy":true,"description":"The constant LITERAL_WITHOUT_INDEXING (value 0b1111_0000 = 0xF0) inside Representation::load is used as a bitmask (top 4 bits), not as the representation byte. Its name implies it IS the representation, matching the other constants like INDEXED = 0b1000_0000. The author's own comment \"TODO: What did I even write here?\" confirms the naming is confusing. Reading the three conditions that use this constant in sequence is hard to follow.","fix":"Rename the constant to something like TOP_NIBBLE_MASK or LITERAL_MASK and add a short comment explaining that the top-4-bit conditions differentiate LiteralWithoutIndexing (0x00) from LiteralNeverIndexed (0x10) from SizeUpdate (0x20-0x3F). Remove the TODO comment once clarified.","locations":[{"ref":"src/hpack/decoder.rs:373-397","code":" 373 | impl Representation {\n 374 | pub fn load(byte: u8) -> Result<Representation, DecoderError> {\n 375 | const INDEXED: u8 = 0b1000_0000;\n 376 | const LITERAL_WITH_INDEXING: u8 = 0b0100_0000;\n 377 | const LITERAL_WITHOUT_INDEXING: u8 = 0b1111_0000;\n 378 | const LITERAL_NEVER_INDEXED: u8 = 0b0001_0000;\n 379 | const SIZE_UPDATE_MASK: u8 = 0b1110_0000;\n 380 | const SIZE_UPDATE: u8 = 0b0010_0000;\n 381 | \n 382 | // TODO: What did I even write here?\n 383 | \n 384 | if byte & INDEXED == INDEXED {\n 385 | Ok(Representation::Indexed)\n 386 | } else if byte & LITERAL_WITH_INDEXING == LITERAL_WITH_INDEXING {\n 387 | Ok(Representation::LiteralWithIndexing)\n 388 | } else if byte & LITERAL_WITHOUT_INDEXING == 0 {\n 389 | Ok(Representation::LiteralWithoutIndexing)\n 390 | } else if byte & LITERAL_WITHOUT_INDEXING == LITERAL_NEVER_INDEXED {\n 391 | Ok(Representation::LiteralNeverIndexed)\n 392 | } else if byte & SIZE_UPDATE_MASK == SIZE_UPDATE {\n 393 | Ok(Representation::SizeUpdate)\n 394 | } else {\n 395 | Err(DecoderError::InvalidRepresentation)\n 396 | }\n 397 | }","lines":25}]},{"id":"CMT_2","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","easy":true,"description":"`counts.rs` contains three copy-pasted comments that describe the wrong counter:\n\n- Line 91 (inside `inc_num_local_error_resets`): \"Increment the number of remote\n initiated streams\" - but this increments `num_local_error_reset_streams`.\n- Line 113 (inside `inc_num_recv_streams`): \"Increment the number of remote\n initiated streams\" - but `num_recv_streams` counts remotely-initiated streams\n (correct concept, wrong counter name in comment).\n- Line 132 (inside `inc_num_send_streams`): \"Increment the number of remote\n initiated streams\" - but this increments `num_send_streams` which tracks locally\n initiated streams.\n\nThe comment at line 132 is outright wrong (says \"remote\" while incrementing\nthe local/send counter).","fix":"Line 91: change to \"Increment the number of locally-reset-due-to-error streams\".\nLine 113: change to \"Increment the number of remotely initiated (receive) streams\".\nLine 132: change to \"Increment the number of locally initiated (send) streams\".","locations":[{"ref":"src/proto/streams/counts.rs:88-92","code":" 88 | pub fn inc_num_local_error_resets(&mut self) {\n 89 | assert!(self.can_inc_num_local_error_resets());\n 90 | \n 91 | // Increment the number of remote initiated streams\n 92 | self.num_local_error_reset_streams += 1;","lines":5},{"ref":"src/proto/streams/counts.rs:109-115","code":" 109 | pub fn inc_num_recv_streams(&mut self, stream: &mut store::Ptr) {\n 110 | assert!(self.can_inc_num_recv_streams());\n 111 | assert!(!stream.is_counted);\n 112 | \n 113 | // Increment the number of remote initiated streams\n 114 | self.num_recv_streams += 1;\n 115 | stream.is_counted = true;","lines":7},{"ref":"src/proto/streams/counts.rs:128-134","code":" 128 | pub fn inc_num_send_streams(&mut self, stream: &mut store::Ptr) {\n 129 | assert!(self.can_inc_num_send_streams());\n 130 | assert!(!stream.is_counted);\n 131 | \n 132 | // Increment the number of remote initiated streams\n 133 | self.num_send_streams += 1;\n 134 | stream.is_counted = true;","lines":7}]},{"id":"CMT_3","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","easy":true,"description":"`error.rs` has two methods (`get_io` at line 67 and `into_io` at line 75) that share the\nidentical doc comment `/// Returns the error if the error is an io::Error`. The second doc\nstring is wrong for `into_io`, which consumes `self` and returns an `Option<io::Error>`,\nnot a reference. The misleading copy-paste doc makes API documentation inaccurate.","fix":"Change the `into_io` doc comment to:\n /// Consumes `self`, returning the inner `io::Error` if this error wraps one.","locations":[{"ref":"src/error.rs:66-72","code":" 66 | /// Returns the error if the error is an io::Error\n 67 | pub fn get_io(&self) -> Option<&io::Error> {\n 68 | match self.kind {\n 69 | Kind::Io(ref e) => Some(e),\n 70 | _ => None,\n 71 | }\n 72 | }","lines":7},{"ref":"src/error.rs:74-80","code":" 74 | /// Returns the error if the error is an io::Error\n 75 | pub fn into_io(self) -> Option<io::Error> {\n 76 | match self.kind {\n 77 | Kind::Io(e) => Some(e),\n 78 | _ => None,\n 79 | }\n 80 | }","lines":7}]},{"id":"CMT_4","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","easy":true,"description":"The TODO comment on line 431 of headers.rs ('TODO: Ensure payload is sized correctly') is inside PushPromise::load()'s padded-frame handling. The immediately following check at line 438 does validate the payload size (though with the wrong threshold - see separate finding). The TODO is misleading because size checking IS present.","fix":"Remove or update the TODO comment to reference only the off-by-one threshold issue (< 5 should be < 4).","locations":[{"ref":"src/frame/headers.rs:431","code":" 431 | // TODO: Ensure payload is sized correctly","lines":1}]},{"id":"CMT_5","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","easy":true,"description":"Comment on line 187 of `prioritize.rs` contains a typo: \"capcaity\" instead of \"capacity\".","fix":"Fix the typo: s/capcaity/capacity/","locations":[{"ref":"src/proto/streams/prioritize.rs:187-188","code":" 187 | // `try_assign_capacity` will queue the stream to `pending_capacity` if the capcaity\n 188 | // cannot be assigned at the time it is called.","lines":2}]},{"id":"ARCH_1","type":"architecture","type_label":"Architecture","severity":"low","easy":false,"description":"The entire assert_valid_state method body is commented out with a block comment in the #[cfg(test)] implementation, leaving only `true`. The TODO inside it notes that cycle-detection in linked lists is not checked. The commented-out validation code is stale - it references a string concatenation using `+` on string literals (\"could not find entry; actual={}; desired={}\" + \"probe={}, dist={}...\" which is not valid Rust syntax), meaning it never compiled and any bug it would catch silently goes undetected during testing.","fix":"Either fix and uncomment the validation code (fixing the string concatenation to use format!() or a raw string), or delete it entirely. Leaving dead commented-out test code with a syntax error creates false confidence in testing and clutters the module.","locations":[{"ref":"src/hpack/table.rs:531-621","code":" 531 | #[cfg(test)]\n 532 | fn assert_valid_state(&self, _msg: &'static str) -> bool {\n 533 | /*\n 534 | // Checks that the internal map structure is valid\n 535 | //\n 536 | // Ensure all hash codes in indices match the associated slot\n 537 | for pos in &self.indices {\n 538 | if let Some(pos) = *pos {\n 539 | let real_idx = pos.index.wrapping_add(self.inserted);\n 540 | \n 541 | if real_idx.wrapping_add(1) != 0 {\n 542 | assert!(real_idx < self.slots.len(),\n 543 | \"out of index; real={}; len={}, msg={}\",\n 544 | real_idx, self.slots.len(), msg);\n 545 | \n 546 | assert_eq!(pos.hash, self.slots[real_idx].hash,\n 547 | \"index hash does not match slot; msg={}\", msg);\n 548 | }\n 549 | }\n 550 | }\n 551 | \n 552 | // Every index is only available once\n 553 | for i in 0..self.indices.len() {\n 554 | if self.indices[i].is_none() {\n 555 | continue;\n 556 | }\n 557 | \n 558 | for j in i+1..self.indices.len() {\n 559 | assert_ne!(self.indices[i], self.indices[j],\n 560 | \"duplicate indices; msg={}\", msg);\n 561 | }\n 562 | }\n 563 | \n 564 | for (index, slot) in self.slots.iter().enumerate() {\n 565 | let mut indexed = None;\n 566 | \n 567 | // First, see if the slot is indexed\n 568 | for (i, pos) in self.indices.iter().enumerate() {\n 569 | if let Some(pos) = *pos {\n 570 | let real_idx = pos.index.wrapping_add(self.inserted);\n 571 | if real_idx == index {\n 572 | indexed = Some(i);\n 573 | // Already know that there is no dup, so break\n 574 | break;\n 575 | }\n 576 | }\n 577 | }\n 578 | \n 579 | if let Some(actual) = indexed {\n 580 | // Ensure that it is accessible..\n 581 | let desired = desired_pos(self.mask, slot.hash);\n 582 | let mut probe = desired;\n 583 | let mut dist = 0;\n 584 | \n 585 | probe_loop!(probe < self.indices.len(), {\n 586 | assert!(self.indices[probe].is_some(),\n 587 | \"unexpected empty slot; probe={}; hash={:?}; msg={}\",\n 588 | probe, slot.hash, msg);\n 589 | \n 590 | let pos = self.indices[probe].unwrap();\n 591 | \n 592 | let their_dist = probe_distance(self.mask, pos.hash, probe);\n 593 | let real_idx = pos.index.wrapping_add(self.inserted);\n 594 | \n 595 | if real_idx == index {\n 596 | break;\n 597 | }\n 598 | \n 599 | assert!(dist <= their_dist,\n 600 | \"could not find entry; actual={}; desired={}\" +\n 601 | \"probe={}, dist={}; their_dist={}; index={}; msg={}\",\n 602 | actual, desired, probe, dist, their_dist,\n 603 | index.wrapping_sub(self.inserted), msg);\n 604 | \n 605 | dist += 1;\n 606 | });\n 607 | } else {\n 608 | // There is exactly one next link\n 609 | let cnt = self.slots.iter().map(|s| s.next)\n 610 | .filter(|n| *n == Some(index.wrapping_sub(self.inserted)))\n 611 | .count();\n 612 | \n 613 | assert_eq!(1, cnt, \"more than one node pointing here; msg={}\", msg);\n 614 | }\n 615 | }\n 616 | */\n 617 | \n 618 | // TODO: Ensure linked lists are correct: no cycles, etc...\n 619 | \n 620 | true\n 621 | }","lines":91}]},{"id":"ARCH_2","type":"architecture","type_label":"Architecture","severity":"negligible","easy":false,"description":"All fields of the Pseudo struct are pub despite the TODO comment stating they should not be (src/frame/headers.rs:66). Public fields allow external crates (or internal code in other modules) to directly mutate pseudo-headers, bypassing any future invariant enforcement and making it harder to add validation.","fix":"Make the Pseudo fields private and expose them via accessor methods. Gate mutations behind setter methods that can enforce invariants (e.g. ensuring status codes are valid for a given context).","locations":[{"ref":"src/frame/headers.rs:66-78","code":" 66 | // TODO: These fields shouldn't be `pub`\n 67 | #[derive(Debug, Default, Eq, PartialEq)]\n 68 | pub struct Pseudo {\n 69 | // Request\n 70 | pub method: Option<Method>,\n 71 | pub scheme: Option<BytesStr>,\n 72 | pub authority: Option<BytesStr>,\n 73 | pub path: Option<BytesStr>,\n 74 | pub protocol: Option<Protocol>,\n 75 | \n 76 | // Response\n 77 | pub status: Option<StatusCode>,\n 78 | }","lines":13}]},{"id":"OTHER_1","type":"other","type_label":"Other","severity":"negligible","easy":true,"description":"`connection.rs` line 568-569 receives PRIORITY frames and drops them with only a\ntrace log and a `// TODO: handle` comment. RFC 7540 allowed PRIORITY frames; RFC\n9113 deprecates them but still requires them to be accepted without error. The\ncurrent behaviour (silently dropping) is protocol-compliant but the `TODO`\ncomment implies unfinished work that could confuse future contributors. There is\nno functional bug.","fix":"Remove the `// TODO: handle` comment and add a brief note explaining that\nPRIORITY frames are intentionally ignored per RFC 9113 §5.3.2 (deprecated). No\nfunctional change is needed.","locations":[{"ref":"src/proto/connection.rs:567-570","code":" 567 | Some(Priority(frame)) => {\n 568 | tracing::trace!(?frame, \"recv PRIORITY\");\n 569 | // TODO: handle\n 570 | }","lines":4}]}]</script>
<script type="application/json" id="PI">{"name":"h2","description":"","git_url":"https://github.com/hyperium/h2.git","repo_url":"https://github.com/hyperium/h2","commit":"29e209d885530ef0e1a8b7f9265f517c4e4ede03","commit_short":"29e209d885","commit_url":"https://github.com/hyperium/h2/commit/29e209d885530ef0e1a8b7f9265f517c4e4ede03","branch":"master","generated":"2026-06-15"}</script>
<div class="app">
<div class="top">
<div>
<h1>Code Audit Report — h2</h1>
<div class="meta">Generated 2026-06-15 — <span id="stats"></span></div>
</div>
<div class="top-right">
<input type="search" id="search" placeholder="Search findings…" autocomplete="off">
<label class="group-label">Group by
<select id="groupby">
<option value="type">Type</option>
<option value="severity">Severity</option>
<option value="file">File</option>
<option value="none">None (flat)</option>
</select>
</label>
</div>
</div>
<div class="project-info"><div class="pi-name">h2</div><div class="pi-row"><a class="pi-repo-btn" href="https://github.com/hyperium/h2" target="_blank" rel="noopener">🔗 https://github.com/hyperium/h2</a><span><span class="pi-k">branch</span> <code>master</code></span><span><span class="pi-k">commit</span> <a href="https://github.com/hyperium/h2/commit/29e209d885530ef0e1a8b7f9265f517c4e4ede03" target="_blank" rel="noopener"><code>29e209d885</code></a></span></div></div>
<div class="filterbar" id="filterbar"></div>
<div id="progress-wrap">
<div id="progress-bar"><div id="progress-fill"></div></div>
<span id="progress-text"></span>
<button class="action-btn" data-action="collapse-all">⊟ Collapse all</button>
<button class="action-btn" data-action="expand-all">⊞ Expand all</button>
<button class="action-btn" data-action="download-md">⬇ MD</button>
<button class="action-btn" data-action="download-md-short">⬇ MD Short</button>
<button class="action-btn" data-action="download-toml">⬇ TOML</button>
</div>
<div id="hidden-bar"></div>
<div id="content"></div>
</div>
<script>
(function(){
'use strict';
// Data
const FINDINGS = JSON.parse(document.getElementById('D').textContent);
const PROJECT_INFO = JSON.parse(document.getElementById('PI').textContent);
const SEVS = ['critical','high','medium','low','negligible'];
const STATUSES = ['open','fixed','wont-fix','false-positive'];
const ALL_TYPES = [...new Set(FINDINGS.map(f => f.type))].sort();
// Build lookup map for O(1) access
const FINDING_BY_ID = new Map(FINDINGS.map(f => [f.id, f]));
// Only severities actually present in the report — the filter bar hides the rest.
const PRESENT_SEVS = SEVS.filter(s => FINDINGS.some(f => (f.severity || 'medium') === s));
// Whether any finding is flagged easy — the "Quick wins" filter row is hidden otherwise.
const HAS_EASY = FINDINGS.some(f => f.easy);
const STORE_KEY = 'audit-status:' + location.href;
const STATUS_CLS = {open:'st-open', fixed:'st-fixed', 'wont-fix':'st-wont', 'false-positive':'st-fp'};
const STATUS_LBL = {open:'● open', fixed:'✓ fixed', 'wont-fix':"— won't fix", 'false-positive':'✗ false pos.'};
const STATUS_NEXT = {open:'fixed', fixed:'wont-fix', 'wont-fix':'false-positive', 'false-positive':'open'};
// State
const S = {
search: '', groupBy: 'type',
hiddenSev: new Set(), hiddenType: new Set(),
hiddenStatus: new Set(), hiddenGroup: new Set(),
collapsed: new Set(), easyOnly: false,
};
const statuses = {};
// Persistence
function loadStatuses() {
try { Object.assign(statuses, JSON.parse(localStorage.getItem(STORE_KEY) || '{}')); } catch {}
}
function saveStatuses() {
try { localStorage.setItem(STORE_KEY, JSON.stringify(statuses)); } catch {}
}
function statusOf(id) { return statuses[id] || 'open'; }
// URL hash — debounced so rapid filter changes don't thrash history
let _hashTimer = 0;
function syncHash() {
clearTimeout(_hashTimer);
_hashTimer = setTimeout(() => {
const p = new URLSearchParams();
if (S.groupBy !== 'type') p.set('g', S.groupBy);
if (S.hiddenSev.size) p.set('hs', [...S.hiddenSev].join(','));
if (S.hiddenType.size) p.set('ht', [...S.hiddenType].join(','));
if (S.hiddenStatus.size) p.set('hst', [...S.hiddenStatus].join(','));
if (S.easyOnly) p.set('e', '1');
if (S.search) p.set('q', S.search);
const str = p.toString();
history.replaceState(null, '', str ? '#' + str : location.pathname + location.search);
}, 300);
}
function loadHash() {
const p = new URLSearchParams(location.hash.slice(1));
if (p.has('g')) { S.groupBy = p.get('g'); document.getElementById('groupby').value = S.groupBy; }
if (p.has('q')) { S.search = p.get('q'); document.getElementById('search').value = S.search; }
if (p.has('hs')) p.get('hs') .split(',').filter(Boolean).forEach(v => S.hiddenSev.add(v));
if (p.has('ht')) p.get('ht') .split(',').filter(Boolean).forEach(v => S.hiddenType.add(v));
if (p.has('hst')) p.get('hst').split(',').filter(Boolean).forEach(v => S.hiddenStatus.add(v));
if (p.has('e')) S.easyOnly = p.get('e') === '1';
}
// Helpers
function esc(s) {
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function highlight(text, q) {
if (!q) return esc(text);
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')', 'gi');
return String(text).split(re).map((p, i) => i % 2 ? '<mark>' + esc(p) + '</mark>' : esc(p)).join('');
}
// Render text with ```fenced``` code blocks and `inline` backticks,
// also paragraph-splitting on blank lines. Output is HTML-safe.
function renderMd(text, q) {
const src = String(text == null ? '' : text);
const FENCE = /```[ \t]*([A-Za-z0-9_+-]*)[ \t]*\r?\n([\s\S]*?)```/g;
const parts = [];
let last = 0, m;
while ((m = FENCE.exec(src)) !== null) {
parts.push({kind: 'text', value: src.slice(last, m.index)});
parts.push({kind: 'code', value: m[2].replace(/\r?\n$/, '')});
last = m.index + m[0].length;
}
parts.push({kind: 'text', value: src.slice(last)});
function renderInline(s) {
const re = /`([^`\n]+)`/g;
let out = '', i = 0, mm;
while ((mm = re.exec(s)) !== null) {
out += highlight(s.slice(i, mm.index), q);
out += '<code class="md-inline">' + highlight(mm[1], q) + '</code>';
i = mm.index + mm[0].length;
}
out += highlight(s.slice(i), q);
return out;
}
function renderTextChunk(s) {
const paragraphs = s.split(/\r?\n[ \t]*\r?\n/);
const out = [];
for (const p of paragraphs) {
const trimmed = p.replace(/^\s+|\s+$/g, '');
if (!trimmed) continue;
out.push('<p>' + renderInline(trimmed).replace(/\r?\n/g, '<br>') + '</p>');
}
return out.join('');
}
let html = '';
for (const p of parts) {
if (p.kind === 'text') html += renderTextChunk(p.value);
else html += '<pre class="md-code">' + highlight(p.value, q) + '</pre>';
}
return html;
}
function sevCls(s) { return SEVS.includes(s) ? 'sev-'+s : 'sev-negligible'; }
function sevLbl(s) { return s ? s[0].toUpperCase()+s.slice(1) : 'Medium'; }
function typeLabel(t) { return t.replace(/_/g,' ').replace(/\b\w/g, c => c.toUpperCase()); }
// First sentence of text (up to and including the first period).
function firstSentence(text) {
const m = String(text).match(/\.(\s|$)/);
return m ? text.slice(0, text.indexOf(m[0]) + 1) : text;
}
// Build full MD report (mirrors to_markdown in Python).
function buildMdFull() {
const SORD = ['critical','high','medium','low','negligible'];
const today = new Date().toISOString().slice(0, 10);
let out = '# Code Audit Report\n\n_Generated ' + today + '_\n\n';
const typeMap = new Map();
for (const f of FINDINGS) {
if (!typeMap.has(f.type)) typeMap.set(f.type, []);
typeMap.get(f.type).push(f);
}
const sortedTypes = [...typeMap.keys()].sort();
out += '## Summary\n\n| Type | Severity | Count |\n|------|----------|-------|\n';
for (const t of sortedTypes) {
const items = typeMap.get(t);
const topSev = items.map(f => f.severity || 'medium').reduce((best, s) => {
const bi = SORD.indexOf(best), si = SORD.indexOf(s);
return (si >= 0 && (bi < 0 || si < bi)) ? s : best;
}, 'negligible');
out += '| ' + typeLabel(t) + ' | ' + topSev + ' | ' + items.length + ' |\n';
}
for (const t of sortedTypes) {
out += '\n## ' + typeLabel(t) + '\n\n';
for (const f of typeMap.get(t)) {
const sevStr = f.severity ? ' `' + f.severity.toUpperCase() + '`' : '';
const easyStr = f.easy ? ' ⚡ easy' : '';
out += '### ' + f.id + sevStr + easyStr + '\n\n';
out += '**Description:** ' + (f.description || '') + '\n\n';
if (f.locations.length) {
out += '**Locations:**\n\n';
for (const l of f.locations) {
if (l.code != null) out += '```\n' + l.ref + '\n' + l.code + '\n```\n\n';
else out += '- `' + l.ref + '` _(file not found)_\n\n';
}
}
out += '**Fix:** ' + (f.fix || '') + '\n\n---\n\n';
}
}
return out;
}
// Build MD short checklist (mirrors to_md_short in Python).
function buildMdShort() {
const lines = [];
const ids = [];
const sorted = [...FINDINGS].sort((a, b) => a.type.localeCompare(b.type));
for (const f of sorted) {
ids.push(f.id);
const desc = firstSentence(f.description || '');
const locs = f.locations.map(l => l.ref);
const locStr = locs.length ? ' - [' + locs.join(', ') + ']' : '';
const sevStr = f.severity ? ' [' + f.severity.toUpperCase() + ']' : '';
const easyStr = f.easy ? ' ⚡' : '';
lines.push('- [ ] - ' + f.id + sevStr + easyStr + ' - ' + desc + locStr);
}
lines.push('', '## IDs only', '');
ids.forEach(id => lines.push('- [ ] - ' + id));
return lines.join('\n') + '\n';
}
function downloadText(text, filename, mime) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], {type: mime || 'text/plain'}));
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
function tomlStr(s) {
return '"' + String(s == null ? '' : s)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t') + '"';
}
// Build TOML matching the original findings.toml format (re-parseable by toml_to_report.py).
function buildFindingsToml() {
function pad(key) { return (key + ' ').slice(0, 12); }
const chunks = [];
for (const f of FINDINGS) {
const lns = ['[[finding]]'];
lns.push(pad('type') + '= ' + tomlStr(f.type));
lns.push(pad('severity') + '= ' + tomlStr(f.severity || 'medium'));
if (f.easy) lns.push(pad('easy') + '= true');
lns.push(pad('description') + '= ' + tomlStr(f.description || ''));
const refs = f.locations.map(l => tomlStr(l.ref));
lns.push(pad('locations') + '= [' + refs.join(', ') + ']');
lns.push(pad('fix') + '= ' + tomlStr(f.fix || ''));
chunks.push(lns.join('\n'));
}
return chunks.join('\n\n') + '\n';
}
// Build project-info.toml from embedded PROJECT_INFO (same format as gen_project_info.py).
function buildProjectInfoToml() {
const ORDER = ['name','description','git_url','repo_url','commit','commit_short','commit_url','branch','generated'];
const pi = Object.assign({}, PROJECT_INFO);
if (!('description' in pi)) pi.description = '';
const lines = [
'# Project metadata for the code audit report (all fields optional).',
'# Hand-edit freely (e.g. fill in description); re-run the audit without --force to keep edits.',
'', '[project]'
];
for (const k of ORDER) {
if (k in pi) lines.push(k + ' = ' + tomlStr(pi[k]));
}
return lines.join('\n') + '\n';
}
// Build the GitHub-ready Markdown for one finding (mirrors findings.md).
function findingToMd(f) {
let out = '### ' + f.id + (f.severity ? ' `' + f.severity.toUpperCase() + '`' : '') + (f.easy ? ' ⚡ easy' : '') + '\n\n';
out += '**Description:** ' + (f.description || '') + '\n\n';
if (f.locations.length) {
out += '**Locations:**\n\n';
for (const l of f.locations) {
if (l.code != null) out += '```\n' + l.ref + '\n' + l.code + '\n```\n\n';
else out += '- `' + l.ref + '` _(file not found)_\n\n';
}
}
out += '**Fix:** ' + (f.fix || '') + '\n';
return out;
}
function flashBtn(btn, msg) {
btn.textContent = msg;
setTimeout(() => { btn.textContent = '⧉ Copy'; }, 1200);
}
function copyText(text, btn) {
const ok = () => flashBtn(btn, '✓ Copied');
const no = () => flashBtn(btn, '✗ Failed');
const fallback = () => {
try {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.top = '-9999px';
document.body.appendChild(ta); ta.select();
const r = document.execCommand('copy');
document.body.removeChild(ta);
r ? ok() : no();
} catch { no(); }
};
if (navigator.clipboard && navigator.clipboard.writeText)
navigator.clipboard.writeText(text).then(ok, fallback);
else fallback();
}
// Filter logic
function getKey(f) {
if (S.groupBy === 'severity') return f.severity || 'medium';
if (S.groupBy === 'file') return f.locations.length ? f.locations[0].ref.replace(/:.*/,'') : '(no location)';
if (S.groupBy === 'none') return '';
return f.type_label;
}
// Pre-compute search haystack per finding (cleared on full re-render)
const _hay = new Map();
function hayOf(f) {
if (!_hay.has(f.id)) {
_hay.set(f.id, [f.id, f.type_label, f.severity, f.description, f.fix,
f.easy ? 'easy quick win' : '',
...f.locations.map(l => l.ref)].join(' ').toLowerCase());
}
return _hay.get(f.id);
}
function matches(f) {
if (S.easyOnly && !f.easy) return false;
if (S.hiddenSev.has(f.severity || 'medium')) return false;
if (S.hiddenType.has(f.type)) return false;
const st = statusOf(f.id);
if (S.hiddenStatus.has(st)) return false;
if (S.search) {
const q = S.search.toLowerCase();
if (!hayOf(f).includes(q) && !st.includes(q)) return false;
}
return true;
}
function sortKeys(keys) {
if (S.groupBy === 'severity') {
return [...keys].sort((a, b) => {
const ai = SEVS.indexOf(a), bi = SEVS.indexOf(b);
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
});
}
return [...keys].sort();
}
// Build body HTML for an expanded card (called lazily on first expand).
function renderCardBody(f, q) {
let locs = '';
for (const l of f.locations) {
const lineInfo = l.lines != null
? ' <span class="loc-lines">(' + l.lines + ' line' + (l.lines === 1 ? '' : 's') + ')</span>'
: '';
locs += '<div class="loc-block"><div class="loc-ref">' + highlight(l.ref, q) + lineInfo + '</div>';
locs += l.code != null ? '<pre>' + esc(l.code) + '</pre>' : '<div class="not-found">(file not found)</div>';
locs += '</div>';
}
return (
'<div class="field-lbl">Description</div>' +
'<div class="desc">' + renderMd(f.description, q) + '</div>' +
(locs ? '<div class="field-lbl">Locations</div>' + locs : '') +
'<div class="field-lbl">Fix</div>' +
'<div class="fix-txt">' + renderMd(f.fix, q) + '</div>'
);
}
// Render card shell only — header + empty or populated body.
// When collapsed the body is empty (display:none) — avoids renderMd for 1800+ cards.
function renderCard(f, q) {
const sc = sevCls(f.severity || 'medium');
const sl = sevLbl(f.severity || 'medium');
const st = statusOf(f.id);
const sc2 = STATUS_CLS[st] || 'st-open';
const sl2 = STATUS_LBL[st] || '● open';
const isCollapsed = S.collapsed.has(f.id);
const isResolved = st !== 'open';
const locSummary = f.locations.length
? f.locations[0].ref + (f.locations.length > 1 ? ' \u2026' : '')
: '';
const bodyInner = isCollapsed ? '' : renderCardBody(f, q);
return (
'<div class="card' + (isResolved ? ' resolved' : '') + '" data-card-id="' + esc(f.id) + '">' +
'<div class="card-header" data-action="toggle-card" data-id="' + esc(f.id) + '">' +
'<span class="card-arrow">' + (isCollapsed ? '\u25b6' : '\u25bc') + '</span>' +
'<span class="card-id">' + esc(f.id) + '</span>' +
'<span class="badge ' + sc + '" style="cursor:default">' + esc(sl) + '</span>' +
'<span class="badge type-badge" style="cursor:default">' + esc(f.type_label) + '</span>' +
(f.easy ? '<span class="badge easy-badge" style="cursor:default" title="Easy to verify & fix - no deep knowledge of the code, SDK or technology required">\u26a1 Easy</span>' : '') +
(locSummary ? '<span class="card-loc" title="' + esc(locSummary) + '">' + esc(locSummary) + '</span>' : '') +
'<button class="status-btn ' + sc2 + '" data-action="cycle-status" data-id="' + esc(f.id) + '">' + sl2 + '</button>' +
'<button class="copy-btn" data-action="copy-md" data-id="' + esc(f.id) + '" title="Copy as Markdown for GitHub">\u29c9 Copy</button>' +
'</div>' +
'<div class="card-body" data-body-id="' + esc(f.id) + '"' + (isCollapsed ? ' style="display:none"' : '') + '>' +
bodyInner +
'</div></div>'
);
}
function initFilterBar() {
const fb = document.getElementById('filterbar');
let h = '<div class="filter-row"><span class="filter-lbl">Severity:</span>';
for (const s of PRESENT_SEVS)
h += '<button class="badge ' + sevCls(s) + '" data-sev="' + s + '">' + sevLbl(s) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="sev"></button></div>';
h += '<div class="filter-row"><span class="filter-lbl">Type:</span>';
for (const t of ALL_TYPES)
h += '<button class="badge type-badge" data-type="' + esc(t) + '">' + esc(typeLabel(t)) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="type"></button></div>';
h += '<div class="filter-row"><span class="filter-lbl">Status:</span>';
for (const st of STATUSES)
h += '<button class="badge ' + (STATUS_CLS[st]||'st-open') + '" data-status="' + st + '">' + (STATUS_LBL[st]||st) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="status"></button></div>';
if (HAS_EASY)
h += '<div class="filter-row"><span class="filter-lbl">Quick wins:</span>'
+ '<button class="badge easy-badge" data-easy="1">\u26a1 Easy only</button></div>';
fb.innerHTML = h;
}
function updateToggles() {
for (const b of document.querySelectorAll('[data-sev]')) b.classList.toggle('off', S.hiddenSev.has(b.dataset.sev));
for (const b of document.querySelectorAll('[data-type]')) b.classList.toggle('off', S.hiddenType.has(b.dataset.type));
for (const b of document.querySelectorAll('[data-status]')) b.classList.toggle('off', S.hiddenStatus.has(b.dataset.status));
for (const b of document.querySelectorAll('[data-easy]')) b.classList.toggle('off', !S.easyOnly);
for (const b of document.querySelectorAll('[data-toggle-all]')) {
const k = b.dataset.toggleAll;
const set = k === 'sev' ? S.hiddenSev : k === 'type' ? S.hiddenType : S.hiddenStatus;
b.textContent = set.size === 0 ? 'Hide all' : 'Show all';
}
}
function updateProgress() {
const total = FINDINGS.length;
const done = FINDINGS.filter(f => statusOf(f.id) !== 'open').length;
const pct = total ? Math.round(done / total * 100) : 0;
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('progress-text').textContent = done + ' / ' + total + ' resolved (' + pct + '%)';
}
// Surgical toggle: flip one card without touching the rest of the DOM.
function toggleCard(id) {
const wasCollapsed = S.collapsed.has(id);
if (wasCollapsed) S.collapsed.delete(id); else S.collapsed.add(id);
const body = document.querySelector('[data-body-id="' + id + '"]');
const arrow = document.querySelector('[data-card-id="' + id + '"] .card-arrow');
if (!body) { render(); return; }
if (wasCollapsed) {
if (!body.firstChild) {
const f = FINDING_BY_ID.get(id);
if (f) body.innerHTML = renderCardBody(f, S.search);
}
body.style.display = '';
if (arrow) arrow.textContent = '\u25bc';
} else {
body.style.display = 'none';
if (arrow) arrow.textContent = '\u25b6';
}
}
// Surgical status update: update one card's button + resolved class.
function cycleStatus(id) {
statuses[id] = STATUS_NEXT[statusOf(id)] || 'open';
saveStatuses();
const st = statusOf(id);
const btn = document.querySelector('.status-btn[data-id="' + id + '"]');
if (!btn) { render(); return; }
btn.className = 'status-btn ' + (STATUS_CLS[st] || 'st-open');
btn.textContent = STATUS_LBL[st] || '\u25cf open';
const card = document.querySelector('[data-card-id="' + id + '"]');
if (card) card.classList.toggle('resolved', st !== 'open');
updateProgress();
}
// Surgical collapse/expand for a group: toggle all bodies in the group without full re-render.
function collapseGroup(key, collapse) {
FINDINGS.forEach(f => {
if (getKey(f) !== key) return;
if (collapse) S.collapsed.add(f.id); else S.collapsed.delete(f.id);
const body = document.querySelector('[data-body-id="' + f.id + '"]');
const arrow = document.querySelector('[data-card-id="' + f.id + '"] .card-arrow');
if (!body) return;
if (collapse) {
body.style.display = 'none';
if (arrow) arrow.textContent = '\u25b6';
} else {
if (!body.firstChild) {
const ff = FINDING_BY_ID.get(f.id);
if (ff) body.innerHTML = renderCardBody(ff, S.search);
}
body.style.display = '';
if (arrow) arrow.textContent = '\u25bc';
}
});
}
// Full re-render: used for filter/group-by/search changes.
// Batched via requestAnimationFrame.
let _rafPending = false;
function scheduleRender() {
if (_rafPending) return;
_rafPending = true;
requestAnimationFrame(() => { _rafPending = false; render(); });
}
function render() {
_hay.clear();
updateToggles();
updateProgress();
const q = S.search;
const visible = FINDINGS.filter(matches);
document.getElementById('stats').textContent = visible.length + ' / ' + FINDINGS.length + ' findings';
// Build groups
const map = new Map();
for (const f of visible) {
const k = getKey(f);
if (!map.has(k)) map.set(k, []);
map.get(k).push(f);
}
// Hidden-groups bar
const hbar = document.getElementById('hidden-bar');
if (S.hiddenGroup.size > 0) {
let hh = '<div class="hidden-inner"><span class="hidden-lbl">Hidden:</span>';
for (const g of [...S.hiddenGroup].sort())
hh += '<button class="show-btn" data-action="show-group" data-key="' + esc(g) + '">' + esc(g || '(all findings)') + '</button>';
hh += '<button class="show-all-btn" data-action="show-all">Show all</button></div>';
hbar.innerHTML = hh;
} else {
hbar.innerHTML = '';
}
// Content
const content = document.getElementById('content');
if (!visible.length) {
content.innerHTML = '<div class="no-results">No findings match the current filters.</div>';
syncHash();
return;
}
let hh = '';
for (const k of sortKeys([...map.keys()])) {
if (S.hiddenGroup.has(k)) continue;
const items = map.get(k);
const title = k || 'All Findings';
const done = items.filter(f => statusOf(f.id) !== 'open').length;
const allDone = done === items.length;
hh += '<div class="group-section">' +
'<div class="group-header">' +
'<span class="group-title' + (allDone ? ' all-done' : '') + '">' + esc(title) + '</span>' +
'<span class="group-count">' + done + '/' + items.length + '</span>' +
'<button class="icon-btn" data-action="collapse-group" data-key="' + esc(k) + '" title="Collapse all">\u229f</button>' +
'<button class="icon-btn" data-action="expand-group" data-key="' + esc(k) + '" title="Expand all">\u229e</button>' +
'<button class="hide-btn" data-action="hide-group" data-key="' + esc(k) + '">Hide</button>' +
'</div>';
for (const f of items) hh += renderCard(f, q);
hh += '</div>';
}
content.innerHTML = hh;
syncHash();
}
// Event delegation
document.getElementById('filterbar').addEventListener('click', e => {
const b = e.target.closest('[data-sev],[data-type],[data-status],[data-toggle-all],[data-easy]');
if (!b) return;
if (b.dataset.easy) {
S.easyOnly = !S.easyOnly;
}
else if (b.dataset.toggleAll) {
const k = b.dataset.toggleAll;
const set = k === 'sev' ? S.hiddenSev : k === 'type' ? S.hiddenType : S.hiddenStatus;
const full = k === 'sev' ? PRESENT_SEVS : k === 'type' ? ALL_TYPES : STATUSES;
if (set.size === 0) full.forEach(v => set.add(v));
else set.clear();
}
else if (b.dataset.sev) { const s = S.hiddenSev; s.has(b.dataset.sev) ? s.delete(b.dataset.sev) : s.add(b.dataset.sev); }
else if (b.dataset.type) { const s = S.hiddenType; s.has(b.dataset.type) ? s.delete(b.dataset.type) : s.add(b.dataset.type); }
else if (b.dataset.status) { const s = S.hiddenStatus; s.has(b.dataset.status) ? s.delete(b.dataset.status) : s.add(b.dataset.status); }
scheduleRender();
});
document.addEventListener('click', e => {
const b = e.target.closest('[data-action]');
if (!b) return;
const { action, key, id } = b.dataset;
if (action === 'hide-group') { S.hiddenGroup.add(key); scheduleRender(); }
else if (action === 'show-group') { S.hiddenGroup.delete(key); scheduleRender(); }
else if (action === 'show-all') { S.hiddenGroup.clear(); scheduleRender(); }
else if (action === 'collapse-all') { FINDINGS.forEach(f => S.collapsed.add(f.id)); scheduleRender(); }
else if (action === 'expand-all') { S.collapsed.clear(); scheduleRender(); }
else if (action === 'copy-md') {
e.stopPropagation();
const f = FINDING_BY_ID.get(id);
if (f) copyText(findingToMd(f), b);
}
else if (action === 'download-md') { downloadText(buildMdFull(), 'findings.md'); }
else if (action === 'download-md-short') { downloadText(buildMdShort(), 'findings-short.md'); }
else if (action === 'download-toml') {
downloadText(buildFindingsToml(), 'findings.toml', 'application/toml');
downloadText(buildProjectInfoToml(), 'project-info.toml', 'application/toml');
}
else if (action === 'cycle-status') {
e.stopPropagation();
cycleStatus(id);
}
else if (action === 'toggle-card') {
toggleCard(id);
}
else if (action === 'collapse-group') {
collapseGroup(key, true);
}
else if (action === 'expand-group') {
collapseGroup(key, false);
}
});
// Debounced search — 180 ms after last keystroke
let _searchTimer = 0;
document.getElementById('search').addEventListener('input', e => {
S.search = e.target.value;
clearTimeout(_searchTimer);
_searchTimer = setTimeout(scheduleRender, 180);
});
document.getElementById('groupby').addEventListener('change', e => {
S.groupBy = e.target.value;
S.hiddenGroup.clear();
scheduleRender();
});
// Init
loadStatuses();
initFilterBar();
loadHash();
FINDINGS.forEach(f => S.collapsed.add(f.id));
render();
})();
</script>
</body></html>