Repository navigation
Expand file tree
/
Copy pathactix-web_20260914.html
More file actions
972 lines (909 loc) · 186 KB
/
Copy pathactix-web_20260914.html
File metadata and controls
972 lines (909 loc) · 186 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
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Code Audit — Interactive</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{color-scheme:dark}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e5e7eb;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:#6b7280;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:#d1d5db;margin-top:.25rem}
.project-info .pi-row{display:flex;flex-wrap:wrap;gap:.4rem .9rem;margin-top:.45rem;font-size:.78rem;color:#9ca3af}
.project-info .pi-row .pi-k{color:#6b7280}
.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:#e5e7eb;padding:.4rem .75rem;border-radius:6px;font-size:.875rem;width:220px;outline:none}
#search:focus{border-color:#3b82f6}
.group-label{font-size:.8rem;color:#9ca3af;display:flex;align-items:center;gap:.4rem}
select{background:#1e293b;border:1px solid #334155;color:#e5e7eb;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:#4b5563;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:#9ca3af;cursor:pointer;margin-left:auto;white-space:nowrap;letter-spacing:.04em}
.filter-toggle:hover{background:#1e293b;color:#e5e7eb;border-color:#4b5563}
.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:#6b7280;border-color:#334155}
.type-badge{background:#0c1e33;color:#7dd3fc;border-color:#0369a1}
.diff-very-easy{background:#052e16;color:#86efac;border-color:#16a34a}
.diff-easy{background:#064e3b;color:#6ee7b7;border-color:#059669}
.diff-medium{background:#1e293b;color:#9ca3af;border-color:#4b5563}
.diff-hard{background:#2e1065;color:#c4b5fd;border-color:#7c3aed}
.st-open{background:#1e293b;color:#4b5563;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:#6b7280;white-space:nowrap}
.action-btn{font-size:.72rem;padding:.25rem .6rem;border-radius:5px;border:1px solid #334155;background:#1e293b;color:#9ca3af;cursor:pointer;white-space:nowrap}
.action-btn:hover{background:#334155;color:#e5e7eb}
.action-btn.active{background:#1d4ed8;color:#fff;border-color:#2563eb}
.action-btn.active:hover{background:#2563eb}
.search-wrap{display:flex;align-items:center;gap:.35rem}
#hidden-bar{margin-bottom:.6rem}
.hidden-inner{display:flex;flex-wrap:wrap;gap:.35rem;align-items:center}
.hidden-lbl{font-size:.72rem;color:#4b5563}
.show-btn{font-size:.7rem;padding:.15rem .45rem;border-radius:4px;background:#1e293b;color:#9ca3af;border:1px solid #334155;cursor:pointer}
.show-btn:hover{background:#334155;color:#e5e7eb}
.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:#d1d5db}
.group-title.all-done{color:#4ade80}
.group-count{font-size:.72rem;color:#4b5563;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:#6b7280}
.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:#6b7280}
.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:#6b7280;font-family:monospace;letter-spacing:.04em}
.card-loc{font-size:.72rem;color:#6b7280;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:#9ca3af;cursor:pointer;white-space:nowrap;transition:background .12s,color .12s}
.copy-btn:hover{background:#334155;color:#e5e7eb}
.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:#d1d5db;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}
a.loc-link{color:inherit;text-decoration:none}
a.loc-link:hover{text-decoration:underline}
.loc-lines{color:#4b5563;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:#e5e7eb;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","difficulty":"medium","description":"The `rustls-*` (and `openssl`) TLS finalizers unconditionally advertise HTTP/2 via ALPN (`let mut protos = vec![b\"h2\".to_vec(), b\"http/1.1\".to_vec()];`) and set `proto = Protocol::Http2` whenever the peer negotiates `h2`, but none of this is gated on the `http2` cargo feature. The features are independent (`default = []`, `rustls-0_23 = [\"__tls\", ...]` does not pull in `http2`). When `actix-http` is built with a rustls/openssl feature but without `http2`, the `call()` handler hits `#[cfg(not(feature = \"http2\"))] Protocol::Http2 => panic!(\"HTTP/2 support is disabled ...\")`. A remote client that selects `h2` in the TLS handshake therefore crashes the worker: a remote-triggerable panic / DoS on a buildable feature combination.","fix":"Only advertise `h2` in ALPN and only classify a connection as `Protocol::Http2` when the `http2` feature is enabled. Gate the `vec![b\"h2\".to_vec(), ...]` push and the `alpn_protocol()` -> `Protocol::Http2` branch behind `#[cfg(feature = \"http2\")]`, so a server without HTTP/2 support never negotiates `h2` and the `panic!` branch becomes unreachable.","locations":[{"ref":"actix-http/src/service.rs:966-969","code":" 966 | #[cfg(not(feature = \"http2\"))]\n 967 | Protocol::Http2 => {\n 968 | panic!(\"HTTP/2 support is disabled (enable with the `http2` feature flag)\")\n 969 | }","lines":4,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/service.rs#L966-L969"},{"ref":"actix-http/src/service.rs:747-749","code":" 747 | let mut protos = vec![b\"h2\".to_vec(), b\"http/1.1\".to_vec()];\n 748 | protos.extend_from_slice(&config.alpn_protocols);\n 749 | config.alpn_protocols = protos;","lines":3,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/service.rs#L747-L749"},{"ref":"actix-http/src/service.rs:763-771","code":" 763 | let proto = if let Some(protos) = io.get_ref().1.alpn_protocol() {\n 764 | if protos.windows(2).any(|window| window == b\"h2\") {\n 765 | Protocol::Http2\n 766 | } else {\n 767 | Protocol::Http1\n 768 | }\n 769 | } else {\n 770 | Protocol::Http1\n 771 | };","lines":9,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/service.rs#L763-L771"}]},
{"id":"PANIC_2","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"`Format::new` panics via `unreachable!(\"regex and code mismatch\")` for any format string of the shape `%{KEY}a` where `KEY` is not exactly `r`. The regex `%(\\{([A-Za-z0-9\\-_]+)\\}([aioe]|x[io])|[%atPrUsbTD]?)` accepts *any* `[A-Za-z0-9\\-_]+` key followed by `a`, but the match arm for group `\"a\"` only accepts `key == \"r\"` (the `%{r}a` real-IP directive) and hits `unreachable!()` otherwise. So `Logger::new(\"%{x}a\")` or even `Logger::new(\"%{R}a\")` (wrong case) panics at construction time. The `unreachable!` comment is therefore also wrong: the regex and code genuinely disagree.","fix":"Either tighten the regex so only `r` is accepted before `a` (e.g. split the alternation so `a` requires key `r`), or replace the `unreachable!` with graceful handling: treat an unrecognized `%{KEY}a` as a literal `FormatText::Str` (or return the raw match text) instead of panicking. For example, change the `else` branch from `unreachable!(\"regex and code mismatch\")` to `FormatText::Str(m.as_str().to_owned())`.","locations":[{"ref":"actix-web/src/middleware/logger.rs:515-522","code":" 515 | results.push(match cap.get(3).unwrap().as_str() {\n 516 | \"a\" => {\n 517 | if key.as_str() == \"r\" {\n 518 | FormatText::RealIpRemoteAddr\n 519 | } else {\n 520 | unreachable!(\"regex and code mismatch\")\n 521 | }\n 522 | }","lines":8,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/middleware/logger.rs#L515-L522"},{"ref":"actix-web/src/middleware/logger.rs:502","code":" 502 | let fmt = Regex::new(r\"%(\\{([A-Za-z0-9\\-_]+)\\}([aioe]|x[io])|[%atPrUsbTD]?)\").unwrap();","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/middleware/logger.rs#L502"}]},
{"id":"PANIC_3","type":"panic","type_label":"Panic","severity":"low","difficulty":"medium","description":"`HeaderName::from_bytes(&slice[idx.name.0..idx.name.1]).unwrap()` unwraps on parsed header-name bytes. It relies on the assumption that every token `httparse` accepts as a header name is also accepted by `http::HeaderName::from_bytes`. If the two crates' token tables ever diverge (they have differed historically at edges), this becomes a reachable panic driven directly by untrusted request bytes. Unlike the `HeaderValue` construction just below, there is no `SAFETY`/rationale comment justifying the unwrap.","fix":"Map the error to `ParseError::Header` instead of unwrapping: `let name = HeaderName::from_bytes(...).map_err(|_| ParseError::Header)?;`.","locations":[{"ref":"actix-http/src/h1/decoder.rs:91-92","code":" 91 | for idx in raw_headers.iter() {\n 92 | let name = HeaderName::from_bytes(&slice[idx.name.0..idx.name.1]).unwrap();","lines":2,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/h1/decoder.rs#L91-L92"}]},
{"id":"PANIC_4","type":"panic","type_label":"Panic","severity":"low","difficulty":"easy","description":"`handshake_response` calls `req.headers().get(header::SEC_WEBSOCKET_KEY).unwrap()`. This is only safe when preceded by `verify_handshake` (as in `handshake()`), but `handshake_response` is a public function and can be called directly on a request without the key header, causing a panic. The `.unwrap()` relies on an unenforced precondition.","fix":"Have `handshake_response` return a `Result` (or take the already-validated key as a parameter), or document the precondition and make the function private if it must only be reached via `handshake`.","locations":[{"ref":"actix-http/src/ws/mod.rs:220-224","code":" 220 | pub fn handshake_response(req: &RequestHead) -> ResponseBuilder {\n 221 | let key = {\n 222 | let key = req.headers().get(header::SEC_WEBSOCKET_KEY).unwrap();\n 223 | proto::hash_key(key.as_ref())\n 224 | };","lines":5,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/ws/mod.rs#L220-L224"}]},
{"id":"PANIC_5","type":"panic","type_label":"Panic","severity":"low","difficulty":"medium","description":"`Field`'s `Stream::poll_next` calls `inner.payload.as_ref().expect(\"Field should not be polled after completion\")` with no guard. `InnerField::payload` is set to `None` (`self.payload.take()`) once the field stream finishes, so polling the `Field` stream again after it has yielded `None` panics instead of returning `Poll::Ready(None)`. Polling a completed stream is a documented no-panic expectation for many combinators; `InnerField::poll` itself guards this with an `if self.payload.is_none()` check, but the outer `Field::poll_next` does not.","fix":"In `Field::poll_next`, return `Poll::Ready(None)` when `inner.payload.is_none()` (mirroring the guard at the top of `InnerField::poll`) instead of `expect`-ing on the taken payload.","locations":[{"ref":"actix-multipart/src/field.rs:174-178","code":" 174 | if let Some(mut buffer) = inner\n 175 | .payload\n 176 | .as_ref()\n 177 | .expect(\"Field should not be polled after completion\")\n 178 | .get_mut(&this.safety)","lines":5,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-multipart/src/field.rs#L174-L178"}]},
{"id":"PANIC_6","type":"panic","type_label":"Panic","severity":"low","difficulty":"easy","description":"`Connector::limit(0)` sets `config.limit = u32::MAX as usize` to mean unlimited. This value is later passed to `Semaphore::new(config.limit)` in `ConnectionPoolInner::new`. On 32-bit targets `u32::MAX` (4294967295) exceeds tokio's `Semaphore::MAX_PERMITS` (`usize::MAX >> 3`, ~536M on 32-bit), so `Semaphore::new` panics. Thus `Connector::new().limit(0).finish()` panics at build time on 32-bit platforms.","fix":"Cap the unlimited sentinel to a value valid on all targets, e.g. use `Semaphore::MAX_PERMITS` (or a large but safe constant) instead of `u32::MAX as usize`.","locations":[{"ref":"awc/src/client/connector.rs:371-378","code":" 371 | pub fn limit(mut self, limit: usize) -> Self {\n 372 | if limit == 0 {\n 373 | self.config.limit = u32::MAX as usize;\n 374 | } else {\n 375 | self.config.limit = limit;\n 376 | }\n 377 | \n 378 | self","lines":8,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/client/connector.rs#L371-L378"},{"ref":"awc/src/client/pool.rs:64-65","code":" 64 | fn new(config: ConnectorConfig) -> Self {\n 65 | let permits = Arc::new(Semaphore::new(config.limit));","lines":2,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/client/pool.rs#L64-L65"}]},
{"id":"PANIC_7","type":"panic","type_label":"Panic","severity":"low","difficulty":"easy","description":"`build_next_uri` calls `prev_uri.scheme().cloned().unwrap()`, `prev_uri.authority().cloned().unwrap()`, and `prev_uri.scheme_str().unwrap()` when the `Location` value is relative or scheme-relative. These `unwrap`s panic if `prev_uri` lacks a scheme or authority. While the initial request URI is validated, a redirect chain feeds each `next_uri` back as the next `prev_uri`, and a malformed `Location` parsed via `Uri::try_from(...).unwrap_or_else(|_| Uri::default())` can yield a URI without these components, making a panic reachable from server-controlled input.","fix":"Replace the `unwrap`s with error propagation, e.g. map missing scheme/authority to `SendRequestError::Url(InvalidUrl::MissingScheme)` / `InvalidUrl::MissingHost` instead of panicking.","locations":[{"ref":"awc/src/middleware/redirect.rs:256-261","code":" 256 | .scheme(prev_uri.scheme().cloned().unwrap())\n 257 | .authority(prev_uri.authority().cloned().unwrap());\n 258 | \n 259 | // scheme-relative address\n 260 | if location.as_bytes().starts_with(b\"//\") {\n 261 | let scheme = prev_uri.scheme_str().unwrap();","lines":6,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/middleware/redirect.rs#L256-L261"}]},
{"id":"PANIC_8","type":"panic","type_label":"Panic","severity":"low","difficulty":"medium","description":"Both `Future::poll` impls for `SendClientRequest` panic with `\"Attempting to call completed future\"` when the `Err` variant is polled after it already yielded its error (the `Option` is `None`). While polling a fused-completed future is generally a caller error, this converts a benign double-poll (e.g. by a combinator that is not aware the future completed) into a hard panic instead of `Poll::Pending` or a clearer contract.","fix":"Either document `SendClientRequest` as not fused and keep the panic intentionally, or return `Poll::Pending` / integrate with `FusedFuture` so a post-completion poll does not abort the process.","locations":[{"ref":"awc/src/sender.rs:125-128","code":" 125 | SendClientRequest::Err(ref mut err) => match err.take() {\n 126 | Some(err) => Poll::Ready(Err(err)),\n 127 | None => panic!(\"Attempting to call completed future\"),\n 128 | },","lines":4,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/sender.rs#L125-L128"},{"ref":"awc/src/sender.rs:150-153","code":" 150 | SendClientRequest::Err(ref mut err) => match err.take() {\n 151 | Some(err) => Poll::Ready(Err(err)),\n 152 | None => panic!(\"Attempting to call completed future\"),\n 153 | },","lines":4,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/sender.rs#L150-L153"}]},
{"id":"PANIC_9","type":"panic","type_label":"Panic","severity":"low","difficulty":"medium","description":"During `connect`, several `HeaderValue` conversions derived from user-controlled input are unwrapped and will panic instead of returning a `WsClientError`. `HeaderValue::try_from(protocols.as_str()).unwrap()` panics if a protocol string contains bytes illegal in a header value, and `HeaderValue::from_str(&Host{..}.to_string()).unwrap()` / `HeaderValue::from_str(&cookie).unwrap()` can panic on an unusual host or cookie. A malformed protocol/host thus aborts the task rather than yielding an error.","fix":"Convert the `unwrap()` calls into fallible handling that stores the conversion error into `self.err` (or returns a `WsClientError`/`HttpError`) instead of panicking, mirroring how `origin` and `header` already record errors.","locations":[{"ref":"awc/src/ws.rs:293","code":" 293 | HeaderValue::from_str(&Host { hostname, port }.to_string()).unwrap(),","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/ws.rs#L293"},{"ref":"awc/src/ws.rs:331-335","code":" 331 | if let Some(protocols) = self.protocols.take() {\n 332 | self.head.headers.insert(\n 333 | header::SEC_WEBSOCKET_PROTOCOL,\n 334 | HeaderValue::try_from(protocols.as_str()).unwrap(),\n 335 | );","lines":5,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/ws.rs#L331-L335"},{"ref":"awc/src/ws.rs:310","code":" 310 | .insert(header::COOKIE, HeaderValue::from_str(&cookie).unwrap());","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/ws.rs#L310"}]},
{"id":"MEM_1","type":"memory_leak","type_label":"Memory Leak","severity":"medium","difficulty":"medium","description":"The connection pool's `available` map is keyed by `Authority` and entries are inserted via `entry(key).or_insert_with(VecDeque::new)` in `Acquired::release`, but empty `VecDeque`s are never removed. In `ConnectionPool::call`, when the last idle connection for a key is popped/evicted the now-empty `VecDeque` is left in the map. A client contacting many distinct authorities (e.g. a crawler) accumulates one permanent empty-`VecDeque` entry per unique authority for the lifetime of the pool, an unbounded `HashMap` growth.","fix":"After draining connections in `call`, remove the map entry when its `VecDeque` becomes empty (e.g. `if conns.is_empty() { map.remove(&key); }`), or use the entry API to prune empty deques on release.","locations":[{"ref":"awc/src/client/pool.rs:193-227","code":" 193 | if let Some(conns) = map.get_mut(&key) {\n 194 | let now = Instant::now();\n 195 | \n 196 | while let Some(mut c) = conns.pop_front() {\n 197 | let config = &inner.config;\n 198 | let idle_dur = now - c.used;\n 199 | let age = now - c.created;\n 200 | let conn_ineligible =\n 201 | idle_dur > config.conn_keep_alive || age > config.conn_lifetime;\n 202 | \n 203 | if conn_ineligible {\n 204 | // drop connections that are too old\n 205 | inner.close(c.conn);\n 206 | } else {\n 207 | // check if the connection is still usable\n 208 | if let ConnectionInnerType::H1(ref mut io) = c.conn {\n 209 | let check = ConnectionCheckFuture { io };\n 210 | match check.now_or_never().expect(\n 211 | \"ConnectionCheckFuture must never yield with Poll::Pending.\",\n 212 | ) {\n 213 | ConnectionState::Tainted => {\n 214 | inner.close(c.conn);\n 215 | continue;\n 216 | }\n 217 | ConnectionState::Skip => continue,\n 218 | ConnectionState::Live => conn = Some(c),\n 219 | }\n 220 | } else {\n 221 | conn = Some(c);\n 222 | }\n 223 | \n 224 | break;\n 225 | }\n 226 | }\n 227 | };","lines":35,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/client/pool.rs#L193-L227"},{"ref":"awc/src/client/pool.rs:364-373","code":" 364 | inner\n 365 | .available\n 366 | .borrow_mut()\n 367 | .entry(key.clone())\n 368 | .or_insert_with(VecDeque::new)\n 369 | .push_back(PooledConnection {\n 370 | conn,\n 371 | created,\n 372 | used: Instant::now(),\n 373 | });","lines":10,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/client/pool.rs#L364-L373"}]},
{"id":"OVF_1","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"medium","difficulty":"medium","description":"Dynamic path segment offsets are stored as `u16` (`PathItem::Segment(u16, u16)`) and the processing cursor `skip` is a `u16`. `Path::skip` does `self.skip += n` and `Path::add` computes `self.skip + begin` / `self.skip + end` with unchecked `u16` arithmetic. For a request path longer than 65535 bytes these additions overflow: in debug builds they panic (DoS), and in release builds they silently wrap, producing bogus start/end indices. The corrupted indices are later used for slicing in `Path::get`, `Path::index`, and `PathIter::next` via `&self.path.path()[(start as usize)..(end as usize)]`, which can panic with an out-of-bounds range or a non-char-boundary slice on attacker-controlled input. `actix-router` imposes no path-length limit of its own.","fix":"Store segment offsets and `skip` as `usize` (or `u32`) so realistic path lengths cannot overflow, or explicitly reject/clamp paths longer than the `u16` range before parsing. At minimum, use `checked_add`/`saturating_add` in `skip` and `add` and guard the slicing sites so malformed indices cannot panic.","locations":[{"ref":"actix-router/src/path.rs:13","code":" 13 | Segment(u16, u16),","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/path.rs#L13"},{"ref":"actix-router/src/path.rs:145","code":" 145 | self.skip += n;","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/path.rs#L145"},{"ref":"actix-router/src/path.rs:153","code":" 153 | PathItem::Segment(self.skip + begin, self.skip + end),","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/path.rs#L153"},{"ref":"actix-router/src/path.rs:187","code":" 187 | Some(&self.path.path()[(*start as usize)..(*end as usize)])","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/path.rs#L187"},{"ref":"actix-router/src/path.rs:247","code":" 247 | &self.params.path.path()[(start as usize)..(end as usize)]","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/path.rs#L247"},{"ref":"actix-router/src/path.rs:272","code":" 272 | PathItem::Segment(start, end) => &self.path.path()[(start as usize)..(end as usize)],","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/path.rs#L272"}]},
{"id":"OVF_2","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","difficulty":"easy","description":"In `parse_metadata`, the 64-bit extended length (`len == 127`) is truncated with `len as usize`. On 32-bit targets a declared length between 2^32 and 2^64 is silently truncated (e.g. 2^32 becomes 0, 2^32+4 becomes 4). The subsequent `idx.checked_add(length)` overflow guard operates on the already-truncated value, so it does not catch this; the parser then consumes the 8 length bytes but reads a wrong (small) payload size, desynchronising the frame stream. 64-bit targets are unaffected.","fix":"Reject lengths that do not fit in `usize` before casting, e.g. `let len = usize::try_from(u64::from_be_bytes(...)).map_err(|_| ProtocolError::Overflow)?;` (also consider enforcing that the top bit is zero and that the value is minimally encoded per RFC 6455).","locations":[{"ref":"actix-http/src/ws/frame.rs:66-72","code":" 66 | } else if len == 127 {\n 67 | if chunk_len < 10 {\n 68 | return Ok(None);\n 69 | }\n 70 | let len = u64::from_be_bytes(TryFrom::try_from(&src[idx..idx + 8]).unwrap());\n 71 | idx += 8;\n 72 | len as usize","lines":7,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/ws/frame.rs#L66-L72"}]},
{"id":"OVF_3","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","difficulty":"medium","description":"In `capture_match_info_fn`, regex match offsets and the matched length are cast from `usize` to `u16` (`m.start() as u16`, `m.end() as u16`, `matched_len as u16`). For request paths longer than 65535 bytes these casts silently truncate (wrap), producing wrong segment boundaries and a wrong `skip` length, which corrupts extracted path parameters and downstream matching. `PathItem` and `Path::skip` are `u16`-based, so nothing rejects such paths at this layer.","fix":"Reject (or saturate and bail out returning `false`) when any offset or `matched_len` exceeds `u16::MAX` before casting, e.g. `if m.end() > u16::MAX as usize { return false; }`, or widen `PathItem`/`skip` to `u32`.","locations":[{"ref":"actix-router/src/resource.rs:700","code":" 700 | segments[no] = PathItem::Segment(m.start() as u16, m.end() as u16);","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/resource.rs#L700"},{"ref":"actix-router/src/resource.rs:724","code":" 724 | segments[no] = PathItem::Segment(m.start() as u16, m.end() as u16);","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/resource.rs#L724"},{"ref":"actix-router/src/resource.rs:748","code":" 748 | path.skip(matched_len as u16);","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/resource.rs#L748"}]},
{"id":"LOGIC_1","type":"logic_error","type_label":"Logic Error","severity":"high","difficulty":"medium","description":"The directory listing generates the `href` attribute by percent-encoding the file URL with only the `CONTROLS` ASCII set (`utf8_percent_encode(&$path, CONTROLS)` in the `encode_file_url!` macro). `CONTROLS` covers only C0 control characters and `DEL`; it does NOT encode `\"`, `<`, `>` or backtick. The encoded value is then written directly inside a double-quoted attribute: `<li><a href=\"{}\">...`. A filesystem entry whose name contains a `\"` (legal on Linux, and attacker-controllable where uploads or user-named files exist) terminates the attribute and injects arbitrary HTML/attributes, i.e. stored XSS in the generated listing. Note the visible link text is correctly escaped via `escape_fmt` (`encode_file_name!`), so only the `href` is vulnerable. Reachable when `Files::show_files_listing()` is enabled.","fix":"Percent-encode the URL path with a set that also escapes HTML/attribute-significant characters. Extend the set used by `encode_file_url!`, e.g. `const HREF_ENCODE_SET: &AsciiSet = &CONTROLS.add(b'\"').add(b'\\'').add(b'<').add(b'>').add(b'`').add(b' ');` (or a stricter path-segment set), and use it in the macro instead of bare `CONTROLS`.","locations":[{"ref":"actix-files/src/directory.rs:49-53","code":" 49 | macro_rules! encode_file_url {\n 50 | ($path:ident) => {\n 51 | utf8_percent_encode(&$path, CONTROLS)\n 52 | };\n 53 | }","lines":5,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-files/src/directory.rs#L49-L53"},{"ref":"actix-files/src/directory.rs:91-104","code":" 91 | let _ = write!(\n 92 | body,\n 93 | \"<li><a href=\\\"{}\\\">{}/<\/a><\/li>\",\n 94 | encode_file_url!(p),\n 95 | encode_file_name!(entry),\n 96 | );\n 97 | } else {\n 98 | let _ = write!(\n 99 | body,\n 100 | \"<li><a href=\\\"{}\\\">{}<\/a><\/li>\",\n 101 | encode_file_url!(p),\n 102 | encode_file_name!(entry),\n 103 | );\n 104 | }","lines":14,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-files/src/directory.rs#L91-L104"}]},
{"id":"LOGIC_2","type":"logic_error","type_label":"Logic Error","severity":"high","difficulty":"easy","description":"In `Readlines::poll_next`, when a payload chunk contains no newline the code appends it to `this.buf` and returns `Poll::Pending` after the inner stream returned `Poll::Ready(Some(..))`. Consuming a `Ready` item from the inner stream and then returning `Pending` without a registered waker violates the `Future`/`Stream` contract: the inner `Payload` did not store a waker (it returned data), so nothing guarantees the task is polled again. Any logical line that spans more than one payload chunk (large lines, slow/streamed network payloads) can hang the `Readlines` stream indefinitely. The unit test only passes because it manually re-polls.","fix":"Wrap the stream-polling section in a `loop` so that after `this.buf.extend_from_slice(&bytes)` with no newline found, control loops back to `ready!(Pin::new(&mut this.stream).poll_next(cx))` instead of returning `Poll::Pending`. Only return `Pending` when the inner stream itself returns `Pending` (which `ready!` already does).","locations":[{"ref":"actix-web/src/types/readlines.rs:147-148","code":" 147 | this.buf.extend_from_slice(&bytes);\n 148 | Poll::Pending","lines":2,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/types/readlines.rs#L147-L148"}]},
{"id":"LOGIC_3","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"hard","description":"The chunked decoder does not support trailer fields. After the last chunk `0`, `read_size_lf` transitions directly to `EndCr`, which requires the next bytes to be exactly `\\r\\n`. A valid chunked request that includes trailers (e.g. `0\\r\\nFoo: bar\\r\\n\\r\\n`) hits `read_end_cr`, sees `F` instead of `\\r`, and fails with `\"Invalid chunk end CR\"`, rejecting a spec-conformant message. RFC 7230 permits a trailer section after the last chunk.","fix":"Add trailer-parsing states between the last-chunk `\\r\\n` and end-of-stream that consume (and optionally expose or discard) trailer header lines until the terminating empty line, instead of requiring `\\r\\n` immediately after `0`.","locations":[{"ref":"actix-http/src/h1/chunked.rs:112-121","code":" 112 | fn read_size_lf(rdr: &mut BytesMut, size: u64) -> Poll<Result<ChunkedState, io::Error>> {\n 113 | match byte!(rdr) {\n 114 | b'\\n' if size > 0 => Poll::Ready(Ok(ChunkedState::Body)),\n 115 | b'\\n' if size == 0 => Poll::Ready(Ok(ChunkedState::EndCr)),\n 116 | _ => Poll::Ready(Err(io::Error::new(\n 117 | io::ErrorKind::InvalidInput,\n 118 | \"Invalid chunk size LF\",\n 119 | ))),\n 120 | }\n 121 | }","lines":10,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/h1/chunked.rs#L112-L121"},{"ref":"actix-http/src/h1/chunked.rs:169-186","code":" 169 | fn read_end_cr(rdr: &mut BytesMut) -> Poll<Result<ChunkedState, io::Error>> {\n 170 | match byte!(rdr) {\n 171 | b'\\r' => Poll::Ready(Ok(ChunkedState::EndLf)),\n 172 | _ => Poll::Ready(Err(io::Error::new(\n 173 | io::ErrorKind::InvalidInput,\n 174 | \"Invalid chunk end CR\",\n 175 | ))),\n 176 | }\n 177 | }\n 178 | fn read_end_lf(rdr: &mut BytesMut) -> Poll<Result<ChunkedState, io::Error>> {\n 179 | match byte!(rdr) {\n 180 | b'\\n' => Poll::Ready(Ok(ChunkedState::End)),\n 181 | _ => Poll::Ready(Err(io::Error::new(\n 182 | io::ErrorKind::InvalidInput,\n 183 | \"Invalid chunk end LF\",\n 184 | ))),\n 185 | }\n 186 | }","lines":18,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/h1/chunked.rs#L169-L186"}]},
{"id":"LOGIC_4","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"medium","description":"The `Connection` header parser matches the whole trimmed header value against the exact tokens `keep-alive`/`close`/`upgrade`, instead of splitting the value into comma-separated tokens. A conformant request such as `Connection: upgrade, http2-settings` therefore does not set `ConnectionType::Upgrade` (it falls through to `None`), so the connection is treated as keep-alive rather than upgrade. This limitation is even acknowledged by the `test_http_request_upgrade_h2c` test in the same crate.","fix":"Split the header value on `,` and `eq_ignore_ascii_case`-compare each trimmed token, treating the connection as upgrade/close/keep-alive if any matching token is present (close taking precedence).","locations":[{"ref":"actix-http/src/h1/decoder.rs:153-167","code":" 153 | header::CONNECTION => {\n 154 | ka = if let Ok(conn) = value.to_str().map(str::trim) {\n 155 | if conn.eq_ignore_ascii_case(\"keep-alive\") {\n 156 | Some(ConnectionType::KeepAlive)\n 157 | } else if conn.eq_ignore_ascii_case(\"close\") {\n 158 | Some(ConnectionType::Close)\n 159 | } else if conn.eq_ignore_ascii_case(\"upgrade\") {\n 160 | Some(ConnectionType::Upgrade)\n 161 | } else {\n 162 | None\n 163 | }\n 164 | } else {\n 165 | None\n 166 | };\n 167 | }","lines":15,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/h1/decoder.rs#L153-L167"}]},
{"id":"LOGIC_5","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"easy","description":"`TestServer::request()` passes the raw `path` straight to `self.client.request(method, path.as_ref())`, unlike every sibling helper (`get`, `post`, `head`, `put`, `patch`, `delete`, `options`) which first calls `self.url(path.as_ref())` to build a fully-qualified URL (scheme + `self.addr` + path). As a result, calling `srv.request(Method::GET, \"/foo\")` sends a request with a bare path and no host/port, so it never reaches the test server (awc will fail or misroute it). This breaks the documented contract \"Connect request with given method and path.\"","fix":"Build the full URL like the other helpers: `self.client.request(method, self.url(path.as_ref()).as_str())` instead of `self.client.request(method, path.as_ref())`.","locations":[{"ref":"actix-test/src/lib.rs:711-713","code":" 711 | pub fn request(&self, method: Method, path: impl AsRef<str>) -> ClientRequest {\n 712 | self.client.request(method, path.as_ref())\n 713 | }","lines":3,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-test/src/lib.rs#L711-L713"}]},
{"id":"LOGIC_6","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"easy","description":"The `quoted-string` escaping regex in `impl fmt::Display for DispositionParam` is `[\\x00-\\x08\\x10-\\x1F\\x7F\"\\\\]`, which skips the byte range `\\x09`-`\\x0F`. The adjacent comment explicitly states that all ASCII control characters (0-31 and DEL) should be escaped, yet `\\x0B` (VT), `\\x0C` (FF), `\\x0E` (SO), and `\\x0F` (SI) are never escaped when serializing a `Filename`/`Unknown` value. Because `HeaderValue::from_maybe_shared` rejects raw control bytes, a filename containing any of these characters will fail to serialize (`try_into_value` returns `Err`) instead of being safely escaped. The gap between `\\x08` and `\\x10` is almost certainly a hex typo (`\\x10` where `\\x0E` was likely intended). Note `test_display_space_tab` asserts tab `\\x09` stays unescaped, so only the non-LWS control chars need fixing.","fix":"Escape the currently missed control characters, keeping the intentional LWS exceptions. For example change the class to `[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F\"\\\\]` (escapes VT/FF/SO/SI and 0x10-0x1F while still leaving tab `\\x09` unescaped as the existing test expects).","locations":[{"ref":"actix-web/src/http/header/content_disposition.rs:557-558","code":" 557 | static RE: Lazy<Regex> =\n 558 | Lazy::new(|| Regex::new(\"[\\x00-\\x08\\x10-\\x1F\\x7F\\\"\\\\\\\\]\").unwrap());","lines":2,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/http/header/content_disposition.rs#L557-L558"}]},
{"id":"LOGIC_7","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"easy","description":"`impl fmt::Display for DispositionParam` renders the `Name` variant as `write!(f, \"name={}\", value)` with no quoting or escaping, unlike `Filename` and `Unknown` which emit an escaped `quoted-string`. A form-data field name containing a space, `;`, `\"`, or a control character produces a malformed `Content-Disposition` value (and, for control characters, causes `try_into_value` to fail in `HeaderValue::from_maybe_shared`). This makes round-tripping such names impossible and can emit an invalid header.","fix":"Emit the name as a quoted-string with the same escaping used for `Filename`/`Unknown`, e.g. `write!(f, \"name=\\\"{}\\\"\", RE.replace_all(value, \"\\\\$0\").as_ref())`.","locations":[{"ref":"actix-web/src/http/header/content_disposition.rs:561","code":" 561 | DispositionParam::Name(ref value) => write!(f, \"name={}\", value),","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/http/header/content_disposition.rs#L561"}]},
{"id":"LOGIC_8","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"easy","description":"`CookieIter::next` uses `v.to_str().ok()?` which propagates `None` out of `next()`, permanently terminating the iterator, whenever a single `Set-Cookie` header value is not valid visible-ASCII (`to_str()` fails). A single un-stringable header value therefore silently drops all remaining cookies instead of skipping just the bad one. Note that `del_cookie` handles the same situation correctly with `if let Ok(s) = v.to_str()` and continues, so the behavior is inconsistent.","fix":"Skip the offending value instead of ending iteration. Change `if let Ok(c) = Cookie::parse_encoded(v.to_str().ok()?)` to first match `v.to_str()` and `continue` on `Err`, e.g.:\n```rust\nfor v in self.iter.by_ref() {\n let Ok(s) = v.to_str() else { continue };\n if let Ok(c) = Cookie::parse_encoded(s) {\n return Some(c);\n }\n}\nNone\n```","locations":[{"ref":"actix-web/src/response/response.rs:386-394","code":" 386 | #[inline]\n 387 | fn next(&mut self) -> Option<Cookie<'a>> {\n 388 | for v in self.iter.by_ref() {\n 389 | if let Ok(c) = Cookie::parse_encoded(v.to_str().ok()?) {\n 390 | return Some(c);\n 391 | }\n 392 | }\n 393 | None\n 394 | }","lines":9,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/response/response.rs#L386-L394"}]},
{"id":"LOGIC_9","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"easy","description":"`Query::from_request` only looks up the config via `req.app_data::<QueryConfig>()`; it does not fall back to `req.app_data::<Data<QueryConfig>>()`. Every other extractor config in this crate (`JsonConfig` at json.rs:272-276, `FormConfig` at form.rs:248-252, `PathConfig` at path.rs:96-99, `PayloadConfig` at payload.rs:323-327) checks both `T` and `Data<T>`. Consequently, a `QueryConfig` registered via `App::app_data(web::Data::new(QueryConfig...))` is silently ignored and the custom error handler never runs.","fix":"Mirror the other extractors: `req.app_data::<QueryConfig>().or_else(|| req.app_data::<web::Data<QueryConfig>>().map(|d| d.as_ref())).and_then(|c| c.err_handler.clone())`.","locations":[{"ref":"actix-web/src/types/query.rs:114-117","code":" 114 | fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {\n 115 | let error_handler = req\n 116 | .app_data::<QueryConfig>()\n 117 | .and_then(|c| c.err_handler.clone());","lines":4,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/types/query.rs#L114-L117"}]},
{"id":"LOGIC_10","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"medium","description":"When following a redirect for a `RequestHeadType::Rc(head, extra_headers)` request, `RedirectService::call` extracts only `head.headers` and discards the `extra_headers` map (the `..` in the pattern). Extra per-request headers added via `FrozenSendBuilder::extra_headers`/`extra_header` are therefore silently dropped on every redirected request, even for same-origin redirects, so the redirect target receives a request missing those headers.","fix":"In the `RequestHeadType::Rc(ref head, ref extra_headers)` arm, merge `extra_headers` into the `headers` map that is carried forward for the redirect (e.g. clone `head.headers` then overlay any `extra_headers` entries), so redirected requests preserve the extra headers.","locations":[{"ref":"awc/src/middleware/redirect.rs:85-92","code":" 85 | let (uri, method, headers) = match head {\n 86 | RequestHeadType::Owned(ref head) => {\n 87 | (head.uri.clone(), head.method.clone(), head.headers.clone())\n 88 | }\n 89 | RequestHeadType::Rc(ref head, ..) => {\n 90 | (head.uri.clone(), head.method.clone(), head.headers.clone())\n 91 | }\n 92 | };","lines":8,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/middleware/redirect.rs#L85-L92"}]},
{"id":"LOGIC_11","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"medium","description":"For `307`/`308` redirects (`reuse_body == true`) the body is only reused when the original body was `AnyBody::Bytes` (captured into `body_opt`). If the original request used a streaming body (`send_stream`) or any non-`Bytes` body, `body_opt` is `None` and the redirect resends the request with `AnyBody::empty()` while keeping the original method (e.g. `POST`). This silently sends an empty body on a method/status that is supposed to preserve it, causing silent data loss rather than an error.","fix":"When a `307`/`308` redirect is required but the body cannot be reused, return an error (e.g. a `SendRequestError`) instead of silently substituting an empty body, or document/buffer the body so it can be replayed.","locations":[{"ref":"awc/src/middleware/redirect.rs:94-97","code":" 94 | let body_opt = match body {\n 95 | AnyBody::Bytes { ref body } => Some(body.clone()),\n 96 | _ => None,\n 97 | };","lines":4,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/middleware/redirect.rs#L94-L97"},{"ref":"awc/src/middleware/redirect.rs:190-199","code":" 190 | let body_new = if reuse_body {\n 191 | // try to reuse saved body\n 192 | match body {\n 193 | Some(ref bytes) => AnyBody::Bytes {\n 194 | body: bytes.clone(),\n 195 | },\n 196 | \n 197 | // body was a non-reusable type so send an empty body instead\n 198 | _ => AnyBody::empty(),\n 199 | }","lines":10,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/middleware/redirect.rs#L190-L199"}]},
{"id":"LOGIC_12","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"medium","description":"When a `301`/`302`/`303` redirect downgrades the method to `GET` and sets the body to `AnyBody::None`, the code carries the original request's `headers` forward unchanged via `head.headers = headers.clone()`. Entity headers describing the now-removed body (e.g. `Content-Type`, and an explicit `Content-Length` set via `ClientRequest::content_length`) are not removed. The resulting GET therefore advertises a body it will not send; a stale `Content-Length` can cause request/response desync or the server to wait for a body that never arrives.","fix":"When `reuse_body` is false (method downgraded to GET / body dropped), strip entity headers from `headers` before cloning into the new head: remove `header::CONTENT_LENGTH`, `header::CONTENT_TYPE`, `header::CONTENT_ENCODING`, and `header::TRANSFER_ENCODING`.","locations":[{"ref":"awc/src/middleware/redirect.rs:189-214","code":" 189 | let mut body = body.take();\n 190 | let body_new = if reuse_body {\n 191 | // try to reuse saved body\n 192 | match body {\n 193 | Some(ref bytes) => AnyBody::Bytes {\n 194 | body: bytes.clone(),\n 195 | },\n 196 | \n 197 | // body was a non-reusable type so send an empty body instead\n 198 | _ => AnyBody::empty(),\n 199 | }\n 200 | } else {\n 201 | body = None;\n 202 | // remove body since we're downgrading to a GET\n 203 | AnyBody::None\n 204 | };\n 205 | \n 206 | let mut headers = headers.take().unwrap();\n 207 | \n 208 | remove_sensitive_headers(&mut headers, &prev_uri, &next_uri);\n 209 | \n 210 | // use a new request head.\n 211 | let mut head = RequestHead::default();\n 212 | head.uri = next_uri.clone();\n 213 | head.method = method.clone();\n 214 | head.headers = headers.clone();","lines":26,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/middleware/redirect.rs#L189-L214"}]},
{"id":"LOGIC_13","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"easy","description":"In `TestResponse::finish`, each cookie from `self.cookies.delta()` is written with `head.headers.insert(header::SET_COOKIE, ...)`. `HeaderMap::insert` removes all pre-existing values for the key, so when the jar holds more than one cookie every iteration overwrites the previous `Set-Cookie` value and only the last cookie survives in the produced `ClientResponse`. A response mocked with multiple cookies silently loses all but one.","fix":"Use `head.headers.append(header::SET_COOKIE, ...)` instead of `head.headers.insert(...)` so each cookie becomes a separate `Set-Cookie` header value.","locations":[{"ref":"awc/src/test.rs:79-86","code":" 79 | for cookie in self.cookies.delta() {\n 80 | use actix_http::header::{self, HeaderValue};\n 81 | \n 82 | head.headers.insert(\n 83 | header::SET_COOKIE,\n 84 | HeaderValue::from_str(&cookie.encoded().to_string()).unwrap(),\n 85 | );\n 86 | }","lines":8,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/test.rs#L79-L86"}]},
{"id":"LOGIC_14","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"medium","description":"In `NamedFile::into_response`, the `Range` header is processed (and can `return` `RANGE_NOT_SATISFIABLE` or `BAD_REQUEST`) before the precondition/`not_modified` results are acted on. RFC 7232 requires conditional requests to be evaluated first. Consequences: (1) a request with a failing `If-Unmodified-Since` and an unsatisfiable range gets `416` instead of `412`; (2) when a range is satisfiable but the resource is unmodified, the code inserts a `Content-Range` header and only afterwards returns `304 Not Modified`, so the `304` carries a spurious `Content-Range`.","fix":"Evaluate `precondition_failed` (return `412`) and `not_modified` (return `304`) before parsing and applying the `Range` header, so range handling only runs once the conditional checks pass.","locations":[{"ref":"actix-files/src/named.rs:550-580","code":" 550 | if let Some(ranges) = req.headers().get(header::RANGE) {\n 551 | if let Ok(ranges_header) = ranges.to_str() {\n 552 | if let Some(range) = HttpRange::parse(ranges_header, length)\n 553 | .ok()\n 554 | .and_then(|ranges| ranges.first().copied())\n 555 | {\n 556 | ranged_req = true;\n 557 | length = range.length;\n 558 | offset = range.start;\n 559 | \n 560 | res.insert_header((\n 561 | header::CONTENT_RANGE,\n 562 | format!(\"bytes {}-{}/{}\", offset, offset + length - 1, self.md.len()),\n 563 | ));\n 564 | } else {\n 565 | res.insert_header((header::CONTENT_RANGE, format!(\"bytes */{}\", length)));\n 566 | return res.status(StatusCode::RANGE_NOT_SATISFIABLE).finish();\n 567 | };\n 568 | } else {\n 569 | return res.status(StatusCode::BAD_REQUEST).finish();\n 570 | };\n 571 | };\n 572 | \n 573 | if precondition_failed {\n 574 | return res.status(StatusCode::PRECONDITION_FAILED).finish();\n 575 | } else if not_modified {\n 576 | return res\n 577 | .status(StatusCode::NOT_MODIFIED)\n 578 | .body(body::None::new())\n 579 | .map_into_boxed_body();\n 580 | }","lines":31,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-files/src/named.rs#L550-L580"}]},
{"id":"LOGIC_15","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"medium","description":"`ServiceConfigBuilder::build` does not normalize the keep-alive value, whereas the legacy `ServiceConfig::new` does (`keep_alive: keep_alive.normalize()`). `KeepAlive::normalize` maps `KeepAlive::Timeout(Duration::ZERO)` to `KeepAlive::Disabled`. If a caller passes a `KeepAlive` value directly (e.g. `HttpServiceBuilder::keep_alive(KeepAlive::Timeout(Duration::ZERO))`, since `Into<KeepAlive>` is the identity for `KeepAlive` and skips the normalizing `From<Duration>` impl), the un-normalized `Timeout(ZERO)` reaches the config. Then `keep_alive().enabled()` reports `true` and `keep_alive_deadline()` returns `Some(now + 0)` instead of the `None` that `Disabled` would yield, producing inconsistent keep-alive behavior between the two construction paths.","fix":"Normalize in `ServiceConfigBuilder::build` (or in the `keep_alive` setter): `self.inner.keep_alive = self.inner.keep_alive.normalize();` before constructing the `Rc<Inner>`, matching `ServiceConfig::new`.","locations":[{"ref":"actix-http/src/config.rs:171-173","code":" 171 | pub fn build(self) -> ServiceConfig {\n 172 | ServiceConfig(Rc::new(self.inner))\n 173 | }","lines":3,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/config.rs#L171-L173"},{"ref":"actix-http/src/builder.rs:87-90","code":" 87 | pub fn keep_alive<W: Into<KeepAlive>>(mut self, val: W) -> Self {\n 88 | self.keep_alive = val.into();\n 89 | self\n 90 | }","lines":4,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/builder.rs#L87-L90"}]},
{"id":"LOGIC_16","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"easy","description":"The `Expect` header is treated as `100-continue` whenever the value merely starts with `100-` (`bytes.len() >= 4 && &bytes[0..4] == b\"100-\"`). Values like `100-foo` or `100-continue-bar` are wrongly accepted, causing the server to send `100 Continue` for expectations it does not understand. RFC 7231 requires responding `417 Expectation Failed` for unknown expectations; only the exact token `100-continue` should trigger continue handling.","fix":"Compare the trimmed value case-insensitively against the full token `100-continue` (`value.to_str().map(str::trim).map(|v| v.eq_ignore_ascii_case(\"100-continue\"))`) rather than matching only the `100-` prefix.","locations":[{"ref":"actix-http/src/h1/decoder.rs:177-182","code":" 177 | header::EXPECT => {\n 178 | let bytes = value.as_bytes();\n 179 | if bytes.len() >= 4 && &bytes[0..4] == b\"100-\" {\n 180 | expect = true;\n 181 | }\n 182 | }","lines":6,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/h1/decoder.rs#L177-L182"}]},
{"id":"LOGIC_17","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"medium","description":"`write_camel_case` upper-cases the character following a hyphen by consuming it with an inner `iter.next()`. When two hyphens are adjacent (e.g. header name `x--y`), the hyphen branch consumes the second hyphen as the \"next\" character (which is not a letter, so nothing is upper-cased) and the letter after the second hyphen is then processed as an ordinary character and never upper-cased. Result is `X--y` instead of the expected `X--Y`. Only affects the (rare) camel-case output path for header names containing consecutive hyphens.","fix":"Do not treat the character consumed after a hyphen as already-processed when it is itself a hyphen. Either re-check the consumed byte for `b'-'` and keep looking, or restructure to peek rather than consume (track the previous byte and upper-case the current byte when the previous was `b'-'`).","locations":[{"ref":"actix-http/src/h1/encoder.rs:519-530","code":" 519 | // remaining characters after hyphens should also be uppercase\n 520 | while let Some(&c) = iter.next() {\n 521 | if c == b'-' {\n 522 | // advance iter by one and uppercase if needed\n 523 | if let Some(c @ b'a'..=b'z') = iter.next() {\n 524 | buffer[index] = c & 0b1101_1111;\n 525 | }\n 526 | index += 1;\n 527 | }\n 528 | \n 529 | index += 1;\n 530 | }","lines":12,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/h1/encoder.rs#L519-L530"}]},
{"id":"LOGIC_18","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"very_easy","description":"The `HTTP_VALUE` percent-encoding set adds `b'-'` to the encode set, so hyphens in RFC 5987 extended values (used by `ExtendedValue`'s `Display` and `http_percent_encode`) are percent-encoded as `%2D`. RFC 5987 lists `-` in `attr-char`, i.e. it is a safe character that must NOT be encoded. The odd placement of `.add(b'-')` between `.add(b'<')` and `.add(b'>')` suggests an accidental insertion. Output still round-trips but is non-canonical and unnecessarily larger.","fix":"Remove the `.add(b'-')` line from the `HTTP_VALUE` `AsciiSet` so hyphens are emitted literally, matching RFC 5987 `attr-char`.","locations":[{"ref":"actix-http/src/header/mod.rs:70-90","code":" 70 | pub(crate) const HTTP_VALUE: &AsciiSet = &CONTROLS\n 71 | .add(b' ')\n 72 | .add(b'\"')\n 73 | .add(b'%')\n 74 | .add(b'\\'')\n 75 | .add(b'(')\n 76 | .add(b')')\n 77 | .add(b'*')\n 78 | .add(b',')\n 79 | .add(b'/')\n 80 | .add(b':')\n 81 | .add(b';')\n 82 | .add(b'<')\n 83 | .add(b'-')\n 84 | .add(b'>')\n 85 | .add(b'?')\n 86 | .add(b'[')\n 87 | .add(b'\\\\')\n 88 | .add(b']')\n 89 | .add(b'{')\n 90 | .add(b'}');","lines":21,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/header/mod.rs#L70-L90"}]},
{"id":"LOGIC_19","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"easy","description":"`Charset` uses a hyphen for Shift_JIS in both directions: `label()` returns `\"Shift-JIS\"` and `FromStr` only matches the upper-cased input against `\"SHIFT-JIS\"`. The IANA/MIME canonical name is `Shift_JIS` (underscore). A real-world charset token `Shift_JIS` upper-cases to `SHIFT_JIS`, fails the `\"SHIFT-JIS\"` arm, and is silently captured as `Charset::Ext(\"SHIFT_JIS\")` instead of the `Shift_Jis` variant.","fix":"Match the canonical `\"SHIFT_JIS\"` (underscore) in `FromStr` and emit `\"Shift_JIS\"` from `label()`; optionally accept the hyphenated form as an alias for compatibility.","locations":[{"ref":"actix-http/src/header/shared/charset.rs:79","code":" 79 | Shift_Jis => \"Shift-JIS\",","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/header/shared/charset.rs#L79"},{"ref":"actix-http/src/header/shared/charset.rs:119","code":" 119 | \"SHIFT-JIS\" => Shift_Jis,","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/header/shared/charset.rs#L119"}]},
{"id":"LOGIC_20","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"very_easy","description":"`Quality::from_f32` computes the internal q-value with `(value * MAX_QUALITY_INT as f32) as u16`, which truncates toward zero. For several valid three-decimal q-values the `f32` product lands just below the integer (e.g. `0.251 * 1000.0 == 250.99998`), so parsing `q=0.251`, `q=0.253`, `q=0.502`, `q=0.506` and `q=0.511` yields `Quality(250)`, `Quality(252)`, `Quality(501)`, `Quality(505)` and `Quality(510)` respectively - each off by one. This corrupts client/server content-negotiation preferences and the re-serialized value (e.g. `0.251` prints back as `0.25`). Verified by emulating IEEE-754 `f32` multiply over all 1001 q-values.","fix":"Round instead of truncate. Before: `Quality((value * MAX_QUALITY_INT as f32) as u16)` After: `Quality((value * MAX_QUALITY_INT as f32).round() as u16)`. This maps all 1001 canonical q-values back to their exact integer.","locations":[{"ref":"actix-http/src/header/shared/quality.rs:51-60","code":" 51 | fn from_f32(value: f32) -> Self {\n 52 | // Check that `value` is within range should be done before calling this method.\n 53 | // Just in case, this debug_assert should catch if we were forgetful.\n 54 | debug_assert!(\n 55 | (0.0..=MAX_QUALITY_FLOAT).contains(&value),\n 56 | \"q value must be between 0.0 and 1.0\"\n 57 | );\n 58 | \n 59 | Quality((value * MAX_QUALITY_INT as f32) as u16)\n 60 | }","lines":10,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/header/shared/quality.rs#L51-L60"}]},
{"id":"LOGIC_21","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"medium","description":"The field-draining loop at the top of `Inner::poll` is structured as `loop { if safety.current() { ... break; } }` with no `else` branch. If `safety.current()` ever evaluates to `false` on entry to an iteration, the loop body does nothing and the `break` is never reached, producing an unbounded busy-spin (100% CPU hang). It is currently safe only because `poll` is reached exclusively when `safety.current()` was true and nothing inside the loop mutates the payload `Rc` strong count; that invariant is subtle and easy to violate in future changes.","fix":"Add an explicit `else { break; }` (or `return Poll::Pending;`) to the `if safety.current()` check so the loop cannot spin when the safety guard is not current.","locations":[{"ref":"actix-multipart/src/multipart.rs:395-415","code":" 395 | loop {\n 396 | // Nested multipart streams of fields has to be consumed\n 397 | // before switching to next\n 398 | if safety.current() {\n 399 | let stop = match self.item {\n 400 | Item::Field(ref mut field) => match field.borrow_mut().poll(safety) {\n 401 | Poll::Pending => return Poll::Pending,\n 402 | Poll::Ready(Some(Ok(_))) => continue,\n 403 | Poll::Ready(Some(Err(err))) => return Poll::Ready(Some(Err(err))),\n 404 | Poll::Ready(None) => true,\n 405 | },\n 406 | Item::None => false,\n 407 | };\n 408 | if stop {\n 409 | self.item = Item::None;\n 410 | }\n 411 | if let Item::None = self.item {\n 412 | break;\n 413 | }\n 414 | }\n 415 | }","lines":21,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-multipart/src/multipart.rs#L395-L415"}]},
{"id":"LOGIC_22","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"medium","description":"`Value::deserialize_bytes`/`deserialize_byte_buf` percent-decode through `requote_str_lossy`, which runs `String::from_utf8_lossy` over the decoded bytes. When a path segment contains percent-encoded bytes that are not valid UTF-8 (e.g. `%FF`), the raw decoded byte `0xFF` is replaced by the U+FFFD replacement character (`0xEF 0xBF 0xBD`) before being handed to `visit_byte_buf`. A consumer deserializing into a byte buffer therefore receives corrupted bytes rather than the actual decoded ones. `Quoter::requote` already returns exact `Vec<u8>`; only the byte-oriented visitor path needs it.","fix":"For the bytes visitor paths, feed the exact decoded bytes from `Quoter::requote(self.value.as_bytes())` (a `Vec<u8>`) directly into `visit_byte_buf`, instead of routing through the lossy UTF-8 conversion in `requote_str_lossy`.","locations":[{"ref":"actix-router/src/de.rs:348-356","code":" 348 | fn deserialize_bytes<V>(self, visitor: V) -> Result<V::Value, Self::Error>\n 349 | where\n 350 | V: Visitor<'de>,\n 351 | {\n 352 | match FULL_QUOTER.with(|q| q.requote_str_lossy(self.value)) {\n 353 | Some(s) => visitor.visit_byte_buf(s.into()),\n 354 | None => visitor.visit_borrowed_bytes(self.value.as_bytes()),\n 355 | }\n 356 | }","lines":9,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/de.rs#L348-L356"},{"ref":"actix-router/src/quoter.rs:89-92","code":" 89 | pub(crate) fn requote_str_lossy(&self, val: &str) -> Option<String> {\n 90 | self.requote(val.as_bytes())\n 91 | .map(|data| String::from_utf8_lossy(&data).into_owned())\n 92 | }","lines":4,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/quoter.rs#L89-L92"}]},
{"id":"LOGIC_23","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"easy","description":"`Patterns::is_empty` unconditionally returns `false` for the `Single` variant, so a `Patterns::Single(String::new())` built from an empty pattern string (e.g. via `\"\".patterns()` or `vec![\"\"].patterns()`) is reported as non-empty. Callers using `is_empty()` to detect an absent/empty pattern set will be misled by an empty single pattern.","fix":"Make `is_empty` consider the content of the single pattern, e.g. `Patterns::Single(pat) => pat.is_empty()`, if an empty single pattern should be treated as empty; otherwise document explicitly why `Single` is always non-empty.","locations":[{"ref":"actix-router/src/pattern.rs:8-15","code":" 8 | impl Patterns {\n 9 | pub fn is_empty(&self) -> bool {\n 10 | match self {\n 11 | Patterns::Single(_) => false,\n 12 | Patterns::List(pats) => pats.is_empty(),\n 13 | }\n 14 | }\n 15 | }","lines":8,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/pattern.rs#L8-L15"}]},
{"id":"LOGIC_24","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"easy","description":"`PatternIter::size_hint` ignores iteration progress. For `Patterns::Single` it always returns `(1, Some(1))` even after the single item has been yielded (`self.done == true`), and for `Patterns::List` it always returns `(patterns.len(), Some(patterns.len()))` regardless of `list_idx`. Consumers relying on `size_hint` (e.g. `collect`/`flat_map` in `join`) can over-reserve, and the reported length is incorrect once iteration has begun.","fix":"Account for consumed state: for `Single` return `(0, Some(0))` when `self.done`; for `List` return `(patterns.len().saturating_sub(self.list_idx), Some(patterns.len().saturating_sub(self.list_idx)))`.","locations":[{"ref":"actix-router/src/resource.rs:478-483","code":" 478 | fn size_hint(&self) -> (usize, Option<usize>) {\n 479 | match &self.patterns {\n 480 | Patterns::Single(_) => (1, Some(1)),\n 481 | Patterns::List(patterns) => (patterns.len(), Some(patterns.len())),\n 482 | }\n 483 | }","lines":6,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/resource.rs#L478-L483"}]},
{"id":"LOGIC_25","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"easy","description":"In `HostGuard::check`, the scheme match compares the guard's scheme against the request URI scheme with `scheme == req_host_uri_scheme`, which is a case-sensitive `String`/`&str` comparison. URI schemes are case-insensitive per RFC 3986, so `Host(\"...\").scheme(\"HTTPS\")` would fail to match a request whose scheme parses as `https`. The guard's stored host is likewise compared case-sensitively at line 100 even though hostnames are case-insensitive.","fix":"Compare schemes (and hosts) case-insensitively, e.g. `scheme.eq_ignore_ascii_case(req_host_uri_scheme)` and `self.host.eq_ignore_ascii_case(uri_host)`.","locations":[{"ref":"actix-web/src/guard/host.rs:108-111","code":" 108 | if let Some(ref scheme) = self.scheme {\n 109 | if let Some(ref req_host_uri_scheme) = req_host_uri.scheme_str() {\n 110 | return scheme == req_host_uri_scheme;\n 111 | }","lines":4,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/guard/host.rs#L108-L111"}]},
{"id":"LOGIC_26","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"easy","description":"`CacheDirective::from_str` unquotes numeric directive values with `s[idx + 1..].trim_matches('\"')`. `trim_matches` strips any number of leading/trailing quote characters independently, so unbalanced input like `max-age=\"200` or `max-age=200\"` is silently accepted. For the `Extension(name, Some(value))` fallback the surrounding quotes are also stripped, so a quoted extension value such as `foo=\"bar baz\"` is stored as `bar baz` and then re-emitted unquoted by `Display` as `foo=bar baz`, which is an invalid (whitespace-containing) directive that does not round-trip.","fix":"Only strip a single matched pair of surrounding quotes (verify the value both starts and ends with `\"` before removing), and preserve/re-quote extension values that contain characters requiring a quoted-string when displaying.","locations":[{"ref":"actix-web/src/http/header/cache_control.rs:177-189","code":" 177 | _ => match s.find('=') {\n 178 | Some(idx) if idx + 1 < s.len() => {\n 179 | match (&s[..idx], s[idx + 1..].trim_matches('\"')) {\n 180 | (\"max-age\", secs) => secs.parse().map(MaxAge).map_err(Some),\n 181 | (\"max-stale\", secs) => secs.parse().map(MaxStale).map_err(Some),\n 182 | (\"min-fresh\", secs) => secs.parse().map(MinFresh).map_err(Some),\n 183 | (\"s-maxage\", secs) => secs.parse().map(SMaxAge).map_err(Some),\n 184 | (left, right) => Ok(Extension(left.to_owned(), Some(right.to_owned()))),\n 185 | }\n 186 | }\n 187 | Some(_) => Err(None),\n 188 | None => Ok(Extension(s.to_owned(), None)),\n 189 | },","lines":13,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/http/header/cache_control.rs#L177-L189"}]},
{"id":"LOGIC_27","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"medium","description":"When `content_type_required` is `false`, `JsonBody::new` returns `can_parse_json = true` unconditionally and never consults the user-supplied `content_type` predicate. A user who configures `JsonConfig::default().content_type(pred).content_type_required(false)` reasonably expects `pred` to still restrict accepted content types, but it is silently ignored and any/no content type is accepted as JSON. The two options interact in a surprising, undocumented way.","fix":"Either document explicitly that setting `content_type_required(false)` disables the custom `content_type` predicate entirely, or, in the `(false, Ok(Some(mime)))` case, still honor a configured `ctype_fn` predicate when a valid content type is present.","locations":[{"ref":"actix-web/src/types/json.rs:331-344","code":" 331 | let can_parse_json = match (ctype_required, req.mime_type()) {\n 332 | (true, Ok(Some(mime))) => {\n 333 | mime.subtype() == mime::JSON\n 334 | || mime.suffix() == Some(mime::JSON)\n 335 | || ctype_fn.is_some_and(|predicate| predicate(mime))\n 336 | }\n 337 | \n 338 | // if content-type is expected but not parsable as mime type, bail\n 339 | (true, _) => false,\n 340 | \n 341 | // if content-type validation is disabled, assume payload is JSON\n 342 | // even when content-type header is missing or invalid mime type\n 343 | (false, _) => true,\n 344 | };","lines":14,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/types/json.rs#L331-L344"}]},
{"id":"LOGIC_28","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"medium","description":"`remove_sensitive_headers` compares origins using `Uri::port()` directly, which returns `None` for an omitted default port. A redirect from `https://host/a` to `https://host:443/b` (same effective origin) yields `prev_uri.port() == None` but `next_uri.port() == Some(443)`, so the ports compare unequal and `Cookie`/`Authorization`/`Proxy-Authorization` are stripped even though the origin is identical. This breaks authenticated same-origin redirects that only differ by explicit vs implicit default port.","fix":"Normalize to effective ports before comparing, e.g. compute `port_or_default` from the scheme (`http` -> 80, `https` -> 443, `ws`/`wss` similarly) for both URIs and compare those, rather than comparing `next_uri.port() != prev_uri.port()` on the raw `Option<Port>`.","locations":[{"ref":"awc/src/middleware/redirect.rs:288-297","code":" 288 | fn remove_sensitive_headers(headers: &mut header::HeaderMap, prev_uri: &Uri, next_uri: &Uri) {\n 289 | if next_uri.host() != prev_uri.host()\n 290 | || next_uri.port() != prev_uri.port()\n 291 | || next_uri.scheme() != prev_uri.scheme()\n 292 | {\n 293 | headers.remove(header::COOKIE);\n 294 | headers.remove(header::AUTHORIZATION);\n 295 | headers.remove(header::PROXY_AUTHORIZATION);\n 296 | }\n 297 | }","lines":10,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/middleware/redirect.rs#L288-L297"}]},
{"id":"LOGIC_29","type":"logic_error","type_label":"Logic Error","severity":"negligible","difficulty":"easy","description":"`WebsocketsRequest::protocols` builds the header by folding with `acc + s.as_ref() + \",\"` and then `protos.pop()` to strip the trailing comma. If the supplied iterator is empty, `protos` is `\"\"`, `pop()` is a no-op, and `self.protocols` becomes `Some(String::new())`, causing an empty `Sec-WebSocket-Protocol` header to be sent during `connect`. An empty protocol list should not emit the header at all.","fix":"Build the list with `protos.into_iter().map(|s| s.as_ref().to_owned()).collect::<Vec<_>>().join(\",\")` and only set `self.protocols` when the result is non-empty (or leave it `None` for an empty iterator).","locations":[{"ref":"awc/src/ws.rs:112-123","code":" 112 | pub fn protocols<U, V>(mut self, protos: U) -> Self\n 113 | where\n 114 | U: IntoIterator<Item = V>,\n 115 | V: AsRef<str>,\n 116 | {\n 117 | let mut protos = protos\n 118 | .into_iter()\n 119 | .fold(String::new(), |acc, s| acc + s.as_ref() + \",\");\n 120 | protos.pop();\n 121 | self.protocols = Some(protos);\n 122 | self\n 123 | }","lines":12,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/ws.rs#L112-L123"},{"ref":"awc/src/ws.rs:331-336","code":" 331 | if let Some(protocols) = self.protocols.take() {\n 332 | self.head.headers.insert(\n 333 | header::SEC_WEBSOCKET_PROTOCOL,\n 334 | HeaderValue::try_from(protocols.as_str()).unwrap(),\n 335 | );\n 336 | }","lines":6,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/ws.rs#L331-L336"}]},
{"id":"CPY_1","type":"copy_paste","type_label":"Copy Paste","severity":"low","difficulty":"easy","description":"In the content `Encoder`, the error `trace!` messages in `ContentEncoder::write` say `\"Error decoding ...\"` even though this is the compression (encoding) path, not decoding. Additionally the `zstd` arm misspells the codec as `\"ztsd\"`. These are misleading log messages copied from the decoder.","fix":"Change each message from `\"Error decoding <codec> encoding\"` to `\"Error encoding <codec>\"`, and fix `\"ztsd\"` to `\"zstd\"` at line 396.","locations":[{"ref":"actix-http/src/encoding/encoder.rs:369","code":" 369 | trace!(\"Error decoding br encoding: {}\", err);","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/encoding/encoder.rs#L369"},{"ref":"actix-http/src/encoding/encoder.rs:378","code":" 378 | trace!(\"Error decoding gzip encoding: {}\", err);","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/encoding/encoder.rs#L378"},{"ref":"actix-http/src/encoding/encoder.rs:387","code":" 387 | trace!(\"Error decoding deflate encoding: {}\", err);","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/encoding/encoder.rs#L387"},{"ref":"actix-http/src/encoding/encoder.rs:396","code":" 396 | trace!(\"Error decoding ztsd encoding: {}\", err);","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/encoding/encoder.rs#L396"}]},
{"id":"CPY_2","type":"copy_paste","type_label":"Copy Paste","severity":"low","difficulty":"easy","description":"`Option<Vec<T>>::handle_field` keys stored state on `field.name().unwrap()`, whereas the sibling collectors (`Vec<T>`, `Option<T>`, and the bare `T` impls) all key on `field.form_field_name`. `field.name()` returns `content_disposition()?.get_name()` (an `Option`), so the `.unwrap()` is a latent panic if a field lacks a Content-Disposition `name`, and it is an inconsistent code path relative to the other three implementations that use the always-populated `form_field_name`. Although `MultipartForm` currently only accepts `multipart/form-data` (where the name is validated to exist), relying on `.unwrap()` here is fragile and easy to break.","fix":"Replace `let field_name = field.name().unwrap().to_string();` with `let field_name = field.form_field_name.clone();` to match the other `FieldGroupReader` impls and remove the `.unwrap()`.","locations":[{"ref":"actix-multipart/src/form/mod.rs:207","code":" 207 | let field_name = field.name().unwrap().to_string();","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-multipart/src/form/mod.rs#L207"}]},
{"id":"CPY_3","type":"copy_paste","type_label":"Copy Paste","severity":"low","difficulty":"very_easy","description":"The `Last-Modified` header doc block shows the wrong ABNF: it reads `Expires = HTTP-date` (copy-pasted from `expires.rs`) instead of `Last-Modified = HTTP-date`.","fix":"Before: `/// Expires = HTTP-date` After: `/// Last-Modified = HTTP-date`.","locations":[{"ref":"actix-web/src/http/header/last_modified.rs:14","code":" 14 | /// Expires = HTTP-date","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/http/header/last_modified.rs#L14"}]},
{"id":"ERR_1","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"very_easy","description":"In the `parse_value!` macro, the value is percent-decoded into `decoded` and then `decoded.parse()` is attempted, but the failure message formats `self.value` (the still-encoded input) instead of `decoded`. The reported error can therefore be misleading, e.g. it will claim it `can not parse \"%2F\"` when what actually failed to parse was the decoded `\"/\"`.","fix":"Format the decoded value in the error message.\nBefore:\n```rust\nde::value::Error::custom(format!(\"can not parse {:?} to a {}\", self.value, $tp))\n```\nAfter:\n```rust\nde::value::Error::custom(format!(\"can not parse {:?} to a {}\", decoded, $tp))\n```","locations":[{"ref":"actix-router/src/de.rs:66-68","code":" 66 | let v = decoded.parse().map_err(|_| {\n 67 | de::value::Error::custom(format!(\"can not parse {:?} to a {}\", self.value, $tp))\n 68 | })?;","lines":3,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/de.rs#L66-L68"}]},
{"id":"ERR_2","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"easy","description":"In `Redirect::respond_to`, when `self.to` cannot be parsed into a `HeaderValue` (for example if it contains control characters or a newline), the code logs an error but still returns a response carrying the redirect status code (`307`/`308`/etc.) with NO `Location` header. Clients receive a redirect response with no target, which is a broken/confusing result rather than a clear failure.","fix":"On parse failure, return an error response instead of a header-less redirect. In the `else` branch of `if let Ok(hdr_val) = self.to.parse()`, build an internal-server-error response (e.g. via `HttpResponse::InternalServerError()`) in addition to logging, so a malformed target does not surface as a status-only redirect.","locations":[{"ref":"actix-web/src/redirect.rs:166-179","code":" 166 | fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> {\n 167 | let mut res = HttpResponse::with_body(self.status_code, ());\n 168 | \n 169 | if let Ok(hdr_val) = self.to.parse() {\n 170 | res.headers_mut().insert(LOCATION, hdr_val);\n 171 | } else {\n 172 | log::error!(\n 173 | \"redirect target location can not be converted to header value: {:?}\",\n 174 | self.to,\n 175 | );\n 176 | }\n 177 | \n 178 | res\n 179 | }","lines":14,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/redirect.rs#L166-L179"}]},
{"id":"ERR_3","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"medium","description":"In `h2proto::send_body`, when `send.poll_capacity(cx)` returns `None` while `buf` still holds unsent body bytes, the function returns `Ok(())` without ever calling `send_data(_, true)`. A closed capacity means the stream can no longer accept data, so the request body is truncated and end-of-stream is never explicitly signalled, yet the caller is told the body was sent successfully.","fix":"Treat a `None` from `poll_capacity` while data remains as an error (return a `SendRequestError` indicating the stream was reset/closed) rather than `Ok(())`.","locations":[{"ref":"awc/src/client/h2proto.rs:165-166","code":" 165 | match poll_fn(|cx| send.poll_capacity(cx)).await {\n 166 | None => return Ok(()),","lines":2,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/client/h2proto.rs#L165-L166"}]},
{"id":"PERF_1","type":"performance","type_label":"Performance","severity":"medium","difficulty":"medium","description":"In `Parser::parse`, an over-sized WebSocket frame is only rejected once the entire declared payload has been buffered. The `length > max_size` check at lines 119-124 runs only after the `if src.len() < frame_len { ... return Ok(None); }` early return. A client can declare a huge `length` (e.g. multi-GB via the 64-bit length field) and stream the bytes; the decoder keeps returning `Ok(None)` and the framed read buffer grows with the incoming data until `src.len() >= idx + length`, at which point it finally errors with `Overflow`. The `max_size`-capped `reserve` only limits pre-allocation, not the accumulation of actually-received bytes, so memory grows unbounded up to the declared frame size. This defeats the purpose of `max_size` as a DoS guard.","fix":"Reject the frame as soon as the declared `length` exceeds `max_size`, before waiting for the full payload. Move the `if length > max_size { return Err(ProtocolError::Overflow); }` check to run right after `length` is known (and before the `src.len() < frame_len` buffering branch), so the connection is torn down without buffering the whole oversized frame.","locations":[{"ref":"actix-http/src/ws/frame.rs:106-133","code":" 106 | let frame_len = match idx.checked_add(length) {\n 107 | Some(len) => len,\n 108 | None => return Err(ProtocolError::Overflow),\n 109 | };\n 110 | \n 111 | // not enough data\n 112 | if src.len() < frame_len {\n 113 | let min_length = min(length, max_size);\n 114 | let required_cap = match idx.checked_add(min_length) {\n 115 | Some(cap) => cap,\n 116 | None => return Err(ProtocolError::Overflow),\n 117 | };\n 118 | \n 119 | if src.capacity() < required_cap {\n 120 | src.reserve(required_cap - src.capacity());\n 121 | }\n 122 | return Ok(None);\n 123 | }\n 124 | \n 125 | // remove prefix\n 126 | src.advance(idx);\n 127 | \n 128 | // check for max allowed size\n 129 | if length > max_size {\n 130 | // drop the payload\n 131 | src.advance(length);\n 132 | return Err(ProtocolError::Overflow);\n 133 | }","lines":28,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/ws/frame.rs#L106-L133"}]},
{"id":"PERF_2","type":"performance","type_label":"Performance","severity":"low","difficulty":"easy","description":"For every visible entry in a directory listing, `entry.metadata()` is called twice: once inside `Directory::is_visible` (to test the file type) and again in `directory_listing` (to decide whether to append a trailing `/`). Each call is a separate `stat`/`lstat` syscall, doubling the syscall count per entry for large directories.","fix":"Fetch the metadata once. Either have `is_visible` return the resolved metadata/file-type, or restructure `directory_listing` to call `entry.metadata()` a single time and reuse the result for both the visibility check and the directory-vs-file branch.","locations":[{"ref":"actix-files/src/directory.rs:36-38","code":" 36 | if let Ok(ref md) = entry.metadata() {\n 37 | let ft = md.file_type();\n 38 | return ft.is_dir() || ft.is_file() || ft.is_symlink();","lines":3,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-files/src/directory.rs#L36-L38"},{"ref":"actix-files/src/directory.rs:79-107","code":" 79 | for entry in dir.path.read_dir()? {\n 80 | if dir.is_visible(&entry) {\n 81 | let entry = entry.unwrap();\n 82 | let p = match entry.path().strip_prefix(&dir.path) {\n 83 | Ok(p) if cfg!(windows) => base.join(p).to_string_lossy().replace('\\\\', \"/\"),\n 84 | Ok(p) => base.join(p).to_string_lossy().into_owned(),\n 85 | Err(_) => continue,\n 86 | };\n 87 | \n 88 | // if file is a directory, add '/' to the end of the name\n 89 | if let Ok(metadata) = entry.metadata() {\n 90 | if metadata.is_dir() {\n 91 | let _ = write!(\n 92 | body,\n 93 | \"<li><a href=\\\"{}\\\">{}/<\/a><\/li>\",\n 94 | encode_file_url!(p),\n 95 | encode_file_name!(entry),\n 96 | );\n 97 | } else {\n 98 | let _ = write!(\n 99 | body,\n 100 | \"<li><a href=\\\"{}\\\">{}<\/a><\/li>\",\n 101 | encode_file_url!(p),\n 102 | encode_file_name!(entry),\n 103 | );\n 104 | }\n 105 | } else {\n 106 | continue;\n 107 | }","lines":29,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-files/src/directory.rs#L79-L107"}]},
{"id":"PERF_3","type":"performance","type_label":"Performance","severity":"low","difficulty":"easy","description":"`Drain::next` pops each header value from the front of the `SmallVec` with `vals.remove(0)`, which is O(n) per element and O(n^2) for a header name with many values (the `// OPTIMIZE: array removals` comment acknowledges this). For pathological multi-value headers this is quadratic.","fix":"Convert the value list into a `smallvec::IntoIter` when first entering a value set (as `IntoIter` already does) and pull from the front in O(1) amortised, or drain from the back if order is not required.","locations":[{"ref":"actix-http/src/header/map.rs:843-853","code":" 843 | if let Some((ref mut name, ref mut vals)) = self.multi_inner {\n 844 | if !vals.is_empty() {\n 845 | // OPTIMIZE: array removals\n 846 | self.remaining -= 1;\n 847 | return Some((name.take(), vals.remove(0)));\n 848 | } else {\n 849 | // no more items in value iterator; reset state\n 850 | self.multi_inner = None;\n 851 | self.multi_idx = 0;\n 852 | }\n 853 | }","lines":11,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/header/map.rs#L843-L853"}]},
{"id":"PERF_4","type":"performance","type_label":"Performance","severity":"low","difficulty":"very_easy","description":"`HttpMessage::chunked` uses `s.to_lowercase()` (Unicode-aware, allocates a `String`) before `contains(\"chunked\")`. Header values are ASCII and the sibling method `RequestHead::upgrade` already uses the cheaper `to_ascii_lowercase`. The Unicode lowering is both slower and semantically inappropriate here.","fix":"Use `s.to_ascii_lowercase().contains(\"chunked\")`, or avoid the allocation entirely with a case-insensitive substring check.","locations":[{"ref":"actix-http/src/http_message.rs:93-103","code":" 93 | fn chunked(&self) -> Result<bool, ParseError> {\n 94 | if let Some(encodings) = self.headers().get(header::TRANSFER_ENCODING) {\n 95 | if let Ok(s) = encodings.to_str() {\n 96 | Ok(s.to_lowercase().contains(\"chunked\"))\n 97 | } else {\n 98 | Err(ParseError::Header)\n 99 | }\n 100 | } else {\n 101 | Ok(false)\n 102 | }\n 103 | }","lines":11,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/http_message.rs#L93-L103"}]},
{"id":"PERF_5","type":"performance","type_label":"Performance","severity":"low","difficulty":"very_easy","description":"`Request::upgrade` lowercases the `Connection` header value with `to_lowercase()`, which performs full Unicode case folding and allocates a `String`, even though HTTP header tokens are ASCII. This runs on every upgrade check.","fix":"Avoid the allocation and Unicode folding. Before:\n```rust\nreturn s.to_lowercase().contains(\"upgrade\");\n```\nAfter:\n```rust\nreturn s.to_ascii_lowercase().contains(\"upgrade\");\n```\n(or, better, compare tokens case-insensitively without allocating).","locations":[{"ref":"actix-http/src/requests/request.rs:163-168","code":" 163 | if let Some(conn) = self.head().headers.get(header::CONNECTION) {\n 164 | if let Ok(s) = conn.to_str() {\n 165 | return s.to_lowercase().contains(\"upgrade\");\n 166 | }\n 167 | }\n 168 | self.head().method == Method::CONNECT","lines":6,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/requests/request.rs#L163-L168"}]},
{"id":"PERF_6","type":"performance","type_label":"Performance","severity":"low","difficulty":"easy","description":"The multipart `Bytes` field reader unconditionally pre-allocates a 128 KiB `BytesMut` (`BytesMut::with_capacity(131_072)`) for every field regardless of the field's actual size or the configured `limits`. A form with many small fields wastes large amounts of memory up front. Note the lower-level `Field::bytes` collector instead starts at a modest 2 KiB and grows.","fix":"Start with a smaller initial capacity (as `Field::bytes` does with `INITIAL_ALLOC_BYTES`) and/or cap the initial allocation at the field limit, letting `BytesMut` grow as needed.","locations":[{"ref":"actix-multipart/src/form/bytes.rs:29-36","code":" 29 | fn read_field(_: &'t HttpRequest, mut field: Field, limits: &'t mut Limits) -> Self::Future {\n 30 | Box::pin(async move {\n 31 | let mut buf = BytesMut::with_capacity(131_072);\n 32 | \n 33 | while let Some(chunk) = field.try_next().await? {\n 34 | limits.try_consume_limits(chunk.len(), true)?;\n 35 | buf.extend(chunk);\n 36 | }","lines":8,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-multipart/src/form/bytes.rs#L29-L36"}]},
{"id":"PERF_7","type":"performance","type_label":"Performance","severity":"low","difficulty":"easy","description":"`NormalizePathNormalization::call` allocates a fresh `String` for every request on the hot path even when nothing changes. For `TrailingSlash::Trim` it calls `.trim_end_matches('/').to_string()` and for `TrailingSlash::MergeOnly` it calls `original_path.to_string()`; the latter is a pure unconditional clone of the path that is immediately handed to `merge_slash.replace_all`, which already returns a borrowing `Cow` when there is nothing to merge. So an already-normalized path still incurs an allocation on every request.","fix":"Keep the intermediate path as a `Cow<str>` / `&str`. For `MergeOnly` pass `original_path` straight into `merge_slash.replace_all(original_path, \"/\")` with no `to_string()`, and for `Trim` keep the `trim_end_matches('/')` slice borrowed instead of calling `.to_string()`. `replace_all` accepts `&str`, so the only allocation happens when the path actually changes.","locations":[{"ref":"actix-web/src/middleware/normalize.rs:184-195","code":" 184 | let path = match self.trailing_slash_behavior {\n 185 | TrailingSlash::Always => format!(\"{}/\", original_path),\n 186 | TrailingSlash::MergeOnly => original_path.to_string(),\n 187 | TrailingSlash::Trim => original_path.trim_end_matches('/').to_string(),\n 188 | };\n 189 | \n 190 | // normalize multiple /'s to one /\n 191 | let path = self.merge_slash.replace_all(&path, \"/\");\n 192 | \n 193 | // Ensure root paths are still resolvable. If resulting path is blank after previous\n 194 | // step it means the path was one or more slashes. Reduce to single slash.\n 195 | let path = if path.is_empty() { \"/\" } else { path.as_ref() };","lines":12,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/middleware/normalize.rs#L184-L195"}]},
{"id":"PERF_8","type":"performance","type_label":"Performance","severity":"low","difficulty":"easy","description":"The buffering extractors always pre-allocate a fixed `BytesMut::with_capacity(8192)` regardless of the (often known) `Content-Length` or configured `limit`. For many small requests this over-allocates, and for large known-length bodies it forces repeated reallocations as the buffer grows from 8kB. The known content length and limit are both available at construction time and could size the buffer more accurately.","fix":"Size the initial `BytesMut` from the known length clamped to the limit, e.g. `BytesMut::with_capacity(length.map_or(8192, |len| len.min(limit)))`, falling back to a sensible default when the length is unknown.","locations":[{"ref":"actix-web/src/types/json.rs:370","code":" 370 | buf: BytesMut::with_capacity(8192),","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/types/json.rs#L370"},{"ref":"actix-web/src/types/form.rs:380","code":" 380 | let mut body = BytesMut::with_capacity(8192);","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/types/form.rs#L380"},{"ref":"actix-web/src/types/payload.rs:395","code":" 395 | buf: BytesMut::with_capacity(8192),","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/types/payload.rs#L395"}]},
{"id":"PERF_9","type":"performance","type_label":"Performance","severity":"negligible","difficulty":"very_easy","description":"`from_comma_delimited` trims each split segment twice: once inside the first `filter_map` (`match x.trim()`), which yields the already-trimmed slice, and again in the second `filter_map` (`x.trim().parse()`). The second `trim()` is a no-op on an already-trimmed slice.","fix":"Drop the redundant `.trim()` in the second closure: `.filter_map(|x| x.parse().ok())`.","locations":[{"ref":"actix-http/src/header/utils.rs:21-28","code":" 21 | result.extend(\n 22 | s.split(',')\n 23 | .filter_map(|x| match x.trim() {\n 24 | \"\" => None,\n 25 | y => Some(y),\n 26 | })\n 27 | .filter_map(|x| x.trim().parse().ok()),\n 28 | )","lines":8,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/header/utils.rs#L21-L28"}]},
{"id":"PERF_10","type":"performance","type_label":"Performance","severity":"negligible","difficulty":"easy","description":"`ConnectionInfo::new` calls `name.trim().to_lowercase()` for every `Forwarded` parameter pair, allocating a new `String` per pair solely to compare against the fixed set `for`/`proto`/`host`/`by`. This is an unnecessary allocation on a hot request path.","fix":"Use `eq_ignore_ascii_case` against the candidate names (e.g. match on `name.trim()` with `.eq_ignore_ascii_case(\"for\")` etc.) to avoid the per-pair `String` allocation, since RFC 7239 parameter names are ASCII and case-insensitive.","locations":[{"ref":"actix-web/src/info.rs:116","code":" 116 | match name.trim().to_lowercase().as_str() {","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/info.rs#L116"}]},
{"id":"PERF_11","type":"performance","type_label":"Performance","severity":"negligible","difficulty":"easy","description":"`UrlEncoded::new` compares content type via `req.content_type().to_lowercase() != \"application/x-www-form-urlencoded\"`, allocating a fresh lowercased `String` on every form request purely for a comparison.","fix":"Use a case-insensitive comparison without allocation, e.g. `req.content_type().eq_ignore_ascii_case(\"application/x-www-form-urlencoded\")`.","locations":[{"ref":"actix-web/src/types/form.rs:292","code":" 292 | if req.content_type().to_lowercase() != \"application/x-www-form-urlencoded\" {","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/types/form.rs#L292"}]},
{"id":"PERF_12","type":"performance","type_label":"Performance","severity":"negligible","difficulty":"very_easy","description":"The HOST header buffer is allocated with `BytesMut::with_capacity(host.len() + 5)`, but the formatted value `host:port` can be `host.len() + 1 (colon) + 5 (max u16 digits) = host.len() + 6` bytes, so a 5-digit port forces a reallocation. Correctness is unaffected (the writer grows) but the capacity hint is off by one.","fix":"Before: `let mut wrt = BytesMut::with_capacity(host.len() + 5).writer();` After: `let mut wrt = BytesMut::with_capacity(host.len() + 6).writer();`","locations":[{"ref":"awc/src/client/h1proto.rs:71","code":" 71 | let mut wrt = BytesMut::with_capacity(host.len() + 5).writer();","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/client/h1proto.rs#L71"}]},
{"id":"PERF_13","type":"performance","type_label":"Performance","severity":"negligible","difficulty":"very_easy","description":"`set_header_if_none` for the `Rc` variant uses `extra_headers.get_or_insert(HeaderMap::new())`, which eagerly constructs a `HeaderMap` on every call even when the `Option` is already `Some`. The lazy form avoids the needless allocation/construction.","fix":"Before:\n```rust\nlet h = extra_headers.get_or_insert(HeaderMap::new());\n```\nAfter:\n```rust\nlet h = extra_headers.get_or_insert_with(HeaderMap::new);\n```","locations":[{"ref":"awc/src/sender.rs:306","code":" 306 | let h = extra_headers.get_or_insert(HeaderMap::new());","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/sender.rs#L306"}]},
{"id":"DEAD_1","type":"dead_code","type_label":"Dead Code","severity":"low","difficulty":"easy","description":"Under the `experimental-io-uring` feature, `ChunkedReadFile` still carries a `read_mode: ReadMode` field and `new_chunked_read` still computes and sets it (`if size < read_mode_threshold ...`), but the io-uring `Stream` impl never reads it (io-uring reads are always async). The field, its initialization, and the sync/async threshold computation are dead under that feature.","fix":"Gate the `read_mode` field and its initialization behind `#[cfg(not(feature = \"experimental-io-uring\"))]` so it is not present/computed when io-uring is enabled.","locations":[{"ref":"actix-files/src/chunked.rs:24-32","code":" 24 | pub struct ChunkedReadFile<F, Fut> {\n 25 | size: u64,\n 26 | offset: u64,\n 27 | #[pin]\n 28 | state: ChunkedReadFileState<Fut>,\n 29 | counter: u64,\n 30 | callback: F,\n 31 | read_mode: ReadMode,\n 32 | }","lines":9,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-files/src/chunked.rs#L24-L32"}]},
{"id":"DEAD_2","type":"dead_code","type_label":"Dead Code","severity":"low","difficulty":"easy","description":"`impl ToTokens for MethodTypeExt` (the plain `to_tokens` that emits an `Ident`) is never exercised. All guard code generation goes through `to_tokens_single_guard`, `to_tokens_multi_guard`, and `to_tokens_multi_guard_or_chain`, which match on the enum and interpolate `#method` (a `&MethodType`) or `#lit` directly; no `quote!` interpolates a `MethodTypeExt` value via `#`. `MethodTypeExt` is a private enum, so this impl is unused dead code (and for `Custom` it would additionally mis-emit a bare method name as an identifier).","fix":"Remove the `impl ToTokens for MethodTypeExt` block, or add a test/usage if it is meant to be used.","locations":[{"ref":"actix-web-codegen/src/route.rs:200-210","code":" 200 | impl ToTokens for MethodTypeExt {\n 201 | fn to_tokens(&self, stream: &mut TokenStream2) {\n 202 | match self {\n 203 | MethodTypeExt::Custom(lit_str) => {\n 204 | let ident = Ident::new(lit_str.value().as_str(), Span::call_site());\n 205 | stream.append(ident);\n 206 | }\n 207 | MethodTypeExt::Standard(method) => method.to_tokens(stream),\n 208 | }\n 209 | }\n 210 | }","lines":11,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web-codegen/src/route.rs#L200-L210"}]},
{"id":"DEAD_3","type":"dead_code","type_label":"Dead Code","severity":"low","difficulty":"easy","description":"`has_allowed_methods_in_scope` accepts an attribute path equal to the identifier `ROUTE` (upper-case). No such macro exists; the routing attribute macros generated by this crate are all lower-case (`route`, `get`, `post`, ...). The `attr.path().is_ident(\"ROUTE\")` branch can never be true for real code and is dead. It also hides the fact that the real multi-route macro `routes` is not handled by `#[scope]`.","fix":"Remove the `|| attr.path().is_ident(\"ROUTE\")` clause. If handling of `#[routes]` inside a scope is desired, add explicit support instead of relying on a non-existent `ROUTE` name.","locations":[{"ref":"actix-web-codegen/src/scope.rs:99-103","code":" 99 | fn has_allowed_methods_in_scope(attr: &syn::Attribute) -> bool {\n 100 | MethodType::from_path(attr.path()).is_ok()\n 101 | || attr.path().is_ident(\"route\")\n 102 | || attr.path().is_ident(\"ROUTE\")\n 103 | }","lines":5,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web-codegen/src/scope.rs#L99-L103"}]},
{"id":"DEAD_4","type":"dead_code","type_label":"Dead Code","severity":"low","difficulty":"easy","description":"The final `else` arm of the URI scheme validation is unreachable. The preceding `else if uri.scheme().is_none()` already returns for the `None` case, so by the time control reaches `else if let Some(scheme) = uri.scheme()` the scheme is always `Some`; the trailing `else { return Err(InvalidUrl::UnknownScheme.into()) }` can never execute.","fix":"Drop the dead trailing `else` branch and fold the scheme match directly, e.g. replace the `else if let Some(scheme) = uri.scheme()` / `else` pair with a single `else` that unwraps the already-known-present scheme via `uri.scheme().unwrap()` and matches on `scheme.as_str()`.","locations":[{"ref":"awc/src/ws.rs:274-285","code":" 274 | if uri.host().is_none() {\n 275 | return Err(InvalidUrl::MissingHost.into());\n 276 | } else if uri.scheme().is_none() {\n 277 | return Err(InvalidUrl::MissingScheme.into());\n 278 | } else if let Some(scheme) = uri.scheme() {\n 279 | match scheme.as_str() {\n 280 | \"http\" | \"ws\" | \"https\" | \"wss\" => {}\n 281 | _ => return Err(InvalidUrl::UnknownScheme.into()),\n 282 | }\n 283 | } else {\n 284 | return Err(InvalidUrl::UnknownScheme.into());\n 285 | }","lines":12,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/ws.rs#L274-L285"}]},
{"id":"DEAD_5","type":"dead_code","type_label":"Dead Code","severity":"negligible","difficulty":"very_easy","description":"`FilesError::IsNotDirectory` is never constructed anywhere in the crate (confirmed: the identifier only appears at its own definition) and is explicitly annotated `#[allow(dead_code)]`. It is dead.","fix":"Remove the unused variant (and its `#[allow(dead_code)]`).\n\nBefore:\n```rust\n /// Path is not a directory.\n #[allow(dead_code)]\n #[display(\"path is not a directory. Unable to serve static files\")]\n IsNotDirectory,\n```\n\nAfter: (delete the variant entirely)","locations":[{"ref":"actix-files/src/error.rs:6-10","code":" 6 | pub enum FilesError {\n 7 | /// Path is not a directory.\n 8 | #[allow(dead_code)]\n 9 | #[display(\"path is not a directory. Unable to serve static files\")]\n 10 | IsNotDirectory,","lines":5,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-files/src/error.rs#L6-L10"}]},
{"id":"DEAD_6","type":"dead_code","type_label":"Dead Code","severity":"negligible","difficulty":"very_easy","description":"The `Drain` iterator's `multi_idx` field is written (initialised to `0` in `Drain::new` and reset to `0` when a value list is exhausted) but never read. Unlike `Iter`, `Drain` advances through the value list with `vals.remove(0)`, so the index is unused state.","fix":"Remove the `multi_idx` field from `Drain` and the two assignments to it.","locations":[{"ref":"actix-http/src/header/map.rs:823","code":" 823 | multi_idx: usize,","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/header/map.rs#L823"},{"ref":"actix-http/src/header/map.rs:838-853","code":" 838 | impl Iterator for Drain<'_> {\n 839 | type Item = (Option<HeaderName>, HeaderValue);\n 840 | \n 841 | fn next(&mut self) -> Option<Self::Item> {\n 842 | // handle in-progress multi value iterators first\n 843 | if let Some((ref mut name, ref mut vals)) = self.multi_inner {\n 844 | if !vals.is_empty() {\n 845 | // OPTIMIZE: array removals\n 846 | self.remaining -= 1;\n 847 | return Some((name.take(), vals.remove(0)));\n 848 | } else {\n 849 | // no more items in value iterator; reset state\n 850 | self.multi_inner = None;\n 851 | self.multi_idx = 0;\n 852 | }\n 853 | }","lines":16,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/header/map.rs#L838-L853"}]},
{"id":"DEAD_7","type":"dead_code","type_label":"Dead Code","severity":"negligible","difficulty":"easy","description":"In `Inner::skip_until_boundary`, the guard `if chunk.is_empty() { return Err(Error::BoundaryMissing); }` can never fire: `chunk` comes from `payload.readline()` (i.e. `read_until(b\"\\n\")`), which only returns `Some` when the newline needle is found, so the returned chunk always contains at least the `\\n` byte and is never empty. The defensive branch is effectively dead.","fix":"Remove the unreachable `chunk.is_empty()` check, or add a comment clarifying it is purely defensive; if empty-line detection is genuinely intended, compare against `b\"\\r\\n\"`/`b\"\\n\"` instead of `is_empty()`.","locations":[{"ref":"actix-multipart/src/multipart.rs:358-361","code":" 358 | Some(chunk) => {\n 359 | if chunk.is_empty() {\n 360 | return Err(Error::BoundaryMissing);\n 361 | }","lines":4,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-multipart/src/multipart.rs#L358-L361"}]},
{"id":"DEAD_8","type":"dead_code","type_label":"Dead Code","severity":"negligible","difficulty":"very_easy","description":"In `Inner::poll`, `self.state = State::Boundary;` is set redundantly. When execution reaches this point the state has already been set to `State::Boundary` immediately after the field headers are read (in the `if self.state == State::Headers` block), and nothing between those two points changes `state`. The second assignment is dead.","fix":"Remove the redundant assignment.\nBefore:\n```rust\n self.state = State::Boundary;\n\n // nested multipart stream is not supported\n```\nAfter:\n```rust\n // nested multipart stream is not supported\n```","locations":[{"ref":"actix-multipart/src/multipart.rs:501","code":" 501 | self.state = State::Boundary;","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-multipart/src/multipart.rs#L501"}]},
{"id":"DEAD_9","type":"dead_code","type_label":"Dead Code","severity":"negligible","difficulty":"very_easy","description":"In `EntityTag::from_str`, the guards `slice.len() >= 2` (strong branch) and `slice.len() >= 4` are partly redundant: the early return already guarantees `slice.len() >= 2`, so the `slice.len() >= 2` re-check on the strong branch is always true. It is dead defensive code that obscures the logic.","fix":"Before: `if slice.len() >= 2 && slice.starts_with('\"') && check_slice_validity(&slice[1..length - 1])` After: `if slice.starts_with('\"') && check_slice_validity(&slice[1..length - 1])` (the `slice.len() >= 2` term is guaranteed by the earlier `slice.len() < 2` early return).","locations":[{"ref":"actix-web/src/http/header/entity.rs:155","code":" 155 | if slice.len() >= 2 && slice.starts_with('\"') && check_slice_validity(&slice[1..length - 1])","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/http/header/entity.rs#L155"}]},
{"id":"DEAD_10","type":"dead_code","type_label":"Dead Code","severity":"negligible","difficulty":"very_easy","description":"In `Acquired::release`, the statement `let _ = &self.permit;` is a no-op: it borrows the field and immediately discards it, doing nothing at runtime and not actually keeping the permit alive any longer (the permit lives as long as `self`/`Acquired` regardless). It only adds noise.","fix":"Remove the line. Before: ` let _ = &self.permit;` After: (delete the line; if intent was documentation, replace with a one-line comment noting the permit is released when `Acquired` drops).","locations":[{"ref":"awc/src/client/pool.rs:375","code":" 375 | let _ = &self.permit;","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/client/pool.rs#L375"}]},
{"id":"CMT_1","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","difficulty":"medium","description":"`handshake_with_protocols` docs state the response contains \"the first protocol in this list [the server's `protocols` argument] which the server also knows\", implying server preference order. The implementation instead iterates over the *client's* requested protocol list (`req_protocols.split(',')`) and returns the first *client* protocol the server knows, i.e. client preference order wins. The test with server list `[\"p3\", \"p2\"]` and client `\"p1, p2, p3\"` returns `\"p2\"` (client order) rather than `\"p3\"` (server order), confirming the mismatch. Either the doc or the selection logic is wrong.","fix":"Decide the intended precedence. To honor server preference (as the doc implies), iterate `protocols` outer and check membership in the request list: `protocols.iter().find(|p| req_protocols.split(',').map(str::trim).any(|rp| rp == **p))`. Otherwise, correct the doc to say the client's preferred order is used.","locations":[{"ref":"actix-web-actors/src/ws.rs:371-435","code":" 371 | /// `protocols` is a sequence of known protocols. On successful handshake, the returned response\n 372 | /// headers contain the first protocol in this list which the server also knows.\n 373 | pub fn handshake_with_protocols(\n 374 | req: &HttpRequest,\n 375 | protocols: &[&str],\n 376 | ) -> Result<HttpResponseBuilder, HandshakeError> {\n 377 | // WebSocket accepts only GET\n 378 | if *req.method() != Method::GET {\n 379 | return Err(HandshakeError::GetMethodRequired);\n 380 | }\n 381 | \n 382 | // check for \"UPGRADE\" to WebSocket header\n 383 | let has_hdr = if let Some(hdr) = req.headers().get(&header::UPGRADE) {\n 384 | if let Ok(s) = hdr.to_str() {\n 385 | s.to_ascii_lowercase().contains(\"websocket\")\n 386 | } else {\n 387 | false\n 388 | }\n 389 | } else {\n 390 | false\n 391 | };\n 392 | if !has_hdr {\n 393 | return Err(HandshakeError::NoWebsocketUpgrade);\n 394 | }\n 395 | \n 396 | // Upgrade connection\n 397 | if !req.head().upgrade() {\n 398 | return Err(HandshakeError::NoConnectionUpgrade);\n 399 | }\n 400 | \n 401 | // check supported version\n 402 | if !req.headers().contains_key(&header::SEC_WEBSOCKET_VERSION) {\n 403 | return Err(HandshakeError::NoVersionHeader);\n 404 | }\n 405 | let supported_ver = {\n 406 | if let Some(hdr) = req.headers().get(&header::SEC_WEBSOCKET_VERSION) {\n 407 | hdr == \"13\" || hdr == \"8\" || hdr == \"7\"\n 408 | } else {\n 409 | false\n 410 | }\n 411 | };\n 412 | if !supported_ver {\n 413 | return Err(HandshakeError::UnsupportedVersion);\n 414 | }\n 415 | \n 416 | // check client handshake for validity\n 417 | if !req.headers().contains_key(&header::SEC_WEBSOCKET_KEY) {\n 418 | return Err(HandshakeError::BadWebsocketKey);\n 419 | }\n 420 | let key = {\n 421 | let key = req.headers().get(&header::SEC_WEBSOCKET_KEY).unwrap();\n 422 | hash_key(key.as_ref())\n 423 | };\n 424 | \n 425 | // check requested protocols\n 426 | let protocol = req\n 427 | .headers()\n 428 | .get(&header::SEC_WEBSOCKET_PROTOCOL)\n 429 | .and_then(|req_protocols| {\n 430 | let req_protocols = req_protocols.to_str().ok()?;\n 431 | req_protocols\n 432 | .split(',')\n 433 | .map(|req_p| req_p.trim())\n 434 | .find(|req_p| protocols.iter().any(|p| p == req_p))\n 435 | });","lines":65,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web-actors/src/ws.rs#L371-L435"}]},
{"id":"CMT_2","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","difficulty":"very_easy","description":"The `UrlencodedError::UnknownLength` variant's doc comment and its `Display` string both read `Payload size is now known.` - a typo; the intended meaning is that the size is *not* known (this is the length-required / unknown-length error). The wrong word is surfaced to end users via the error response body.","fix":"Replace `now` with `not` in both lines.\nBefore:\n```\n /// Payload size is now known.\n #[display(\"Payload size is now known.\")]\n```\nAfter:\n```\n /// Payload size is not known.\n #[display(\"Payload size is not known.\")]\n```","locations":[{"ref":"actix-web/src/error/mod.rs:74-76","code":" 74 | /// Payload size is now known.\n 75 | #[display(\"Payload size is now known.\")]\n 76 | UnknownLength,","lines":3,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/error/mod.rs#L74-L76"}]},
{"id":"CMT_3","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","difficulty":"very_easy","description":"The doc comment for `DispositionParam::as_unknown_ext` describes the `UnknownExt` variant but links to `[`Unknown`](DispositionParam::Unknown)` (copy-paste from `as_unknown`). The method actually matches `DispositionParam::UnknownExt`, so the rendered docs point to the wrong variant.","fix":"Fix the variant reference.\nBefore:\n```\n /// [`Unknown`](DispositionParam::Unknown) and the `name` matches.\n```\nAfter:\n```\n /// [`UnknownExt`](DispositionParam::UnknownExt) and the `name` matches.\n```","locations":[{"ref":"actix-web/src/http/header/content_disposition.rs:194-195","code":" 194 | /// Returns the value of the unrecognized extended parameter if it is\n 195 | /// [`Unknown`](DispositionParam::Unknown) and the `name` matches.","lines":2,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/http/header/content_disposition.rs#L194-L195"}]},
{"id":"CMT_4","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","difficulty":"easy","description":"The `Logger` format documentation states that `%b` is the \"Size of response in bytes, including HTTP headers\", but `StreamLog` only accumulates body chunk lengths (`*this.size += chunk.len()`) and never counts header bytes. The reported size excludes headers entirely, so the doc is inaccurate.","fix":"Correct the doc to reflect reality: `%b` is the size of the response body in bytes (excluding HTTP headers), matching the Apache `%b`/`%B` convention. Alternatively, if header bytes should be included, actually add them to `size`, but the simplest correct fix is updating the description.","locations":[{"ref":"actix-web/src/middleware/logger.rs:69","code":" 69 | /// `%b` | Size of response in bytes, including HTTP headers","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/middleware/logger.rs#L69"},{"ref":"actix-web/src/middleware/logger.rs:474-477","code":" 474 | match ready!(this.body.poll_next(cx)) {\n 475 | Some(Ok(chunk)) => {\n 476 | *this.size += chunk.len();\n 477 | Poll::Ready(Some(Ok(chunk)))","lines":4,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/middleware/logger.rs#L474-L477"}]},
{"id":"CMT_5","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","difficulty":"very_easy","description":"The doc comment on `Format::new` claims it returns `None` on invalid syntax, but the function returns `Format` (not `Option<Format>`); it never signals an error and silently drops/reinterprets unrecognized directives. The comment misleads callers into thinking they can detect malformed format strings.","fix":"Remove the false sentence. Before: `/// Returns `None` if the format string syntax is incorrect.` After: delete that line (the function is infallible and returns `Format`).","locations":[{"ref":"actix-web/src/middleware/logger.rs:497-499","code":" 497 | /// Create a `Format` from a format string.\n 498 | ///\n 499 | /// Returns `None` if the format string syntax is incorrect.","lines":3,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/middleware/logger.rs#L497-L499"}]},
{"id":"CMT_6","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","difficulty":"very_easy","description":"The doc comment on `UrlEncoded::limit` claims a 256kB default, but `UrlEncoded::new` (line 326) and `UrlEncoded::err` (line 337) both set `limit: 32_768`, i.e. 32kB. The comment misstates the actual default by 8x.","fix":"Change `/// Set maximum accepted payload size. The default limit is 256kB.` to `/// Set maximum accepted payload size. The default limit is 32kB.`","locations":[{"ref":"actix-web/src/types/form.rs:344","code":" 344 | /// Set maximum accepted payload size. The default limit is 256kB.","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/types/form.rs#L344"}]},
{"id":"CMT_7","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","difficulty":"very_easy","description":"The `ConnectionState::Skip` doc comment claims the IO `should be skipped but not dropped`, but in `ConnectionPool::call` the `ConnectionState::Skip => continue` arm drops the popped connection `c` at the end of the loop iteration (its `Io` is not returned to the pool nor gracefully closed), so it is in fact dropped.","fix":"Update the comment to reflect reality. Before: `/// IO should be skipped but not dropped.` After: `/// IO reached EOF (or errored) and is dropped, ending its use for this acquire.`","locations":[{"ref":"awc/src/client/pool.rs:271","code":" 271 | /// IO should be skipped but not dropped.","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/client/pool.rs#L271"},{"ref":"awc/src/client/pool.rs:217","code":" 217 | ConnectionState::Skip => continue,","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/client/pool.rs#L217"}]},
{"id":"CMT_8","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","difficulty":"very_easy","description":"The doc comment on `ResponseBody` (the raw `Bytes` body reader) is copy-pasted from `JsonBody`: it states the future errors if `content type is not application/json` and references `[limit](JsonBody::limit)`. `ResponseBody` neither checks content type nor relates to `JsonBody`; it only fails on payload-length overflow / unknown length.","fix":"Rewrite the `ResponseBody` doc comment. Before: `/// - content type is not `application/json`;` and `[limit](JsonBody::limit)`. After: remove the content-type bullet and reference `[limit](ResponseBody::limit)`, describing only the overflow / unknown-length error cases.","locations":[{"ref":"awc/src/responses/response_body.rs:18-22","code":" 18 | ///\n 19 | /// # Errors\n 20 | /// `Future` implementation returns error if:\n 21 | /// - content type is not `application/json`;\n 22 | /// - content length is greater than [limit](JsonBody::limit) (default: 2 MiB).","lines":5,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/responses/response_body.rs#L18-L22"}]},
{"id":"CMT_9","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"very_easy","description":"The doc comment for `equiv_utf8_text` says it \"Transforms MIME `text/*` types into their UTF-8 equivalent\", but the function also converts `application/javascript` (which is not a `text/*` type), as the comment's own bullet list acknowledges. The summary line is therefore misleading.","fix":"Reword the summary, e.g.\n\nBefore:\n```rust\n/// Transforms MIME `text/*` types into their UTF-8 equivalent, if supported.\n```\n\nAfter:\n```rust\n/// Transforms select text-based MIME types into their UTF-8 equivalent, if supported.\n```","locations":[{"ref":"actix-files/src/encoding.rs:3-11","code":" 3 | /// Transforms MIME `text/*` types into their UTF-8 equivalent, if supported.\n 4 | ///\n 5 | /// MIME types that are converted\n 6 | /// - application/javascript\n 7 | /// - text/html\n 8 | /// - text/css\n 9 | /// - text/plain\n 10 | /// - text/csv\n 11 | /// - text/tab-separated-values","lines":9,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-files/src/encoding.rs#L3-L11"}]},
{"id":"CMT_10","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"very_easy","description":"The `kind` field of `ErrorInner` is annotated `#[allow(dead_code)]`, but it is actively read in `From<Error> for Response`, `Debug`, and `Display`. The annotation is stale and misleading, suggesting the field is unused when it is not.","fix":"Before:\n```\n #[allow(dead_code)]\n kind: Kind,\n```\nAfter:\n```\n kind: Kind,\n```","locations":[{"ref":"actix-http/src/error.rs:16-18","code":" 16 | #[allow(dead_code)]\n 17 | kind: Kind,\n 18 | cause: Option<Box<dyn StdError>>,","lines":3,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/error.rs#L16-L18"}]},
{"id":"CMT_11","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"very_easy","description":"`Inner::register_io`'s doc comment is malformed: the first line uses a plain `//` comment (`// Register future feeding data to payload.`) while the following line is a `///` doc comment. The `///` line therefore doc-attaches while the intended summary line is a normal comment, and the wording duplicates `register`'s comment style inconsistently.","fix":"Make both lines `///` (or merge into one `///` doc comment) so the summary line is part of the doc comment.","locations":[{"ref":"actix-http/src/h1/payload.rs:199-200","code":" 199 | // Register future feeding data to payload.\n 200 | /// Waker would be used in `Inner::wake_io`","lines":2,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/h1/payload.rs#L199-L200"}]},
{"id":"CMT_12","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"very_easy","description":"Doc text refers to a method named `patterns_iter` (plural) that does not exist; the method is `pattern_iter`. The link target `Self::pattern_iter` is correct but the visible label is wrong.","fix":"Before: `/// all patterns, use [\\`patterns_iter\\`][Self::pattern_iter].`\nAfter: `/// all patterns, use [\\`pattern_iter\\`][Self::pattern_iter].`","locations":[{"ref":"actix-router/src/resource.rs:410","code":" 410 | /// all patterns, use [`patterns_iter`][Self::pattern_iter]. If resource has 0 patterns,","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/resource.rs#L410"}]},
{"id":"CMT_13","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"very_easy","description":"The doc comment on `static_match` claims it \"Returns true if `prefix` acts as a proper prefix (i.e., separated by a slash) in `path`\", but the function also performs exact matching for non-prefix resources (the `is_prefix == false` arm) and returns an `Option<usize>` length, not a bool. The comment describes only the prefix case and the wrong return type.","fix":"Reword to describe both cases, e.g. `/// Returns match length if \\`pattern\\` matches \\`path\\` exactly (non-prefix) or as a segment-aligned prefix (prefix).`","locations":[{"ref":"actix-router/src/resource.rs:831","code":" 831 | /// Returns true if `prefix` acts as a proper prefix (i.e., separated by a slash) in `path`.","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/resource.rs#L831"}]},
{"id":"CMT_14","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"very_easy","description":"Typo in an intentional-leak comment: `Bok::leak(Box::new(name))` should be `Box::leak`.","fix":"Before: `// \\`Bok::leak(Box::new(name))\\` is an intentional memory leak.`\nAfter: `// \\`Box::leak(Box::new(name))\\` is an intentional memory leak.`","locations":[{"ref":"actix-router/src/resource.rs:1069","code":" 1069 | // `Bok::leak(Box::new(name))` is an intentional memory leak. In typical applications the","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-router/src/resource.rs#L1069"}]},
{"id":"CMT_15","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"very_easy","description":"Intra-doc link definition for `[CONNECT]` contains a duplicated `macro@` prefix, producing a broken rustdoc link.","fix":"Before: `//! [CONNECT]: macro@macro@connect`\nAfter: `//! [CONNECT]: macro@connect`","locations":[{"ref":"actix-web-codegen/src/lib.rs:69","code":" 69 | //! [CONNECT]: macro@macro@connect","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web-codegen/src/lib.rs#L69"}]},
{"id":"CMT_16","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"very_easy","description":"Dangling/incomplete comment in the scope trailing-slash check: the sentence ends with \"rather than\" and is never finished.","fix":"Complete or remove the comment, e.g. `// it's better to reject them so developers fix the path explicitly`.","locations":[{"ref":"actix-web-codegen/src/scope.rs:35-36","code":" 35 | // trailing slashes cause non-obvious problems\n 36 | // it's better to point them out to developers rather than","lines":2,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web-codegen/src/scope.rs#L35-L36"}]},
{"id":"CMT_17","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"very_easy","description":"The `IfRange` doc comment contains escaped double quotes (`\\\"short-circuit\\\"`, `\\\"xyzzy\\\"`) in ordinary prose/example-value lines. Because these are plain `///` doc lines (not code), rustdoc renders the backslashes literally, producing `\\\"short-circuit\\\"` in the generated documentation.","fix":"Replace the escaped `\\\"` sequences in the prose with plain `\"` characters so rustdoc renders them correctly.","locations":[{"ref":"actix-web/src/http/header/if_range.rs:20","code":" 20 | /// The `If-Range` header field allows a client to \\\"short-circuit\\\" the","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/http/header/if_range.rs#L20"},{"ref":"actix-web/src/http/header/if_range.rs:35","code":" 35 | /// * `Sat, 29 Oct 1994 19:43:31 GMT`","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/http/header/if_range.rs#L35"}]},
{"id":"CMT_18","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"very_easy","description":"Typo in the middleware module docs: `In the more general ase` should read `In the more general case`.","fix":"Before: `//! In the more general ase, a middleware is a pair of types that implements the [`Service`] trait`\nAfter: `//! In the more general case, a middleware is a pair of types that implements the [`Service`] trait`","locations":[{"ref":"actix-web/src/middleware/mod.rs:53","code":" 53 | //! In the more general ase, a middleware is a pair of types that implements the [`Service`] trait","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/middleware/mod.rs#L53"}]},
{"id":"CMT_19","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"easy","description":"The intra-doc link definition ``[`from_fn`]: crate`` points `from_fn` at the crate root, but `from_fn` actually lives at `crate::middleware::from_fn` and is not re-exported at the crate root, so this link resolves to a page that does not document `from_fn`. It is also an orphan definition: the prose uses the label ``[`from_fn()`]`` (with parentheses), not ``[`from_fn`]``, so this definition is unused.","fix":"Point the link at the real item and match the used label, e.g. ``[`from_fn()`]: crate::middleware::from_fn``, or remove the unused definition.","locations":[{"ref":"actix-web/src/middleware/mod.rs:243","code":" 243 | //! [`from_fn`]: crate","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/middleware/mod.rs#L243"}]},
{"id":"CMT_20","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"very_easy","description":"The `Json` type-level doc has a broken sentence with a missing verb and a double space: `The \\`Json\\` type JSON formatted responses.`","fix":"Change `/// The \\`Json\\` type JSON formatted responses. A handler may return a value of type` to `/// The \\`Json\\` type produces JSON formatted responses. A handler may return a value of type`","locations":[{"ref":"actix-web/src/types/json.rs:56","code":" 56 | /// The `Json` type JSON formatted responses. A handler may return a value of type","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/types/json.rs#L56"}]},
{"id":"CMT_21","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"very_easy","description":"The `Responder` impl doc comment for `Json` is a dangling, incomplete sentence: `/// If serialization failed`. It says nothing about what actually happens on serialization failure.","fix":"Replace `/// If serialization failed` with a complete sentence, e.g. `/// If serialization fails, an error response is generated instead.`","locations":[{"ref":"actix-web/src/types/json.rs:117","code":" 117 | /// If serialization failed","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/types/json.rs#L117"}]},
{"id":"DUP_1","type":"duplicate_code","type_label":"Duplicate Code","severity":"low","difficulty":"easy","description":"`verify_handshake` looks up the `Sec-WebSocket-Version` header twice: first `req.headers().contains_key(SEC_WEBSOCKET_VERSION)` to emit `NoVersionHeader`, then `req.headers().get(SEC_WEBSOCKET_VERSION)` inside an `if let Some(..)` whose `else => false` branch is dead (the header is already known to exist). This is a redundant double lookup with an unreachable branch.","fix":"Do a single `get`: `match req.headers().get(SEC_WEBSOCKET_VERSION) { None => return Err(HandshakeError::NoVersionHeader), Some(hdr) if hdr == \"13\" || hdr == \"8\" || hdr == \"7\" => {}, Some(_) => return Err(HandshakeError::UnsupportedVersion) }`.","locations":[{"ref":"actix-http/src/ws/mod.rs:196-208","code":" 196 | if !req.headers().contains_key(header::SEC_WEBSOCKET_VERSION) {\n 197 | return Err(HandshakeError::NoVersionHeader);\n 198 | }\n 199 | let supported_ver = {\n 200 | if let Some(hdr) = req.headers().get(header::SEC_WEBSOCKET_VERSION) {\n 201 | hdr == \"13\" || hdr == \"8\" || hdr == \"7\"\n 202 | } else {\n 203 | false\n 204 | }\n 205 | };\n 206 | if !supported_ver {\n 207 | return Err(HandshakeError::UnsupportedVersion);\n 208 | }","lines":13,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/ws/mod.rs#L196-L208"}]},
{"id":"DUP_2","type":"duplicate_code","type_label":"Duplicate Code","severity":"low","difficulty":"medium","description":"`HttpRequest::cookies` and `HttpRequest::cookies_raw` are near-identical copies (same UTF-8 decode, same `split(';')`/`trim`/filter loop, same insert-into-extensions dance); they differ only in `Cookie::parse_encoded` vs `Cookie::parse` and the `Cookies` vs `RawCookies` wrapper type. This duplication invites divergent maintenance (a fix applied to one, such as the cookie-parsing bug, can be missed in the other).","fix":"Extract a private helper parameterized over the parse function and marker type, e.g. `fn load_cookies<F>(&self, parse: F) -> ...` returning the built `Vec<Cookie<'static>>`, and have both public methods delegate to it.","locations":[{"ref":"actix-web/src/request.rs:477-522","code":" 477 | pub fn cookies(&self) -> Result<Ref<'_, Vec<Cookie<'static>>>, CookieParseError> {\n 478 | use actix_http::header::COOKIE;\n 479 | \n 480 | if self.extensions().get::<Cookies>().is_none() {\n 481 | let mut cookies = Vec::new();\n 482 | for hdr in self.headers().get_all(COOKIE) {\n 483 | let s = str::from_utf8(hdr.as_bytes()).map_err(CookieParseError::from)?;\n 484 | for cookie_str in s.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {\n 485 | if let Ok(cookie) = Cookie::parse_encoded(cookie_str) {\n 486 | cookies.push(cookie.into_owned());\n 487 | }\n 488 | }\n 489 | }\n 490 | self.extensions_mut().insert(Cookies(cookies));\n 491 | }\n 492 | \n 493 | Ok(Ref::map(self.extensions(), |ext| {\n 494 | &ext.get::<Cookies>().unwrap().0\n 495 | }))\n 496 | }\n 497 | \n 498 | /// Load request cookies **without** percent-decoding their names and values.\n 499 | ///\n 500 | /// Any cookie that cannot be parsed is omitted from the result.\n 501 | /// This includes cookies with an empty name (e.g. `document.cookie = \"=value\"`).\n 502 | #[cfg(feature = \"cookies\")]\n 503 | pub fn cookies_raw(&self) -> Result<Ref<'_, Vec<Cookie<'static>>>, CookieParseError> {\n 504 | use actix_http::header::COOKIE;\n 505 | \n 506 | if self.extensions().get::<RawCookies>().is_none() {\n 507 | let mut cookies = Vec::new();\n 508 | for hdr in self.headers().get_all(COOKIE) {\n 509 | let s = str::from_utf8(hdr.as_bytes()).map_err(CookieParseError::from)?;\n 510 | for cookie_str in s.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {\n 511 | if let Ok(cookie) = Cookie::parse(cookie_str) {\n 512 | cookies.push(cookie.into_owned());\n 513 | }\n 514 | }\n 515 | }\n 516 | self.extensions_mut().insert(RawCookies(cookies));\n 517 | }\n 518 | \n 519 | Ok(Ref::map(self.extensions(), |ext| {\n 520 | &ext.get::<RawCookies>().unwrap().0\n 521 | }))\n 522 | }","lines":46,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web/src/request.rs#L477-L522"}]},
{"id":"ARCH_1","type":"architecture","type_label":"Architecture","severity":"medium","difficulty":"hard","description":"`H2ConnectionInner::drop` attempts to wake the spawned connection task by calling `self.sender.send_request(http::Request::new(()), true)` and only aborts the task if that call errors. When the connection is still healthy the `send_request` succeeds, meaning a spurious empty HTTP/2 request is actually dispatched to the remote server purely as a side effect of dropping the connection wrapper. The in-code TODO acknowledges this can emit extraneous requests, which is a real protocol-correctness problem (unexpected requests hitting the server).","fix":"Signal task shutdown without emitting a real request, e.g. always `self.handle.abort()` on drop (dropping the `SendRequest` already lets the `h2` connection close gracefully), or hold an explicit shutdown channel/`gracefully close` handle instead of sending a dummy request.","locations":[{"ref":"awc/src/client/connection.rs:167-178","code":" 167 | impl Drop for H2ConnectionInner {\n 168 | fn drop(&mut self) {\n 169 | // TODO: this can end up sending extraneous requests; see if there is a better way to handle\n 170 | if self\n 171 | .sender\n 172 | .send_request(http::Request::new(()), true)\n 173 | .is_err()\n 174 | {\n 175 | self.handle.abort();\n 176 | }\n 177 | }\n 178 | }","lines":12,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/awc/src/client/connection.rs#L167-L178"}]},
{"id":"OTHER_1","type":"other","type_label":"Other","severity":"high","difficulty":"medium","description":"The content-encoding `Decoder` applies no limit on decompressed output size, making it vulnerable to a decompression bomb (DoS). In `poll_next`, chunks smaller than `MAX_CHUNK_SIZE_DECODE_IN_PLACE` (2049 bytes) are fed to `decoder.feed_data(chunk)` synchronously on the async task; `feed_data` calls `write_all` into the decompressor whose output accumulates in `Writer.buf` (an unbounded `BytesMut`). A tiny, highly-compressible `gzip`/`deflate`/`brotli`/`zstd` payload can expand to gigabytes in a single call, causing a large synchronous allocation that both exhausts memory and blocks the runtime reactor. Larger chunks use `spawn_blocking` but are still unbounded. The downstream payload byte-limit only sees data after it has already been fully decompressed into memory.","fix":"Track cumulative decompressed bytes per stream and abort with `PayloadError::Overflow` once a configurable ratio/absolute cap is exceeded. Enforce the cap inside `feed_data` around each `write_all`/`flush`/`take` so a single input chunk cannot expand without bound before the check fires.","locations":[{"ref":"actix-http/src/encoding/decoder.rs:122-135","code":" 122 | if let Some(mut decoder) = this.decoder.take() {\n 123 | if chunk.len() < MAX_CHUNK_SIZE_DECODE_IN_PLACE {\n 124 | let chunk = decoder.feed_data(chunk)?;\n 125 | *this.decoder = Some(decoder);\n 126 | \n 127 | if let Some(chunk) = chunk {\n 128 | return Poll::Ready(Some(Ok(chunk)));\n 129 | }\n 130 | } else {\n 131 | *this.fut = Some(spawn_blocking(move || {\n 132 | let chunk = decoder.feed_data(chunk)?;\n 133 | Ok((chunk, decoder))\n 134 | }));\n 135 | }","lines":14,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/encoding/decoder.rs#L122-L135"},{"ref":"actix-http/src/encoding/decoder.rs:236-298","code":" 236 | fn feed_data(&mut self, data: Bytes) -> io::Result<Option<Bytes>> {\n 237 | match self {\n 238 | #[cfg(feature = \"compress-brotli\")]\n 239 | ContentDecoder::Brotli(ref mut decoder) => match decoder.write_all(&data) {\n 240 | Ok(_) => {\n 241 | decoder.flush()?;\n 242 | let b = decoder.get_mut().take();\n 243 | \n 244 | if !b.is_empty() {\n 245 | Ok(Some(b))\n 246 | } else {\n 247 | Ok(None)\n 248 | }\n 249 | }\n 250 | Err(err) => Err(err),\n 251 | },\n 252 | \n 253 | #[cfg(feature = \"compress-gzip\")]\n 254 | ContentDecoder::Gzip(ref mut decoder) => match decoder.write_all(&data) {\n 255 | Ok(_) => {\n 256 | decoder.flush()?;\n 257 | let b = decoder.get_mut().take();\n 258 | \n 259 | if !b.is_empty() {\n 260 | Ok(Some(b))\n 261 | } else {\n 262 | Ok(None)\n 263 | }\n 264 | }\n 265 | Err(err) => Err(err),\n 266 | },\n 267 | \n 268 | #[cfg(feature = \"compress-gzip\")]\n 269 | ContentDecoder::Deflate(ref mut decoder) => match decoder.write_all(&data) {\n 270 | Ok(_) => {\n 271 | decoder.flush()?;\n 272 | \n 273 | let b = decoder.get_mut().take();\n 274 | if !b.is_empty() {\n 275 | Ok(Some(b))\n 276 | } else {\n 277 | Ok(None)\n 278 | }\n 279 | }\n 280 | Err(err) => Err(err),\n 281 | },\n 282 | \n 283 | #[cfg(feature = \"compress-zstd\")]\n 284 | ContentDecoder::Zstd(ref mut decoder) => match decoder.write_all(&data) {\n 285 | Ok(_) => {\n 286 | decoder.flush()?;\n 287 | \n 288 | let b = decoder.get_mut().take();\n 289 | if !b.is_empty() {\n 290 | Ok(Some(b))\n 291 | } else {\n 292 | Ok(None)\n 293 | }\n 294 | }\n 295 | Err(err) => Err(err),\n 296 | },\n 297 | }\n 298 | }","lines":63,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/encoding/decoder.rs#L236-L298"},{"ref":"actix-http/src/encoding/mod.rs:19-40","code":" 19 | impl Writer {\n 20 | fn new() -> Writer {\n 21 | Writer {\n 22 | buf: BytesMut::with_capacity(8192),\n 23 | }\n 24 | }\n 25 | \n 26 | fn take(&mut self) -> Bytes {\n 27 | self.buf.split().freeze()\n 28 | }\n 29 | }\n 30 | \n 31 | impl io::Write for Writer {\n 32 | fn write(&mut self, buf: &[u8]) -> io::Result<usize> {\n 33 | self.buf.extend_from_slice(buf);\n 34 | Ok(buf.len())\n 35 | }\n 36 | \n 37 | fn flush(&mut self) -> io::Result<()> {\n 38 | Ok(())\n 39 | }\n 40 | }","lines":22,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/encoding/mod.rs#L19-L40"}]},
{"id":"OTHER_2","type":"other","type_label":"Other","severity":"low","difficulty":"medium","description":"Method guards are collected into a `HashSet<MethodTypeExt>` and later iterated (`methods.iter()`, taking `first` then the rest) to emit the guard chain. `std::collections::HashSet` has non-deterministic iteration order (randomly seeded `RandomState`), so the generated guard token stream ordering varies between compilations. This does not affect routing correctness (methods are OR-combined) but makes proc-macro output non-reproducible and can confuse expansion diffing/snapshot tests.","fix":"Use an order-preserving collection (e.g. an insertion-ordered `Vec` with a dedup check, or `indexmap::IndexSet`) for `methods` so guard emission order is deterministic.","locations":[{"ref":"actix-web-codegen/src/route.rs:231","code":" 231 | methods: HashSet<MethodTypeExt>,","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web-codegen/src/route.rs#L231"},{"ref":"actix-web-codegen/src/route.rs:439-454","code":" 439 | let method_guards = {\n 440 | debug_assert!(!methods.is_empty(), \"Args::methods should not be empty\");\n 441 | \n 442 | let mut others = methods.iter();\n 443 | let first = others.next().unwrap();\n 444 | \n 445 | if methods.len() > 1 {\n 446 | let other_method_guards = others\n 447 | .map(|method_ext| method_ext.to_tokens_multi_guard_or_chain())\n 448 | .collect();\n 449 | \n 450 | first.to_tokens_multi_guard(other_method_guards)\n 451 | } else {\n 452 | first.to_tokens_single_guard()\n 453 | }\n 454 | };","lines":16,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-web-codegen/src/route.rs#L439-L454"}]},
{"id":"OTHER_3","type":"other","type_label":"Other","severity":"negligible","difficulty":"very_easy","description":"The `brotli` decompressor is constructed with a buffer size of `8_096`, a non-power-of-two value that is almost certainly a typo for `8_192` (8 KiB), which is the size the crate's own `Writer` pre-allocates and the natural intended value.","fix":"Before: `brotli::DecompressorWriter::new(Writer::new(), 8_096)` After: `brotli::DecompressorWriter::new(Writer::new(), 8_192)`","locations":[{"ref":"actix-http/src/encoding/decoder.rs:46","code":" 46 | brotli::DecompressorWriter::new(Writer::new(), 8_096),","lines":1,"link":"https://github.com/actix/actix-web/blob/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2/actix-http/src/encoding/decoder.rs#L46"}]}
]</script>
<script type="application/json" id="PI">{"name":"actix-web","git_url":"https://github.com/actix/actix-web.git","repo_url":"https://github.com/actix/actix-web","commit":"d7ce1a3faba0eb565abad4f852c1f714ed9e19d2","commit_short":"d7ce1a3fab","commit_url":"https://github.com/actix/actix-web/commit/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2","branch":"main","generated":"2026-09-14","description":"Actix Web is a powerful, pragmatic, and extremely fast web framework for Rust."}</script>
<div class="app">
<div class="top">
<div>
<h1>Code Audit Report — actix-web</h1>
<div class="meta">Generated 2026-09-14 — <span id="stats"></span></div>
</div>
<div class="top-right">
<div class="search-wrap">
<input type="search" id="search" placeholder="Search findings…" autocomplete="off">
<button id="search-scope" class="action-btn" type="button" title="Toggle: search everywhere vs. only in file/folder paths">📁 Paths only</button>
</div>
<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="folder">Folder</option>
<option value="none">None (flat)</option>
</select>
</label>
</div>
</div>
<div class="project-info"><div class="pi-name">actix-web</div><div class="pi-desc">Actix Web is a powerful, pragmatic, and extremely fast web framework for Rust.</div><div class="pi-row"><a class="pi-repo-btn" href="https://github.com/actix/actix-web" target="_blank" rel="noopener">🔗 https://github.com/actix/actix-web</a><span><span class="pi-k">branch</span> <code>main</code></span><span><span class="pi-k">commit</span> <a href="https://github.com/actix/actix-web/commit/d7ce1a3faba0eb565abad4f852c1f714ed9e19d2" target="_blank" rel="noopener"><code>d7ce1a3fab</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(cmpTypes);
// 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 DIFFICULTY_ORDER = ['very_easy','easy','medium','hard'];
const DIFFICULTY_LBL = {very_easy:'⚡ Very Easy', easy:'Easy', medium:'Medium', hard:'Hard'};
const DIFFICULTY_CLS = {very_easy:'diff-very-easy', easy:'diff-easy', medium:'diff-medium', hard:'diff-hard'};
const PRESENT_DIFFS = DIFFICULTY_ORDER.filter(d => FINDINGS.some(f => f.difficulty === d));
const HAS_DIFFICULTY = PRESENT_DIFFS.length > 0;
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: '', searchScope: 'all', groupBy: 'type',
hiddenSev: new Set(), hiddenType: new Set(),
hiddenStatus: new Set(), hiddenDiff: new Set(), hiddenGroup: new Set(),
collapsed: new Set(),
};
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.hiddenDiff.size) p.set('hd', [...S.hiddenDiff].join(','));
if (S.search) p.set('q', S.search);
if (S.searchScope !== 'all') p.set('qs', S.searchScope);
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('qs')) { S.searchScope = p.get('qs'); }
document.getElementById('search-scope').classList.toggle('active', S.searchScope === 'locations');
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('hd')) p.get('hd') .split(',').filter(Boolean).forEach(v => S.hiddenDiff.add(v));
}
// 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()); }
function isCustom(t) { return String(t).toLowerCase() === 'custom'; }
function typeCls(t) { return 'type-badge' + (isCustom(t) ? ' custom-type' : ''); }
// 'custom' findings are the user's own externally-sourced ones; pin them first
// everywhere types are listed so they never blend into the audit's own categories.
function cmpTypes(a, b) {
if (isCustom(a) !== isCustom(b)) return isCustom(a) ? -1 : 1;
return String(a).localeCompare(String(b));
}
// 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;
}
// Render a "**Label:** value" line. GitHub/CommonMark only recognizes a
// ``` fence when it starts at the beginning of a line, so if the value
// itself opens with a fence, put it on its own line instead of right
// after the inline label - otherwise the fence marker gets glued onto
// "**Label:** ```" and never renders as a code block.
function mdField(label, value) {
value = value || '';
return /^```/.test(value.trim())
? '**' + label + ':**\n\n' + value
: '**' + label + ':** ' + value;
}
// Render the "**Locations:**" block. When a location has a commit-pinned
// permalink (GitHub/GitLab), just paste the bare URL on its own line -
// GitHub/GitLab auto-expand a line-range blob link into an inline code
// preview, so re-pasting the snippet as a fenced block would duplicate it.
// Forges without a detected permalink shape (or missing commit/repo info)
// fall back to the fenced ref+snippet so the location is still readable.
function mdLocations(locations) {
if (!locations.length) return '';
let out = '**Locations:**\n\n';
for (const l of locations) {
if (l.link) out += l.link + '\n\n';
else if (l.code != null) out += '```\n' + l.ref + '\n' + l.code + '\n```\n\n';
else out += '- `' + l.ref + '` _(file not found)_\n\n';
}
return out;
}
// Build full MD report for in-browser download.
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(cmpTypes);
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() + '`' : '';
out += '### ' + f.id + sevStr + '\n\n';
out += mdField('Description', f.description) + '\n\n';
out += mdLocations(f.locations);
out += mdField('Fix', f.fix) + '\n\n---\n\n';
}
}
return out;
}
// Build MD short checklist for in-browser download.
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() + ']' : '';
lines.push('- [ ] - ' + f.id + sevStr + ' - ' + 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 a [project] header block from embedded PROJECT_INFO, so the exported
// findings.toml is self-contained (doesn't depend on a sibling project-info.toml
// that a second, separately-triggered download could silently fail to save).
// '' when there is no project info at all.
function buildProjectToml() {
const ORDER = ['name','description','git_url','repo_url','commit','commit_short','commit_url','branch','generated'];
const pi = PROJECT_INFO || {};
if (!Object.keys(pi).length) return '';
const lines = ['[project]'];
for (const k of ORDER) {
if (k in pi) lines.push(k + ' = ' + tomlStr(pi[k]));
}
return lines.join('\n') + '\n\n';
}
// 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]]'];
// Pin the id so re-rendering this export - or filtering it down later - can't
// renumber findings that were already referenced by id elsewhere.
if (f.id && f.id !== '?') lns.push(pad('id') + '= ' + tomlStr(f.id));
lns.push(pad('type') + '= ' + tomlStr(f.type));
lns.push(pad('severity') + '= ' + tomlStr(f.severity || 'medium'));
if (f.difficulty) lns.push(pad('difficulty') + '= ' + tomlStr(f.difficulty));
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 buildProjectToml() + chunks.join('\n\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() + '`' : '') + '\n\n';
out += mdField('Description', f.description) + '\n\n';
out += mdLocations(f.locations);
out += mdField('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 === 'folder') {
if (!f.locations.length) return '(no location)';
const path = f.locations[0].ref.replace(/:.*/,'');
const idx = path.lastIndexOf('/');
return idx === -1 ? '(root)' : path.slice(0, idx);
}
if (S.groupBy === 'none') return '';
return f.type_label;
}
// Pre-compute search haystacks per finding (cleared on full re-render).
// Two variants: everything (default), and file/folder paths only - for when a
// search term (e.g. a directory name like 'dlls/abc') also happens to appear
// inside description/fix prose and you only want path matches.
const _hay = new Map();
const _hayLoc = 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.difficulty ? (DIFFICULTY_LBL[f.difficulty] || f.difficulty) : '',
...f.locations.map(l => l.ref)].join(' ').toLowerCase());
}
return _hay.get(f.id);
}
function hayLocOf(f) {
if (!_hayLoc.has(f.id)) {
_hayLoc.set(f.id, f.locations.map(l => l.ref).join(' ').toLowerCase());
}
return _hayLoc.get(f.id);
}
function matches(f) {
if (S.hiddenDiff.size > 0 && f.difficulty && S.hiddenDiff.has(f.difficulty)) 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 (S.searchScope === 'locations') {
if (!hayLocOf(f).includes(q)) return false;
} else 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);
});
}
if (S.groupBy === 'type') return [...keys].sort(cmpTypes);
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>'
: '';
const refHtml = l.link
? '<a class="loc-link" href="' + esc(l.link) + '" target="_blank" rel="noopener" title="Open at this commit">' + highlight(l.ref, q) + ' ↗</a>'
: highlight(l.ref, q);
locs += '<div class="loc-block"><div class="loc-ref">' + refHtml + 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 ' + typeCls(f.type) + '" style="cursor:default">' + esc(f.type_label) + '</span>' +
(f.difficulty ? '<span class="badge ' + (DIFFICULTY_CLS[f.difficulty]||'') + '" style="cursor:default" title="Difficulty: ' + esc(DIFFICULTY_LBL[f.difficulty]||f.difficulty) + '">' + esc(DIFFICULTY_LBL[f.difficulty]||f.difficulty) + '</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 ' + typeCls(t) + '" 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_DIFFICULTY) {
h += '<div class="filter-row"><span class="filter-lbl">Difficulty:</span>';
for (const d of PRESENT_DIFFS)
h += '<button class="badge ' + (DIFFICULTY_CLS[d]||'') + '" data-diff="' + d + '">' + esc(DIFFICULTY_LBL[d]||d) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="diff"></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-diff]')) b.classList.toggle('off', S.hiddenDiff.has(b.dataset.diff));
for (const b of document.querySelectorAll('[data-toggle-all]')) {
const k = b.dataset.toggleAll;
const set = k === 'sev' ? S.hiddenSev : k === 'type' ? S.hiddenType : k === 'status' ? S.hiddenStatus : k === 'diff' ? S.hiddenDiff : null;
if (set) 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();
_hayLoc.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();
}
// Long-press solo: hold a filter badge 450 ms to isolate that value (hide all others in the group).
let _soloTimer = 0;
let _soloFired = false;
function _applySolo(b) {
_soloFired = true;
const sev = b.dataset.sev, typ = b.dataset.type, sta = b.dataset.status, dif = b.dataset.diff;
if (sev) { S.hiddenSev.clear(); PRESENT_SEVS.filter(v => v !== sev).forEach(v => S.hiddenSev.add(v)); }
else if (typ) { S.hiddenType.clear(); ALL_TYPES.filter(v => v !== typ).forEach(v => S.hiddenType.add(v)); }
else if (sta) { S.hiddenStatus.clear(); STATUSES.filter(v => v !== sta).forEach(v => S.hiddenStatus.add(v)); }
else if (dif) { S.hiddenDiff.clear(); PRESENT_DIFFS.filter(v => v !== dif).forEach(v => S.hiddenDiff.add(v)); }
scheduleRender();
}
const _fb = document.getElementById('filterbar');
_fb.addEventListener('pointerdown', e => {
const b = e.target.closest('[data-sev],[data-type],[data-status],[data-diff]');
if (!b) return;
_soloFired = false;
clearTimeout(_soloTimer);
_soloTimer = setTimeout(() => _applySolo(b), 450);
}, {passive: true});
_fb.addEventListener('pointerup', () => clearTimeout(_soloTimer), {passive: true});
_fb.addEventListener('pointercancel', () => clearTimeout(_soloTimer), {passive: true});
_fb.addEventListener('pointermove', e => {
if (!_soloTimer) return;
if (!e.target.closest('[data-sev],[data-type],[data-status],[data-diff]')) clearTimeout(_soloTimer);
}, {passive: true});
// Event delegation
_fb.addEventListener('click', e => {
if (_soloFired) { _soloFired = false; return; }
const b = e.target.closest('[data-sev],[data-type],[data-status],[data-toggle-all],[data-diff]');
if (!b) return;
if (b.dataset.toggleAll) {
const k = b.dataset.toggleAll;
const set = k === 'sev' ? S.hiddenSev : k === 'type' ? S.hiddenType : k === 'status' ? S.hiddenStatus : k === 'diff' ? S.hiddenDiff : null;
const full = k === 'sev' ? PRESENT_SEVS : k === 'type' ? ALL_TYPES : k === 'status' ? STATUSES : k === 'diff' ? PRESENT_DIFFS : [];
if (!set) return;
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); }
else if (b.dataset.diff) { const s = S.hiddenDiff; s.has(b.dataset.diff) ? s.delete(b.dataset.diff) : s.add(b.dataset.diff); }
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') {
// Single download (was two) - a second auto-triggered download is commonly
// blocked by the browser unless the user explicitly allows it, which silently
// drops project-info.toml. Embedding [project] in findings.toml itself avoids
// that failure mode entirely.
downloadText(buildFindingsToml(), 'findings.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();
});
document.getElementById('search-scope').addEventListener('click', e => {
S.searchScope = S.searchScope === 'locations' ? 'all' : 'locations';
e.currentTarget.classList.toggle('active', S.searchScope === 'locations');
scheduleRender();
});
// Init
loadStatuses();
initFilterBar();
loadHash();
FINDINGS.forEach(f => S.collapsed.add(f.id));
render();
})();
</script>
</body></html>