-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcrater_20260621.html
More file actions
766 lines (709 loc) · 367 KB
/
Copy pathcrater_20260621.html
File metadata and controls
766 lines (709 loc) · 367 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Code Audit — Interactive</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;font-size:14px;line-height:1.5}
.app{max-width:1100px;margin:0 auto;padding:1.5rem 1rem}
.top{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;margin-bottom:1rem;flex-wrap:wrap}
h1{font-size:1.5rem;font-weight:700;color:#f1f5f9}
.meta{font-size:.8rem;color:#64748b;margin-top:.2rem}
.top-right{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}
.project-info{margin-bottom:1rem;padding:.75rem .9rem;background:#1e293b;border:1px solid #334155;border-radius:8px}
.project-info .pi-name{font-size:.95rem;font-weight:700;color:#f1f5f9}
.project-info .pi-desc{font-size:.82rem;color:#cbd5e1;margin-top:.25rem}
.project-info .pi-row{display:flex;flex-wrap:wrap;gap:.4rem .9rem;margin-top:.45rem;font-size:.78rem;color:#94a3b8}
.project-info .pi-row .pi-k{color:#64748b}
.project-info a{color:#60a5fa;text-decoration:none}
.project-info a:hover{text-decoration:underline}
.project-info code{font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-size:.74rem;color:#93c5fd}
.pi-repo-btn{display:inline-flex;align-items:center;gap:.3rem;padding:.18rem .5rem;background:#0f172a;border:1px solid #3b82f6;border-radius:6px;color:#60a5fa !important;font-weight:600}
.pi-repo-btn:hover{background:#1d283a;text-decoration:none !important}
#search{background:#1e293b;border:1px solid #334155;color:#e2e8f0;padding:.4rem .75rem;border-radius:6px;font-size:.875rem;width:220px;outline:none}
#search:focus{border-color:#3b82f6}
.group-label{font-size:.8rem;color:#94a3b8;display:flex;align-items:center;gap:.4rem}
select{background:#1e293b;border:1px solid #334155;color:#e2e8f0;padding:.35rem .6rem;border-radius:6px;font-size:.8rem;outline:none;cursor:pointer}
select:focus{border-color:#3b82f6}
.filterbar{display:flex;flex-direction:column;gap:.4rem;margin-bottom:.6rem;padding:.6rem .75rem;background:#1e293b;border:1px solid #334155;border-radius:8px}
.filter-row{display:flex;flex-wrap:wrap;gap:.3rem;align-items:center}
.filter-lbl{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#475569;white-space:nowrap;margin-right:.25rem}
.filter-toggle{font-size:.65rem;font-weight:600;padding:.18rem .5rem;border-radius:4px;border:1px solid #334155;background:#0f172a;color:#94a3b8;cursor:pointer;margin-left:auto;white-space:nowrap;letter-spacing:.04em}
.filter-toggle:hover{background:#1e293b;color:#e2e8f0;border-color:#475569}
.badge{font-size:.68rem;font-weight:700;padding:.18rem .5rem;border-radius:4px;border:1px solid transparent;cursor:pointer;letter-spacing:.03em;transition:opacity .12s,filter .12s;user-select:none}
.badge.off{opacity:.28;filter:grayscale(.5)}
.sev-critical{background:#450a0a;color:#fca5a5;border-color:#991b1b}
.sev-high {background:#431407;color:#fdba74;border-color:#9a3412}
.sev-medium {background:#422006;color:#fcd34d;border-color:#854d0e}
.sev-low {background:#0c1a3a;color:#93c5fd;border-color:#1d4ed8}
.sev-negligible{background:#1e293b;color:#64748b;border-color:#334155}
.type-badge{background:#0c1e33;color:#7dd3fc;border-color:#0369a1}
.easy-badge{background:#052e16;color:#86efac;border-color:#16a34a}
.st-open{background:#1e293b;color:#475569;border-color:#334155}
.st-fixed{background:#052e16;color:#4ade80;border-color:#16a34a}
.st-wont{background:#431407;color:#fdba74;border-color:#9a3412}
.st-fp{background:#2e1065;color:#c4b5fd;border-color:#7c3aed}
#progress-wrap{display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem;flex-wrap:wrap}
#progress-bar{flex:1;min-width:120px;height:7px;background:#1e293b;border-radius:4px;overflow:hidden;border:1px solid #334155}
#progress-fill{height:100%;background:#22c55e;border-radius:4px;transition:width .35s ease;width:0}
#progress-text{font-size:.75rem;color:#64748b;white-space:nowrap}
.action-btn{font-size:.72rem;padding:.25rem .6rem;border-radius:5px;border:1px solid #334155;background:#1e293b;color:#94a3b8;cursor:pointer;white-space:nowrap}
.action-btn:hover{background:#334155;color:#e2e8f0}
#hidden-bar{margin-bottom:.6rem}
.hidden-inner{display:flex;flex-wrap:wrap;gap:.35rem;align-items:center}
.hidden-lbl{font-size:.72rem;color:#475569}
.show-btn{font-size:.7rem;padding:.15rem .45rem;border-radius:4px;background:#1e293b;color:#94a3b8;border:1px solid #334155;cursor:pointer}
.show-btn:hover{background:#334155;color:#e2e8f0}
.show-all-btn{font-size:.7rem;padding:.15rem .45rem;border-radius:4px;background:#2563eb;color:#fff;border:none;cursor:pointer}
.show-all-btn:hover{background:#1d4ed8}
.group-section{margin-bottom:1.5rem}
.group-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;padding-bottom:.35rem;border-bottom:1px solid #334155}
.group-title{font-size:.95rem;font-weight:700;color:#cbd5e1}
.group-title.all-done{color:#4ade80}
.group-count{font-size:.72rem;color:#475569;background:#1e293b;padding:.1rem .4rem;border-radius:10px;white-space:nowrap}
.icon-btn{background:transparent;border:none;color:#334155;cursor:pointer;font-size:.85rem;padding:.05rem .2rem;line-height:1}
.icon-btn:hover{color:#64748b}
.hide-btn{margin-left:auto;font-size:.68rem;padding:.12rem .45rem;border-radius:4px;background:transparent;color:#334155;border:1px solid #1e293b;cursor:pointer}
.hide-btn:hover{background:#1e293b;color:#64748b}
.card{background:#1e293b;border:1px solid #334155;border-radius:8px;margin-bottom:.6rem;overflow:hidden;transition:opacity .15s}
.card.resolved{opacity:.55;border-left:3px solid #16a34a}
.card.resolved:hover{opacity:.85}
.card-header{display:flex;align-items:center;gap:.5rem;padding:.45rem .85rem;background:#162032;border-bottom:1px solid #334155;flex-wrap:wrap;cursor:pointer;user-select:none}
.card-header:hover{background:#1a2840}
.card-arrow{font-size:.65rem;color:#334155;flex-shrink:0}
.card-id{font-size:.75rem;font-weight:700;color:#64748b;font-family:monospace;letter-spacing:.04em}
.card-loc{font-size:.72rem;color:#64748b;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.status-btn{margin-left:auto;font-size:.68rem;font-weight:600;padding:.15rem .5rem;border-radius:4px;border:1px solid transparent;cursor:pointer;white-space:nowrap;transition:filter .1s}
.status-btn:hover{filter:brightness(1.25)}
.copy-btn{font-size:.68rem;font-weight:600;padding:.15rem .5rem;border-radius:4px;border:1px solid #334155;background:#0f172a;color:#94a3b8;cursor:pointer;white-space:nowrap;transition:background .12s,color .12s}
.copy-btn:hover{background:#334155;color:#e2e8f0}
.card-body{padding:.85rem .9rem}
.field-lbl{font-size:.85rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#7dd3fc;margin:1rem 0 .35rem;padding-bottom:.18rem;border-bottom:1px solid #1e293b}
.field-lbl:first-child{margin-top:0}
.desc,.fix-txt{font-size:.875rem;color:#cbd5e1;line-height:1.55}
.desc p,.fix-txt p{margin:.4rem 0}
.desc p:first-child,.fix-txt p:first-child{margin-top:0}
.desc p:last-child,.fix-txt p:last-child{margin-bottom:0}
.loc-block{margin:.3rem 0 .6rem}
.loc-ref{font-size:.8rem;color:#38bdf8;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-weight:600;margin-bottom:.22rem}
a.loc-link{color:inherit;text-decoration:none}
a.loc-link:hover{text-decoration:underline}
.loc-lines{color:#475569;font-weight:400}
pre{background:#0f172a;color:#cdd6f4;border-radius:5px;padding:.6rem .8rem;overflow-x:auto;white-space:pre;font-size:.8rem;line-height:1.55;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;border:1px solid #1e293b;max-height:320px}
.desc pre.md-code,.fix-txt pre.md-code{margin:.5rem 0;font-size:.82rem;color:#e2e8f0;background:#0b1224;border:1px solid #1e293b;max-height:none}
code.md-inline{font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-size:.86em;background:#0b1224;color:#fca5a5;border:1px solid #1e293b;border-radius:3px;padding:.04rem .32rem;white-space:pre-wrap;word-break:break-word}
.not-found{font-size:.8rem;color:#334155;font-style:italic}
mark{background:#854d0e;color:#fef9c3;border-radius:2px;padding:0 .1rem}
.no-results{text-align:center;color:#334155;padding:3rem 1rem;font-size:.9rem}
</style>
</head>
<body>
<script type="application/json" id="D">[{"id":"PANIC_1","type":"panic","type_label":"Panic","severity":"high","easy":false,"description":"load_log parses the stored `encoding` column with `.unwrap()`. A malformed/unexpected value in the `encoding` column (corruption, schema drift, or a value not in {plain,gzip}) panics the whole process instead of returning an error. Every other parse path in this module propagates errors via `?`; this one does not.","fix":"Replace `let encoding = encoding.parse().unwrap();` with proper error handling, e.g. map the parse error into a rusqlite error or return a Fallible and use `?`. Mirror what load_test_result does (it parses via `?`).","locations":[{"ref":"src/results/db.rs:189-189","code":" 189 | let encoding = encoding.parse().unwrap();","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/results/db.rs#L189"}]},{"id":"PANIC_2","type":"panic","type_label":"Panic","severity":"medium","easy":true,"description":"build.rs unwraps the result of the second and third `git rev-parse` invocations. If `git rev-parse --short HEAD` succeeds (so the `if let Some` branch is taken) but `--symbolic`/`--symbolic-full-name` fails for any reason (detached HEAD edge cases, unusual git versions, permissions), `cmd(...).unwrap()` panics and aborts the entire build instead of degrading gracefully the way the outer `else` branch does.","fix":"Handle the Option from the symbolic lookups gracefully (e.g. fall back to skipping the rerun-if-changed lines, or `let symbolic = cmd(...).unwrap_or_default();`) instead of unwrapping.","locations":[{"ref":"build.rs:14-17","code":" 14 | if let Some(sha) = cmd(&[\"git\", \"rev-parse\", \"--short\", \"HEAD\"]) {\n 15 | let symbolic = cmd(&[\"git\", \"rev-parse\", \"--symbolic\", \"HEAD\"]).unwrap();\n 16 | let symbolic_full = cmd(&[\"git\", \"rev-parse\", \"--symbolic-full-name\", \"HEAD\"]).unwrap();\n 17 | ","lines":4,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/build.rs#L14-L17"}]},{"id":"PANIC_3","type":"panic","type_label":"Panic","severity":"medium","easy":false,"description":"TempfileBackedBuffer::new calls NonZeroUsize::new(len).unwrap() where len is the tempfile length. If the assembled tar.zst archive is empty (zero crates / all skipped, producing a zero-length file after finish, though zstd adds a header so unlikely) or if file length exceeds usize on a 32-bit target, try_into().unwrap()/NonZeroUsize::new().unwrap() panics. mmap of a zero-length region is also invalid. The size try_into().unwrap() at line 24 can also panic on 32-bit platforms for files >4GiB.","fix":"Handle the zero-length case explicitly (return an empty buffer) and return a Fallible error instead of unwrapping the NonZeroUsize / try_into conversions.","locations":[{"ref":"src/report/archives.rs:23-24","code":" 23 | fn new(file: File) -> Fallible<TempfileBackedBuffer> {\n 24 | let len = file.metadata()?.len().try_into().unwrap();","lines":2,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/archives.rs#L23-L24"},{"ref":"src/report/archives.rs:28-28","code":" 28 | NonZeroUsize::new(len).unwrap(),","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/archives.rs#L28"}]},{"id":"PANIC_4","type":"panic","type_label":"Panic","severity":"medium","easy":false,"description":"compare() can panic with `panic!(\"can't compare {res1} and {res2}\")` for the TestSkipped vs TestFail/TestPass combinations. This panic is reachable from generate_report (during report generation) and from archives::iterate. If a real experiment ever produces a (TestSkipped, TestPass)/(TestPass, TestSkipped)/(TestFail, TestSkipped)/(TestSkipped, TestFail) pair, the entire report generation aborts via panic instead of degrading to Unknown.","fix":"Return a defined Comparison (e.g. Comparison::Unknown or a dedicated variant) instead of panicking, so report generation is robust to unexpected result pairs.","locations":[{"ref":"src/report/mod.rs:540-542","code":" 540 | (TestFail(_) | TestPass, TestSkipped) | (TestSkipped, TestFail(_) | TestPass) => {\n 541 | panic!(\"can't compare {res1} and {res2}\");\n 542 | }","lines":3,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/mod.rs#L540-L542"}]},{"id":"PANIC_5","type":"panic","type_label":"Panic","severity":"medium","easy":false,"description":"assert_eq!(cargo_args[0], \"rustdoc\") asserts on the output of the external docsrs-metadata crate while running untrusted crates. If a future version of docsrs-metadata (or unusual crate metadata) returns args not starting with \"rustdoc\", this panics the worker thread mid-experiment instead of degrading gracefully. Worker panics on untrusted input should be avoided in the hot execution path.","fix":"Replace the assert with a checked condition that returns an Err / logs and skips the docs.rs pass when the first arg is unexpected, rather than panicking.","locations":[{"ref":"src/runner/test.rs:490-494","code":" 490 | let cargo_args = metadata.cargo_args(\n 491 | &[\"--frozen\".into(), \"--message-format=json\".into()],\n 492 | &[\"--document-private-items\".into()],\n 493 | );\n 494 | assert_eq!(cargo_args[0], \"rustdoc\");","lines":5,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/test.rs#L490-L494"}]},{"id":"PANIC_6","type":"panic","type_label":"Panic","severity":"medium","easy":false,"description":"record_heartbeat and set_git_revision assert_eq!(changes, 1) on the number of rows updated. If the named agent row does not exist (e.g. an agent whose token was removed from tokens.toml but still sends a heartbeat, or a race with synchronize() deleting the row), the UPDATE affects 0 rows and the assert panics, taking down the request thread instead of returning a clean error.","fix":"Replace the assert with a checked error: if changes != 1 return an Err (or log and ignore for an unknown agent) rather than panicking.","locations":[{"ref":"src/server/agents.rs:183-191","code":" 183 | pub fn record_heartbeat(&self, agent: &str) -> Fallible<()> {\n 184 | let changes = self.db.execute(\n 185 | \"UPDATE agents SET last_heartbeat = ?1 WHERE name = ?2;\",\n 186 | &[&Utc::now(), &agent],\n 187 | )?;\n 188 | assert_eq!(changes, 1);\n 189 | \n 190 | Ok(())\n 191 | }","lines":9,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/agents.rs#L183-L191"},{"ref":"src/server/agents.rs:193-201","code":" 193 | pub fn set_git_revision(&self, agent: &str, revision: &str) -> Fallible<()> {\n 194 | let changes = self.db.execute(\n 195 | \"UPDATE agents SET git_revision = ?1 WHERE name = ?2;\",\n 196 | &[&revision, &agent],\n 197 | )?;\n 198 | assert_eq!(changes, 1);\n 199 | \n 200 | Ok(())\n 201 | }","lines":9,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/agents.rs#L193-L201"}]},{"id":"PANIC_7","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"RegistryList::fetch unwraps three Option entry handles (crates_entry/versions_entry/dependencies_entry) with `.unwrap()`. If the db-dump.zip layout ever changes and one of data/crates.csv, data/versions.csv, data/dependencies.csv is missing or renamed, this panics instead of producing a contextual error. There is also `.split('?').next().unwrap()` patterns elsewhere (those are safe), but these archive unwraps depend on a remote file's structure.","fix":"Replace each `.unwrap()` with `.ok_or_else(|| anyhow!(\"db-dump.zip missing data/<file>.csv\"))?` for a clear error if the dump format changes.","locations":[{"ref":"src/crates/sources/registry.rs:167-167","code":" 167 | let entry = archive.get_entry(crates_entry.unwrap())?;","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/sources/registry.rs#L167"},{"ref":"src/crates/sources/registry.rs:179-179","code":" 179 | let entry = archive.get_entry(versions_entry.unwrap())?;","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/sources/registry.rs#L179"},{"ref":"src/crates/sources/registry.rs:221-221","code":" 221 | let entry = archive.get_entry(dependencies_entry.unwrap())?;","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/sources/registry.rs#L221"}]},{"id":"PANIC_8","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"raw_progress() calls .unwrap() on the Option returned by get_row for both COUNT queries. COUNT(*) always returns a row so this is currently safe, but it is an unguarded unwrap in a hot path; if the query were ever changed (e.g. a WHERE that filters the aggregate via HAVING) it would panic in production rather than return an error.","fix":"Use .unwrap_or(0) or propagate with ok_or_else to avoid a latent panic.","locations":[{"ref":"src/experiments.rs:551-566","code":" 551 | let results_len: u32 = db\n 552 | .get_row(\n 553 | \"SELECT COUNT(*) AS count FROM results WHERE experiment = ?1;\",\n 554 | [&self.name.as_str()],\n 555 | |r| r.get(\"count\"),\n 556 | )?\n 557 | .unwrap();\n 558 | \n 559 | let crates_len: u32 = db\n 560 | .get_row(\n 561 | \"SELECT COUNT(*) AS count FROM experiment_crates \\\n 562 | WHERE experiment = ?1 AND skipped = 0;\",\n 563 | [&self.name.as_str()],\n 564 | |r| r.get(\"count\"),\n 565 | )?\n 566 | .unwrap();","lines":16,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L551-L566"}]},{"id":"PANIC_9","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"endpoint_next_experiment, endpoint_error, and the record-progress worker call mutex.lock().unwrap() / Experiment::get(...).unwrap(). A panic while holding the Data mutex poisons it, after which every subsequent .lock().unwrap() on that mutex panics, taking down all agent endpoints that use the mutex (next-experiment, error). The record-progress worker wraps work in catch_unwind but the lock().unwrap() in the warp handlers do not.","fix":"Use lock().unwrap_or_else(|e| e.into_inner()) as done in RecordProgressThread, or otherwise recover from poisoning, to avoid cascading failures.","locations":[{"ref":"src/server/routes/agent.rs:120-120","code":" 120 | let data = mutex.lock().unwrap();","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/agent.rs#L120"},{"ref":"src/server/routes/agent.rs:408-408","code":" 408 | let data = mutex.lock().unwrap();","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/agent.rs#L408"},{"ref":"src/server/routes/agent.rs:218-218","code":" 218 | if let Some(ex) = Experiment::get(&db, &result.experiment_name).unwrap() {","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/agent.rs#L218"}]},{"id":"PANIC_10","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"endpoint_metrics builds the response via String::from_utf8(buffer).unwrap(). Prometheus text encoding is ASCII so this won't panic in practice, but the unwrap is a latent panic on the request thread; prefer from_utf8_lossy or propagating the error.","fix":"Use String::from_utf8_lossy or return the error via the existing Fallible path instead of unwrap().","locations":[{"ref":"src/server/routes/metrics.rs:40-40","code":" 40 | Ok(Response::new(String::from_utf8(buffer).unwrap().into()))","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/metrics.rs#L40"}]},{"id":"PANIC_11","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"endpoint_metrics unwraps String::from_utf8 on the encoded Prometheus output. While the TextEncoder produces valid UTF-8 today, this is an unnecessary panic point in an HTTP handler. If any future label/metric value introduces non-UTF-8 bytes the metrics endpoint will panic the request task instead of returning a 500.","fix":"Use String::from_utf8_lossy(&buffer).into_owned() or propagate the error via ? with map_err, instead of unwrap().","locations":[{"ref":"src/server/routes/metrics.rs:40-40","code":" 40 | Ok(Response::new(String::from_utf8(buffer).unwrap().into()))","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/metrics.rs#L40"}]},{"id":"PANIC_12","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"endpoint_list calls agent.capabilities().expect(\"Capabilities were loaded from the db\"). If capabilities were not loaded (e.g. an agent that never sent config, or a load ordering change), the agents UI page panics and returns 500 instead of degrading gracefully.","fix":"Handle the None/Err case by defaulting to an empty capabilities list instead of expect().","locations":[{"ref":"src/server/routes/ui/agents.rs:36-38","code":" 36 | .capabilities()\n 37 | .expect(\"Capabilities were loaded from the db\")\n 38 | .iter()","lines":3,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/ui/agents.rs#L36-L38"}]},{"id":"PANIC_13","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"line[line.find(' ').unwrap()..] relies on starts_with(&start) where start ends with a space, so a space is guaranteed present and the unwrap cannot currently fire. However this couples to the exact format of `start` (the trailing space in format!(\"@{} \", bot_username)). If `start` is ever changed to not contain a space the unwrap panics on every comment. The invariant is non-obvious and unguarded.","fix":"Use line.split_once(' ') and handle the None case, or split off the known `start` prefix length explicitly instead of searching for a space.","locations":[{"ref":"src/server/routes/webhooks/mod.rs:82-88","code":" 82 | let start = format!(\"@{} \", github_data.bot_username);\n 83 | for line in body.lines() {\n 84 | if !line.starts_with(&start) {\n 85 | continue;\n 86 | }\n 87 | \n 88 | let command = line[line.find(' ').unwrap()..].trim();","lines":7,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/webhooks/mod.rs#L82-L88"}]},{"id":"PANIC_14","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"process_command does `line[line.find(' ').unwrap()..]` after checking the line starts with `@{bot} ` (which contains a space), so find(' ') cannot be None here - but the unwrap relies on that prefix always containing a space; if bot_username were empty the start string is '@ ' which still has a space, so it's safe, yet the unwrap is a fragile invariant worth a comment. (Cross-module: webhooks, outside slice but reached via messages/auth flow.)","fix":"Use split_once(' ') to avoid the unwrap and make the invariant explicit.","locations":[{"ref":"src/server/routes/webhooks/mod.rs:88-88","code":" 88 | let command = line[line.find(' ').unwrap()..].trim();","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/webhooks/mod.rs#L88"}]},{"id":"PANIC_15","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"HmacSha1::new_from_slice(secret.as_bytes()).unwrap() will panic if the configured webhook secret produces an invalid key. HMAC accepts keys of any length so this is currently infeasible, but a panic in the signature-verification path on attacker-influenced timing is undesirable; the unwrap masks the fact that the secret comes from config.","fix":"expect with a descriptive message at minimum, or validate the secret once at startup.","locations":[{"ref":"src/server/routes/webhooks/mod.rs:186-186","code":" 186 | let mut mac = HmacSha1::new_from_slice(secret.as_bytes()).unwrap();","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/webhooks/mod.rs#L186"}]},{"id":"RACE_1","type":"race_condition","type_label":"Race Condition","severity":"medium","easy":false,"description":"endpoint_next_crate_inner has a TOCTOU/duplication window: it pops from uncompleted_cache without coordinating with experiment state, and the cache is populated from get_uncompleted_crates with a 120s freshness window. Two concurrent agents can each refill the cache (line 156-165) after both find it empty, or a crate handed out from cache may already be completed/assigned. The 120-second staleness check only filters by wall-clock age, not by whether the crate is still uncompleted, so agents can be assigned already-completed crates, wasting work.","fix":"Validate that a cached crate is still uncompleted before returning it, or hold a lock around the check-and-refill so two agents cannot both repopulate the cache.","locations":[{"ref":"src/server/routes/agent.rs:144-173","code":" 144 | fn endpoint_next_crate_inner(\n 145 | experiment: String,\n 146 | data: Arc<Data>,\n 147 | ) -> Fallible<Option<crate::crates::Crate>> {\n 148 | let result: Option<crate::crates::Crate> =\n 149 | if let Some(ex) = Experiment::get(&data.db, &experiment)? {\n 150 | while let Some(next) = data.uncompleted_cache.lock().unwrap().pop_front() {\n 151 | if next.0.elapsed() <= std::time::Duration::from_secs(120) {\n 152 | return Ok(Some(next.1));\n 153 | }\n 154 | }\n 155 | \n 156 | let mut crates = ex.get_uncompleted_crates(&data.db, Some(1000))?;\n 157 | if crates.is_empty() {\n 158 | None\n 159 | } else {\n 160 | let now = std::time::Instant::now();\n 161 | let ret = crates.pop().unwrap();\n 162 | data.uncompleted_cache\n 163 | .lock()\n 164 | .unwrap()\n 165 | .extend(crates.into_iter().map(|c| (now, c)));\n 166 | Some(ret)\n 167 | }\n 168 | } else {\n 169 | None\n 170 | };\n 171 | \n 172 | Ok(result)\n 173 | }","lines":30,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/agent.rs#L144-L173"}]},{"id":"OVF_1","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"medium","easy":true,"description":"Size::to_bytes multiplies a usize by up to 1024^4 with plain `*`. On a 32-bit target a value like `Gigabytes(8)` overflows usize entirely; even on 64-bit, large but plausible config values (e.g. several thousand terabytes) overflow. In debug builds this panics, in release it silently wraps to a bogus (often tiny) byte count that is then used as a memory/log-size limit. Config values are user-controlled (config.toml), so a typo produces wrong limits silently.","fix":"Use checked or saturating arithmetic, e.g. `kb.checked_mul(1024).ok_or(...)?` returning a Result, or `saturating_mul`, and store/compute in u64 rather than usize so 32-bit hosts behave consistently.","locations":[{"ref":"src/utils/size.rs:18-26","code":" 18 | pub(crate) fn to_bytes(&self) -> usize {\n 19 | match self {\n 20 | Size::Bytes(b) => *b,\n 21 | Size::Kilobytes(kb) => kb * 1024,\n 22 | Size::Megabytes(mb) => mb * 1024 * 1024,\n 23 | Size::Gigabytes(gb) => gb * 1024 * 1024 * 1024,\n 24 | Size::Terabytes(tb) => tb * 1024 * 1024 * 1024 * 1024,\n 25 | }\n 26 | }","lines":9,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/utils/size.rs#L18-L26"}]},{"id":"OVF_2","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"progress() computes (results_len as f32 * 100.0 / crates_len as f32).ceil() as u8. If results_len exceeds crates_len (the FIXME at lines 318-320 explicitly says this happens: 'more results than crates'), the percentage exceeds 100 and the `as u8` cast saturates at 255 (or yields a misleading >100 value before saturation). f32 also loses precision for large counts.","fix":"Clamp to 100 before casting: `((results_len * 100).min(crates_len*100) ... )` or `result.min(100.0) as u8`; use integer math to avoid f32 precision loss.","locations":[{"ref":"src/experiments.rs:585-593","code":" 585 | pub fn progress(&self, db: &Database) -> Fallible<u8> {\n 586 | let (results_len, crates_len) = self.raw_progress(db)?;\n 587 | \n 588 | if crates_len != 0 {\n 589 | Ok((results_len as f32 * 100.0 / crates_len as f32).ceil() as u8)\n 590 | } else {\n 591 | Ok(0)\n 592 | }\n 593 | }","lines":9,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L585-L593"}]},{"id":"OVF_3","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"estimated_end computes job_duration * (total_jobs as i32 - completed_jobs as i32). total_jobs and completed_jobs are u32; casting to i32 truncates/wraps for counts above i32::MAX (~2.1 billion). More realistically, chrono Duration multiplication by a large i32 can panic on overflow. For huge experiments the multiplication job_duration * N could overflow the Duration's internal i64 milliseconds and panic.","fix":"Use checked Duration arithmetic and u32->i64 conversions; guard against overflow when estimating remaining time.","locations":[{"ref":"src/server/routes/ui/experiments.rs:157-165","code":" 157 | (Some(total), None, total / completed_jobs as i32)\n 158 | } else {\n 159 | let total = Utc::now().signed_duration_since(started_at);\n 160 | let job_duration = total / completed_jobs as i32;\n 161 | (\n 162 | None,\n 163 | Some(job_duration * (total_jobs as i32 - completed_jobs as i32)),\n 164 | job_duration,\n 165 | )","lines":9,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/ui/experiments.rs#L157-L165"}]},{"id":"LOGIC_1","type":"logic_error","type_label":"Logic Error","severity":"high","easy":false,"description":"raw_progress() returns (results_len, crates_len*2), assuming every crate produces exactly 2 results (one per toolchain). The comment in ready_for_report and the 'completed >= all' check rely on this. But results are counted regardless of toolchain or crate, so duplicate/extra results (acknowledged in the FIXME at lines 318-320) or modes that produce a different number of results per crate make this comparison unreliable, causing experiments to be considered 'done' prematurely or never. The *2 is a hard-coded magic assumption about the two-toolchain layout.","fix":"Compute the expected result count from actual toolchains/mode, or count distinct (crate,toolchain) pairs in results rather than multiplying crate count by 2.","locations":[{"ref":"src/experiments.rs:550-569","code":" 550 | pub fn raw_progress(&self, db: &Database) -> Fallible<(u32, u32)> {\n 551 | let results_len: u32 = db\n 552 | .get_row(\n 553 | \"SELECT COUNT(*) AS count FROM results WHERE experiment = ?1;\",\n 554 | [&self.name.as_str()],\n 555 | |r| r.get(\"count\"),\n 556 | )?\n 557 | .unwrap();\n 558 | \n 559 | let crates_len: u32 = db\n 560 | .get_row(\n 561 | \"SELECT COUNT(*) AS count FROM experiment_crates \\\n 562 | WHERE experiment = ?1 AND skipped = 0;\",\n 563 | [&self.name.as_str()],\n 564 | |r| r.get(\"count\"),\n 565 | )?\n 566 | .unwrap();\n 567 | \n 568 | Ok((results_len, crates_len * 2))\n 569 | }","lines":20,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L550-L569"},{"ref":"src/experiments.rs:316-323","code":" 316 | let (completed, all) = ex.raw_progress(db)?;\n 317 | // FIXME: We often see more results than crates -- presumably, some\n 318 | // crates are run more than once and marked more than once, but the\n 319 | // details are not clear. For now, just assume that we're done if we\n 320 | // have 'more than enough' results too.\n 321 | if completed >= all {\n 322 | return Ok(Some(ex));\n 323 | }","lines":8,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L316-L323"}]},{"id":"LOGIC_2","type":"logic_error","type_label":"Logic Error","severity":"high","easy":false,"description":"Windows path-separator normalization is broken: it replaces the single-quote character ('\\'') instead of the backslash path separator. On Windows, PathBuf::to_str() produces backslash-separated paths (e.g. `stable\\reg\\foo`), but this code replaces `'` (apostrophe) with `/`, so backslashes survive into the stored `log` URLs and the comment's intent (normalize Windows paths) is never achieved. The log links in the generated report would be broken when the report is generated on Windows.","fix":"Replace the backslash separator: `.replace('\\\\', \"/\")` instead of `.replace('\\'', \"/\")`. Better, build the URL from path components joined with '/' directly rather than relying on platform-specific PathBuf rendering.","locations":[{"ref":"src/report/mod.rs:259-259","code":" 259 | .replace('\\'', \"/\"), // Normalize paths in reports generated on Windows","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/mod.rs#L259"}]},{"id":"LOGIC_3","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"CreateExperiment::apply calls get_crates (which can perform expensive list resolution) BEFORE opening the transaction, and the duplicate-name check (Experiment::exists) is done outside the transaction too. Two concurrent CreateExperiment calls with the same name can both pass the exists() check and then race on the INSERT; the second INSERT fails on the PRIMARY KEY/unique constraint with a raw SQL error rather than the friendly AlreadyExists error. TOCTOU on the uniqueness check.","fix":"Rely on the DB unique constraint inside the transaction and map the constraint violation to ExperimentError::AlreadyExists, instead of a pre-check that races.","locations":[{"ref":"src/actions/experiments/create.rs:42-89","code":" 42 | fn apply(self, ctx: &ActionsCtx) -> Fallible<()> {\n 43 | // Ensure no duplicate experiments are created\n 44 | if Experiment::exists(ctx.db, &self.name)? {\n 45 | return Err(ExperimentError::AlreadyExists(self.name).into());\n 46 | }\n 47 | \n 48 | // Ensure no experiment with duplicate toolchains is created\n 49 | if self.toolchains[0] == self.toolchains[1] {\n 50 | return Err(ExperimentError::DuplicateToolchains.into());\n 51 | }\n 52 | \n 53 | let crates = crate::crates::lists::get_crates(&self.crates, ctx.db, ctx.config)?;\n 54 | \n 55 | ctx.db.transaction(true, |transaction| {\n 56 | transaction.execute(\n 57 | \"INSERT INTO experiments \\\n 58 | (name, mode, cap_lints, toolchain_start, toolchain_end, priority, created_at, \\\n 59 | status, github_issue, github_issue_url, github_issue_number, ignore_blacklist, \\\n 60 | assigned_to, requirement) \\\n 61 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14);\",\n 62 | &[\n 63 | &self.name,\n 64 | &self.mode.to_str(),\n 65 | &self.cap_lints.to_str(),\n 66 | &self.toolchains[0].to_string(),\n 67 | &self.toolchains[1].to_string(),\n 68 | &self.priority,\n 69 | &Utc::now(),\n 70 | &Status::Queued.to_str(),\n 71 | &self.github_issue.as_ref().map(|i| i.api_url.as_str()),\n 72 | &self.github_issue.as_ref().map(|i| i.html_url.as_str()),\n 73 | &self.github_issue.as_ref().map(|i| i.number),\n 74 | &self.ignore_blacklist,\n 75 | &self.assign.map(|a| a.to_string()),\n 76 | &self.requirement,\n 77 | ],\n 78 | )?;\n 79 | \n 80 | for krate in &crates {\n 81 | let skipped = !self.ignore_blacklist && ctx.config.should_skip(krate);\n 82 | transaction.execute(\n 83 | \"INSERT INTO experiment_crates (experiment, crate, skipped, status) VALUES (?1, ?2, ?3, ?4);\",\n 84 | &[&self.name, &krate.id(), &skipped, &Status::Queued.to_string()],\n 85 | )?;\n 86 | }\n 87 | \n 88 | Ok(())\n 89 | })?;","lines":48,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/create.rs#L42-L89"}]},{"id":"LOGIC_4","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"EditExperiment recomputes the skipped flag for crates using `ex.ignore_blacklist` (the in-memory copy updated earlier in the same transaction). This works only because ignore_blacklist is updated before the crates block. However, when ignore_blacklist is NOT changed but crates ARE changed, the skipped flag is recomputed from ex.ignore_blacklist which was loaded from DB - correct. But if crates is changed in the SAME edit where ignore_blacklist is also changed, ordering must hold; the dependency is implicit and brittle. There is no comment marking that the ignore_blacklist update MUST precede the crates rebuild.","fix":"Add an invariant comment, or compute skipped from a single resolved ignore_blacklist value determined up front before any branch.","locations":[{"ref":"src/actions/experiments/edit.rs:69-108","code":" 69 | if let Some(ignore_blacklist) = self.ignore_blacklist {\n 70 | let changes = t.execute(\n 71 | \"UPDATE experiments SET ignore_blacklist = ?1 WHERE name = ?2;\",\n 72 | &[&ignore_blacklist, &self.name],\n 73 | )?;\n 74 | assert_eq!(changes, 1);\n 75 | ex.ignore_blacklist = ignore_blacklist;\n 76 | }\n 77 | \n 78 | // Try to update the list of crates\n 79 | // This is also done if ignore_blacklist is changed to recalculate the skipped crates\n 80 | let new_crates = if let Some(crates) = self.crates {\n 81 | Some(crate::crates::lists::get_crates(\n 82 | &crates, ctx.db, ctx.config,\n 83 | )?)\n 84 | } else if self.ignore_blacklist.is_some() {\n 85 | Some(ex.get_crates(ctx.db)?)\n 86 | } else {\n 87 | None\n 88 | };\n 89 | if let Some(crates_vec) = new_crates {\n 90 | // Recreate the list of crates without checking if it was the same\n 91 | // This is done to allow reloading the list of crates in an existing experiment\n 92 | t.execute(\n 93 | \"DELETE FROM experiment_crates WHERE experiment = ?1;\",\n 94 | &[&self.name],\n 95 | )?;\n 96 | for krate in &crates_vec {\n 97 | t.execute(\n 98 | \"INSERT INTO experiment_crates (experiment, crate, skipped, status) \\\n 99 | VALUES (?1, ?2, ?3, ?4);\",\n 100 | &[\n 101 | &self.name,\n 102 | &krate.id(),\n 103 | &(!ex.ignore_blacklist && ctx.config.should_skip(krate)),\n 104 | &Status::Queued.to_string(),\n 105 | ],\n 106 | )?;\n 107 | }\n 108 | }","lines":40,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/edit.rs#L69-L108"}]},{"id":"LOGIC_5","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"EditExperiment deletes ALL rows from experiment_crates and re-inserts when crates change OR ignore_blacklist changes. This resets the per-crate status/started_at columns. Although edit is only allowed on Queued experiments (guarded at edit.rs:44), the DELETE+reinsert silently discards any per-crate state and resets status to Queued for every crate, which is acceptable for queued experiments but is an expensive and total rewrite even when only the blacklist flag toggled.","fix":"For an ignore_blacklist-only change, issue a targeted UPDATE of the skipped column rather than deleting and recreating all experiment_crates rows.","locations":[{"ref":"src/actions/experiments/edit.rs:78-108","code":" 78 | // Try to update the list of crates\n 79 | // This is also done if ignore_blacklist is changed to recalculate the skipped crates\n 80 | let new_crates = if let Some(crates) = self.crates {\n 81 | Some(crate::crates::lists::get_crates(\n 82 | &crates, ctx.db, ctx.config,\n 83 | )?)\n 84 | } else if self.ignore_blacklist.is_some() {\n 85 | Some(ex.get_crates(ctx.db)?)\n 86 | } else {\n 87 | None\n 88 | };\n 89 | if let Some(crates_vec) = new_crates {\n 90 | // Recreate the list of crates without checking if it was the same\n 91 | // This is done to allow reloading the list of crates in an existing experiment\n 92 | t.execute(\n 93 | \"DELETE FROM experiment_crates WHERE experiment = ?1;\",\n 94 | &[&self.name],\n 95 | )?;\n 96 | for krate in &crates_vec {\n 97 | t.execute(\n 98 | \"INSERT INTO experiment_crates (experiment, crate, skipped, status) \\\n 99 | VALUES (?1, ?2, ?3, ?4);\",\n 100 | &[\n 101 | &self.name,\n 102 | &krate.id(),\n 103 | &(!ex.ignore_blacklist && ctx.config.should_skip(krate)),\n 104 | &Status::Queued.to_string(),\n 105 | ],\n 106 | )?;\n 107 | }\n 108 | }","lines":31,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/edit.rs#L78-L108"}]},{"id":"LOGIC_6","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"RegistryList sorts crates ascending by reverse-dependency count (sort_unstable_by_key with the raw count). CrateSelect::Top(n) then `truncate(n)` to take the first n. Combined this yields the n LEAST-depended-on crates, not the most popular ('top') ones. Unless a later reverse is applied (none is in get_crates), `Top` selects the least-popular crates.","fix":"Sort descending by reverse-dependency count (e.g. negate the key or use Reverse) so that Top(n) selects the most-depended-upon crates, or reverse the list before truncating in get_crates.","locations":[{"ref":"src/crates/sources/registry.rs:259-265","code":" 259 | list.sort_unstable_by_key(|a| {\n 260 | if let Crate::Registry(ref a) = a {\n 261 | by_name[&a.name].1\n 262 | } else {\n 263 | panic!(\"non-registry crate produced in the registry list\");\n 264 | }\n 265 | });","lines":7,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/sources/registry.rs#L259-L265"},{"ref":"src/crates/lists.rs:152-155","code":" 152 | CrateSelect::Top(n) => {\n 153 | crates.append(&mut RegistryList::get(db)?);\n 154 | crates.truncate(*n as usize);\n 155 | }","lines":4,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/lists.rs#L152-L155"}]},{"id":"LOGIC_7","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"experiment_crates table is created with no PRIMARY KEY or UNIQUE constraint on (experiment, crate) (initial migration), and later columns (skipped, status, assigned_to, started_at) are added. Nothing enforces one row per (experiment, crate), so a crate accidentally inserted twice for an experiment would be served/recorded twice and skew progress accounting. The results table by contrast has PRIMARY KEY (experiment, crate, toolchain). This asymmetry is a latent data-integrity gap in the crate the worker scans for the next job.","fix":"Add a UNIQUE constraint / primary key on (experiment, crate) for experiment_crates (via a new migration that rebuilds the table), so duplicate crate assignments cannot occur.","locations":[{"ref":"src/db/migrations.rs:38-43","code":" 38 | CREATE TABLE experiment_crates (\n 39 | experiment TEXT NOT NULL,\n 40 | crate TEXT NOT NULL,\n 41 | \n 42 | FOREIGN KEY (experiment) REFERENCES experiments(name) ON DELETE CASCADE\n 43 | );","lines":6,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/db/migrations.rs#L38-L43"}]},{"id":"LOGIC_8","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"assign_experiment() unconditionally writes latest_work_for for the agent (line 370-373) even when the experiment is NOT new (already Running) and even when find_next returned an experiment that was originally selected for a different reason. More importantly, when new_ex is true it always overwrites assigned_to with Distributed unless already set, but it re-issues set_assigned_to even when assigned_to already equals what's stored, producing a redundant UPDATE every time. Combined with the unconditional latest_work_for UPDATE, this is a write on every poll.","fix":"Only update latest_work_for / assigned_to when the value actually changes; skip the writes for already-Running experiments returned by run_by().","locations":[{"ref":"src/experiments.rs:363-392","code":" 363 | fn assign_experiment(\n 364 | db: &Database,\n 365 | ex: Option<Experiment>,\n 366 | agent: &Assignee,\n 367 | ) -> Fallible<Option<(bool, Experiment)>> {\n 368 | if let Some(mut experiment) = ex {\n 369 | if let Assignee::Agent(name) = agent {\n 370 | db.execute(\n 371 | \"update agents set latest_work_for = ?2 where agents.name = ?1;\",\n 372 | rusqlite::params![&name, &experiment.name],\n 373 | )?;\n 374 | }\n 375 | \n 376 | let new_ex = experiment.status != Status::Running;\n 377 | if new_ex {\n 378 | experiment.set_status(db, Status::Running)?;\n 379 | // If this experiment was not assigned to a specific agent make it distributed\n 380 | experiment.set_assigned_to(\n 381 | db,\n 382 | experiment\n 383 | .assigned_to\n 384 | .clone()\n 385 | .or(Some(Assignee::Distributed))\n 386 | .as_ref(),\n 387 | )?;\n 388 | }\n 389 | return Ok(Some((new_ex, experiment)));\n 390 | }\n 391 | Ok(None)\n 392 | }","lines":30,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L363-L392"}]},{"id":"LOGIC_9","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"set_status uses a match on (self.status, status) to set started_at/completed_at. The completed_at arm only fires when transitioning OUT of Running ((Status::Running, _)). If an experiment goes Running -> NeedsReport -> GeneratingReport -> Completed, completed_at is set at the Running->NeedsReport transition, not when actually Completed, so completed_at reflects when running stopped rather than when the experiment completed. This may be intended but is surprising and undocumented; also a direct Queued->Completed transition (not via Running) never sets completed_at at all.","fix":"Set completed_at on the transition INTO Status::Completed (and started_at into Running), making the semantics explicit and covering non-Running source states.","locations":[{"ref":"src/experiments.rs:504-522","code":" 504 | match (self.status, status) {\n 505 | // Check if the new status is \"running\" and there is no starting date\n 506 | (_, Status::Running) if self.started_at.is_none() => {\n 507 | db.execute(\n 508 | \"UPDATE experiments SET started_at = ?1 WHERE name = ?2;\",\n 509 | &[&now, &self.name.as_str()],\n 510 | )?;\n 511 | self.started_at = Some(now);\n 512 | }\n 513 | // Check if the old status was \"running\" and there is no completed date\n 514 | (Status::Running, _) if self.completed_at.is_none() => {\n 515 | db.execute(\n 516 | \"UPDATE experiments SET completed_at = ?1 WHERE name = ?2;\",\n 517 | &[&now, &self.name.as_str()],\n 518 | )?;\n 519 | self.completed_at = Some(now);\n 520 | }\n 521 | _ => (),\n 522 | }","lines":19,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L504-L522"}]},{"id":"LOGIC_10","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"Markdown report only ever generates a non-full (summary) report ('markdown.md' with full=false), so categories that don't show_in_summary (Skipped, SameBuildFail, SameTestFail, SameTestSkipped, SameTestPass, Broken) never appear in any markdown output. The HTML report writes both index.html (summary) and full.html; markdown has no full equivalent, which may be intentional but is an undocumented asymmetry/loss of data in the markdown artifact.","fix":"If a complete markdown is desired, also emit a full markdown report (full=true) analogous to full.html; otherwise document that markdown.md is summary-only.","locations":[{"ref":"src/report/markdown.rs:202-219","code":" 202 | pub fn write_markdown_report<W: ReportWriter>(\n 203 | ex: &Experiment,\n 204 | crates_count: usize,\n 205 | res: &TestResults,\n 206 | dest: &W,\n 207 | output_templates: bool,\n 208 | ) -> Fallible<()> {\n 209 | write_report(\n 210 | ex,\n 211 | crates_count,\n 212 | res,\n 213 | false,\n 214 | \"markdown.md\",\n 215 | dest,\n 216 | output_templates,\n 217 | )?;\n 218 | Ok(())\n 219 | }","lines":18,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/markdown.rs#L202-L219"},{"ref":"src/report/markdown.rs:154-154","code":" 154 | .filter(|(category, _)| full || category.show_in_summary())","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/markdown.rs#L154"}]},{"id":"LOGIC_11","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"mark_crate_as_completed only flips status to Completed when `(SELECT COUNT(*) FROM results WHERE experiment=? AND crate=?) > 1`. This silently encodes the assumption that every experiment has exactly two toolchains and both must have stored a result. For a single-toolchain experiment a crate would never be marked completed, and for >2 toolchains it is marked complete after only 2 of N results. The magic `> 1` is undocumented and fragile.","fix":"Compare the distinct toolchain count against the experiment's actual toolchain count (e.g. `COUNT(DISTINCT toolchain) >= (number of toolchains in experiment)`), or document/justify the hard-coded `> 1` with the two-toolchain invariant.","locations":[{"ref":"src/results/db.rs:124-130","code":" 124 | fn mark_crate_as_completed(&self, ex: &Experiment, krate: &Crate) -> Fallible<usize> {\n 125 | self.db.execute(\n 126 | \"UPDATE experiment_crates SET status = ?1 WHERE experiment = ?2 AND crate = ?3 \\\n 127 | AND ( (SELECT COUNT(*) FROM results WHERE experiment = ?2 AND crate = ?3) > 1 )\",\n 128 | &[&Status::Completed.to_string(), &ex.name, &krate.id()],\n 129 | )\n 130 | }","lines":7,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/results/db.rs#L124-L130"}]},{"id":"LOGIC_12","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"RecordProgress::record_progress for DatabaseDB stores the result and updates the crate version, but unlike DatabaseDB::store it never calls mark_crate_as_completed. Depending on which path the runner uses to persist progress, crates persisted via this trait impl may never be marked completed in experiment_crates, causing re-runs. The two write paths (store vs record_progress) have diverged in their post-write bookkeeping.","fix":"Confirm intent: if record_progress is meant to be a complete write path, it should also mark completion; if not, document why the two paths differ and which one the runner relies on.","locations":[{"ref":"src/results/db.rs:259-274","code":" 259 | impl crate::runner::RecordProgress for DatabaseDB<'_> {\n 260 | fn record_progress(\n 261 | &self,\n 262 | ex: &Experiment,\n 263 | krate: &Crate,\n 264 | toolchain: &Toolchain,\n 265 | log: &[u8],\n 266 | result: &TestResult,\n 267 | version: Option<(&Crate, &Crate)>,\n 268 | ) -> Fallible<()> {\n 269 | self.store_result(ex, krate, toolchain, result, log, EncodingType::Plain)?;\n 270 | if let Some((old, new)) = version {\n 271 | self.update_crate_version(ex, old, new)?;\n 272 | }\n 273 | Ok(())\n 274 | }","lines":16,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/results/db.rs#L259-L274"},{"ref":"src/results/db.rs:108-121","code":" 108 | self.store_result(\n 109 | ex,\n 110 | krate,\n 111 | &data.result.toolchain,\n 112 | &data.result.result,\n 113 | &base64::engine::general_purpose::STANDARD\n 114 | .decode(&data.result.log)\n 115 | .with_context(|| \"invalid base64 log provided\")?,\n 116 | encoding_type,\n 117 | )?;\n 118 | \n 119 | self.mark_crate_as_completed(ex, krate)?;\n 120 | \n 121 | Ok(())","lines":14,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/results/db.rs#L108-L121"}]},{"id":"LOGIC_13","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":true,"description":"find_unstable_features selects files with file_name().contains(\".rs\") instead of an extension check. This matches non-Rust files such as `foo.rsx`, `notes.rsync`, `bar.rs.bak`, `file.rspec`, and also matches a directory-like name; it then tries to read and parse them as Rust source. Conversely it relies on a substring match that is both too broad (false positives) and subtly wrong.","fix":"Use entry.path().extension().map(|e| e == \"rs\").unwrap_or(false) (or s.ends_with(\".rs\")) so only real .rs files are parsed.","locations":[{"ref":"src/runner/unstable_features.rs:22-29","code":" 22 | if !entry\n 23 | .file_name()\n 24 | .to_str()\n 25 | .map(|s| s.contains(\".rs\"))\n 26 | .unwrap_or(false)\n 27 | {\n 28 | continue;\n 29 | }","lines":8,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/unstable_features.rs#L22-L29"}]},{"id":"LOGIC_14","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"remove_experiment_jobs() resets the ENTIRE crater_progress_report metric (which is labeled by experiment AND kind) whenever any single experiment completes. This wipes progress-report counters for all other still-running experiments, not just the completed one, losing their metrics. The completed-jobs metric is correctly removed per-experiment, but the progress report is cleared globally.","fix":"Remove only the label values for the completed experiment from crater_progress_report (iterate kinds and remove_label_values), instead of calling reset() on the whole metric.","locations":[{"ref":"src/server/metrics.rs:96-106","code":" 96 | fn remove_experiment_jobs(&self, experiment: &str) -> Fallible<()> {\n 97 | self.record_completed_jobs(experiment, 0);\n 98 | self.crater_completed_jobs_total\n 99 | .remove_label_values(&[experiment])?;\n 100 | \n 101 | // Clear out all values from the progress report to avoid indefinitely retaining experiment\n 102 | // metrics.\n 103 | self.crater_progress_report.reset();\n 104 | \n 105 | Ok(())\n 106 | }","lines":11,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/metrics.rs#L96-L106"}]},{"id":"LOGIC_15","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"try_builds::detect runs on every issue_comment 'created' event BEFORE the ACL check in process_command. An unauthenticated/unauthorized GitHub user (anyone who can comment on the repo) can trigger try-build SHA detection/storage by posting a crafted comment, since detect is called unconditionally on line 39-45 while authorization is only enforced later inside process_command. Confirm detect has no side effects that an unauthorized user should not be able to trigger.","fix":"Audit try_builds::detect for side effects; if it mutates state, gate it behind authorization or ensure it only records benign data.","locations":[{"ref":"src/server/routes/webhooks/mod.rs:39-45","code":" 39 | crate::server::try_builds::detect(\n 40 | &data.db,\n 41 | &github_data.api,\n 42 | &p.repository.full_name,\n 43 | p.issue.number,\n 44 | &p.comment.body,\n 45 | )?;","lines":7,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/webhooks/mod.rs#L39-L45"},{"ref":"src/server/routes/webhooks/commands.rs:93-107","code":" 93 | );\n 94 | let pr_head = github_data\n 95 | .api\n 96 | .get_pr_head_sha(&repo.full_name, issue.number)?;\n 97 | let mut merge_commit = github_data\n 98 | .api\n 99 | .get_commit(&repo.full_name, &build.merge_sha)?;\n 100 | if merge_commit.parents.len() == 2 {\n 101 | // The first parent is the rust-lang/rust commit, and the second\n 102 | // parent (index 1) is the PR commit\n 103 | let old_pr_head = merge_commit.parents.remove(1).sha;\n 104 | if pr_head != old_pr_head {\n 105 | message = message.line(\n 106 | \"warning\",\n 107 | format!(","lines":15,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/webhooks/commands.rs#L93-L107"}]},{"id":"LOGIC_16","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"base_commit() assumes a try-merge commit always has exactly 2 parents and takes parents[0] as the base. For an octopus or otherwise unusual merge it returns None (silently skips recording the try build). More subtly, GitHub/homu merge commits list parents as [base, pr-head]; relying on parent[0] being the base is an undocumented invariant that, if homu changes ordering, silently records the wrong base_sha for the regression diff.","fix":"Document/verify the parent-ordering invariant (base first) and consider validating that parents[1] corresponds to the PR head before recording.","locations":[{"ref":"src/server/try_builds.rs:21-27","code":" 21 | fn base_commit(gh: &dyn GitHub, repo: &str, merge_sha: &str) -> Fallible<Option<String>> {\n 22 | let mut commit = gh.get_commit(repo, merge_sha)?;\n 23 | if commit.parents.len() != 2 {\n 24 | return Ok(None);\n 25 | }\n 26 | Ok(Some(commit.parents.remove(0).sha))\n 27 | }","lines":7,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/try_builds.rs#L21-L27"}]},{"id":"LOGIC_17","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"DeleteExperiment::apply checks Experiment::exists then issues DELETE in two separate non-transactional DB calls. Between the exists check and the DELETE another actor could delete/modify the experiment (TOCTOU). The exists check is also redundant from a correctness standpoint - DELETE on a missing row affects 0 rows; the only purpose is the NotFound error, which could be derived from the DELETE's affected-row count atomically.","fix":"Issue the DELETE and return NotFound if execute() reports 0 affected rows, eliminating the separate exists query and the race.","locations":[{"ref":"src/actions/experiments/delete.rs:11-22","code":" 11 | fn apply(self, ctx: &ActionsCtx) -> Fallible<()> {\n 12 | if !Experiment::exists(ctx.db, &self.name)? {\n 13 | return Err(ExperimentError::NotFound(self.name).into());\n 14 | }\n 15 | \n 16 | // This will also delete all the data related to this experiment, thanks to the foreign\n 17 | // keys in the SQLite database\n 18 | ctx.db\n 19 | .execute(\"DELETE FROM experiments WHERE name = ?1;\", &[&self.name])?;\n 20 | \n 21 | Ok(())\n 22 | }","lines":12,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/delete.rs#L11-L22"}]},{"id":"LOGIC_18","type":"logic_error","type_label":"Logic Error","severity":"low","easy":true,"description":"In the retry backoff, the cap is applied to `retry_interval` (the upper bound of the random range, in seconds) *after* it is used to compute the sleep range, and the cap value `8 * 60` is compared against `retry_interval` which is itself in seconds while the sleep range multiplies by 1000. The intent is presumably an 8-minute maximum, but because the doubling and cap happen after sleeping, the first sleep after reaching the cap can already have used an uncapped interval. The mixing of seconds and the `*1000` ms conversion makes the cap easy to misread and maintain incorrectly.","fix":"Cap retry_interval immediately after computing the sleep duration, and use explicit Duration arithmetic (e.g. a MAX_BACKOFF: Duration constant) instead of bare second/millisecond integers to make the bound unambiguous.","locations":[{"ref":"src/agent/api.rs:92-118","code":" 92 | fn retry<T, F: Fn(&Self) -> Fallible<T>>(&self, f: F) -> Fallible<T> {\n 93 | let mut retry_interval = 16u64;\n 94 | loop {\n 95 | match f(self) {\n 96 | Ok(res) => return Ok(res),\n 97 | Err(err) => {\n 98 | let retry = if let Some(AgentApiError::ServerUnavailable) = err.downcast_ref() {\n 99 | true\n 100 | } else if let Some(err) = err.downcast_ref::<::reqwest::Error>() {\n 101 | err.is_timeout() || err.is_connect()\n 102 | } else {\n 103 | // We retry these errors. Ideally it's something the\n 104 | // server would handle, but that's (unfortunately) hard\n 105 | // in practice.\n 106 | format!(\"{err:?}\").contains(\"database is locked\")\n 107 | };\n 108 | \n 109 | if retry {\n 110 | let sleep_for =\n 111 | Duration::from_millis(rand::random_range(500..(retry_interval * 1000)));\n 112 | warn!(\"connection to the server failed. retrying in {sleep_for:?}...\");\n 113 | ::std::thread::sleep(sleep_for);\n 114 | retry_interval *= 2;\n 115 | if retry_interval >= 8 * 60 {\n 116 | retry_interval = 8 * 60;\n 117 | }\n 118 | ","lines":27,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/agent/api.rs#L92-L118"}]},{"id":"LOGIC_19","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"health_thread only re-evaluates the liveness deadline inside the loop body, which runs only after `listener.accept()` returns. `accept()` blocks until a connection arrives, so if no health-check probe ever connects, `last_check.elapsed()` is never inspected and the HEALTH_CHECK flag is never consumed. The 15-minute self-replacement logic therefore depends entirely on an external prober connecting frequently; without it the health state is never advanced. The comment implies a time-based guarantee that the code does not actually provide on its own.","fix":"Use a non-blocking/timeout-bounded accept (set a read/accept timeout on the listener) or a separate timer so the deadline check runs even when no connection arrives.","locations":[{"ref":"src/agent/mod.rs:108-133","code":" 108 | fn health_thread() {\n 109 | std::thread::spawn(move || {\n 110 | let mut last_check = Instant::now();\n 111 | \n 112 | let listener = std::net::TcpListener::bind(\"0.0.0.0:4343\").unwrap();\n 113 | loop {\n 114 | // Accept a connection...\n 115 | drop(listener.accept());\n 116 | \n 117 | // Then check whether we should still be healthy. If not, we simply\n 118 | // drop the listening socket by breaking out of the loop, meaning\n 119 | // that we'll stop responding as healthy to future connects.\n 120 | //\n 121 | // A build has a maximum timeout of 15 minutes in rustwide, so we\n 122 | // currently expect checkpoints at least that often. It likely makes\n 123 | // sense for us to be more eager, but ultimately crater runtimes are\n 124 | // long enough that 15 minutes on one builder hopefully won't matter\n 125 | // too much.\n 126 | if last_check.elapsed() > Duration::from_secs(15 * 60) {\n 127 | last_check = Instant::now();\n 128 | if !HEALTH_CHECK.swap(false, Ordering::SeqCst) {\n 129 | break;\n 130 | }\n 131 | }\n 132 | }\n 133 | });","lines":26,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/agent/mod.rs#L108-L133"}]},{"id":"LOGIC_20","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"List::update deletes all rows for the list then re-inserts inside a transaction, recording `loaded_at = now` per row but never using it; meanwhile List::get orders by rowid (insertion order) not by loaded_at. The loaded_at column is written on every row but appears unused for ordering/selection here, suggesting either dead data or a missing intended ordering.","fix":"Confirm whether loaded_at is consumed elsewhere; if only for diagnostics, that's fine, but the per-row identical timestamp could be stored once. If ordering by recency was intended, order by loaded_at.","locations":[{"ref":"src/crates/lists.rs:23-46","code":" 23 | let now = Utc::now();\n 24 | db.transaction(true, |t| {\n 25 | // Replace the existing list in the database\n 26 | t.execute(\"DELETE FROM crates WHERE list = ?1;\", &[&Self::NAME])?;\n 27 | for krate in &crates {\n 28 | t.execute(\n 29 | \"INSERT INTO crates (crate, list, loaded_at) VALUES (?1, ?2, ?3);\",\n 30 | &[&krate.id(), &Self::NAME, &now],\n 31 | )\n 32 | .with_context(|| {\n 33 | format!(\n 34 | \"failed to insert crate {} into the {} list\",\n 35 | krate,\n 36 | Self::NAME\n 37 | )\n 38 | })?;\n 39 | }\n 40 | \n 41 | Ok(())\n 42 | })?;\n 43 | \n 44 | info!(\"loaded {} crates in the {} list\", crates.len(), Self::NAME);\n 45 | Ok(())\n 46 | }","lines":24,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/lists.rs#L23-L46"},{"ref":"src/crates/lists.rs:48-53","code":" 48 | fn get(db: &Database) -> Fallible<Vec<Crate>> {\n 49 | let crates_results = db.query(\n 50 | \"SELECT crate FROM crates WHERE list = ?1 ORDER BY rowid;\",\n 51 | [&Self::NAME],\n 52 | |r| r.get::<_, String>(0),\n 53 | )?;","lines":6,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/lists.rs#L48-L53"}]},{"id":"LOGIC_21","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"Crate::id() for Crate::Local is `local/{name}` and Crate::Path is `path/{percent_encoded_path}`. A local crate whose directory name contains a '/' (impossible on disk) is fine, but Local names are NOT percent-encoded while Path is. If a local crate name ever contained a '/', from_str would mis-split it. More importantly, Local name is taken verbatim from a filesystem dir entry (local.rs) with no encoding, so a name containing '/' or matching another variant's prefix could round-trip incorrectly. Low severity because dir names rarely contain '/'.","fix":"Percent-encode the local crate name in id() (as is done for Path), or validate/reject names containing '/' when listing local crates.","locations":[{"ref":"src/crates/mod.rs:43-43","code":" 43 | Crate::Local(ref name) => format!(\"local/{name}\"),","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/mod.rs#L43"},{"ref":"src/crates/sources/local.rs:30-42","code":" 30 | if entry.path().join(\"Cargo.toml\").is_file() {\n 31 | let name = entry\n 32 | .file_name()\n 33 | .to_str()\n 34 | .ok_or_else(|| {\n 35 | anyhow!(\n 36 | \"invalid UTF-8 in local crate name: {}\",\n 37 | entry.file_name().to_string_lossy()\n 38 | )\n 39 | })?\n 40 | .to_string();\n 41 | \n 42 | list.push(Crate::Local(name));","lines":13,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/sources/local.rs#L30-L42"}]},{"id":"LOGIC_22","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"GitHubRepo::from_str strips the prefix with trim_start_matches(\"https://github.com/\"), but trim_start_matches removes the pattern repeatedly. An input like \"https://github.com/https://github.com/org/repo\" would have both occurrences stripped. More realistically, any url not starting with that exact prefix is parsed as a bare path, so e.g. \"http://github.com/org/repo\" (http) keeps the scheme and yields org=\"http:\" name=\"\". The function trusts callers to pass a normalized https URL.","fix":"Use strip_prefix (single, optional removal) and validate the scheme/host, or parse via the url crate, rather than trim_start_matches.","locations":[{"ref":"src/crates/sources/github.rs:93-98","code":" 93 | fn from_str(input: &str) -> Fallible<Self> {\n 94 | let mut components = input\n 95 | .trim_start_matches(\"https://github.com/\")\n 96 | .split('/')\n 97 | .rev()\n 98 | .collect::<Vec<_>>();","lines":6,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/sources/github.rs#L93-L98"}]},{"id":"LOGIC_23","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"The HTTP Range header requests `bytes={offset}-{1 + offset + buf.len()}`, i.e. it asks for (buf.len()+2) bytes for a buffer of buf.len() (Range is inclusive on both ends, so to read N bytes from offset you want `offset-(offset+N-1)`). It happens to work because the read loop stops at `read == buf.len()` and short reads are tolerated, but the arithmetic is wrong/misleading and fetches 2 extra bytes per request.","fix":"Use `format!(\"bytes={offset}-{}\", offset + u64::try_from(buf.len()).unwrap() - 1)` to request exactly buf.len() bytes.","locations":[{"ref":"src/crates/sources/registry.rs:33-38","code":" 33 | reqwest::header::RANGE,\n 34 | format!(\n 35 | \"bytes={offset}-{}\",\n 36 | 1 + offset + u64::try_from(buf.len()).unwrap()\n 37 | ),\n 38 | )","lines":6,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/sources/registry.rs#L33-L38"}]},{"id":"LOGIC_24","type":"logic_error","type_label":"Logic Error","severity":"low","easy":true,"description":"CrateSelect::FromStr parses `top-<n>` and `random-<n>` with `n: u32 = ...parse()?`. `top-0` and `random-0` parse successfully into Top(0)/Random(0), silently producing an experiment selecting zero crates rather than rejecting the obviously-invalid count.","fix":"Reject n == 0 with a descriptive bail!().","locations":[{"ref":"src/experiments.rs:63-73","code":" 63 | let ret = match s {\n 64 | s if s.starts_with(\"top-\") => {\n 65 | let n: u32 = s[\"top-\".len()..].parse()?;\n 66 | CrateSelect::Top(n)\n 67 | }\n 68 | \n 69 | \"small-random\" => CrateSelect::Random(SMALL_RANDOM_COUNT),\n 70 | s if s.starts_with(\"random-\") => {\n 71 | let n: u32 = s[\"random-\".len()..].parse()?;\n 72 | CrateSelect::Random(n)\n 73 | }","lines":11,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L63-L73"}]},{"id":"LOGIC_25","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"CrateSelect::from_newline_separated_list rejects any input containing a comma with a generic error, but it is reached via DeferredCrateSelect::resolve for a remotely-fetched list. A single stray comma anywhere in a fetched crate list aborts the whole experiment creation with a terse message and no indication of which line offended.","fix":"Report the offending line, or split on whitespace/newlines and validate each identifier individually.","locations":[{"ref":"src/experiments.rs:123-132","code":" 123 | impl CrateSelect {\n 124 | fn from_newline_separated_list(s: &str) -> Fallible<CrateSelect> {\n 125 | if s.contains(',') {\n 126 | bail!(\"Crate identifiers must not contain a comma\");\n 127 | }\n 128 | \n 129 | let crates = s.split_whitespace().map(|s| s.to_owned()).collect();\n 130 | Ok(CrateSelect::List(crates))\n 131 | }\n 132 | }","lines":10,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L123-L132"},{"ref":"src/experiments.rs:147-157","code":" 147 | impl DeferredCrateSelect {\n 148 | pub fn resolve(self) -> Fallible<CrateSelect> {\n 149 | let url = match self {\n 150 | DeferredCrateSelect::Direct(v) => return Ok(v),\n 151 | DeferredCrateSelect::Indirect(url) => url,\n 152 | };\n 153 | \n 154 | let body = utils::http::get_sync(url.as_str())?.text()?;\n 155 | CrateSelect::from_newline_separated_list(&body)\n 156 | }\n 157 | }","lines":11,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L147-L157"}]},{"id":"LOGIC_26","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"Assignee::from_str treats 'agent' kind by requiring a non-empty name but does NOT reject extra colons beyond the first because splitn(2, ':') keeps the remainder; e.g. 'agent:a:b' becomes Agent(\"a:b\"). Meanwhile to_string() for Agent(name) emits 'agent:{name}', so round-tripping an agent name containing a colon is lossy/ambiguous. Combined with assigned_to being stored as this string and matched in SQL ('agent:' || agents.name), an agent name containing ':' could mismatch.","fix":"Document/validate that agent names cannot contain ':' or use a non-ambiguous encoding for the assigned_to column.","locations":[{"ref":"src/experiments.rs:200-237","code":" 200 | impl FromStr for Assignee {\n 201 | type Err = AssigneeParseError;\n 202 | \n 203 | fn from_str(input: &str) -> Result<Self, AssigneeParseError> {\n 204 | if input.trim().is_empty() {\n 205 | return Err(AssigneeParseError::Empty);\n 206 | }\n 207 | \n 208 | let mut split = input.splitn(2, ':');\n 209 | let kind = split.next().ok_or(AssigneeParseError::Empty)?;\n 210 | \n 211 | match kind {\n 212 | \"agent\" => {\n 213 | let name = split.next().ok_or(AssigneeParseError::Empty)?;\n 214 | if name.trim().is_empty() {\n 215 | return Err(AssigneeParseError::Empty);\n 216 | }\n 217 | \n 218 | Ok(Assignee::Agent(name.to_string()))\n 219 | }\n 220 | \"cli\" => {\n 221 | if split.next().is_some() {\n 222 | return Err(AssigneeParseError::UnexpectedPayload);\n 223 | }\n 224 | \n 225 | Ok(Assignee::CLI)\n 226 | }\n 227 | \"distributed\" => {\n 228 | if split.next().is_some() {\n 229 | return Err(AssigneeParseError::UnexpectedPayload);\n 230 | }\n 231 | \n 232 | Ok(Assignee::Distributed)\n 233 | }\n 234 | invalid => Err(AssigneeParseError::InvalidKind(invalid.into())),\n 235 | }\n 236 | }\n 237 | }","lines":38,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L200-L237"},{"ref":"src/experiments.rs:281-289","code":" 281 | pub fn run_by(db: &Database, assignee: &Assignee) -> Fallible<Option<Experiment>> {\n 282 | let record = db.get_row(\n 283 | \"select * from experiments where name = (\n 284 | select latest_work_for from agents where ('agent:' || agents.name) = ?1\n 285 | ) and status = ?2 \\\n 286 | limit 1\",\n 287 | [&assignee.to_string(), Status::Running.to_str()],\n 288 | |r| ExperimentDBRecord::from_row(r),\n 289 | )?;","lines":9,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L281-L289"}]},{"id":"LOGIC_27","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"crate_to_path_fragment does not sanitize the Local crate name (line 170) nor the toolchain for the Local branch's name component, unlike every other Crate variant which routes through dest.sanitize(). A Local crate name containing path-traversal or reserved characters would be written verbatim into the report/log path, unlike Registry/GitHub/Path/Git which are percent-encoded.","fix":"Apply dest.sanitize(name) for the Local branch as is done for the other variants.","locations":[{"ref":"src/report/mod.rs:168-171","code":" 168 | Crate::Local(ref name) => {\n 169 | path.push(\"local\");\n 170 | path.push(name);\n 171 | }","lines":4,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/mod.rs#L168-L171"}]},{"id":"LOGIC_28","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"get_crate_version_status iterates index_krate.versions().iter().rev() to find the tested version, but `outdated` is computed once from most_recent_version(). If the tested version equals the most recent it returns UpToDate; otherwise Outdated. The reverse iteration is pointless for correctness (it just linear-scans for an exact version match) and the loop bails with an error if the exact version isn't found, even though that 'not found' case is then swallowed into MissingFromIndex by the caller. The combination makes 'Outdated' and 'MissingFromIndex' semantics fragile.","fix":"Use versions().iter().any()/find() to locate the exact version without the misleading .rev(); document that a missing exact version is an error vs. truly missing crate.","locations":[{"ref":"src/report/mod.rs:211-226","code":" 211 | let outdated = index_krate.most_recent_version().version() != krate.version;\n 212 | \n 213 | for version in index_krate.versions().iter().rev() {\n 214 | // Check if the tested version is yanked\n 215 | if version.version() == krate.version {\n 216 | if version.is_yanked() {\n 217 | return Ok(Some(CrateVersionStatus::Yanked));\n 218 | } else if outdated {\n 219 | return Ok(Some(CrateVersionStatus::Outdated));\n 220 | } else {\n 221 | return Ok(Some(CrateVersionStatus::UpToDate));\n 222 | }\n 223 | }\n 224 | }\n 225 | \n 226 | bail!(\"crate version {krate:?} not found in sparse index\");","lines":16,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/mod.rs#L211-L226"}]},{"id":"LOGIC_29","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"S3Prefix::from_str rejects URLs with a port (parsed.port().is_some()) but the test 's3://bucket:80' relies on this; however an IP-literal host (Host::Ipv4/Ipv6) is rejected as BadUrl because only Host::Domain is accepted. S3 bucket URLs are always domain-style so this is acceptable, but the error message 'bad S3 url' gives no hint which constraint failed, making misconfiguration hard to diagnose.","fix":"Include the specific rejected component in the error message (e.g. 'port not allowed', 'non-domain host') to aid operators.","locations":[{"ref":"src/report/s3.rs:31-45","code":" 31 | if parsed.scheme() != \"s3\"\n 32 | || parsed.username() != \"\"\n 33 | || parsed.password().is_some()\n 34 | || parsed.port().is_some()\n 35 | || parsed.query().is_some()\n 36 | || parsed.fragment().is_some()\n 37 | {\n 38 | return Err(S3Error::BadUrl(url.into()).into());\n 39 | }\n 40 | \n 41 | let bucket = if let Some(Host::Domain(host)) = parsed.host() {\n 42 | host.to_string()\n 43 | } else {\n 44 | return Err(S3Error::BadUrl(url.into()).into());\n 45 | };","lines":15,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/s3.rs#L31-L45"}]},{"id":"LOGIC_30","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"detect_broken downcasts `err.downcast_ref()` to PrepareError with an inferred type via `if let Some(error) = err.downcast_ref()`. The type is only pinned by the later `match *error` arms. This works, but the catch-all `_` arm at line 74 recomputes failure_reason(&err) and, for spurious/Unknown reasons, returns TestResult::PrepareFail wrapped in OverrideResult even though the original error was already a PrepareError - so a generic PrepareError that is spurious gets an OverrideResult(PrepareFail) context, while a non-PrepareError spurious error (the else branch, line 85) is returned untouched and later mapped differently by callers. The two spurious-failure paths diverge for no clear reason.","fix":"Unify handling so spurious/Unknown failures take the same path whether or not the underlying error happens to be a PrepareError, to avoid result-classification depending on incidental error typing.","locations":[{"ref":"src/runner/test.rs:47-89","code":" 47 | pub(super) fn detect_broken<T>(res: Result<T, Error>) -> Result<T, Error> {\n 48 | match res {\n 49 | Ok(ok) => Ok(ok),\n 50 | Err(err) => {\n 51 | if let Some(error) = err.downcast_ref() {\n 52 | let reason = match *error {\n 53 | PrepareError::MissingCargoToml => {\n 54 | TestResult::BrokenCrate(BrokenReason::CargoToml)\n 55 | }\n 56 | PrepareError::InvalidCargoTomlSyntax => {\n 57 | TestResult::BrokenCrate(BrokenReason::CargoToml)\n 58 | }\n 59 | PrepareError::BrokenDependencies(_) => {\n 60 | TestResult::BrokenCrate(BrokenReason::BrokenDependencies)\n 61 | }\n 62 | PrepareError::YankedDependencies(_) => {\n 63 | TestResult::BrokenCrate(BrokenReason::Yanked)\n 64 | }\n 65 | PrepareError::MissingDependencies(_) => {\n 66 | TestResult::BrokenCrate(BrokenReason::MissingDependencies)\n 67 | }\n 68 | PrepareError::InvalidCargoLock(_) => {\n 69 | TestResult::BrokenCrate(BrokenReason::InvalidCargoLock)\n 70 | }\n 71 | PrepareError::PrivateGitRepository => {\n 72 | TestResult::BrokenCrate(BrokenReason::MissingGitRepository)\n 73 | }\n 74 | _ => {\n 75 | let reason = failure_reason(&err);\n 76 | if reason.is_spurious() || matches!(reason, FailureReason::Unknown) {\n 77 | TestResult::PrepareFail(reason)\n 78 | } else {\n 79 | TestResult::BrokenCrate(BrokenReason::Unknown)\n 80 | }\n 81 | }\n 82 | };\n 83 | \n 84 | Err(err.context(OverrideResult(reason)))\n 85 | } else {\n 86 | Err(err)\n 87 | }\n 88 | }\n 89 | }","lines":43,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/test.rs#L47-L89"}]},{"id":"LOGIC_31","type":"logic_error","type_label":"Logic Error","severity":"low","easy":true,"description":"Worker::new indexes ex.toolchains[0] and ex.toolchains[1] directly to build the build_dir map. If an experiment is ever constructed with fewer than two toolchains this panics with an out-of-bounds index at worker startup. The two-toolchain invariant is assumed implicitly here (and only checked elsewhere as `len() == 2`), so a single-toolchain experiment would crash all workers rather than producing a clear error.","fix":"Iterate over ex.toolchains to populate build_dir for whatever toolchains exist, or validate/assert the expected count with a descriptive message before indexing.","locations":[{"ref":"src/runner/worker.rs:66-74","code":" 66 | let mut build_dir = HashMap::new();\n 67 | build_dir.insert(\n 68 | &ex.toolchains[0],\n 69 | Mutex::new(workspace.build_dir(&format!(\"{name}-tc1\"))),\n 70 | );\n 71 | build_dir.insert(\n 72 | &ex.toolchains[1],\n 73 | Mutex::new(workspace.build_dir(&format!(\"{name}-tc2\"))),\n 74 | );","lines":9,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/worker.rs#L66-L74"}]},{"id":"LOGIC_32","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"run_task retries the whole task up to 5 times only when the failing toolchain is the LAST one (regressions). The retry re-runs task.run unconditionally on every iteration, but the retry decision is recomputed identically each loop from immutable state (task.step / ex.toolchains), so a non-retryable task still executes the body once and then breaks - correct - but a retryable task that keeps failing logs 'Retrying ... [run/max]' AFTER already running attempt `run`, off-by-one in the log message: on the final attempt (run==5) it still logs 'Retrying [5/5]' even though no further retry happens (the loop ends because run reaches max_attempts).","fix":"Move the 'Retrying' log before the next attempt and guard it with `if run < max_attempts`, or restructure so the log reflects the attempt actually about to run; this avoids a misleading 'Retrying [5/5]' that is never honored.","locations":[{"ref":"src/runner/worker.rs:101-141","code":" 101 | for run in 1..=max_attempts {\n 102 | // If we're running a task, we call ourselves healthy.\n 103 | crate::agent::set_healthy();\n 104 | \n 105 | match task.run(self.config, &self.build_dir, self.ex, storage) {\n 106 | Ok(res) => return Ok(res),\n 107 | Err(e) => {\n 108 | res = Some(e);\n 109 | }\n 110 | }\n 111 | \n 112 | // We retry task failing on the second toolchain (i.e., regressions). In\n 113 | // the future we might expand this list further but for now this helps\n 114 | // prevent spurious test failures and such.\n 115 | //\n 116 | // For now we make no distinction between build failures and test failures\n 117 | // here, but that may change if this proves too slow.\n 118 | let mut should_retry = false;\n 119 | if self.ex.toolchains.len() == 2 {\n 120 | let toolchain = match &task.step {\n 121 | TaskStep::BuildAndTest { tc, .. }\n 122 | | TaskStep::BuildOnly { tc, .. }\n 123 | | TaskStep::CheckOnly { tc, .. }\n 124 | | TaskStep::Clippy { tc, .. }\n 125 | | TaskStep::Rustdoc { tc, .. }\n 126 | | TaskStep::UnstableFeatures { tc }\n 127 | | TaskStep::Fix { tc, .. } => Some(tc),\n 128 | };\n 129 | if let Some(toolchain) = toolchain {\n 130 | if toolchain == self.ex.toolchains.last().unwrap() {\n 131 | should_retry = true;\n 132 | }\n 133 | }\n 134 | }\n 135 | \n 136 | if !should_retry {\n 137 | break;\n 138 | }\n 139 | \n 140 | log::info!(\"Retrying task {task:?} [{run}/{max_attempts}]\");\n 141 | }","lines":41,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/worker.rs#L101-L141"}]},{"id":"LOGIC_33","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"active_worker_count() expires workers after 10 minutes but record_worker_count is only updated when /metrics is scraped (routes/agent.rs:392 path and metrics). The 'active list' retain runs lazily on call; if no one calls active_worker_count for a long time, stale workers linger in memory. Minor unbounded-growth risk if the metrics endpoint is not scraped while many distinct worker ids connect.","fix":"Either prune on insert in add_worker as well, or document that pruning only happens on read; cap the map size.","locations":[{"ref":"src/server/agents.rs:98-112","code":" 98 | pub fn active_worker_count(&self) -> usize {\n 99 | let mut guard = self.current_workers.lock().unwrap();\n 100 | guard.retain(|_, (_, timestamp)| {\n 101 | // It's been 10 minutes since we heard from this worker, drop it from our active list.\n 102 | timestamp.elapsed() < std::time::Duration::from_secs(60 * 10)\n 103 | });\n 104 | guard.len()\n 105 | }\n 106 | \n 107 | pub fn add_worker(&self, id: WorkerInfo) {\n 108 | self.current_workers\n 109 | .lock()\n 110 | .unwrap()\n 111 | .insert(id.id.clone(), (id, std::time::Instant::now()));\n 112 | }","lines":15,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/agents.rs#L98-L112"}]},{"id":"LOGIC_34","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"messages::send() relies on the configured `remove` regex to clear the previous state label (e.g. experiment-queued) when applying a new one (e.g. experiment-completed). Removal of the old state label only happens if it matches the user-configured `remove` regex; a misconfigured regex silently leaves stale state labels on the issue. The transition correctness depends on config the code never validates.","fix":"Explicitly remove the known previous managed state labels on transition rather than relying solely on the user-configured `remove` regex.","locations":[{"ref":"src/server/messages.rs:90-108","code":" 90 | // Remove all the labels matching the provided regex\n 91 | // If the label is already present don't reapply it though\n 92 | let regex = &data.config.server.labels.remove;\n 93 | let current_labels = github_data.api.list_labels(issue_url)?;\n 94 | let mut label_already_present = false;\n 95 | for current_label in ¤t_labels {\n 96 | if current_label.name == *label {\n 97 | label_already_present = true;\n 98 | } else if regex.is_match(¤t_label.name) {\n 99 | github_data\n 100 | .api\n 101 | .remove_label(issue_url, ¤t_label.name)?;\n 102 | }\n 103 | }\n 104 | \n 105 | if !label_already_present {\n 106 | github_data.api.add_label(issue_url, label)?;\n 107 | }\n 108 | }","lines":19,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/messages.rs#L90-L108"}]},{"id":"LOGIC_35","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"humanize uses 60*60*60 (216000 seconds = 60 hours) as the threshold between 'hours' and 'days'. This is almost certainly a copy-paste/typo: the minute->hour boundary is 60*60, so the hour->day boundary should be 60*60*24 (one day). As written, durations between 60 and 1440 minutes that exceed 60 hours... actually durations from 60 hours up to where days kick in are still shown in hours, but the day branch only triggers above 60 hours instead of 24 hours, so e.g. a 30-hour duration is shown as '30.0 hours' (fine) while a 50-hour one shows '50.0 hours' instead of '2.1 days'. The constant should be 60*60*24.","fix":"Change the threshold from 60 * 60 * 60 to 60 * 60 * 24 so the hours/days boundary is one day.","locations":[{"ref":"src/server/routes/ui/experiments.rs:141-145","code":" 141 | } else if duration.as_secs() < 60 * 60 * 60 {\n 142 | format!(\"{:.1} hours\", duration.as_secs_f64() / 60.0 / 60.0)\n 143 | } else {\n 144 | format!(\"{:.1} days\", duration.as_secs_f64() / 60.0 / 60.0 / 24.0)\n 145 | }","lines":5,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/ui/experiments.rs#L141-L145"}]},{"id":"LOGIC_36","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"The macro @parser splits each arg with splitn(2, '='). For a flag without '=', segments.next() for the value returns None and produces InvalidArgument. But for a key like 'name=' (trailing equals, empty value), value becomes the empty string and is parsed; for Option<String> this yields Some(\"\"), silently accepting an empty experiment name. Empty/whitespace experiment names are not validated and can flow into actions::CreateExperiment.","fix":"Reject empty values for string-typed args, or validate experiment names are non-empty/well-formed before creating experiments.","locations":[{"ref":"src/server/routes/webhooks/args.rs:83-94","code":" 83 | let mut segments = part.splitn(2, '=');\n 84 | let key = segments.next().ok_or_else(|| CommandParseError::InvalidArgument(part.to_string()))?;\n 85 | let value = segments.next().ok_or_else(|| CommandParseError::InvalidArgument(part.to_string()))?;\n 86 | \n 87 | if false {}\n 88 | $(else if key == $name {\n 89 | if args.$flag.is_none() {\n 90 | args.$flag = Some(value.parse()?)\n 91 | } else {\n 92 | return Err(CommandParseError::DuplicateKey(key.to_string()).into());\n 93 | }\n 94 | })*","lines":12,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/webhooks/args.rs#L83-L94"},{"ref":"src/server/routes/webhooks/commands.rs:59-59","code":" 59 | let name = setup_run_name(&data.db, issue, args.name)?;","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/webhooks/commands.rs#L59"}]},{"id":"LOGIC_37","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"process_webhook only verifies the signature inside process_webhook, but for the 'ping' event it returns after just logging-correct. However for an unknown event it bails AFTER signature verification, returning a 500 with the error string. A forged-but-unsigned request is rejected (good), but a validly-signed unknown event produces an INTERNAL_SERVER_ERROR (500) rather than a benign 2xx/204, which can cause GitHub to mark deliveries as failed and retry. Unknown events should be ignored, not error.","fix":"For unrecognized events return Ok(()) (ignore) instead of bail!, so GitHub does not see repeated 500s for event types Crater does not handle.","locations":[{"ref":"src/server/routes/webhooks/mod.rs:66-66","code":" 66 | e => bail!(\"invalid event received: {}\", e),","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/webhooks/mod.rs#L66"},{"ref":"src/server/routes/webhooks/mod.rs:228-250","code":" 228 | warp::post()\n 229 | .and(warp::path::end())\n 230 | .and(data_filter)\n 231 | .and(github_data_filter)\n 232 | .and(warp::header::headers_cloned())\n 233 | .and(warp::body::bytes())\n 234 | .map(\n 235 | |data: Arc<Data>, github_data: Arc<GithubData>, headers: HeaderMap, body: Bytes| {\n 236 | let mut resp: Response;\n 237 | match receive_endpoint(data, github_data, headers, body) {\n 238 | Ok(()) => resp = Response::new(\"OK\\n\".into()),\n 239 | Err(err) => {\n 240 | error!(\"error while processing webhook\");\n 241 | crate::utils::report_failure(&err);\n 242 | \n 243 | resp = Response::new(format!(\"Error: {err}\\n\").into());\n 244 | *resp.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;\n 245 | }\n 246 | }\n 247 | \n 248 | resp\n 249 | },\n 250 | )","lines":23,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/webhooks/mod.rs#L228-L250"}]},{"id":"LOGIC_38","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"The ACL allowed() check is performed inside the per-line loop, after the command substring is extracted and logged-but only on lines that start with the bot mention. If a user posts multiple bot-mention lines, the unauthorized message is sent and the function returns on the first one, which is fine; however the auth check is re-evaluated each iteration rather than once. More importantly, info!(\"user sent command\") on line 109 only logs after the ACL passes, but the structure couples parsing/auth/dispatch tightly per line, making the control flow error-prone (only the first matching command line is ever executed due to the unconditional break).","fix":"Extract the first bot-mention command line first, then do the ACL check once, then parse and dispatch. This separates responsibilities and avoids re-running the ACL query.","locations":[{"ref":"src/server/routes/webhooks/mod.rs:83-149","code":" 83 | for line in body.lines() {\n 84 | if !line.starts_with(&start) {\n 85 | continue;\n 86 | }\n 87 | \n 88 | let command = line[line.find(' ').unwrap()..].trim();\n 89 | if command.is_empty() {\n 90 | continue;\n 91 | }\n 92 | \n 93 | if !data.acl.allowed(sender, sender_id)? {\n 94 | Message::new()\n 95 | .line(\n 96 | \"lock\",\n 97 | \"**Error:** you're not allowed to interact with this bot.\",\n 98 | )\n 99 | .note(\n 100 | \"key\",\n 101 | \"If you are a member of a Rust team and need access, please update \\\n 102 | rust-lang/team to grant your team or yourself access to the `crater` \\\n 103 | permission.\",\n 104 | )\n 105 | .send(&issue.url, data, github_data)?;\n 106 | return Ok(());\n 107 | }\n 108 | \n 109 | info!(\"user @{sender} sent command: {command}\");\n 110 | \n 111 | let args: Command =\n 112 | Command::from_str(command).with_context(|| \"failed to parse the command\")?;\n 113 | \n 114 | match args {\n 115 | Command::Ping(_) => {\n 116 | commands::ping(data, github_data, issue)?;\n 117 | }\n 118 | \n 119 | Command::Run(args) => {\n 120 | commands::run(host, data, github_data, repo, issue, args)?;\n 121 | }\n 122 | \n 123 | Command::Check(args) => {\n 124 | commands::check(host, data, github_data, repo, issue, args)?;\n 125 | }\n 126 | \n 127 | Command::Edit(args) => {\n 128 | commands::edit(data, github_data, issue, args)?;\n 129 | }\n 130 | \n 131 | Command::RetryReport(args) => {\n 132 | commands::retry_report(data, github_data, issue, args)?;\n 133 | }\n 134 | \n 135 | Command::Retry(args) => {\n 136 | commands::retry(data, github_data, issue, args)?;\n 137 | }\n 138 | \n 139 | Command::Abort(args) => {\n 140 | commands::abort(data, github_data, issue, args)?;\n 141 | }\n 142 | \n 143 | Command::ReloadACL(_) => {\n 144 | commands::reload_acl(data, github_data, issue)?;\n 145 | }\n 146 | }\n 147 | \n 148 | break;\n 149 | }","lines":67,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/webhooks/mod.rs#L83-L149"}]},{"id":"LOGIC_39","type":"logic_error","type_label":"Logic Error","severity":"negligible","easy":false,"description":"Size::FromStr finds the unit suffix using `chars().last()` (Unicode-aware) but then slices with byte indices `input[..input.len() - 1]`. This happens to be safe today only because every recognized suffix is single-byte ASCII; a future addition of a multibyte suffix character, or refactoring, would make the slice panic on a non-char-boundary. The implicit coupling between the char check and the byte slice is non-obvious.","fix":"Strip the suffix using char-aware operations (e.g. `input.strip_suffix(['K','k',...])` or `char_indices`) rather than `len() - 1` byte slicing.","locations":[{"ref":"src/utils/size.rs:44-63","code":" 44 | fn from_str(mut input: &str) -> Fallible<Size> {\n 45 | let mut last = input.chars().last().ok_or_else(|| anyhow!(\"empty size\"))?;\n 46 | \n 47 | // Eat a trailing 'b'\n 48 | if last == 'b' || last == 'B' {\n 49 | input = &input[..input.len() - 1];\n 50 | last = input.chars().last().ok_or_else(|| anyhow!(\"empty size\"))?;\n 51 | }\n 52 | \n 53 | if last == 'K' || last == 'k' {\n 54 | Ok(Size::Kilobytes(input[..input.len() - 1].parse()?))\n 55 | } else if last == 'M' || last == 'm' {\n 56 | Ok(Size::Megabytes(input[..input.len() - 1].parse()?))\n 57 | } else if last == 'G' || last == 'g' {\n 58 | Ok(Size::Gigabytes(input[..input.len() - 1].parse()?))\n 59 | } else if last == 'T' || last == 't' {\n 60 | Ok(Size::Terabytes(input[..input.len() - 1].parse()?))\n 61 | } else {\n 62 | Ok(Size::Bytes(input.parse()?))\n 63 | }","lines":20,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/utils/size.rs#L44-L63"}]},{"id":"CPY_1","type":"copy_paste","type_label":"Copy Paste","severity":"low","easy":true,"description":"The crater_agent_failure metric is registered with the help string \"total completed jobs\" (AGENT_FAILED opts), copy-pasted from the JOBS_METRIC help text. The help text should describe agent failures, not completed jobs.","fix":"Change the help string for AGENT_FAILED to something like \"total agent failures\".","locations":[{"ref":"src/server/metrics.rs:38-40","code":" 38 | let failure_opts = prometheus::opts!(AGENT_FAILED, \"total completed jobs\");\n 39 | let crater_agent_failure =\n 40 | prometheus::register_int_counter_vec!(failure_opts, &[\"agent\", \"experiment\"])?;","lines":3,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/metrics.rs#L38-L40"},{"ref":"src/server/metrics.rs:31-33","code":" 31 | let jobs_opts = prometheus::opts!(JOBS_METRIC, \"total completed jobs\");\n 32 | let crater_completed_jobs_total =\n 33 | prometheus::register_int_counter_vec!(jobs_opts, &[\"experiment\"])?;","lines":3,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/metrics.rs#L31-L33"}]},{"id":"ERR_1","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"high","easy":false,"description":"query_row swallows row-iteration errors. It uses `if let Ok(Some(row)) = rows.next()` and falls through to `Ok(None)` on `Err(_)`. A real SQLite error while fetching the first row (I/O error, corruption, type mismatch in a column read inside next()) is silently turned into 'no row found', masking failures and potentially driving wrong control flow (e.g. treating a present row as absent). The sibling get_row (lines 248-255) correctly propagates with `item?`.","fix":"Match and propagate the error: `match rows.next()? { Some(row) => Ok(Some(func(row)?)), None => Ok(None) }`.","locations":[{"ref":"src/db/mod.rs:288-295","code":" 288 | let mut prepared = conn.prepare(sql)?;\n 289 | let mut rows = prepared.query(params)?;\n 290 | if let Ok(Some(row)) = rows.next() {\n 291 | return Ok(Some(func(row)?));\n 292 | }\n 293 | Ok(None)\n 294 | })\n 295 | })","lines":8,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/db/mod.rs#L288-L295"}]},{"id":"ERR_2","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","easy":false,"description":"The retry classifier decides whether a 'database is locked' error is retryable by formatting the error with `{err:?}` and doing a substring match on the resulting string. This is extremely fragile: it depends on the Debug representation of an arbitrary error chain, breaks silently if any underlying library changes its message wording, and can produce false positives if the substring appears for unrelated reasons. It also allocates a full debug string on every error path.","fix":"Match on the concrete error type (e.g. downcast to the rusqlite/diesel error and inspect its error code for SQLITE_BUSY/locked) rather than substring-matching a formatted Debug string.","locations":[{"ref":"src/agent/api.rs:98-107","code":" 98 | let retry = if let Some(AgentApiError::ServerUnavailable) = err.downcast_ref() {\n 99 | true\n 100 | } else if let Some(err) = err.downcast_ref::<::reqwest::Error>() {\n 101 | err.is_timeout() || err.is_connect()\n 102 | } else {\n 103 | // We retry these errors. Ideally it's something the\n 104 | // server would handle, but that's (unfortunately) hard\n 105 | // in practice.\n 106 | format!(\"{err:?}\").contains(\"database is locked\")\n 107 | };","lines":10,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/agent/api.rs#L98-L107"}]},{"id":"ERR_3","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","easy":false,"description":"Crate::try_from for the whitespace pkgid branch indexes `parts[..]` via slice patterns and, in the `[_, _, \"git\", repo]` arm, calls parts.pop() etc. The leading branch builds `parts` then matches fixed-arity patterns; a pkgid with unexpected whitespace structure that doesn't match any arm falls through to the catch-all bail (fine), but `parts[..]` patterns assume punctuation trimming of every token. `s.trim_matches(is_ascii_punctuation)` will also strip legitimate trailing/leading punctuation from names/versions/paths (e.g. a path token), silently corrupting the value rather than failing. This is brittle string surgery on an external format.","fix":"Prefer the PackageIdSpec::parse path for all inputs where possible, or at minimum only trim the single wrapping parentheses rather than all ascii punctuation, to avoid mangling tokens that legitimately contain punctuation.","locations":[{"ref":"src/crates/mod.rs:78-123","code":" 78 | if pkgid.repr.contains(|c: char| c.is_ascii_whitespace()) {\n 79 | let parts = &pkgid\n 80 | .repr\n 81 | .split_ascii_whitespace()\n 82 | .flat_map(|s| {\n 83 | // remove ()\n 84 | s.trim_matches(|c: char| c.is_ascii_punctuation())\n 85 | // split resource and protocol\n 86 | .split('+')\n 87 | })\n 88 | .collect::<Vec<_>>();\n 89 | \n 90 | match parts[..] {\n 91 | [name, version, \"registry\", _] => Ok(Crate::Registry(RegistryCrate {\n 92 | name: name.to_smolstr(),\n 93 | version: version.to_smolstr(),\n 94 | })),\n 95 | [_, _, \"path\", path] => Ok(Crate::Path(path.to_string())),\n 96 | [_, _, \"git\", repo] => {\n 97 | if repo.starts_with(\"https://github.com\") {\n 98 | Ok(Crate::GitHub(repo.replace('#', \"/\").parse()?))\n 99 | } else {\n 100 | let mut parts = repo.split('#').rev().collect::<Vec<_>>();\n 101 | let url = parts.pop();\n 102 | let sha = parts.pop();\n 103 | \n 104 | match (url, sha) {\n 105 | (Some(url), None) => Ok(Crate::Git(GitRepo {\n 106 | url: url.to_string(),\n 107 | sha: None,\n 108 | })),\n 109 | (Some(url), Some(sha)) => Ok(Crate::Git(GitRepo {\n 110 | // remove additional queries if the sha is present\n 111 | // as the crate version is already uniquely determined\n 112 | url: url.split('?').next().unwrap().to_string(),\n 113 | sha: Some(sha.to_string()),\n 114 | })),\n 115 | _ => bail!(\"malformed git repo: {}\", repo),\n 116 | }\n 117 | }\n 118 | }\n 119 | _ => bail!(\n 120 | \"malformed pkgid format: {}\\n maybe the representation has changed?\",\n 121 | pkgid.repr\n 122 | ),\n 123 | }","lines":46,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/mod.rs#L78-L123"}]},{"id":"ERR_4","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","easy":false,"description":"get_crate_version_status errors are silently coerced to Some(MissingFromIndex). `unwrap_or(Some(CrateVersionStatus::MissingFromIndex))` maps ANY error (network failure, HTTP error, parse error, crate version genuinely not in index) to the same 'missing from the index' status. A transient network/index outage during report generation will mislabel every up-to-date crate as missing from the index, with no logged warning.","fix":"Distinguish error kinds: log the error (utils::report_failure) and only use MissingFromIndex for the genuine not-found case; for transient errors return None or propagate.","locations":[{"ref":"src/report/mod.rs:274-275","code":" 274 | status: get_crate_version_status(&index, krate)\n 275 | .unwrap_or(Some(CrateVersionStatus::MissingFromIndex)),","lines":2,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/mod.rs#L274-L275"}]},{"id":"ERR_5","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","easy":false,"description":"store() ignores the row count returned by mark_crate_as_completed. If zero rows match (e.g. the crate id was never inserted into experiment_crates, or the version-update step changed the id in a way that doesn't match), the crate is silently never marked complete and will be retried indefinitely. The function comment even warns about exactly this 'run this crate many times, effectively never completing it' scenario, yet the UPDATE result is discarded.","fix":"Inspect the returned usize; if 0 rows were updated when a completion was expected, log a warning (or error) so the stuck-crate condition is observable.","locations":[{"ref":"src/results/db.rs:119-119","code":" 119 | self.mark_crate_as_completed(ex, krate)?;","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/results/db.rs#L119"},{"ref":"src/results/db.rs:124-130","code":" 124 | fn mark_crate_as_completed(&self, ex: &Experiment, krate: &Crate) -> Fallible<usize> {\n 125 | self.db.execute(\n 126 | \"UPDATE experiment_crates SET status = ?1 WHERE experiment = ?2 AND crate = ?3 \\\n 127 | AND ( (SELECT COUNT(*) FROM results WHERE experiment = ?2 AND crate = ?3) > 1 )\",\n 128 | &[&Status::Completed.to_string(), &ex.name, &krate.id()],\n 129 | )\n 130 | }","lines":7,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/results/db.rs#L124-L130"}]},{"id":"ERR_6","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","easy":false,"description":"In test_rustdoc, the `run` closure unconditionally calls remove_dir_all(host_target_dir/doc)? after every cargo invocation. If `cargo doc` fails early (before any doc/ directory is produced, e.g. a metadata or build error), the doc directory may not exist and remove_dir_all returns a NotFound error, which is propagated via `?`. This converts a normal BuildFail result into a hard task error (Err out of test_rustdoc), losing the real failure_reason and counting the crate as an infrastructure Error instead of a documentation build failure.","fix":"Ignore NotFound from remove_dir_all (e.g. match the error kind, or check path.exists() first), and run the cleanup only when the doc directory is expected; ensure the cargo result is returned even if cleanup of a missing dir 'fails'.","locations":[{"ref":"src/runner/test.rs:461-466","code":" 461 | // Make sure to remove the built documentation\n 462 | // There is no point in storing it after the build is done\n 463 | remove_dir_all(build_env.host_target_dir().join(\"doc\"))?;\n 464 | \n 465 | res\n 466 | };","lines":6,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/test.rs#L461-L466"},{"ref":"src/runner/test.rs:479-481","code":" 479 | if let Err(err) = res {\n 480 | return Ok(TestResult::BuildFail(failure_reason(&err)));\n 481 | }","lines":3,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/test.rs#L479-L481"}]},{"id":"ERR_7","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","easy":false,"description":"In endpoint_error, after logging, it loads the experiment and returns an error (ok_or_else 'no experiment run by this agent') if the experiment does not exist. This converts a missing-experiment case into an internal error response to the agent for what is just an error report. The agent already logged its failure; failing the error-reporting endpoint because the experiment was concurrently deleted/aborted is the wrong behavior and loses the metrics.record_error call.","fix":"If the experiment is not found, return success (or record a generic error metric) instead of bailing; the agent's error has already been logged.","locations":[{"ref":"src/server/routes/agent.rs:409-412","code":" 409 | let ex = Experiment::get(&data.db, &error.experiment_name)?\n 410 | .ok_or_else(|| anyhow!(\"no experiment run by this agent\"))?;\n 411 | \n 412 | data.metrics.record_error(&auth.name, &ex.name);","lines":4,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/agent.rs#L409-L412"}]},{"id":"ERR_8","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"EditExperiment uses assert_eq!(changes, 1) after each UPDATE. If a concurrent DeleteExperiment removed the experiment between the initial Experiment::get and these UPDATEs (the edit is not protected against the row disappearing mid-transaction in all engines), the assert would panic the process instead of returning a clean error. Asserts on DB row counts turn data races into panics.","fix":"Replace assert_eq!(changes, 1) with a returned error (e.g. ExperimentError::NotFound) so a missing row doesn't abort the process.","locations":[{"ref":"src/actions/experiments/edit.rs:63-63","code":" 63 | assert_eq!(changes, 1);","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/edit.rs#L63"},{"ref":"src/actions/experiments/edit.rs:116-116","code":" 116 | assert_eq!(changes, 1);","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/edit.rs#L116"},{"ref":"src/actions/experiments/edit.rs:126-126","code":" 126 | assert_eq!(changes, 1);","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/edit.rs#L126"},{"ref":"src/actions/experiments/edit.rs:136-136","code":" 136 | assert_eq!(changes, 1);","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/edit.rs#L136"},{"ref":"src/actions/experiments/edit.rs:146-146","code":" 146 | assert_eq!(changes, 1);","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/edit.rs#L146"},{"ref":"src/actions/experiments/edit.rs:156-156","code":" 156 | assert_eq!(changes, 1);","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/edit.rs#L156"}]},{"id":"ERR_9","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"In RunGraph the uncompleted-crate queue is shared via a Mutex whose guard is unwrapped on every pop (`crates.lock().unwrap()`). If any worker thread panics while holding the lock, the mutex becomes poisoned and every other worker's `unwrap()` panics in turn, turning a single crate-level failure into a cascading crash of the whole run rather than a recoverable error.","fix":"Handle a poisoned lock (e.g. `.lock().unwrap_or_else(|e| e.into_inner())`) or use a queue type designed for concurrent access that does not poison.","locations":[{"ref":"src/cli.rs:435-444","code":" 435 | let crates =\n 436 | std::sync::Mutex::new(experiment.get_uncompleted_crates(&db, None)?);\n 437 | let res = runner::run_ex(\n 438 | &experiment,\n 439 | &workspace,\n 440 | &result_db,\n 441 | threads,\n 442 | &config,\n 443 | &|| Ok(crates.lock().unwrap().pop()),\n 444 | );","lines":10,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/cli.rs#L435-L444"}]},{"id":"ERR_10","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"GitHubList::fetch deserializes the CSV and on a per-row deserialization error returns `line?` which aborts the entire list load. A single malformed row in the upstream rust-repos github.csv fails the whole registry update, whereas malformed *names* are merely warned-and-skipped (line 61). Inconsistent resilience to bad upstream data.","fix":"Decide on a consistent policy; if partial tolerance is desired, log-and-skip rows that fail to deserialize rather than propagating the error.","locations":[{"ref":"src/crates/sources/github.rs:41-62","code":" 41 | for line in reader.deserialize() {\n 42 | let line: ListRepo = line?;\n 43 | \n 44 | // Only import repos with a Cargo.toml or Cargo.lock\n 45 | if !line.has_cargo_toml || !line.has_cargo_lock {\n 46 | continue;\n 47 | }\n 48 | \n 49 | let mut name_parts = line.name.split('/');\n 50 | let org = name_parts.next();\n 51 | let name = name_parts.next();\n 52 | let trailing = name_parts.next();\n 53 | \n 54 | if let (Some(org), Some(name), None) = (org, name, trailing) {\n 55 | list.push(Crate::GitHub(GitHubRepo {\n 56 | org: org.to_string(),\n 57 | name: name.to_string(),\n 58 | sha: None,\n 59 | }));\n 60 | } else {\n 61 | warn!(\"skipping malformed repo name: {}\", line.name);\n 62 | }","lines":22,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/sources/github.rs#L41-L62"}]},{"id":"ERR_11","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":true,"description":"Multiple pragma_update calls in the connection manager and Database::new use .unwrap() (foreign_keys, cache_size, optimize, journal_mode WAL, synchronous NORMAL). A transient failure to set any pragma (e.g. disk/locking error during connect) panics the thread acquiring the connection instead of surfacing a recoverable r2d2 error, and connect() returning via panic rather than Err defeats the pool's retry/error-handling.","fix":"Propagate pragma_update errors (map into the manager Error / Fallible) instead of unwrapping, so the pool's connection_timeout and ErrorHandler can handle them gracefully.","locations":[{"ref":"src/db/mod.rs:26-41","code":" 26 | connection\n 27 | .pragma_update(None, \"foreign_keys\", \"ON\")\n 28 | .unwrap();\n 29 | \n 30 | // the goal of this is to try to keep `experiment_crates` mostly cached in memory.\n 31 | // that makes it much faster to scan it for the next crate we want to serve.\n 32 | connection\n 33 | .pragma_update(None, \"cache_size\", \"-307200\")\n 34 | .unwrap();\n 35 | \n 36 | // per docs, this is recommended for relatively long-lived connections (like what we have\n 37 | // due to the r2d2 pooling)\n 38 | // https://www.sqlite.org/pragma.html#pragma_optimize\n 39 | connection\n 40 | .pragma_update(None, \"optimize\", \"0x10002\")\n 41 | .unwrap();","lines":16,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/db/mod.rs#L26-L41"},{"ref":"src/db/mod.rs:137-151","code":" 137 | connection\n 138 | .pragma_update(None, \"journal_mode\", \"WAL\")\n 139 | .unwrap();\n 140 | }\n 141 | \n 142 | // we're ok losing durability in the event of a crash, and per docs this is still safe from\n 143 | // corruption under WAL mode.\n 144 | if connection.pragma_query_value(None, \"synchronous\", |r| {\n 145 | let current = r.get_ref(0)?.as_i64()?;\n 146 | Ok(current != 1)\n 147 | })? {\n 148 | connection\n 149 | .pragma_update(None, \"synchronous\", \"NORMAL\")\n 150 | .unwrap();\n 151 | }","lines":15,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/db/mod.rs#L137-L151"}]},{"id":"ERR_12","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"DeferredCrateSelect::resolve performs a synchronous blocking HTTP GET (utils::http::get_sync) inside what is otherwise a pure data-transformation path used during experiment creation. Network errors are propagated, but a slow/hanging URL blocks the calling thread (potentially a web request handler) with no timeout visible here. TOCTOU-style: the list is fetched at resolve time, not creation time, so two calls can yield different crate sets.","fix":"Ensure get_sync has a bounded timeout; document that resolution performs network I/O so callers can run it off the request thread.","locations":[{"ref":"src/experiments.rs:147-157","code":" 147 | impl DeferredCrateSelect {\n 148 | pub fn resolve(self) -> Fallible<CrateSelect> {\n 149 | let url = match self {\n 150 | DeferredCrateSelect::Direct(v) => return Ok(v),\n 151 | DeferredCrateSelect::Indirect(url) => url,\n 152 | };\n 153 | \n 154 | let body = utils::http::get_sync(url.as_str())?.text()?;\n 155 | CrateSelect::from_newline_separated_list(&body)\n 156 | }\n 157 | }","lines":11,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L147-L157"}]},{"id":"ERR_13","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"Inconsistent record_progress error handling within Worker::run. In the skip branch (lines 186-195) and the prepare-failure branch (lines 263-273) a failed record_progress is merely logged via report_failure and execution continues, but in the normal task-result branches (lines 325-343) a failed record_progress is propagated with `?`, killing the entire worker thread. The same failure to talk to the server thus has wildly different consequences depending on which crate state hit it.","fix":"Choose one policy for record_progress failures (most likely: log-and-continue, matching the skip/prepare branches, since one failed report should not tear down a worker) and apply it consistently, or document why the task branch must abort.","locations":[{"ref":"src/runner/worker.rs:186-195","code":" 186 | if let Err(e) = self.api.record_progress(\n 187 | self.ex,\n 188 | &krate,\n 189 | tc,\n 190 | \"crate skipped\".as_bytes(),\n 191 | &TestResult::Skipped,\n 192 | None,\n 193 | ) {\n 194 | crate::utils::report_failure(&e);\n 195 | }","lines":10,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/worker.rs#L186-L195"},{"ref":"src/runner/worker.rs:263-273","code":" 263 | if let Err(e) = self.api.record_progress(\n 264 | self.ex,\n 265 | &krate,\n 266 | tc,\n 267 | format!(\"{logs}\\n\\nthis task or one of its parent failed: {err:?}\")\n 268 | .as_bytes(),\n 269 | &result,\n 270 | updated_version.as_ref().map(|new| (&krate, new)),\n 271 | ) {\n 272 | crate::utils::report_failure(&e);\n 273 | }","lines":11,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/worker.rs#L263-L273"},{"ref":"src/runner/worker.rs:325-343","code":" 325 | self.api.record_progress(\n 326 | self.ex,\n 327 | &task.krate,\n 328 | tc,\n 329 | storage.to_string().as_bytes(),\n 330 | &res,\n 331 | updated_version.as_ref().map(|new| (&krate, new)),\n 332 | )?;\n 333 | }\n 334 | Err((err, test_result)) => {\n 335 | self.api.record_progress(\n 336 | self.ex,\n 337 | &task.krate,\n 338 | tc,\n 339 | format!(\"{storage}\\n\\n{err:?}\").as_bytes(),\n 340 | &test_result,\n 341 | updated_version.as_ref().map(|new| (&krate, new)),\n 342 | )?;\n 343 | }","lines":19,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/worker.rs#L325-L343"}]},{"id":"ERR_14","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"The GitHub label calls each hard-code one exact expected status (post_comment expects 201, list_labels/add_label/remove_label expect 200) and treat any other 2xx as failure, then attempt to deserialize the body into the Error struct. If GitHub returns an equivalent-but-different success code, the call is reported as an error and the body parse likely fails too, producing a confusing error. Inconsistent with get_commit/get_pr_head_sha which use error_for_status().","fix":"Use response.status().is_success() (or error_for_status) consistently across all GitHub calls instead of hard-coding one exact status code per call.","locations":[{"ref":"src/server/github.rs:75-116","code":" 75 | fn list_labels(&self, issue_url: &str) -> Fallible<Vec<Label>> {\n 76 | let response = self\n 77 | .build_request(Method::GET, &format!(\"{issue_url}/labels\"))\n 78 | .send()?;\n 79 | \n 80 | let status = response.status();\n 81 | if status == StatusCode::OK {\n 82 | Ok(response.json()?)\n 83 | } else {\n 84 | let error: Error = response.json()?;\n 85 | Err(GitHubError::RequestFailed(status, error.message).into())\n 86 | }\n 87 | }\n 88 | \n 89 | fn add_label(&self, issue_url: &str, label: &str) -> Fallible<()> {\n 90 | let response = self\n 91 | .build_request(Method::POST, &format!(\"{issue_url}/labels\"))\n 92 | .json(&json!([label]))\n 93 | .send()?;\n 94 | \n 95 | let status = response.status();\n 96 | if status == StatusCode::OK {\n 97 | Ok(())\n 98 | } else {\n 99 | let error: Error = response.json()?;\n 100 | Err(GitHubError::RequestFailed(status, error.message).into())\n 101 | }\n 102 | }\n 103 | \n 104 | fn remove_label(&self, issue_url: &str, label: &str) -> Fallible<()> {\n 105 | let response = self\n 106 | .build_request(Method::DELETE, &format!(\"{issue_url}/labels/{label}\"))\n 107 | .send()?;\n 108 | \n 109 | let status = response.status();\n 110 | if status == StatusCode::OK {\n 111 | Ok(())\n 112 | } else {\n 113 | let error: Error = response.json()?;\n 114 | Err(GitHubError::RequestFailed(status, error.message).into())\n 115 | }\n 116 | }","lines":42,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/github.rs#L75-L116"},{"ref":"src/server/github.rs:58-73","code":" 58 | fn post_comment(&self, issue_url: &str, body: &str) -> Fallible<()> {\n 59 | let response = self\n 60 | .build_request(Method::POST, &format!(\"{issue_url}/comments\"))\n 61 | .json(&json!({\n 62 | \"body\": body,\n 63 | }))\n 64 | .send()?;\n 65 | \n 66 | let status = response.status();\n 67 | if status == StatusCode::CREATED {\n 68 | Ok(())\n 69 | } else {\n 70 | let error: Error = response.json()?;\n 71 | Err(GitHubError::RequestFailed(status, error.message).into())\n 72 | }\n 73 | }","lines":16,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/github.rs#L58-L73"}]},{"id":"ERR_15","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"ReportsWorker::spawn stores the worker thread handle, but reports_thread runs an infinite loop; if it returns an error the outer closure logs it and immediately loops again with no backoff, which can busy-loop hammering the DB/GitHub on a persistent error (e.g. DB connection failure in Experiment::ready_for_report).","fix":"Add a sleep/backoff before respawning the reports loop after an error to avoid a tight failure loop.","locations":[{"ref":"src/server/reports.rs:163-172","code":" 163 | pub fn spawn(&self, data: Data, github_data: Option<GithubData>) {\n 164 | let joiner = thread::spawn(move || loop {\n 165 | let result = reports_thread(&data.clone(), github_data.as_ref())\n 166 | .with_context(|| \"the reports generator thread crashed\");\n 167 | if let Err(e) = result {\n 168 | utils::report_failure(&e);\n 169 | }\n 170 | });\n 171 | *self.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(joiner.thread().clone());\n 172 | }","lines":10,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/reports.rs#L163-L172"},{"ref":"src/server/reports.rs:53-62","code":" 53 | loop {\n 54 | let mut ex = match Experiment::ready_for_report(&data.db)? {\n 55 | Some(ex) => ex,\n 56 | None => {\n 57 | // This will sleep AUTOMATIC_THREAD_WAKEUP seconds *or* until a wake is received\n 58 | std::thread::park_timeout(timeout);\n 59 | \n 60 | continue;\n 61 | }\n 62 | };","lines":10,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/reports.rs#L53-L62"}]},{"id":"ERR_16","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"The error log message is missing the actual error. error!(\"error while processing metrics\") logs no context; the real error is only sent to report_failure. A reader of the logs alone cannot tell what failed.","fix":"Include the error in the log, e.g. error!(\"error while processing metrics: {err:?}\").","locations":[{"ref":"src/server/routes/metrics.rs:19-20","code":" 19 | error!(\"error while processing metrics\");\n 20 | crate::utils::report_failure(&err);","lines":2,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/metrics.rs#L19-L20"}]},{"id":"ERR_17","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"endpoint_assets returns 404 for both a missing asset and a failure to read/decode an existing asset's content (asset.content() Err is folded into the 404 path). A genuine I/O or decode error for a known asset is reported to the client as 'not found', masking server-side problems.","fix":"Distinguish assets::load Err/None (404) from asset.content() Err (500), and report failures for known assets as server errors.","locations":[{"ref":"src/server/routes/ui/mod.rs:69-82","code":" 69 | fn endpoint_assets(path: String) -> Fallible<Response> {\n 70 | if let Ok(asset) = assets::load(&path) {\n 71 | if let Ok(content) = asset.content() {\n 72 | let mut resp = Response::new(content.into_owned().into());\n 73 | resp.headers_mut().insert(\n 74 | CONTENT_TYPE,\n 75 | HeaderValue::from_str(asset.mime().as_ref()).unwrap(),\n 76 | );\n 77 | return Ok(resp);\n 78 | }\n 79 | }\n 80 | \n 81 | error_404()\n 82 | }","lines":14,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/ui/mod.rs#L69-L82"}]},{"id":"API_1","type":"api_misuse","type_label":"Api Misuse","severity":"medium","easy":false,"description":"In get_uncompleted_crates, the UPDATE query is built with `\"?,\".repeat(params.len() - 2)` then `\"?)\"`. params includes a 1-element header (self.name) plus the chunk of crate names. This relies on every chunk having at least 1 crate so that params.len() >= 2; chunks() never yields an empty slice so it holds, but the arithmetic `params.len() - 2` would underflow-panic (usize subtract) if header length or chunk size assumptions ever change. The placeholder construction is fragile and duplicates logic that QueryUtils could provide.","fix":"Use a helper that generates exactly params.len() placeholders (e.g. join of '?' with ','), avoiding the off-by-constant `- 2` arithmetic and its underflow risk.","locations":[{"ref":"src/experiments.rs:636-654","code":" 636 | let params_header: &[&dyn rusqlite::types::ToSql] = &[&self.name];\n 637 | //SQLite cannot handle queries with more than 999 variables\n 638 | for params in params.chunks(SQL_VARIABLE_LIMIT) {\n 639 | let params = [params_header, params].concat();\n 640 | let update_query = &[\n 641 | \"\n 642 | UPDATE experiment_crates\n 643 | SET started_at = datetime('now')\n 644 | WHERE experiment = ?1\n 645 | AND crate IN (\"\n 646 | .to_string(),\n 647 | \"?,\".repeat(params.len() - 2),\n 648 | \"?)\".to_string(),\n 649 | ]\n 650 | .join(\"\");\n 651 | \n 652 | //update the status of the previously selected crates to 'Running'\n 653 | transaction.execute(update_query, ¶ms)?;\n 654 | }","lines":19,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L636-L654"}]},{"id":"API_2","type":"api_misuse","type_label":"Api Misuse","severity":"medium","easy":false,"description":"verify_signature uses HMAC-SHA1, which GitHub deprecated in favor of SHA-256 (X-Hub-Signature-256). The webhook handler only reads X-Hub-Signature (SHA-1) and rejects any non-sha1 algorithm. SHA-1 HMAC is still accepted by GitHub for backwards compatibility, but relying solely on it is a weak/legacy choice for webhook authentication.","fix":"Support and prefer X-Hub-Signature-256 (HMAC-SHA256) for webhook signature verification, keeping SHA-1 only as a fallback if needed.","locations":[{"ref":"src/server/routes/webhooks/mod.rs:154-189","code":" 154 | fn verify_signature(secret: &str, payload: &[u8], raw_signature: &str) -> bool {\n 155 | type HmacSha1 = Hmac<sha1::Sha1>;\n 156 | \n 157 | // The signature must have a =\n 158 | if !raw_signature.contains('=') {\n 159 | return false;\n 160 | }\n 161 | \n 162 | // Split the raw signature to get the algorithm and the signature\n 163 | let splitted: Vec<&str> = raw_signature.split('=').collect();\n 164 | let algorithm = &splitted[0];\n 165 | let hex_signature = splitted\n 166 | .iter()\n 167 | .skip(1)\n 168 | .cloned()\n 169 | .collect::<Vec<&str>>()\n 170 | .join(\"=\");\n 171 | \n 172 | // Convert the signature from hex\n 173 | let signature = if let Ok(converted) = crate::utils::hex::from_hex(&hex_signature) {\n 174 | converted\n 175 | } else {\n 176 | // This is not hex\n 177 | return false;\n 178 | };\n 179 | \n 180 | // Only SHA-1 is supported\n 181 | if *algorithm != \"sha1\" {\n 182 | return false;\n 183 | }\n 184 | \n 185 | // Verify the HMAC signature\n 186 | let mut mac = HmacSha1::new_from_slice(secret.as_bytes()).unwrap();\n 187 | mac.update(payload);\n 188 | mac.verify_slice(&signature).is_ok()\n 189 | }","lines":36,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/webhooks/mod.rs#L154-L189"},{"ref":"src/server/routes/webhooks/mod.rs:197-200","code":" 197 | let signature = headers\n 198 | .get(\"X-Hub-Signature\")\n 199 | .and_then(|h| h.to_str().ok())\n 200 | .ok_or_else(|| anyhow!(\"missing header X-Hub-Signature\\n\"))?;","lines":4,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/webhooks/mod.rs#L197-L200"}]},{"id":"API_3","type":"api_misuse","type_label":"Api Misuse","severity":"low","easy":false,"description":"get_crate_version_status builds a reqwest request via `.body(...).unwrap()` and `builder...headers_mut().unwrap()` and `builder.body(...).unwrap()`. These unwraps assume the crates_index request and http::response::Builder never error; a malformed header value from the index server would panic the whole report generation rather than producing a recoverable error.","fix":"Propagate these with `?` (the function already returns Fallible) instead of unwrap().","locations":[{"ref":"src/report/mod.rs:191-205","code":" 191 | let req: reqwest::blocking::Request = req\n 192 | .body(reqwest::blocking::Body::from(vec![]))\n 193 | .unwrap()\n 194 | .try_into()?;\n 195 | \n 196 | let resp = crate::utils::http::HTTP_SYNC_CLIENT.execute(req)?;\n 197 | let mut builder = crates_index::http::Response::builder()\n 198 | .status(resp.status())\n 199 | .version(resp.version());\n 200 | builder\n 201 | .headers_mut()\n 202 | .unwrap()\n 203 | .extend(resp.headers().iter().map(|(k, v)| (k.clone(), v.clone())));\n 204 | let body = resp.bytes()?;\n 205 | let res = builder.body(Vec::from(body)).unwrap();","lines":15,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/mod.rs#L191-L205"}]},{"id":"API_4","type":"api_misuse","type_label":"Api Misuse","severity":"low","easy":false,"description":"Multiple `.unwrap()` on path.as_ref().to_str() in S3Writer (and on upload_id()/key()/e_tag() AWS responses). path.to_str() returns None for non-UTF-8 paths and would panic; the AWS response field unwraps assume the service always populates them. These are panics on malformed input / unexpected API responses during upload.","fix":"Use ok_or_else/bail! to convert None into a Fallible error rather than unwrapping; report paths are ASCII so to_str rarely fails, but the AWS-response unwraps are the riskier ones.","locations":[{"ref":"src/report/s3.rs:93-93","code":" 93 | path.as_ref().to_str().unwrap()","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/s3.rs#L93"},{"ref":"src/report/s3.rs:123-124","code":" 123 | .upload_id(upload.upload_id().unwrap())\n 124 | .key(upload.key().unwrap())","lines":2,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/s3.rs#L123-L124"},{"ref":"src/report/s3.rs:130-130","code":" 130 | .e_tag(p.e_tag.clone().unwrap())","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/s3.rs#L130"},{"ref":"src/report/s3.rs:170-170","code":" 170 | path.as_ref().to_str().unwrap()","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/s3.rs#L170"}]},{"id":"PERF_1","type":"performance","type_label":"Performance","severity":"high","easy":true,"description":"Busy-wait spin loop when docker is not running. The while loop has no sleep: it logs an error and increments a counter as fast as the CPU allows until docker comes up. This pegs a core at 100% and can emit millions of log lines per second (filling disk / log pipelines) while waiting for docker, which the surrounding comment explicitly expects to be a common transient state.","fix":"Add a sleep inside the loop, e.g. std::thread::sleep(Duration::from_secs(1)) (or exponential backoff) after the log::error! call, so the agent polls docker_running at a bounded rate.","locations":[{"ref":"src/runner/mod.rs:49-53","code":" 49 | let mut i = 0;\n 50 | while !rustwide::cmd::docker_running(workspace) {\n 51 | log::error!(\"docker is not currently up, waiting for it to start (tried {i} times)\");\n 52 | i += 1;\n 53 | }","lines":5,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/mod.rs#L49-L53"}]},{"id":"PERF_2","type":"performance","type_label":"Performance","severity":"high","easy":false,"description":"ACL::allowed() performs a synchronous blocking HTTP GET to the rust-team-data service on EVERY authorization check when rust_teams is enabled. This runs inside process_command for each command line in a webhook, blocking the request thread on network I/O, and there is no caching of the rust-team permission list. A slow or unavailable team-data service stalls (or fails) every bot command. It also means a transient network error makes an authorized user appear unauthorized.","fix":"Cache the team-data permission list (github_ids) like cached_usernames is cached, refreshing it periodically / on the refresh_cache path, instead of fetching synchronously on every allowed() call.","locations":[{"ref":"src/server/auth.rs:170-179","code":" 170 | pub fn allowed(&self, username: &str, user_id: u64) -> Fallible<bool> {\n 171 | if self.rust_teams {\n 172 | let url = format!(\"{}/permissions/crater.json\", team_data::BASE_URL);\n 173 | let members: team_data::Permission = crate::utils::http::get_sync(&url)?.json()?;\n 174 | if members.github_ids.contains(&user_id) {\n 175 | return Ok(true);\n 176 | }\n 177 | }\n 178 | Ok(self.cached_usernames.read().unwrap().contains(username))\n 179 | }","lines":10,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/auth.rs#L170-L179"}]},{"id":"PERF_3","type":"performance","type_label":"Performance","severity":"medium","easy":false,"description":"ready_for_report() iterates over every unfinished experiment and runs two COUNT(*) queries (raw_progress) per experiment on the (potentially huge) results and experiment_crates tables, even though it returns at the first match. With many unfinished experiments this is O(N) extra round-trips and full-table COUNTs every time the report scheduler polls. The results-count query has no index hint and scans all rows for the experiment.","fix":"Cache per-experiment progress or maintain a running results counter; at minimum break out of the loop is already done, but avoid re-COUNTing every poll (e.g. memoize completed/all per experiment until results change).","locations":[{"ref":"src/experiments.rs:302-327","code":" 302 | pub fn ready_for_report(db: &Database) -> Fallible<Option<Experiment>> {\n 303 | let unfinished = Self::unfinished(db)?;\n 304 | for ex in unfinished {\n 305 | if ex.status == Status::ReportFailed {\n 306 | // Skip experiments whose report failed to generate. This avoids\n 307 | // constantly retrying reports (and posting a message each time\n 308 | // about the attempt); the retry-report command can override the\n 309 | // failure state. In practice we rarely *fail* to generate\n 310 | // reports in a clean way (instead OOMing or panicking, in which\n 311 | // case it is fine to automatically retry the report, as we've\n 312 | // not posted anything on GitHub -- it may be a problem from a\n 313 | // performance perspective but no more than that).\n 314 | continue;\n 315 | }\n 316 | let (completed, all) = ex.raw_progress(db)?;\n 317 | // FIXME: We often see more results than crates -- presumably, some\n 318 | // crates are run more than once and marked more than once, but the\n 319 | // details are not clear. For now, just assume that we're done if we\n 320 | // have 'more than enough' results too.\n 321 | if completed >= all {\n 322 | return Ok(Some(ex));\n 323 | }\n 324 | }\n 325 | \n 326 | Ok(None)\n 327 | }","lines":26,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L302-L327"},{"ref":"src/experiments.rs:550-569","code":" 550 | pub fn raw_progress(&self, db: &Database) -> Fallible<(u32, u32)> {\n 551 | let results_len: u32 = db\n 552 | .get_row(\n 553 | \"SELECT COUNT(*) AS count FROM results WHERE experiment = ?1;\",\n 554 | [&self.name.as_str()],\n 555 | |r| r.get(\"count\"),\n 556 | )?\n 557 | .unwrap();\n 558 | \n 559 | let crates_len: u32 = db\n 560 | .get_row(\n 561 | \"SELECT COUNT(*) AS count FROM experiment_crates \\\n 562 | WHERE experiment = ?1 AND skipped = 0;\",\n 563 | [&self.name.as_str()],\n 564 | |r| r.get(\"count\"),\n 565 | )?\n 566 | .unwrap();\n 567 | \n 568 | Ok((results_len, crates_len * 2))\n 569 | }","lines":20,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L550-L569"}]},{"id":"PERF_4","type":"performance","type_label":"Performance","severity":"medium","easy":false,"description":"analyze_detailed clones CrateResult heavily. Each crate that is a DependsOn build-failure is cloned once per dependency (line 48), and root crates with multiple compiler-error codes are cloned once per code (line 64). CrateResult contains Strings, a Crate, and two BuildTestResults (each with a String log). For large regression sets this is a lot of deep cloning. Also line 58 does `krate.runs[toolchain].clone().unwrap().res` which clones the whole Option<BuildTestResult> just to read res, where `.as_ref().unwrap()` would avoid the clone.","fix":"Use `.as_ref().unwrap()` at line 58 to avoid the needless clone; consider storing Rc/indices instead of cloning full CrateResult per dependency/code.","locations":[{"ref":"src/report/analyzer.rs:42-72","code":" 42 | if let BuildFail(FailureReason::DependsOn(ref deps)) =\n 43 | krate.runs[toolchain].as_ref().unwrap().res\n 44 | {\n 45 | for dep in deps {\n 46 | tree.entry(dep.clone())\n 47 | .or_insert_with(Vec::new)\n 48 | .push(krate.clone())\n 49 | }\n 50 | } else {\n 51 | root.push(krate);\n 52 | }\n 53 | }\n 54 | \n 55 | for krate in root {\n 56 | // record results only for root crates\n 57 | if let BuildFail(FailureReason::CompilerError(codes)) =\n 58 | krate.runs[toolchain].clone().unwrap().res\n 59 | {\n 60 | for code in codes {\n 61 | results\n 62 | .entry(BuildFail(FailureReason::CompilerError(btreeset![code])))\n 63 | .or_insert_with(Vec::new)\n 64 | .push(krate.clone())\n 65 | }\n 66 | } else {\n 67 | results\n 68 | .entry(krate.runs[toolchain].as_ref().unwrap().res.clone())\n 69 | .or_insert_with(Vec::new)\n 70 | .push(krate)\n 71 | }\n 72 | }","lines":31,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/analyzer.rs#L42-L72"},{"ref":"src/report/analyzer.rs:57-59","code":" 57 | if let BuildFail(FailureReason::CompilerError(codes)) =\n 58 | krate.runs[toolchain].clone().unwrap().res\n 59 | {","lines":3,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/analyzer.rs#L57-L59"}]},{"id":"PERF_5","type":"performance","type_label":"Performance","severity":"medium","easy":false,"description":"write_logs_archives calls iterate() a second time over all crates (line 224) after write_all_archive already iterated once (which itself re-runs each retry). Each iterate pass re-loads every test result and re-decodes every log from the DB (load_test_result + load_log + to_plain) for every crate and toolchain. For large experiments this doubles (or more, with retries) the DB reads and zstd/gzip decompression work.","fix":"Collect the LogEntry list once and reuse it for both the all-archive and the per-comparison archives instead of re-iterating the DB.","locations":[{"ref":"src/report/archives.rs:222-231","code":" 222 | archives.push(write_all_archive(db, ex, crates, dest, config)?);\n 223 | \n 224 | for entry in iterate(db, ex, crates, config) {\n 225 | let entry = entry?;\n 226 | \n 227 | by_comparison\n 228 | .entry(entry.comparison)\n 229 | .or_insert_with(|| TarBuilder::new(zstd::stream::Encoder::new(Vec::new(), 3).unwrap()))\n 230 | .append_data(&mut entry.header(), &entry.path, &entry.log_bytes[..])?;\n 231 | }","lines":10,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/archives.rs#L222-L231"},{"ref":"src/report/archives.rs:150-161","code":" 150 | for i in 1..=RETRIES {\n 151 | // We write this large-ish tarball into a tempfile, which moves the I/O to disk operations\n 152 | // rather than keeping it in memory. This avoids complicating the code by doing incremental\n 153 | // writes to S3 (requiring buffer management etc) while avoiding keeping the blob entirely\n 154 | // in memory.\n 155 | let backing = tempfile()?;\n 156 | let mut all = TarBuilder::new(zstd::stream::Encoder::new(backing, 0)?);\n 157 | for entry in iterate(db, ex, crates, config) {\n 158 | let entry = entry?;\n 159 | let mut header = entry.header();\n 160 | all.append_data(&mut header, &entry.path, &entry.log_bytes[..])?;\n 161 | }","lines":12,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/archives.rs#L150-L161"}]},{"id":"PERF_6","type":"performance","type_label":"Performance","severity":"medium","easy":false,"description":"logs.to_string() is called inside the fetch retry loop to test for 'No space left on device' on every failed attempt (up to 15 times), and to_string() materializes the entire captured log buffer each time. For large fetch logs this is repeated O(attempts) full-buffer string allocations + substring scans in a retry path that already sleeps. The captured buffer also only grows across attempts, so the work compounds.","fix":"Inspect the specific error `e` (downcast to CommandError/io kind) for the no-space condition instead of stringifying the whole accumulated log buffer; or capture the log string once per attempt into a local and scan only the new tail.","locations":[{"ref":"src/runner/worker.rs:204-236","code":" 204 | for attempt in 1..=15 {\n 205 | match detect_broken(rustwide_crate.fetch(self.workspace)) {\n 206 | Ok(()) => break,\n 207 | Err(e) => {\n 208 | if logs.to_string().contains(\"No space left on device\") {\n 209 | if attempt == 15 {\n 210 | // If we've failed 15 times, then\n 211 | // just give up. It's been at least\n 212 | // 45 seconds, which is enough that\n 213 | // our disk space check should\n 214 | // have run at least once in this\n 215 | // time. If that's not helped, then\n 216 | // maybe this git repository *is*\n 217 | // actually too big.\n 218 | //\n 219 | // Ideally we'd have some kind of\n 220 | // per-worker counter and if we hit\n 221 | // this too often we'd replace the\n 222 | // machine, but it's not very clear\n 223 | // what \"too often\" means here.\n 224 | return Err(e);\n 225 | } else {\n 226 | log::warn!(\n 227 | \"Retrying crate fetch in 3 seconds (attempt {attempt})\"\n 228 | );\n 229 | std::thread::sleep(std::time::Duration::from_secs(3));\n 230 | }\n 231 | } else {\n 232 | return Err(e);\n 233 | }\n 234 | }\n 235 | }\n 236 | }","lines":33,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/worker.rs#L204-L236"}]},{"id":"PERF_7","type":"performance","type_label":"Performance","severity":"medium","easy":false,"description":"Agents::all() is an N+1 query: it loads all agents, then for each agent issues a separate query for its assigned experiment (with_experiment) and another for its capabilities (with_capabilities). This is called on every /metrics scrape (endpoint_metrics) and on the agents UI/list pages, so the number of DB round-trips grows linearly with agent count on a hot path.","fix":"Batch-load experiments and capabilities for all agents in one or two queries (e.g. join or IN-list) instead of per-agent queries.","locations":[{"ref":"src/server/agents.rs:141-161","code":" 141 | pub fn all(&self) -> Fallible<Vec<Agent>> {\n 142 | self.db\n 143 | .query(\"SELECT * FROM agents ORDER BY name;\", [], |row| {\n 144 | Ok(Agent {\n 145 | name: row.get(\"name\")?,\n 146 | last_heartbeat: row.get(\"last_heartbeat\")?,\n 147 | git_revision: row.get(\"git_revision\")?,\n 148 | \n 149 | // Lazy loaded after this\n 150 | experiment: None,\n 151 | capabilities: None,\n 152 | })\n 153 | })?\n 154 | .into_iter()\n 155 | .map(|agent| {\n 156 | agent\n 157 | .with_experiment(&self.db)\n 158 | .and_then(|agent| agent.with_capabilities(&self.db))\n 159 | })\n 160 | .collect()\n 161 | }","lines":21,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/agents.rs#L141-L161"},{"ref":"src/server/routes/metrics.rs:29-35","code":" 29 | fn endpoint_metrics(data: Arc<Data>) -> Fallible<Response> {\n 30 | data.metrics.update_agent_status(\n 31 | &data.db,\n 32 | &data.agents.all()?.iter().collect::<Vec<&Agent>>(),\n 33 | )?;\n 34 | \n 35 | data.metrics.update_crates_lists(&data.db)?;","lines":7,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/metrics.rs#L29-L35"}]},{"id":"PERF_8","type":"performance","type_label":"Performance","severity":"low","easy":false,"description":"CreateExperiment inserts experiment_crates one row per execute() call inside the transaction loop. For Full/Top(n) experiments this is thousands of individual INSERT statements. Same applies to EditExperiment's re-insert loop. Batching would dramatically reduce statement-preparation overhead.","fix":"Use a prepared/cached statement reused across iterations (execute_cached) or multi-row INSERT batching.","locations":[{"ref":"src/actions/experiments/create.rs:80-86","code":" 80 | for krate in &crates {\n 81 | let skipped = !self.ignore_blacklist && ctx.config.should_skip(krate);\n 82 | transaction.execute(\n 83 | \"INSERT INTO experiment_crates (experiment, crate, skipped, status) VALUES (?1, ?2, ?3, ?4);\",\n 84 | &[&self.name, &krate.id(), &skipped, &Status::Queued.to_string()],\n 85 | )?;\n 86 | }","lines":7,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/create.rs#L80-L86"},{"ref":"src/actions/experiments/edit.rs:96-107","code":" 96 | for krate in &crates_vec {\n 97 | t.execute(\n 98 | \"INSERT INTO experiment_crates (experiment, crate, skipped, status) \\\n 99 | VALUES (?1, ?2, ?3, ?4);\",\n 100 | &[\n 101 | &self.name,\n 102 | &krate.id(),\n 103 | &(!ex.ignore_blacklist && ctx.config.should_skip(krate)),\n 104 | &Status::Queued.to_string(),\n 105 | ],\n 106 | )?;\n 107 | }","lines":12,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/edit.rs#L96-L107"}]},{"id":"PERF_9","type":"performance","type_label":"Performance","severity":"low","easy":false,"description":"get_crates for CrateSelect::Random/Top/Full loads the entire registry + github lists from the DB (potentially hundreds of thousands of rows parsed into Crate via per-row string parsing) even when only a small `n` is needed, then shuffles/truncates in memory. Each List::get also re-parses every crate id string with Crate::from_str. For Top(n) especially this is wasteful since the DB rows are already ordered by rowid.","fix":"For Top(n) push a LIMIT into the SQL query; for Random consider reservoir sampling or `ORDER BY RANDOM() LIMIT n` rather than loading and shuffling the full list.","locations":[{"ref":"src/crates/lists.rs:48-57","code":" 48 | fn get(db: &Database) -> Fallible<Vec<Crate>> {\n 49 | let crates_results = db.query(\n 50 | \"SELECT crate FROM crates WHERE list = ?1 ORDER BY rowid;\",\n 51 | [&Self::NAME],\n 52 | |r| r.get::<_, String>(0),\n 53 | )?;\n 54 | \n 55 | // Turns Vec<Fallible<Crate>> into Fallible<Vec<Crate>>\n 56 | crates_results.into_iter().map(|v| v.parse()).collect()\n 57 | }","lines":10,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/lists.rs#L48-L57"},{"ref":"src/crates/lists.rs:144-155","code":" 144 | CrateSelect::Random(n) => {\n 145 | crates.append(&mut RegistryList::get(db)?);\n 146 | crates.append(&mut GitHubList::get(db)?);\n 147 | \n 148 | let mut rng = rand::rng();\n 149 | crates.shuffle(&mut rng);\n 150 | crates.truncate(*n as usize);\n 151 | }\n 152 | CrateSelect::Top(n) => {\n 153 | crates.append(&mut RegistryList::get(db)?);\n 154 | crates.truncate(*n as usize);\n 155 | }","lines":12,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/lists.rs#L144-L155"}]},{"id":"PERF_10","type":"performance","type_label":"Performance","severity":"low","easy":false,"description":"DbDumpReader::read_at issues a fresh blocking HTTP HEAD/GET (new connection setup via prepare_sync) for every ranged read during zip parsing, and additionally logs every read at info! level. For a multi-hundred-MB db-dump.zip this can be a large number of round-trips with verbose logging on the hot path.","fix":"Lower the per-read log to trace!/debug!, and consider reusing a client/connection or batching reads. At minimum drop the info! to avoid log spam proportional to dump size.","locations":[{"ref":"src/crates/sources/registry.rs:31-40","code":" 31 | let mut res = crate::utils::http::prepare_sync(reqwest::Method::GET, BASE_URL)\n 32 | .header(\n 33 | reqwest::header::RANGE,\n 34 | format!(\n 35 | \"bytes={offset}-{}\",\n 36 | 1 + offset + u64::try_from(buf.len()).unwrap()\n 37 | ),\n 38 | )\n 39 | .send()\n 40 | .map_err(|e| std::io::Error::other(e))?;","lines":10,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/sources/registry.rs#L31-L40"},{"ref":"src/crates/sources/registry.rs:70-77","code":" 70 | info!(\n 71 | \"requesting {}..{}: read {:?} (out of {})\",\n 72 | offset,\n 73 | offset + buf.len() as u64,\n 74 | read,\n 75 | l\n 76 | );\n 77 | Ok(read)","lines":8,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/sources/registry.rs#L70-L77"}]},{"id":"PERF_11","type":"performance","type_label":"Performance","severity":"low","easy":false,"description":"In RegistryList::fetch the final HashMap `by_name` is built keyed by crate name, then immediately iterated to build the Vec and again indexed `by_name[&a.name]` inside the sort key closure. Building a name-keyed map and re-looking-up during sort adds hashing overhead; the reverse-dependency count could be stored alongside each Crate before sorting (e.g. sort a Vec<(count, Crate)>) to avoid the per-comparison map lookups.","fix":"Collect into a Vec<(u64 count, RegistryCrate)>, sort_unstable_by_key on the count field, then map to Crate::Registry, avoiding the HashMap index inside the comparator.","locations":[{"ref":"src/crates/sources/registry.rs:241-265","code":" 241 | let by_name = crates\n 242 | .into_iter()\n 243 | .filter(|v| v.1 .1.is_some())\n 244 | .map(|(_, v)| (v.0, (v.1.unwrap(), v.2)))\n 245 | .collect::<HashMap<_, _>>();\n 246 | \n 247 | assert_eq!(by_name.len(), versions_selected.len());\n 248 | info!(\"{} unique crate names\", by_name.len());\n 249 | \n 250 | let mut list = by_name\n 251 | .iter()\n 252 | .map(|(name, v)| {\n 253 | Crate::Registry(RegistryCrate {\n 254 | name: name.clone(),\n 255 | version: v.0.num.clone(),\n 256 | })\n 257 | })\n 258 | .collect::<Vec<_>>();\n 259 | list.sort_unstable_by_key(|a| {\n 260 | if let Crate::Registry(ref a) = a {\n 261 | by_name[&a.name].1\n 262 | } else {\n 263 | panic!(\"non-registry crate produced in the registry list\");\n 264 | }\n 265 | });","lines":25,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/sources/registry.rs#L241-L265"}]},{"id":"PERF_12","type":"performance","type_label":"Performance","severity":"low","easy":false,"description":"find_next() can issue up to three separate next_inner() calls, each running a full SELECT with a correlated subquery against agent_capabilities, plus run_by() beforehand, and then next() may run additional UPDATE/set_status/set_assigned_to statements. For an agent polling for work this is several DB round-trips per request where one combined query could suffice.","fix":"Combine the assigned/distributed/unassigned candidate selection into a single ordered SQL query (UNION or ORDER BY on assigned_to precedence) to reduce round-trips.","locations":[{"ref":"src/experiments.rs:329-358","code":" 329 | pub fn find_next(db: &Database, assignee: &Assignee) -> Fallible<Option<Experiment>> {\n 330 | // Avoid assigning two experiments to the same agent\n 331 | if let Some(experiment) = Experiment::run_by(db, assignee)? {\n 332 | return Ok(Some(experiment));\n 333 | }\n 334 | \n 335 | // Get an experiment whose requirements are met by this agent, preferring (in order of\n 336 | // importance):\n 337 | // - experiments that were explicitly assigned to us.\n 338 | // - distributed experiments.\n 339 | // - experiments with a higher priority.\n 340 | // - older experiments.\n 341 | Experiment::next_inner(db, Some(assignee), assignee)\n 342 | .and_then(|ex| {\n 343 | ex.map_or_else(\n 344 | || Experiment::next_inner(db, Some(&Assignee::Distributed), assignee),\n 345 | |exp| Ok(Some(exp)),\n 346 | )\n 347 | })\n 348 | .and_then(|ex| {\n 349 | ex.map_or_else(\n 350 | || Experiment::next_inner(db, None, assignee),\n 351 | |exp| Ok(Some(exp)),\n 352 | )\n 353 | })\n 354 | }\n 355 | \n 356 | pub fn next(db: &Database, assignee: &Assignee) -> Fallible<Option<(bool, Experiment)>> {\n 357 | Self::find_next(db, assignee).and_then(|ex| Self::assign_experiment(db, ex, assignee))\n 358 | }","lines":30,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L329-L358"}]},{"id":"PERF_13","type":"performance","type_label":"Performance","severity":"low","easy":false,"description":"set_status issues up to two separate UPDATE statements (one for status, a second for started_at or completed_at) in two round-trips, not wrapped in a transaction. A crash between them leaves status updated but timestamp unset. Same pattern means each status change is 2 DB calls.","fix":"Combine into a single UPDATE setting status and the relevant timestamp together, or wrap in a transaction.","locations":[{"ref":"src/experiments.rs:496-525","code":" 496 | pub fn set_status(&mut self, db: &Database, status: Status) -> Fallible<()> {\n 497 | db.execute(\n 498 | \"UPDATE experiments SET status = ?1 WHERE name = ?2;\",\n 499 | &[&status.to_str(), &self.name.as_str()],\n 500 | )?;\n 501 | \n 502 | let now = Utc::now();\n 503 | \n 504 | match (self.status, status) {\n 505 | // Check if the new status is \"running\" and there is no starting date\n 506 | (_, Status::Running) if self.started_at.is_none() => {\n 507 | db.execute(\n 508 | \"UPDATE experiments SET started_at = ?1 WHERE name = ?2;\",\n 509 | &[&now, &self.name.as_str()],\n 510 | )?;\n 511 | self.started_at = Some(now);\n 512 | }\n 513 | // Check if the old status was \"running\" and there is no completed date\n 514 | (Status::Running, _) if self.completed_at.is_none() => {\n 515 | db.execute(\n 516 | \"UPDATE experiments SET completed_at = ?1 WHERE name = ?2;\",\n 517 | &[&now, &self.name.as_str()],\n 518 | )?;\n 519 | self.completed_at = Some(now);\n 520 | }\n 521 | _ => (),\n 522 | }\n 523 | \n 524 | self.status = status;\n 525 | Ok(())","lines":30,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L496-L525"}]},{"id":"PERF_14","type":"performance","type_label":"Performance","severity":"low","easy":false,"description":"get_uncompleted_crates collects selected crate names into a Vec<String>, then pushes &String refs into a params Vec, then for each chunk does `[params_header, params].concat()` allocating a new Vec per chunk, and finally re-parses each crate string into Crate. Several intermediate allocations per call; on the large-experiment path (limit 1000) this runs frequently for every agent request.","fix":"Avoid the per-chunk concat by passing the header as a fixed first bound parameter, and parse crates once.","locations":[{"ref":"src/experiments.rs:617-659","code":" 617 | db.transaction(true, |transaction| {\n 618 | //get the first 'limit' queued crates from the experiment crates list\n 619 | let mut params: Vec<&dyn rusqlite::types::ToSql> = Vec::new();\n 620 | let crates = transaction\n 621 | .query(\n 622 | &format!(\n 623 | \"SELECT crate FROM experiment_crates WHERE experiment = ?1\n 624 | AND skipped = 0\n 625 | AND status = 'queued'\n 626 | AND (started_at is null or started_at <= datetime('now', '-{RUN_TIMEOUT} minutes'))\n 627 | LIMIT ?2;\",\n 628 | ),\n 629 | rusqlite::params![self.name, limit],\n 630 | |r| r.get(\"crate\"),\n 631 | )?\n 632 | .into_iter()\n 633 | .collect::<Vec<String>>();\n 634 | \n 635 | crates.iter().for_each(|krate| params.push(krate));\n 636 | let params_header: &[&dyn rusqlite::types::ToSql] = &[&self.name];\n 637 | //SQLite cannot handle queries with more than 999 variables\n 638 | for params in params.chunks(SQL_VARIABLE_LIMIT) {\n 639 | let params = [params_header, params].concat();\n 640 | let update_query = &[\n 641 | \"\n 642 | UPDATE experiment_crates\n 643 | SET started_at = datetime('now')\n 644 | WHERE experiment = ?1\n 645 | AND crate IN (\"\n 646 | .to_string(),\n 647 | \"?,\".repeat(params.len() - 2),\n 648 | \"?)\".to_string(),\n 649 | ]\n 650 | .join(\"\");\n 651 | \n 652 | //update the status of the previously selected crates to 'Running'\n 653 | transaction.execute(update_query, ¶ms)?;\n 654 | }\n 655 | crates\n 656 | .iter()\n 657 | .map(|krate| Ok(krate.parse()?))\n 658 | .collect::<Fallible<Vec<Crate>>>()\n 659 | })","lines":43,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L617-L659"}]},{"id":"PERF_15","type":"performance","type_label":"Performance","severity":"low","easy":false,"description":"markdown write_report calls crates.to_owned() (line 155) which deep-clones every ReportCrates (all CrateResult vectors, including the log Strings) for every category, purely to consume them by value afterward. Since `res: &TestResults` is borrowed and only read, cloning the entire result set is wasteful for large experiments.","fix":"Restructure to borrow and build the MD context from references, or have write_markdown_report take ownership of the relevant data once instead of cloning per-category.","locations":[{"ref":"src/report/markdown.rs:151-177","code":" 151 | let categories = res\n 152 | .categories\n 153 | .iter()\n 154 | .filter(|(category, _)| full || category.show_in_summary())\n 155 | .map(|(&category, crates)| (category, crates.to_owned()))\n 156 | .map(|(category, crates)| match crates {\n 157 | ReportCrates::Plain(crates) => (\n 158 | category,\n 159 | ReportCratesMD::Plain(crates.into_iter().collect::<Vec<_>>()),\n 160 | ),\n 161 | ReportCrates::Complete { mut tree, results } => {\n 162 | let res = results\n 163 | .into_iter()\n 164 | .flat_map(|(_key, values)| values.into_iter())\n 165 | .collect::<IndexSet<_>>() // remove duplicates\n 166 | .into_iter()\n 167 | .map(|krate| {\n 168 | // done here to avoid cloning krate\n 169 | let deps = tree.shift_remove(&krate.krate).unwrap_or_default();\n 170 | (krate, deps)\n 171 | })\n 172 | .collect::<IndexMap<_, _>>();\n 173 | \n 174 | (category, ReportCratesMD::Complete { res, orphans: tree })\n 175 | }\n 176 | })\n 177 | .collect();","lines":27,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/markdown.rs#L151-L177"}]},{"id":"PERF_16","type":"performance","type_label":"Performance","severity":"low","easy":false,"description":"write_logs distributes work over `channels[i % channels.len()]` where i is the crate index, but crates are filtered with `should_skip` (continue) before reaching the send. Skipped crates still consume their index slot, so the modulo distribution is uneven and a run of skipped crates can starve some worker channels. Minor, but the load balancing is not what the comment ('not overwhelm systems while keeping things moving') implies.","fix":"Use a separate monotonically-incremented counter (incremented only for actually-sent items) for channel selection, or a single shared channel with N consumers.","locations":[{"ref":"src/report/mod.rs:319-355","code":" 319 | for (i, krate) in crates.iter().enumerate() {\n 320 | if i.is_multiple_of(progress_every) {\n 321 | info!(\"wrote logs for {i}/{num_crates} crates\")\n 322 | }\n 323 | \n 324 | if config.should_skip(krate) {\n 325 | continue;\n 326 | }\n 327 | \n 328 | for tc in &ex.toolchains {\n 329 | let log_path =\n 330 | crate_to_path_fragment(tc, krate, SanitizationContext::Path).join(\"log.txt\");\n 331 | let content = db\n 332 | .load_log(ex, tc, krate)\n 333 | .and_then(|c| c.ok_or_else(|| anyhow!(\"missing logs\")))\n 334 | .with_context(|| format!(\"failed to read log of {krate} on {tc}\"));\n 335 | let content = match content {\n 336 | Ok(c) => c,\n 337 | Err(e) => {\n 338 | utils::report_failure(&e);\n 339 | continue;\n 340 | }\n 341 | };\n 342 | \n 343 | match content {\n 344 | EncodedLog::Plain(data) => {\n 345 | channels[i % channels.len()]\n 346 | .send((log_path, data, EncodingType::Plain))\n 347 | .unwrap();\n 348 | }\n 349 | EncodedLog::Gzip(data) => {\n 350 | channels[i % channels.len()]\n 351 | .send((log_path, data, EncodingType::Gzip))\n 352 | .unwrap();\n 353 | }\n 354 | }\n 355 | }","lines":37,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/mod.rs#L319-L355"}]},{"id":"PERF_17","type":"performance","type_label":"Performance","severity":"low","easy":false,"description":"run_cargo rebuilds the local_packages_id HashSet from local_packages on every single invocation (build, then test --no-run, then check/clippy/doc, etc.), and get_local_packages itself re-runs `cargo metadata` once per task. For check/clippy each crate triggers a metadata subprocess; the HashSet rebuild is cheap but the per-call metadata invocation in the hot path across the whole crate corpus is the larger cost. The detect_error closure also lowercases every output line (line.to_lowercase()) for the no-space check on every log line.","fix":"Compute the package-id set once per task and pass it down; avoid per-line to_lowercase by matching the known-case substring(s) directly or lowercasing only when a cheap pre-check (e.g. contains(\"space\")) matches.","locations":[{"ref":"src/runner/test.rs:114-114","code":" 114 | let local_packages_id: HashSet<_> = local_packages.iter().map(|p| &p.id).collect();","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/test.rs#L114"},{"ref":"src/runner/test.rs:147-166","code":" 147 | let mut detect_error = |line: &str, actions: &mut ProcessLinesActions| {\n 148 | if line.contains(\"urlopen error\") && line.contains(\"Temporary failure in name resolution\") {\n 149 | did_network = true;\n 150 | }\n 151 | if line.contains(\"Address already in use\") {\n 152 | did_network = true;\n 153 | }\n 154 | if line.contains(\"collect2: fatal error: ld terminated with signal 7 [Bus error]\") {\n 155 | // the cause of the bus error is running out of disk space\n 156 | ran_out_of_space = true;\n 157 | }\n 158 | if line.to_lowercase().contains(\"no space left on device\") {\n 159 | ran_out_of_space = true;\n 160 | }\n 161 | if line.contains(\"code: 111\") && line.contains(\"Connection refused\") {\n 162 | did_network = true;\n 163 | }\n 164 | if line.contains(\"the environment variable TRYBUILD=overwrite\") {\n 165 | did_trybuild = true;\n 166 | }","lines":20,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/test.rs#L147-L166"},{"ref":"src/runner/test.rs:92-103","code":" 92 | fn get_local_packages(build_env: &Build) -> Fallible<Vec<Package>> {\n 93 | Ok(build_env\n 94 | .cargo()\n 95 | .args([\"metadata\", \"--no-deps\", \"--format-version=1\"])\n 96 | .log_output(false)\n 97 | .run_capture()?\n 98 | .stdout_lines()\n 99 | .iter()\n 100 | .filter_map(|line| serde_json::from_str::<Metadata>(line).ok())\n 101 | .flat_map(|metadata| metadata.packages)\n 102 | .collect())\n 103 | }","lines":12,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/test.rs#L92-L103"}]},{"id":"PERF_18","type":"performance","type_label":"Performance","severity":"low","easy":false,"description":"endpoint_queue builds five Vecs then appends them into one. ExperimentData::new calls experiment.progress(&data.db) for every Queued and Running experiment, issuing one DB query per experiment (N+1 query pattern) on a page that lists the whole queue. With many queued experiments this serially hits the database per row.","fix":"Batch-load progress for all experiments in one query, or cache progress, instead of querying per experiment.","locations":[{"ref":"src/server/routes/ui/experiments.rs:63-93","code":" 63 | pub fn endpoint_queue(data: Arc<Data>) -> Fallible<Response> {\n 64 | let mut queued = Vec::new();\n 65 | let mut running = Vec::new();\n 66 | let mut needs_report = Vec::new();\n 67 | let mut generating_report = Vec::new();\n 68 | let mut report_failed = Vec::new();\n 69 | \n 70 | for experiment in &Experiment::unfinished(&data.db)? {\n 71 | // Don't include completed experiments in the queue\n 72 | if experiment.status == Status::Completed {\n 73 | continue;\n 74 | }\n 75 | \n 76 | let ex = ExperimentData::new(&data, experiment)?;\n 77 | \n 78 | match experiment.status {\n 79 | Status::Queued => queued.push(ex),\n 80 | Status::Running => running.push(ex),\n 81 | Status::NeedsReport => needs_report.push(ex),\n 82 | Status::GeneratingReport => generating_report.push(ex),\n 83 | Status::ReportFailed => report_failed.push(ex),\n 84 | Status::Completed => unreachable!(),\n 85 | };\n 86 | }\n 87 | \n 88 | let mut experiments = Vec::new();\n 89 | experiments.append(&mut report_failed);\n 90 | experiments.append(&mut generating_report);\n 91 | experiments.append(&mut needs_report);\n 92 | experiments.append(&mut running);\n 93 | experiments.append(&mut queued);","lines":31,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/ui/experiments.rs#L63-L93"},{"ref":"src/server/routes/ui/experiments.rs:48-52","code":" 48 | progress: if show_progress {\n 49 | experiment.progress(&data.db)?\n 50 | } else {\n 51 | 100\n 52 | },","lines":5,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/ui/experiments.rs#L48-L52"}]},{"id":"PERF_19","type":"performance","type_label":"Performance","severity":"low","easy":false,"description":"DiskUsage casts `blocks_available` and `blocks` (64-bit block counts) to f32 before dividing. f32 has only ~24 bits of mantissa, so on large filesystems both counts lose precision before the division, making the computed usage fraction inaccurate (the threshold check that triggers expensive cache purges relies on this value).","fix":"Compute the ratio in f64 (cast u64 -> f64) and only narrow to f32 (or keep f64) for the final stored value, or compute the percentage with integer arithmetic.","locations":[{"ref":"src/utils/disk_usage.rs:13-19","code":" 13 | let available = stat.blocks_available();\n 14 | let total = stat.blocks();\n 15 | info!(\"{available} / {total} blocks available in {path:?}\");\n 16 | \n 17 | Ok(Self {\n 18 | usage: 1.0 - available as f32 / total as f32,\n 19 | })","lines":7,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/utils/disk_usage.rs#L13-L19"}]},{"id":"PERF_20","type":"performance","type_label":"Performance","severity":"negligible","easy":false,"description":"Experiment::get_crates parses every crate string into Crate via collect into Fallible<Vec<Crate>>, which is fine, but it is also called inside EditExperiment to reload the existing crate list (edit.rs:85) only to immediately re-serialize each crate back to krate.id() for re-insertion - a parse-then-stringify round-trip purely to recompute skipped flags.","fix":"When only ignore_blacklist changed, UPDATE experiment_crates.skipped in place with a single SQL statement instead of deleting and re-inserting all rows after a parse/stringify round-trip.","locations":[{"ref":"src/actions/experiments/edit.rs:84-107","code":" 84 | } else if self.ignore_blacklist.is_some() {\n 85 | Some(ex.get_crates(ctx.db)?)\n 86 | } else {\n 87 | None\n 88 | };\n 89 | if let Some(crates_vec) = new_crates {\n 90 | // Recreate the list of crates without checking if it was the same\n 91 | // This is done to allow reloading the list of crates in an existing experiment\n 92 | t.execute(\n 93 | \"DELETE FROM experiment_crates WHERE experiment = ?1;\",\n 94 | &[&self.name],\n 95 | )?;\n 96 | for krate in &crates_vec {\n 97 | t.execute(\n 98 | \"INSERT INTO experiment_crates (experiment, crate, skipped, status) \\\n 99 | VALUES (?1, ?2, ?3, ?4);\",\n 100 | &[\n 101 | &self.name,\n 102 | &krate.id(),\n 103 | &(!ex.ignore_blacklist && ctx.config.should_skip(krate)),\n 104 | &Status::Queued.to_string(),\n 105 | ],\n 106 | )?;\n 107 | }","lines":24,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/edit.rs#L84-L107"},{"ref":"src/experiments.rs:595-604","code":" 595 | pub fn get_crates(&self, db: &Database) -> Fallible<Vec<Crate>> {\n 596 | db.query(\n 597 | \"SELECT crate FROM experiment_crates WHERE experiment = ?1;\",\n 598 | [&self.name],\n 599 | |r| r.get(0),\n 600 | )?\n 601 | .into_iter()\n 602 | .map(|c: String| c.parse())\n 603 | .collect::<Fallible<Vec<Crate>>>()\n 604 | }","lines":10,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L595-L604"}]},{"id":"PERF_21","type":"performance","type_label":"Performance","severity":"negligible","easy":false,"description":"DummyDB::load_log and load_test_result clone both the Crate and Toolchain on every lookup to build the HashMap key tuple `(krate.clone(), toolchain.clone())`. This is test-only code, but the clones are avoidable with a borrowed-key lookup.","fix":"Use a Borrow-based key or a (&Crate,&Toolchain) tuple lookup to avoid cloning on read. Negligible since it is test infrastructure.","locations":[{"ref":"src/results/dummy.rs:57-61","code":" 57 | Ok(self\n 58 | .get_data(ex)?\n 59 | .logs\n 60 | .get(&(krate.clone(), toolchain.clone()))\n 61 | .cloned())","lines":5,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/results/dummy.rs#L57-L61"},{"ref":"src/results/dummy.rs:70-74","code":" 70 | Ok(self\n 71 | .get_data(ex)?\n 72 | .results\n 73 | .get(&(krate.clone(), toolchain.clone()))\n 74 | .cloned())","lines":5,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/results/dummy.rs#L70-L74"}]},{"id":"PERF_22","type":"performance","type_label":"Performance","severity":"negligible","easy":false,"description":"EncodedLog::to_plain clones the entire plain log buffer via data.to_vec() even though the caller often only needs a &[u8] (as_slice already exists). For large logs this is an avoidable full copy when the data is already plain.","fix":"Where callers only read the bytes, prefer as_slice(); if an owned Vec is genuinely needed, consider returning Cow<[u8]> to avoid copying the already-plain case.","locations":[{"ref":"src/results/mod.rs:72-82","code":" 72 | pub fn to_plain(&self) -> Fallible<Vec<u8>> {\n 73 | match self {\n 74 | EncodedLog::Plain(data) => Ok(data.to_vec()),\n 75 | EncodedLog::Gzip(data) => {\n 76 | let mut decoded_log = GzDecoder::new(data.as_slice());\n 77 | let mut new_log = Vec::new();\n 78 | decoded_log.read_to_end(&mut new_log)?;\n 79 | Ok(new_log)\n 80 | }\n 81 | }\n 82 | }","lines":11,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/results/mod.rs#L72-L82"}]},{"id":"PERF_23","type":"performance","type_label":"Performance","severity":"negligible","easy":false,"description":"verify_signature splits the raw signature on '=' into a Vec, then re-joins all parts after the first with '=' to reconstruct the hex signature. This allocates two Vecs and a String on every webhook request for what split_once('=') would do without allocation.","fix":"Use raw_signature.split_once('=') to get (algorithm, hex_signature) without allocating intermediate collections.","locations":[{"ref":"src/server/routes/webhooks/mod.rs:163-170","code":" 163 | let splitted: Vec<&str> = raw_signature.split('=').collect();\n 164 | let algorithm = &splitted[0];\n 165 | let hex_signature = splitted\n 166 | .iter()\n 167 | .skip(1)\n 168 | .cloned()\n 169 | .collect::<Vec<&str>>()\n 170 | .join(\"=\");","lines":8,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/webhooks/mod.rs#L163-L170"}]},{"id":"DEAD_1","type":"dead_code","type_label":"Dead Code","severity":"low","easy":true,"description":"next_inner contains an unreachable CLI_QUERY constant placed after an unconditional unimplemented!() macro, guarded by #[allow(unreachable_code)]. The constant and its params vec are dead code that can never execute. It is kept as documentation but compiles as unreachable.","fix":"Move the intended CLI query into a comment or a clearly-marked TODO function instead of unreachable live code behind an allow attribute.","locations":[{"ref":"src/experiments.rs:394-444","code":" 394 | //CLI query is only partially implemented and is therefore preceded by \"unimplemented!\"\n 395 | #[allow(unreachable_code)]\n 396 | fn next_inner(\n 397 | db: &Database,\n 398 | assignee: Option<&Assignee>,\n 399 | agent: &Assignee,\n 400 | ) -> Fallible<Option<Experiment>> {\n 401 | let agent_name = if let Assignee::Agent(agent_name) = agent {\n 402 | agent_name.to_string()\n 403 | } else {\n 404 | unimplemented!(\"experiment requirements are not respected when assigning to CLI\");\n 405 | };\n 406 | \n 407 | let (query, params) = if let Some(assignee) = assignee {\n 408 | match assignee {\n 409 | Assignee::Distributed | Assignee::Agent(_) => {\n 410 | const AGENT_QUERY: &str = r#\"\n 411 | SELECT *\n 412 | FROM experiments ex\n 413 | WHERE (ex.status = \"queued\" OR status = \"running\")\n 414 | AND ( ex.assigned_to = ?1 )\n 415 | AND ( ex.requirement IS NULL\n 416 | OR ex.requirement IN (SELECT capability\n 417 | FROM agent_capabilities\n 418 | WHERE agent_name = ?2) )\n 419 | ORDER BY ex.priority DESC,\n 420 | ex.created_at\n 421 | LIMIT 1;\n 422 | \"#;\n 423 | \n 424 | (AGENT_QUERY, vec![assignee.to_string(), agent_name])\n 425 | }\n 426 | // FIXME: We don't respect experiment requirements when assigning experiments to the\n 427 | // CLI. We need to decide what capabilities the CLI should have first.\n 428 | _ => {\n 429 | unimplemented!(\n 430 | \"experiment requirements are not respected when assigning to CLI\"\n 431 | );\n 432 | const CLI_QUERY: &str = r#\"\n 433 | SELECT *\n 434 | FROM experiments ex\n 435 | WHERE (ex.status = \"queued\" OR status = \"running\")\n 436 | AND (ex.assigned_to IS NULL OR ex.assigned_to = ?1)\n 437 | ORDER BY ex.assigned_to IS NULL,\n 438 | ex.priority DESC,\n 439 | ex.created_at\n 440 | LIMIT 1;\n 441 | \"#;\n 442 | \n 443 | (CLI_QUERY, vec![assignee.to_string()])\n 444 | }","lines":51,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L394-L444"}]},{"id":"DEAD_2","type":"dead_code","type_label":"Dead Code","severity":"negligible","easy":true,"description":"In failure_reason the final match arm is `CommandError::ExecutionFailed { .. } | _ => FailureReason::Unknown`. The explicit ExecutionFailed pattern is fully subsumed by the `_` wildcard and both map to the same value, so naming ExecutionFailed adds no behavior and is misleading (it looks like it is special-cased).","fix":"Drop the redundant `CommandError::ExecutionFailed { .. } |` and keep just `_ => FailureReason::Unknown`, or give ExecutionFailed a distinct mapping if one was intended.","locations":[{"ref":"src/runner/test.rs:40-40","code":" 40 | CommandError::ExecutionFailed { .. } | _ => FailureReason::Unknown,","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/test.rs#L40"}]},{"id":"CMT_1","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","easy":false,"description":"The comment in DbDumpReader::read_at says version pinning 'should work fine' and a later comment in fetch admits 'this actually has no effect today, the version ID is ignored by cloudfront's cache'. The read_at code nonetheless hard-errors when x-amz-version-id is missing or mismatched. The two comments are contradictory about whether version pinning is effective, which is confusing for maintainers; the strict error path can also spuriously fail if the CDN strips the header.","fix":"Reconcile the comments and decide whether missing/mismatched version IDs should hard-error or merely warn, given the CDN behavior described.","locations":[{"ref":"src/crates/sources/registry.rs:42-54","code":" 42 | if let Some(version) = res.headers().get(\"x-amz-version-id\") {\n 43 | if version != self.version {\n 44 | return Err(std::io::Error::other(format!(\n 45 | \"wrong version returned in ranged GET, found {:?} but expected {:?}\",\n 46 | version, self.version\n 47 | )));\n 48 | }\n 49 | } else {\n 50 | return Err(std::io::Error::other(format!(\n 51 | \"missing version ID in range get ({:?})\",\n 52 | res,\n 53 | )));\n 54 | }","lines":13,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/sources/registry.rs#L42-L54"},{"ref":"src/crates/sources/registry.rs:121-129","code":" 121 | // We pin the version we're reading from so we don't end up reading from two different\n 122 | // files while streaming (partial) contents. Stale versions are retained for at least 24 hours today (per\n 123 | // S3 lifecycle configuration) so this should work fine.\n 124 | //\n 125 | // Note: this actually has no effect today, the version ID is ignored by cloudfront's cache\n 126 | // (we don't include query strings in the cache). Maybe we could hit S3 directly instead...\n 127 | let Some(version) = head_response.headers().get(\"x-amz-version-id\") else {\n 128 | anyhow::bail!(\"missing x-amz-version-id in response: {head_response:?}\");\n 129 | };","lines":9,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/sources/registry.rs#L121-L129"}]},{"id":"CMT_2","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","easy":true,"description":"Comment at lines 637-638 says 'SQLite cannot handle queries with more than 999 variables', but SQL_VARIABLE_LIMIT is 500, and the comment at line 14 separately notes the actual limit. The 999 figure is the historical SQLite default (SQLITE_MAX_VARIABLE_NUMBER), inconsistent with the 500 actually used, which is misleading to maintainers.","fix":"Make the comment reference SQL_VARIABLE_LIMIT and explain why 500 (margin below the 999 default) was chosen.","locations":[{"ref":"src/experiments.rs:637-638","code":" 637 | //SQLite cannot handle queries with more than 999 variables\n 638 | for params in params.chunks(SQL_VARIABLE_LIMIT) {","lines":2,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L637-L638"},{"ref":"src/experiments.rs:15-15","code":" 15 | static SQL_VARIABLE_LIMIT: usize = 500;","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L15"}]},{"id":"CMT_3","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","easy":true,"description":"Comment claims the endpoint-time histogram starts at \"5ms\" but the start bucket is 0.05 seconds = 50ms, and the top bucket is 0.05 * 1.2^24 ~= 4.0s, not 'approximately 5 seconds'. Both figures in the comment are wrong/misleading.","fix":"Correct the comment to '50ms as start, top bucket ending at ~4 seconds', or adjust the bucket parameters to match the intended 5ms/5s range.","locations":[{"ref":"src/server/metrics.rs:51-56","code":" 51 | let crater_endpoint_time = prometheus::register_histogram_vec!(\n 52 | prometheus::HistogramOpts::new(ENDPOINT_TIME, \"duration of endpoint requests\")\n 53 | // Exponential buckets, with 5ms as start and top bucket ending at\n 54 | // approximately 5 seconds.\n 55 | .buckets(prometheus::exponential_buckets(0.05, 1.2, 25).unwrap()),\n 56 | &[\"endpoint\"]","lines":6,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/metrics.rs#L51-L56"}]},{"id":"CMT_4","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","easy":true,"description":"Comment says cache_size pragma keeps experiment_crates 'mostly cached in memory' with value -307200. -307200 means 307200 KiB = 300 MiB of page cache, which is a fixed budget, not a guarantee the table stays resident; fine, but the adjacent 'optimize 0x10002' pragma is run at connect() time on a fresh connection where there are no query statistics yet to optimize, partially defeating the cited recommendation (PRAGMA optimize is meant to run periodically / before close, not only right after open).","fix":"Run PRAGMA optimize periodically and/or before returning connections to the pool / on shutdown, not solely at connection open; adjust the comment to reflect actual behavior.","locations":[{"ref":"src/db/mod.rs:30-41","code":" 30 | // the goal of this is to try to keep `experiment_crates` mostly cached in memory.\n 31 | // that makes it much faster to scan it for the next crate we want to serve.\n 32 | connection\n 33 | .pragma_update(None, \"cache_size\", \"-307200\")\n 34 | .unwrap();\n 35 | \n 36 | // per docs, this is recommended for relatively long-lived connections (like what we have\n 37 | // due to the r2d2 pooling)\n 38 | // https://www.sqlite.org/pragma.html#pragma_optimize\n 39 | connection\n 40 | .pragma_update(None, \"optimize\", \"0x10002\")\n 41 | .unwrap();","lines":12,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/db/mod.rs#L30-L41"}]},{"id":"CMT_5","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","easy":true,"description":"Comment at line 14 says 'sqlite limit is ignored if the expression evaluates to a negative value', referring to the LIMIT clause; it is placed on SQL_VARIABLE_LIMIT (=500), which is about the max number of bound variables per statement, NOT the SQL LIMIT clause. The comment about negative LIMIT actually pertains to the `limit.unwrap_or(-1)` trick at line 611. The comment is attached to the wrong constant.","fix":"Move the negative-LIMIT comment to line 611 and document SQL_VARIABLE_LIMIT as the SQLite max-bound-variables-per-statement limit.","locations":[{"ref":"src/experiments.rs:14-15","code":" 14 | //sqlite limit is ignored if the expression evaluates to a negative value\n 15 | static SQL_VARIABLE_LIMIT: usize = 500;","lines":2,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L14-L15"}]},{"id":"CMT_6","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","easy":false,"description":"The comment on RecordProgressThread.queue says '// String is the worker name', but the field type is Sender<ExperimentData<ProgressData>> and there is no String worker-name involved. The comment is stale/misleading.","fix":"Remove or correct the comment to describe the actual queued type.","locations":[{"ref":"src/server/routes/agent.rs:187-189","code":" 187 | pub struct RecordProgressThread {\n 188 | // String is the worker name\n 189 | queue: Sender<ExperimentData<ProgressData>>,","lines":3,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/agent.rs#L187-L189"}]},{"id":"CMT_7","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","easy":true,"description":"The doc comment on FILENAME_ENCODE_SET claims it is the set of characters that cannot be used in a Windows filename, but the set is applied unconditionally (via Toolchain::to_path_component) on all platforms including Unix, where most of these characters (e.g. ':', '\\', '|') are legal. The comment misleads readers into thinking the encoding is Windows-only.","fix":"Reword the comment to state these characters are percent-encoded in path components on all platforms (chosen to be safe on Windows), not that they are merely the Windows-illegal set.","locations":[{"ref":"src/utils/mod.rs:16-28","code":" 16 | /// The set of characters which cannot be used in a [filename on Windows][windows].\n 17 | ///\n 18 | /// [windows]: https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file#naming-conventions\n 19 | pub(crate) const FILENAME_ENCODE_SET: AsciiSet = CONTROLS\n 20 | .add(b'<')\n 21 | .add(b'>')\n 22 | .add(b':')\n 23 | .add(b'\"')\n 24 | .add(b'/')\n 25 | .add(b'\\\\')\n 26 | .add(b'|')\n 27 | .add(b'?')\n 28 | .add(b'*');","lines":13,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/utils/mod.rs#L16-L28"},{"ref":"src/toolchain.rs:45-49","code":" 45 | pub fn to_path_component(&self) -> String {\n 46 | use percent_encoding::utf8_percent_encode as encode;\n 47 | \n 48 | encode(&self.to_string(), &utils::FILENAME_ENCODE_SET).to_string()\n 49 | }","lines":5,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/toolchain.rs#L45-L49"}]},{"id":"DUP_1","type":"duplicate_code","type_label":"Duplicate Code","severity":"low","easy":false,"description":"The crate-insertion loop and skipped-computation `!ignore_blacklist && config.should_skip(krate)` plus the experiment_crates INSERT is duplicated almost verbatim between create.rs (lines 80-86) and edit.rs (lines 96-107). The skipped/INSERT logic should be shared to avoid divergence (e.g. one uses Status::Queued.to_string()) .","fix":"Extract a helper fn insert_experiment_crates(tx, name, crates, ignore_blacklist, config) used by both create and edit.","locations":[{"ref":"src/actions/experiments/create.rs:80-86","code":" 80 | for krate in &crates {\n 81 | let skipped = !self.ignore_blacklist && ctx.config.should_skip(krate);\n 82 | transaction.execute(\n 83 | \"INSERT INTO experiment_crates (experiment, crate, skipped, status) VALUES (?1, ?2, ?3, ?4);\",\n 84 | &[&self.name, &krate.id(), &skipped, &Status::Queued.to_string()],\n 85 | )?;\n 86 | }","lines":7,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/create.rs#L80-L86"},{"ref":"src/actions/experiments/edit.rs:96-107","code":" 96 | for krate in &crates_vec {\n 97 | t.execute(\n 98 | \"INSERT INTO experiment_crates (experiment, crate, skipped, status) \\\n 99 | VALUES (?1, ?2, ?3, ?4);\",\n 100 | &[\n 101 | &self.name,\n 102 | &krate.id(),\n 103 | &(!ex.ignore_blacklist && ctx.config.should_skip(krate)),\n 104 | &Status::Queued.to_string(),\n 105 | ],\n 106 | )?;\n 107 | }","lines":12,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/actions/experiments/edit.rs#L96-L107"}]},{"id":"DUP_2","type":"duplicate_code","type_label":"Duplicate Code","severity":"low","easy":false,"description":"The github.com URL-path extraction (org = path.next(), repo_name = path.next(), trim '.git', build GitHubRepo) is copy-pasted twice within try_from (the SourceKind::Git branch and the None/url scheme branch) with only the sha differing. Divergence risk if one copy is fixed and the other isn't.","fix":"Factor the github org/repo extraction into a single helper that takes the url and an Option<sha>.","locations":[{"ref":"src/crates/mod.rs:133-154","code":" 133 | if url.domain() == Some(\"github.com\") {\n 134 | if let Some(mut path) = url.path_segments() {\n 135 | let Some(org) = path.next() else {\n 136 | bail!(\"Github URL path is too short\")\n 137 | };\n 138 | \n 139 | let Some(repo_name) = path.next() else {\n 140 | bail!(\"Github URL path is too short\")\n 141 | };\n 142 | \n 143 | Ok(Crate::GitHub(GitHubRepo {\n 144 | org: org.to_string(),\n 145 | name: repo_name.trim_end_matches(\".git\").to_string(),\n 146 | sha: match rev {\n 147 | GitReference::Rev(rev)\n 148 | if rev.chars().all(|c| c.is_ascii_hexdigit()) =>\n 149 | {\n 150 | Some(rev.to_string())\n 151 | }\n 152 | _ => None,\n 153 | },\n 154 | }))","lines":22,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/mod.rs#L133-L154"},{"ref":"src/crates/mod.rs:204-221","code":" 204 | if url.domain() == Some(\"github.com\") {\n 205 | if let Some(mut path) = url.path_segments() {\n 206 | let Some(org) = path.next() else {\n 207 | bail!(\"Github URL path is too short\")\n 208 | };\n 209 | \n 210 | let Some(repo_name) = path.next() else {\n 211 | bail!(\"Github URL path is too short\")\n 212 | };\n 213 | \n 214 | Ok(Crate::GitHub(GitHubRepo {\n 215 | org: org.to_string(),\n 216 | name: repo_name.trim_end_matches(\".git\").to_string(),\n 217 | sha: None,\n 218 | }))\n 219 | } else {\n 220 | bail!(\"Github Git URL doesn't have a valid path\")\n 221 | }","lines":18,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/mod.rs#L204-L221"}]},{"id":"DUP_3","type":"duplicate_code","type_label":"Duplicate Code","severity":"low","easy":true,"description":"execute and execute_cached have identical bodies; both call conn.prepare_cached(sql). The existence of two names implies one is cached and one is not, but execute also caches, so the distinction is misleading and one method is redundant.","fix":"Either make execute use conn.prepare(sql) (truly non-cached) to match its name, or delete execute_cached and keep a single method. Update call sites accordingly.","locations":[{"ref":"src/db/mod.rs:216-234","code":" 216 | fn execute(&self, sql: &str, params: &[&dyn ToSql]) -> Fallible<usize> {\n 217 | self.with_conn(|conn| {\n 218 | self.trace(sql, || {\n 219 | let mut prepared = conn.prepare_cached(sql)?;\n 220 | let changes = prepared.execute(params)?;\n 221 | Ok(changes)\n 222 | })\n 223 | })\n 224 | }\n 225 | \n 226 | fn execute_cached(&self, sql: &str, params: &[&dyn ToSql]) -> Fallible<usize> {\n 227 | self.with_conn(|conn| {\n 228 | self.trace(sql, || {\n 229 | let mut prepared = conn.prepare_cached(sql)?;\n 230 | let changes = prepared.execute(params)?;\n 231 | Ok(changes)\n 232 | })\n 233 | })\n 234 | }","lines":19,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/db/mod.rs#L216-L234"}]},{"id":"DUP_4","type":"duplicate_code","type_label":"Duplicate Code","severity":"low","easy":false,"description":"S3Writer::write_bytes duplicates the key-format/ACL/content-type/content-encoding builder setup between the multipart branch and the put_object branch, and the four `match self.runtime.block_on(...) { Err(e) => bail!(...) }` blocks are near-identical. The 50MB/20MB magic numbers and `application/zstd` parse().unwrap() are also repeated across the module.","fix":"Extract a helper to build the common request (key, acl, content-type, encoding) and a helper to run a request mapping errors to bail!; lift chunk/threshold constants to named consts.","locations":[{"ref":"src/report/s3.rs:84-187","code":" 84 | // At least 50 MB, then use a multipart upload...\n 85 | if body.len() >= 50 * 1024 * 1024 {\n 86 | let mut request = self\n 87 | .client\n 88 | .create_multipart_upload()\n 89 | .acl(aws_sdk_s3::types::ObjectCannedAcl::PublicRead)\n 90 | .key(format!(\n 91 | \"{}/{}\",\n 92 | self.prefix,\n 93 | path.as_ref().to_str().unwrap()\n 94 | ))\n 95 | .content_type(mime.to_string())\n 96 | .bucket(self.bucket.clone());\n 97 | match encoding_type {\n 98 | EncodingType::Plain => {}\n 99 | EncodingType::Gzip => {\n 100 | request = request.content_encoding(\"gzip\");\n 101 | }\n 102 | }\n 103 | let upload = match self.runtime.block_on(request.send()) {\n 104 | Ok(u) => u,\n 105 | Err(e) => {\n 106 | bail!(\"Failed to upload to {:?}: {:?}\", path.as_ref(), e);\n 107 | }\n 108 | };\n 109 | \n 110 | let chunk_size = 20 * 1024 * 1024;\n 111 | let mut part = 1;\n 112 | let mut start = 0;\n 113 | let mut parts = aws_sdk_s3::types::CompletedMultipartUpload::builder();\n 114 | while start < body.len() {\n 115 | let chunk = &body[start..std::cmp::min(start + chunk_size, body.len())];\n 116 | let chunk = bytes::Bytes::copy_from_slice(chunk);\n 117 | \n 118 | let request = self\n 119 | .client\n 120 | .upload_part()\n 121 | .part_number(part)\n 122 | .body(chunk.into())\n 123 | .upload_id(upload.upload_id().unwrap())\n 124 | .key(upload.key().unwrap())\n 125 | .bucket(self.bucket.clone());\n 126 | match self.runtime.block_on(request.send()) {\n 127 | Ok(p) => {\n 128 | parts = parts.parts(\n 129 | aws_sdk_s3::types::CompletedPart::builder()\n 130 | .e_tag(p.e_tag.clone().unwrap())\n 131 | .part_number(part)\n 132 | .build(),\n 133 | )\n 134 | }\n 135 | Err(e) => {\n 136 | bail!(\"Failed to upload to {:?}: {:?}\", path.as_ref(), e);\n 137 | }\n 138 | };\n 139 | \n 140 | start += chunk_size;\n 141 | part += 1;\n 142 | }\n 143 | \n 144 | let request = self\n 145 | .client\n 146 | .complete_multipart_upload()\n 147 | .multipart_upload(parts.build())\n 148 | .upload_id(upload.upload_id().unwrap())\n 149 | .key(upload.key().unwrap())\n 150 | .bucket(self.bucket.clone());\n 151 | match self.runtime.block_on(request.send()) {\n 152 | Ok(_) => (),\n 153 | Err(e) => {\n 154 | bail!(\"Failed to upload to {:?}: {:?}\", path.as_ref(), e);\n 155 | }\n 156 | };\n 157 | \n 158 | Ok(())\n 159 | } else {\n 160 | let mut request = self\n 161 | .client\n 162 | .put_object()\n 163 | .body(aws_sdk_s3::primitives::ByteStream::from(\n 164 | bytes::Bytes::copy_from_slice(body),\n 165 | ))\n 166 | .acl(aws_sdk_s3::types::ObjectCannedAcl::PublicRead)\n 167 | .key(format!(\n 168 | \"{}/{}\",\n 169 | self.prefix,\n 170 | path.as_ref().to_str().unwrap()\n 171 | ))\n 172 | .content_type(mime.to_string())\n 173 | .bucket(self.bucket.clone());\n 174 | match encoding_type {\n 175 | EncodingType::Plain => {}\n 176 | EncodingType::Gzip => {\n 177 | request = request.content_encoding(\"gzip\");\n 178 | }\n 179 | }\n 180 | match self.runtime.block_on(request.send()) {\n 181 | Ok(_) => Ok(()),\n 182 | Err(e) => {\n 183 | bail!(\"Failed to upload to {:?}: {:?}\", path.as_ref(), e);\n 184 | }\n 185 | }\n 186 | }\n 187 | }","lines":104,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/s3.rs#L84-L187"}]},{"id":"ARCH_1","type":"architecture","type_label":"Architecture","severity":"medium","easy":false,"description":"src/experiments.rs is a 1032-line module mixing many responsibilities: enum/string parsing (CrateSelect, Assignee, Mode, CapLints), the Experiment domain model, all SQL queries (scheduling, progress, crates), the DB row mapping (ExperimentDBRecord), and a large test module. The Experiment impl alone holds scheduling logic (find_next/next_inner/assign_experiment), progress accounting, and CRUD. This concentration makes the scheduling SQL hard to review and couples persistence with domain logic.","fix":"Split into modules: parsing/types, the Experiment model, a scheduling/query layer, and the DB record mapping.","locations":[{"ref":"src/experiments.rs:1-740","code":" 1 | use crate::crates::Crate;\n 2 | use crate::db::{Database, QueryUtils};\n 3 | use crate::prelude::*;\n 4 | use crate::results::TestResult;\n 5 | use crate::toolchain::Toolchain;\n 6 | use crate::utils;\n 7 | use chrono::{DateTime, Utc};\n 8 | use rusqlite::Row;\n 9 | use std::collections::HashSet;\n 10 | use std::fmt;\n 11 | use std::str::FromStr;\n 12 | use url::Url;\n 13 | \n 14 | //sqlite limit is ignored if the expression evaluates to a negative value\n 15 | static SQL_VARIABLE_LIMIT: usize = 500;\n 16 | \n 17 | string_enum!(pub enum Status {\n 18 | Queued => \"queued\",\n 19 | Running => \"running\",\n 20 | NeedsReport => \"needs-report\",\n 21 | GeneratingReport => \"generating-report\",\n 22 | ReportFailed => \"report-failed\",\n 23 | Completed => \"completed\",\n 24 | });\n 25 | \n 26 | string_enum!(pub enum Mode {\n 27 | BuildAndTest => \"build-and-test\",\n 28 | BuildOnly => \"build-only\",\n 29 | CheckOnly => \"check-only\",\n 30 | Clippy => \"clippy\",\n 31 | Rustdoc => \"rustdoc\",\n 32 | UnstableFeatures => \"unstable-features\",\n 33 | Fix => \"fix\",\n 34 | });\n 35 | \n 36 | string_enum!(pub enum CapLints {\n 37 | Allow => \"allow\",\n 38 | Warn => \"warn\",\n 39 | Deny => \"deny\",\n 40 | Forbid => \"forbid\",\n 41 | });\n 42 | \n 43 | const SMALL_RANDOM_COUNT: u32 = 20;\n 44 | \n 45 | #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]\n 46 | #[serde(try_from = \"String\", into = \"String\")]\n 47 | pub enum CrateSelect {\n 48 | Full,\n 49 | Demo,\n 50 | Top(u32),\n 51 | Local,\n 52 | Dummy,\n 53 | Random(u32),\n 54 | List(HashSet<String>),\n 55 | }\n 56 | \n 57 | from_into_string!(CrateSelect);\n 58 | \n 59 | impl FromStr for CrateSelect {\n 60 | type Err = anyhow::Error;\n 61 | \n 62 | fn from_str(s: &str) -> Fallible<Self> {\n 63 | let ret = match s {\n 64 | s if s.starts_with(\"top-\") => {\n 65 | let n: u32 = s[\"top-\".len()..].parse()?;\n 66 | CrateSelect::Top(n)\n 67 | }\n 68 | \n 69 | \"small-random\" => CrateSelect::Random(SMALL_RANDOM_COUNT),\n 70 | s if s.starts_with(\"random-\") => {\n 71 | let n: u32 = s[\"random-\".len()..].parse()?;\n 72 | CrateSelect::Random(n)\n 73 | }\n 74 | \n 75 | s if s.starts_with(\"list:\") => {\n 76 | let list = s[\"list:\".len()..]\n 77 | .split(',')\n 78 | .map(|s| s.to_owned())\n 79 | .collect();\n 80 | \n 81 | CrateSelect::List(list)\n 82 | }\n 83 | \n 84 | \"full\" => CrateSelect::Full,\n 85 | \"demo\" => CrateSelect::Demo,\n 86 | \"local\" => CrateSelect::Local,\n 87 | \"dummy\" => CrateSelect::Dummy,\n 88 | s => bail!(\"invalid CrateSelect: {}\", s),\n 89 | };\n 90 | \n 91 | Ok(ret)\n 92 | }\n 93 | }\n 94 | \n 95 | impl fmt::Display for CrateSelect {\n 96 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n 97 | match self {\n 98 | CrateSelect::Full => write!(f, \"full\"),\n 99 | CrateSelect::Demo => write!(f, \"demo\"),\n 100 | CrateSelect::Dummy => write!(f, \"dummy\"),\n 101 | CrateSelect::Top(n) => write!(f, \"top-{n}\"),\n 102 | CrateSelect::Local => write!(f, \"local\"),\n 103 | CrateSelect::Random(n) => write!(f, \"random-{n}\"),\n 104 | CrateSelect::List(list) => {\n 105 | let mut first = true;\n 106 | write!(f, \"list:\")?;\n 107 | \n 108 | for krate in list {\n 109 | if !first {\n 110 | write!(f, \",\")?;\n 111 | }\n 112 | \n 113 | write!(f, \"{krate}\")?;\n 114 | first = false;\n 115 | }\n 116 | \n 117 | Ok(())\n 118 | }\n 119 | }\n 120 | }\n 121 | }\n 122 | \n 123 | impl CrateSelect {\n 124 | fn from_newline_separated_list(s: &str) -> Fallible<CrateSelect> {\n 125 | if s.contains(',') {\n 126 | bail!(\"Crate identifiers must not contain a comma\");\n 127 | }\n 128 | \n 129 | let crates = s.split_whitespace().map(|s| s.to_owned()).collect();\n 130 | Ok(CrateSelect::List(crates))\n 131 | }\n 132 | }\n 133 | \n 134 | /// Either a `CrateSelect` or `Url` pointing to a list of crates.\n 135 | #[derive(Debug, PartialEq, Eq, Clone)]\n 136 | pub enum DeferredCrateSelect {\n 137 | Direct(CrateSelect),\n 138 | Indirect(Url),\n 139 | }\n 140 | \n 141 | impl From<CrateSelect> for DeferredCrateSelect {\n 142 | fn from(v: CrateSelect) -> Self {\n 143 | DeferredCrateSelect::Direct(v)\n 144 | }\n 145 | }\n 146 | \n 147 | impl DeferredCrateSelect {\n 148 | pub fn resolve(self) -> Fallible<CrateSelect> {\n 149 | let url = match self {\n 150 | DeferredCrateSelect::Direct(v) => return Ok(v),\n 151 | DeferredCrateSelect::Indirect(url) => url,\n 152 | };\n 153 | \n 154 | let body = utils::http::get_sync(url.as_str())?.text()?;\n 155 | CrateSelect::from_newline_separated_list(&body)\n 156 | }\n 157 | }\n 158 | \n 159 | impl FromStr for DeferredCrateSelect {\n 160 | type Err = anyhow::Error;\n 161 | \n 162 | fn from_str(input: &str) -> Fallible<Self> {\n 163 | if input.starts_with(\"https://\") || input.starts_with(\"http://\") {\n 164 | Ok(DeferredCrateSelect::Indirect(input.parse()?))\n 165 | } else {\n 166 | Ok(DeferredCrateSelect::Direct(input.parse()?))\n 167 | }\n 168 | }\n 169 | }\n 170 | \n 171 | #[cfg_attr(test, derive(Debug, PartialEq, Eq))]\n 172 | #[derive(Clone, Serialize, Deserialize)]\n 173 | pub enum Assignee {\n 174 | Agent(String),\n 175 | Distributed,\n 176 | CLI,\n 177 | }\n 178 | \n 179 | impl fmt::Display for Assignee {\n 180 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n 181 | match self {\n 182 | Assignee::Agent(ref name) => write!(f, \"agent:{name}\"),\n 183 | Assignee::Distributed => write!(f, \"distributed\"),\n 184 | Assignee::CLI => write!(f, \"cli\"),\n 185 | }\n 186 | }\n 187 | }\n 188 | \n 189 | #[derive(Debug, thiserror::Error)]\n 190 | #[cfg_attr(test, derive(PartialEq, Eq))]\n 191 | pub enum AssigneeParseError {\n 192 | #[error(\"the assignee is empty\")]\n 193 | Empty,\n 194 | #[error(\"unexpected assignee payload\")]\n 195 | UnexpectedPayload,\n 196 | #[error(\"invalid assignee kind: {0}\")]\n 197 | InvalidKind(String),\n 198 | }\n 199 | \n 200 | impl FromStr for Assignee {\n 201 | type Err = AssigneeParseError;\n 202 | \n 203 | fn from_str(input: &str) -> Result<Self, AssigneeParseError> {\n 204 | if input.trim().is_empty() {\n 205 | return Err(AssigneeParseError::Empty);\n 206 | }\n 207 | \n 208 | let mut split = input.splitn(2, ':');\n 209 | let kind = split.next().ok_or(AssigneeParseError::Empty)?;\n 210 | \n 211 | match kind {\n 212 | \"agent\" => {\n 213 | let name = split.next().ok_or(AssigneeParseError::Empty)?;\n 214 | if name.trim().is_empty() {\n 215 | return Err(AssigneeParseError::Empty);\n 216 | }\n 217 | \n 218 | Ok(Assignee::Agent(name.to_string()))\n 219 | }\n 220 | \"cli\" => {\n 221 | if split.next().is_some() {\n 222 | return Err(AssigneeParseError::UnexpectedPayload);\n 223 | }\n 224 | \n 225 | Ok(Assignee::CLI)\n 226 | }\n 227 | \"distributed\" => {\n 228 | if split.next().is_some() {\n 229 | return Err(AssigneeParseError::UnexpectedPayload);\n 230 | }\n 231 | \n 232 | Ok(Assignee::Distributed)\n 233 | }\n 234 | invalid => Err(AssigneeParseError::InvalidKind(invalid.into())),\n 235 | }\n 236 | }\n 237 | }\n 238 | \n 239 | #[derive(Serialize, Deserialize, Clone)]\n 240 | pub struct GitHubIssue {\n 241 | pub api_url: String,\n 242 | pub html_url: String,\n 243 | pub number: i32,\n 244 | }\n 245 | \n 246 | #[derive(Serialize, Deserialize, Clone)]\n 247 | pub struct Experiment {\n 248 | pub name: String,\n 249 | pub toolchains: [Toolchain; 2],\n 250 | pub mode: Mode,\n 251 | pub cap_lints: CapLints,\n 252 | pub priority: i32,\n 253 | pub created_at: DateTime<Utc>,\n 254 | pub started_at: Option<DateTime<Utc>>,\n 255 | pub completed_at: Option<DateTime<Utc>>,\n 256 | pub github_issue: Option<GitHubIssue>,\n 257 | pub status: Status,\n 258 | pub assigned_to: Option<Assignee>,\n 259 | pub report_url: Option<String>,\n 260 | pub ignore_blacklist: bool,\n 261 | pub requirement: Option<String>,\n 262 | }\n 263 | \n 264 | impl Experiment {\n 265 | pub fn exists(db: &Database, name: &str) -> Fallible<bool> {\n 266 | Ok(db.exists(\"SELECT rowid FROM experiments WHERE name = ?1;\", &[&name])?)\n 267 | }\n 268 | \n 269 | pub fn unfinished(db: &Database) -> Fallible<Vec<Experiment>> {\n 270 | let records = db.query(\n 271 | \"SELECT * FROM experiments WHERE status != ?1 ORDER BY priority DESC, created_at;\",\n 272 | [&Status::Completed.to_str()],\n 273 | |r| ExperimentDBRecord::from_row(r),\n 274 | )?;\n 275 | records\n 276 | .into_iter()\n 277 | .map(|record| record.into_experiment())\n 278 | .collect::<Fallible<_>>()\n 279 | }\n 280 | \n 281 | pub fn run_by(db: &Database, assignee: &Assignee) -> Fallible<Option<Experiment>> {\n 282 | let record = db.get_row(\n 283 | \"select * from experiments where name = (\n 284 | select latest_work_for from agents where ('agent:' || agents.name) = ?1\n 285 | ) and status = ?2 \\\n 286 | limit 1\",\n 287 | [&assignee.to_string(), Status::Running.to_str()],\n 288 | |r| ExperimentDBRecord::from_row(r),\n 289 | )?;\n 290 | \n 291 | if let Some(record) = record {\n 292 | Ok(Some(record.into_experiment()?))\n 293 | } else {\n 294 | Ok(None)\n 295 | }\n 296 | }\n 297 | \n 298 | // Returns the first experiment which has all results ready (and so can\n 299 | // produce a complete report). However, the experiment should not be\n 300 | // *completed* yet. Note that this may return an experiment which has had\n 301 | // report generation already start.\n 302 | pub fn ready_for_report(db: &Database) -> Fallible<Option<Experiment>> {\n 303 | let unfinished = Self::unfinished(db)?;\n 304 | for ex in unfinished {\n 305 | if ex.status == Status::ReportFailed {\n 306 | // Skip experiments whose report failed to generate. This avoids\n 307 | // constantly retrying reports (and posting a message each time\n 308 | // about the attempt); the retry-report command can override the\n 309 | // failure state. In practice we rarely *fail* to generate\n 310 | // reports in a clean way (instead OOMing or panicking, in which\n 311 | // case it is fine to automatically retry the report, as we've\n 312 | // not posted anything on GitHub -- it may be a problem from a\n 313 | // performance perspective but no more than that).\n 314 | continue;\n 315 | }\n 316 | let (completed, all) = ex.raw_progress(db)?;\n 317 | // FIXME: We often see more results than crates -- presumably, some\n 318 | // crates are run more than once and marked more than once, but the\n 319 | // details are not clear. For now, just assume that we're done if we\n 320 | // have 'more than enough' results too.\n 321 | if completed >= all {\n 322 | return Ok(Some(ex));\n 323 | }\n 324 | }\n 325 | \n 326 | Ok(None)\n 327 | }\n 328 | \n 329 | pub fn find_next(db: &Database, assignee: &Assignee) -> Fallible<Option<Experiment>> {\n 330 | // Avoid assigning two experiments to the same agent\n 331 | if let Some(experiment) = Experiment::run_by(db, assignee)? {\n 332 | return Ok(Some(experiment));\n 333 | }\n 334 | \n 335 | // Get an experiment whose requirements are met by this agent, preferring (in order of\n 336 | // importance):\n 337 | // - experiments that were explicitly assigned to us.\n 338 | // - distributed experiments.\n 339 | // - experiments with a higher priority.\n 340 | // - older experiments.\n 341 | Experiment::next_inner(db, Some(assignee), assignee)\n 342 | .and_then(|ex| {\n 343 | ex.map_or_else(\n 344 | || Experiment::next_inner(db, Some(&Assignee::Distributed), assignee),\n 345 | |exp| Ok(Some(exp)),\n 346 | )\n 347 | })\n 348 | .and_then(|ex| {\n 349 | ex.map_or_else(\n 350 | || Experiment::next_inner(db, None, assignee),\n 351 | |exp| Ok(Some(exp)),\n 352 | )\n 353 | })\n 354 | }\n 355 | \n 356 | pub fn next(db: &Database, assignee: &Assignee) -> Fallible<Option<(bool, Experiment)>> {\n 357 | Self::find_next(db, assignee).and_then(|ex| Self::assign_experiment(db, ex, assignee))\n 358 | }\n 359 | pub fn has_next(db: &Database, assignee: &Assignee) -> Fallible<bool> {\n 360 | Ok(Self::find_next(db, assignee)?.is_some())\n 361 | }\n 362 | \n 363 | fn assign_experiment(\n 364 | db: &Database,\n 365 | ex: Option<Experiment>,\n 366 | agent: &Assignee,\n 367 | ) -> Fallible<Option<(bool, Experiment)>> {\n 368 | if let Some(mut experiment) = ex {\n 369 | if let Assignee::Agent(name) = agent {\n 370 | db.execute(\n 371 | \"update agents set latest_work_for = ?2 where agents.name = ?1;\",\n 372 | rusqlite::params![&name, &experiment.name],\n 373 | )?;\n 374 | }\n 375 | \n 376 | let new_ex = experiment.status != Status::Running;\n 377 | if new_ex {\n 378 | experiment.set_status(db, Status::Running)?;\n 379 | // If this experiment was not assigned to a specific agent make it distributed\n 380 | experiment.set_assigned_to(\n 381 | db,\n 382 | experiment\n 383 | .assigned_to\n 384 | .clone()\n 385 | .or(Some(Assignee::Distributed))\n 386 | .as_ref(),\n 387 | )?;\n 388 | }\n 389 | return Ok(Some((new_ex, experiment)));\n 390 | }\n 391 | Ok(None)\n 392 | }\n 393 | \n 394 | //CLI query is only partially implemented and is therefore preceded by \"unimplemented!\"\n 395 | #[allow(unreachable_code)]\n 396 | fn next_inner(\n 397 | db: &Database,\n 398 | assignee: Option<&Assignee>,\n 399 | agent: &Assignee,\n 400 | ) -> Fallible<Option<Experiment>> {\n 401 | let agent_name = if let Assignee::Agent(agent_name) = agent {\n 402 | agent_name.to_string()\n 403 | } else {\n 404 | unimplemented!(\"experiment requirements are not respected when assigning to CLI\");\n 405 | };\n 406 | \n 407 | let (query, params) = if let Some(assignee) = assignee {\n 408 | match assignee {\n 409 | Assignee::Distributed | Assignee::Agent(_) => {\n 410 | const AGENT_QUERY: &str = r#\"\n 411 | SELECT *\n 412 | FROM experiments ex\n 413 | WHERE (ex.status = \"queued\" OR status = \"running\")\n 414 | AND ( ex.assigned_to = ?1 )\n 415 | AND ( ex.requirement IS NULL\n 416 | OR ex.requirement IN (SELECT capability\n 417 | FROM agent_capabilities\n 418 | WHERE agent_name = ?2) )\n 419 | ORDER BY ex.priority DESC,\n 420 | ex.created_at\n 421 | LIMIT 1;\n 422 | \"#;\n 423 | \n 424 | (AGENT_QUERY, vec![assignee.to_string(), agent_name])\n 425 | }\n 426 | // FIXME: We don't respect experiment requirements when assigning experiments to the\n 427 | // CLI. We need to decide what capabilities the CLI should have first.\n 428 | _ => {\n 429 | unimplemented!(\n 430 | \"experiment requirements are not respected when assigning to CLI\"\n 431 | );\n 432 | const CLI_QUERY: &str = r#\"\n 433 | SELECT *\n 434 | FROM experiments ex\n 435 | WHERE (ex.status = \"queued\" OR status = \"running\")\n 436 | AND (ex.assigned_to IS NULL OR ex.assigned_to = ?1)\n 437 | ORDER BY ex.assigned_to IS NULL,\n 438 | ex.priority DESC,\n 439 | ex.created_at\n 440 | LIMIT 1;\n 441 | \"#;\n 442 | \n 443 | (CLI_QUERY, vec![assignee.to_string()])\n 444 | }\n 445 | }\n 446 | } else {\n 447 | const AGENT_UNASSIGNED_QUERY: &str = r#\"\n 448 | SELECT *\n 449 | FROM experiments ex\n 450 | WHERE (ex.status = \"queued\" OR status = \"running\")\n 451 | AND ( ex.assigned_to IS NULL )\n 452 | AND ( ex.requirement IS NULL\n 453 | OR ex.requirement IN ( SELECT capability\n 454 | FROM agent_capabilities\n 455 | WHERE agent_name = ?1) )\n 456 | ORDER BY ex.priority DESC,\n 457 | ex.created_at\n 458 | LIMIT 1;\n 459 | \"#;\n 460 | \n 461 | (AGENT_UNASSIGNED_QUERY, vec![agent_name])\n 462 | };\n 463 | \n 464 | if let Some(record) = db.get_row(query, rusqlite::params_from_iter(params.iter()), |r| {\n 465 | ExperimentDBRecord::from_row(r)\n 466 | })? {\n 467 | let ex = record.into_experiment()?;\n 468 | let (completed, all) = ex.raw_progress(db)?;\n 469 | // FIXME: in this case, ideally we'd start running the next\n 470 | // experiment. In practice, this only happens with artifically short\n 471 | // experiments (i.e., those that take less time to run than we take\n 472 | // to generate a report), which is pretty rare. So this fix is\n 473 | // enough to make sure we're not constantly feeding a finished\n 474 | // experiment to our agents.\n 475 | if completed >= all {\n 476 | return Ok(None);\n 477 | }\n 478 | Ok(Some(ex))\n 479 | } else {\n 480 | Ok(None)\n 481 | }\n 482 | }\n 483 | \n 484 | pub fn get(db: &Database, name: &str) -> Fallible<Option<Experiment>> {\n 485 | let record = db.get_row(\"SELECT * FROM experiments WHERE name = ?1;\", [&name], |r| {\n 486 | ExperimentDBRecord::from_row(r)\n 487 | })?;\n 488 | \n 489 | if let Some(record) = record {\n 490 | Ok(Some(record.into_experiment()?))\n 491 | } else {\n 492 | Ok(None)\n 493 | }\n 494 | }\n 495 | \n 496 | pub fn set_status(&mut self, db: &Database, status: Status) -> Fallible<()> {\n 497 | db.execute(\n 498 | \"UPDATE experiments SET status = ?1 WHERE name = ?2;\",\n 499 | &[&status.to_str(), &self.name.as_str()],\n 500 | )?;\n 501 | \n 502 | let now = Utc::now();\n 503 | \n 504 | match (self.status, status) {\n 505 | // Check if the new status is \"running\" and there is no starting date\n 506 | (_, Status::Running) if self.started_at.is_none() => {\n 507 | db.execute(\n 508 | \"UPDATE experiments SET started_at = ?1 WHERE name = ?2;\",\n 509 | &[&now, &self.name.as_str()],\n 510 | )?;\n 511 | self.started_at = Some(now);\n 512 | }\n 513 | // Check if the old status was \"running\" and there is no completed date\n 514 | (Status::Running, _) if self.completed_at.is_none() => {\n 515 | db.execute(\n 516 | \"UPDATE experiments SET completed_at = ?1 WHERE name = ?2;\",\n 517 | &[&now, &self.name.as_str()],\n 518 | )?;\n 519 | self.completed_at = Some(now);\n 520 | }\n 521 | _ => (),\n 522 | }\n 523 | \n 524 | self.status = status;\n 525 | Ok(())\n 526 | }\n 527 | \n 528 | pub fn set_assigned_to(\n 529 | &mut self,\n 530 | db: &Database,\n 531 | assigned_to: Option<&Assignee>,\n 532 | ) -> Fallible<()> {\n 533 | db.execute(\n 534 | \"UPDATE experiments SET assigned_to = ?1 WHERE name = ?2;\",\n 535 | &[&assigned_to.map(|a| a.to_string()), &self.name.as_str()],\n 536 | )?;\n 537 | self.assigned_to = assigned_to.cloned();\n 538 | Ok(())\n 539 | }\n 540 | \n 541 | pub fn set_report_url(&mut self, db: &Database, url: &str) -> Fallible<()> {\n 542 | db.execute(\n 543 | \"UPDATE experiments SET report_url = ?1 WHERE name = ?2;\",\n 544 | &[&url, &self.name.as_str()],\n 545 | )?;\n 546 | self.report_url = Some(url.to_string());\n 547 | Ok(())\n 548 | }\n 549 | \n 550 | pub fn raw_progress(&self, db: &Database) -> Fallible<(u32, u32)> {\n 551 | let results_len: u32 = db\n 552 | .get_row(\n 553 | \"SELECT COUNT(*) AS count FROM results WHERE experiment = ?1;\",\n 554 | [&self.name.as_str()],\n 555 | |r| r.get(\"count\"),\n 556 | )?\n 557 | .unwrap();\n 558 | \n 559 | let crates_len: u32 = db\n 560 | .get_row(\n 561 | \"SELECT COUNT(*) AS count FROM experiment_crates \\\n 562 | WHERE experiment = ?1 AND skipped = 0;\",\n 563 | [&self.name.as_str()],\n 564 | |r| r.get(\"count\"),\n 565 | )?\n 566 | .unwrap();\n 567 | \n 568 | Ok((results_len, crates_len * 2))\n 569 | }\n 570 | \n 571 | pub fn get_result_counts(&self, db: &Database) -> Fallible<Vec<(TestResult, u32)>> {\n 572 | let results: Vec<(String, u32)> = db.query(\n 573 | \"SELECT result, COUNT(*) FROM results \\\n 574 | WHERE experiment = ?1 GROUP BY result;\",\n 575 | [&self.name.as_str()],\n 576 | |r| Ok((r.get::<_, String>(0)?, r.get(1)?)),\n 577 | )?;\n 578 | \n 579 | results\n 580 | .into_iter()\n 581 | .map(|(result, count)| Ok((TestResult::from_str(&result)?, count)))\n 582 | .collect()\n 583 | }\n 584 | \n 585 | pub fn progress(&self, db: &Database) -> Fallible<u8> {\n 586 | let (results_len, crates_len) = self.raw_progress(db)?;\n 587 | \n 588 | if crates_len != 0 {\n 589 | Ok((results_len as f32 * 100.0 / crates_len as f32).ceil() as u8)\n 590 | } else {\n 591 | Ok(0)\n 592 | }\n 593 | }\n 594 | \n 595 | pub fn get_crates(&self, db: &Database) -> Fallible<Vec<Crate>> {\n 596 | db.query(\n 597 | \"SELECT crate FROM experiment_crates WHERE experiment = ?1;\",\n 598 | [&self.name],\n 599 | |r| r.get(0),\n 600 | )?\n 601 | .into_iter()\n 602 | .map(|c: String| c.parse())\n 603 | .collect::<Fallible<Vec<Crate>>>()\n 604 | }\n 605 | \n 606 | pub fn get_uncompleted_crates(\n 607 | &self,\n 608 | db: &Database,\n 609 | limit: Option<u32>,\n 610 | ) -> Fallible<Vec<Crate>> {\n 611 | let limit = limit.map(|l| l as i32).unwrap_or(-1);\n 612 | #[cfg(not(test))]\n 613 | const RUN_TIMEOUT: u32 = 20;\n 614 | #[cfg(test)]\n 615 | const RUN_TIMEOUT: u32 = 1;\n 616 | \n 617 | db.transaction(true, |transaction| {\n 618 | //get the first 'limit' queued crates from the experiment crates list\n 619 | let mut params: Vec<&dyn rusqlite::types::ToSql> = Vec::new();\n 620 | let crates = transaction\n 621 | .query(\n 622 | &format!(\n 623 | \"SELECT crate FROM experiment_crates WHERE experiment = ?1\n 624 | AND skipped = 0\n 625 | AND status = 'queued'\n 626 | AND (started_at is null or started_at <= datetime('now', '-{RUN_TIMEOUT} minutes'))\n 627 | LIMIT ?2;\",\n 628 | ),\n 629 | rusqlite::params![self.name, limit],\n 630 | |r| r.get(\"crate\"),\n 631 | )?\n 632 | .into_iter()\n 633 | .collect::<Vec<String>>();\n 634 | \n 635 | crates.iter().for_each(|krate| params.push(krate));\n 636 | let params_header: &[&dyn rusqlite::types::ToSql] = &[&self.name];\n 637 | //SQLite cannot handle queries with more than 999 variables\n 638 | for params in params.chunks(SQL_VARIABLE_LIMIT) {\n 639 | let params = [params_header, params].concat();\n 640 | let update_query = &[\n 641 | \"\n 642 | UPDATE experiment_crates\n 643 | SET started_at = datetime('now')\n 644 | WHERE experiment = ?1\n 645 | AND crate IN (\"\n 646 | .to_string(),\n 647 | \"?,\".repeat(params.len() - 2),\n 648 | \"?)\".to_string(),\n 649 | ]\n 650 | .join(\"\");\n 651 | \n 652 | //update the status of the previously selected crates to 'Running'\n 653 | transaction.execute(update_query, ¶ms)?;\n 654 | }\n 655 | crates\n 656 | .iter()\n 657 | .map(|krate| Ok(krate.parse()?))\n 658 | .collect::<Fallible<Vec<Crate>>>()\n 659 | })\n 660 | }\n 661 | }\n 662 | \n 663 | pub struct ExperimentDBRecord {\n 664 | name: String,\n 665 | mode: String,\n 666 | cap_lints: String,\n 667 | toolchain_start: String,\n 668 | toolchain_end: String,\n 669 | priority: i32,\n 670 | created_at: DateTime<Utc>,\n 671 | started_at: Option<DateTime<Utc>>,\n 672 | completed_at: Option<DateTime<Utc>>,\n 673 | github_issue: Option<String>,\n 674 | github_issue_url: Option<String>,\n 675 | github_issue_number: Option<i32>,\n 676 | status: String,\n 677 | assigned_to: Option<String>,\n 678 | report_url: Option<String>,\n 679 | ignore_blacklist: bool,\n 680 | requirement: Option<String>,\n 681 | }\n 682 | \n 683 | impl ExperimentDBRecord {\n 684 | pub fn from_row(row: &Row) -> rusqlite::Result<Self> {\n 685 | Ok(ExperimentDBRecord {\n 686 | name: row.get(\"name\")?,\n 687 | mode: row.get(\"mode\")?,\n 688 | cap_lints: row.get(\"cap_lints\")?,\n 689 | toolchain_start: row.get(\"toolchain_start\")?,\n 690 | toolchain_end: row.get(\"toolchain_end\")?,\n 691 | priority: row.get(\"priority\")?,\n 692 | created_at: row.get(\"created_at\")?,\n 693 | started_at: row.get(\"started_at\")?,\n 694 | completed_at: row.get(\"completed_at\")?,\n 695 | status: row.get(\"status\")?,\n 696 | github_issue: row.get(\"github_issue\")?,\n 697 | github_issue_url: row.get(\"github_issue_url\")?,\n 698 | github_issue_number: row.get(\"github_issue_number\")?,\n 699 | assigned_to: row.get(\"assigned_to\")?,\n 700 | report_url: row.get(\"report_url\")?,\n 701 | ignore_blacklist: row.get(\"ignore_blacklist\")?,\n 702 | requirement: row.get(\"requirement\")?,\n 703 | })\n 704 | }\n 705 | \n 706 | pub fn into_experiment(self) -> Fallible<Experiment> {\n 707 | Ok(Experiment {\n 708 | name: self.name,\n 709 | toolchains: [self.toolchain_start.parse()?, self.toolchain_end.parse()?],\n 710 | cap_lints: self.cap_lints.parse()?,\n 711 | mode: self.mode.parse()?,\n 712 | priority: self.priority,\n 713 | created_at: self.created_at,\n 714 | started_at: self.started_at,\n 715 | completed_at: self.completed_at,\n 716 | github_issue: if let (Some(api_url), Some(html_url), Some(number)) = (\n 717 | self.github_issue,\n 718 | self.github_issue_url,\n 719 | self.github_issue_number,\n 720 | ) {\n 721 | Some(GitHubIssue {\n 722 | api_url,\n 723 | html_url,\n 724 | number,\n 725 | })\n 726 | } else {\n 727 | None\n 728 | },\n 729 | assigned_to: if let Some(assignee) = self.assigned_to {\n 730 | Some(assignee.parse()?)\n 731 | } else {\n 732 | None\n 733 | },\n 734 | status: self.status.parse()?,\n 735 | report_url: self.report_url,\n 736 | ignore_blacklist: self.ignore_blacklist,\n 737 | requirement: self.requirement,\n 738 | })\n 739 | }\n 740 | }","lines":740,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L1-L740"}]},{"id":"ARCH_2","type":"architecture","type_label":"Architecture","severity":"medium","easy":false,"description":"Worker::run is a ~190-line monster function mixing many responsibilities: crate fetching with bespoke retry/no-space handling, sha capture, blacklist/skip reporting, per-toolchain task construction (a large match over Mode duplicated from nowhere else), result recording, and error reporting. This makes the untrusted-build orchestration path hard to reason about for correctness (e.g. it is non-obvious that record_progress errors in the skip/prepare branches are swallowed via report_failure while the success/fail task branches use `?` and abort the worker).","fix":"Extract helpers: fetch_and_prepare(krate) -> Result, build_task(tc, krate) -> Task, and report(result) so the loop body is readable and the error-handling policy (swallow vs propagate) is consistent and explicit.","locations":[{"ref":"src/runner/worker.rs:158-347","code":" 158 | pub(super) fn run(&self) -> Fallible<()> {\n 159 | loop {\n 160 | let krate = if let Some(next) = (self.next_crate)()? {\n 161 | next\n 162 | } else {\n 163 | // Backoff from calling the server again, to reduce load when we're spinning until\n 164 | // the next experiment is ready.\n 165 | std::thread::sleep(Duration::from_secs(rand::random_range(60..120)));\n 166 | \n 167 | if let Some(cb) = self.between_crates.get() {\n 168 | cb(true);\n 169 | }\n 170 | \n 171 | // We're done if no more crates left.\n 172 | return Ok(());\n 173 | };\n 174 | \n 175 | info!(\"{} processing crate {}\", self.name, krate);\n 176 | \n 177 | if let Some(cb) = self.between_crates.get() {\n 178 | cb(false);\n 179 | }\n 180 | \n 181 | if !self.ex.ignore_blacklist && self.config.should_skip(&krate) {\n 182 | for tc in &self.ex.toolchains {\n 183 | // If a skipped crate is somehow sent to the agent (for example, when a crate was\n 184 | // added to the experiment and *then* blacklisted) report the crate as skipped\n 185 | // instead of silently ignoring it.\n 186 | if let Err(e) = self.api.record_progress(\n 187 | self.ex,\n 188 | &krate,\n 189 | tc,\n 190 | \"crate skipped\".as_bytes(),\n 191 | &TestResult::Skipped,\n 192 | None,\n 193 | ) {\n 194 | crate::utils::report_failure(&e);\n 195 | }\n 196 | }\n 197 | continue;\n 198 | }\n 199 | \n 200 | let mut updated_version = None;\n 201 | let logs = LogStorage::from(self.config);\n 202 | let prepare = logging::capture(&logs, || {\n 203 | let rustwide_crate = krate.to_rustwide();\n 204 | for attempt in 1..=15 {\n 205 | match detect_broken(rustwide_crate.fetch(self.workspace)) {\n 206 | Ok(()) => break,\n 207 | Err(e) => {\n 208 | if logs.to_string().contains(\"No space left on device\") {\n 209 | if attempt == 15 {\n 210 | // If we've failed 15 times, then\n 211 | // just give up. It's been at least\n 212 | // 45 seconds, which is enough that\n 213 | // our disk space check should\n 214 | // have run at least once in this\n 215 | // time. If that's not helped, then\n 216 | // maybe this git repository *is*\n 217 | // actually too big.\n 218 | //\n 219 | // Ideally we'd have some kind of\n 220 | // per-worker counter and if we hit\n 221 | // this too often we'd replace the\n 222 | // machine, but it's not very clear\n 223 | // what \"too often\" means here.\n 224 | return Err(e);\n 225 | } else {\n 226 | log::warn!(\n 227 | \"Retrying crate fetch in 3 seconds (attempt {attempt})\"\n 228 | );\n 229 | std::thread::sleep(std::time::Duration::from_secs(3));\n 230 | }\n 231 | } else {\n 232 | return Err(e);\n 233 | }\n 234 | }\n 235 | }\n 236 | }\n 237 | \n 238 | if let Crate::GitHub(repo) = &krate {\n 239 | if let Some(sha) = rustwide_crate.git_commit(self.workspace) {\n 240 | let updated = crate::crates::GitHubRepo {\n 241 | sha: Some(sha),\n 242 | ..repo.clone()\n 243 | };\n 244 | updated_version = Some(Crate::GitHub(updated));\n 245 | } else {\n 246 | bail!(\"unable to capture sha for {}\", repo.slug());\n 247 | }\n 248 | }\n 249 | Ok(())\n 250 | });\n 251 | if let Err(err) = prepare {\n 252 | let mut result = if self.config.is_broken(&krate) {\n 253 | TestResult::BrokenCrate(BrokenReason::Unknown)\n 254 | } else {\n 255 | TestResult::PrepareFail(failure_reason(&err))\n 256 | };\n 257 | \n 258 | if let Some(OverrideResult(res)) = err.downcast_ref() {\n 259 | result = res.clone();\n 260 | }\n 261 | \n 262 | for tc in &self.ex.toolchains {\n 263 | if let Err(e) = self.api.record_progress(\n 264 | self.ex,\n 265 | &krate,\n 266 | tc,\n 267 | format!(\"{logs}\\n\\nthis task or one of its parent failed: {err:?}\")\n 268 | .as_bytes(),\n 269 | &result,\n 270 | updated_version.as_ref().map(|new| (&krate, new)),\n 271 | ) {\n 272 | crate::utils::report_failure(&e);\n 273 | }\n 274 | }\n 275 | continue;\n 276 | }\n 277 | \n 278 | for tc in &self.ex.toolchains {\n 279 | let quiet = self.config.is_quiet(&krate);\n 280 | let task = Task {\n 281 | krate: krate.clone(),\n 282 | step: match self.ex.mode {\n 283 | Mode::BuildOnly => TaskStep::BuildOnly {\n 284 | tc: tc.clone(),\n 285 | quiet,\n 286 | },\n 287 | Mode::BuildAndTest\n 288 | if !self.ex.ignore_blacklist\n 289 | && self.config.should_skip_tests(&krate) =>\n 290 | {\n 291 | TaskStep::BuildOnly {\n 292 | tc: tc.clone(),\n 293 | quiet,\n 294 | }\n 295 | }\n 296 | Mode::BuildAndTest => TaskStep::BuildAndTest {\n 297 | tc: tc.clone(),\n 298 | quiet,\n 299 | },\n 300 | Mode::CheckOnly => TaskStep::CheckOnly {\n 301 | tc: tc.clone(),\n 302 | quiet,\n 303 | },\n 304 | Mode::Clippy => TaskStep::Clippy {\n 305 | tc: tc.clone(),\n 306 | quiet,\n 307 | },\n 308 | Mode::Rustdoc => TaskStep::Rustdoc {\n 309 | tc: tc.clone(),\n 310 | quiet,\n 311 | },\n 312 | Mode::UnstableFeatures => TaskStep::UnstableFeatures { tc: tc.clone() },\n 313 | Mode::Fix => TaskStep::Fix {\n 314 | tc: tc.clone(),\n 315 | quiet,\n 316 | },\n 317 | },\n 318 | };\n 319 | \n 320 | // Fork logs off to distinct branch, so that each toolchain has its own log file,\n 321 | // while keeping the shared prepare step in common.\n 322 | let storage = logs.duplicate();\n 323 | match self.run_task(&task, &storage) {\n 324 | Ok(res) => {\n 325 | self.api.record_progress(\n 326 | self.ex,\n 327 | &task.krate,\n 328 | tc,\n 329 | storage.to_string().as_bytes(),\n 330 | &res,\n 331 | updated_version.as_ref().map(|new| (&krate, new)),\n 332 | )?;\n 333 | }\n 334 | Err((err, test_result)) => {\n 335 | self.api.record_progress(\n 336 | self.ex,\n 337 | &task.krate,\n 338 | tc,\n 339 | format!(\"{storage}\\n\\n{err:?}\").as_bytes(),\n 340 | &test_result,\n 341 | updated_version.as_ref().map(|new| (&krate, new)),\n 342 | )?;\n 343 | }\n 344 | }\n 345 | }\n 346 | }\n 347 | }","lines":190,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/worker.rs#L158-L347"}]},{"id":"ARCH_3","type":"architecture","type_label":"Architecture","severity":"medium","easy":false,"description":"The rust_teams permission set fetched in ACL::allowed() is never refreshed by refresh_cache(); only cached_usernames (config users + GitHub team members) is refreshed. Conversely the github-team cache is only refreshed when the `refresh` webhook command is issued (routes/webhooks/commands.rs), never on a timer/cronjob, so team membership changes are not picked up until someone manually triggers a refresh. The two authorization sources have inconsistent freshness semantics.","fix":"Drive ACL.refresh_cache from a cronjob on a fixed interval (and include the rust-team-data list in the cached state) so authorization data stays current without manual intervention.","locations":[{"ref":"src/server/auth.rs:121-179","code":" 121 | pub fn refresh_cache(&self, github: &GitHubApi) -> Fallible<()> {\n 122 | // A new HashSet is created instead of clearing the old one\n 123 | // This is done because if an error occurs the old cache is not flushed\n 124 | let mut new_cache = HashSet::new();\n 125 | \n 126 | for user in &self.users {\n 127 | new_cache.insert(user.clone());\n 128 | }\n 129 | \n 130 | let mut orgs = HashMap::new();\n 131 | for (org, team) in &self.teams {\n 132 | if let Err(err) = self.load_team(github, &mut new_cache, &mut orgs, org, team) {\n 133 | warn!(\"failed to authorize members of {org}/{team} to use the bot\");\n 134 | warn!(\"caused by: {err}\");\n 135 | }\n 136 | }\n 137 | \n 138 | // Update the shared cache\n 139 | let mut cache = self.cached_usernames.write().unwrap();\n 140 | *cache = new_cache;\n 141 | \n 142 | Ok(())\n 143 | }\n 144 | \n 145 | fn load_team(\n 146 | &self,\n 147 | github: &GitHubApi,\n 148 | new_cache: &mut HashSet<String>,\n 149 | orgs: &mut HashMap<String, HashMap<String, usize>>,\n 150 | org: &str,\n 151 | team: &str,\n 152 | ) -> Fallible<()> {\n 153 | // Cache the list of teams in an org\n 154 | if !orgs.contains_key(org) {\n 155 | orgs.insert(org.to_string(), github.list_teams(org)?);\n 156 | }\n 157 | \n 158 | let members = github.team_members(\n 159 | *orgs[org]\n 160 | .get(team)\n 161 | .ok_or_else(|| anyhow!(\"team {org}/{team} doesn't exist\"))?,\n 162 | )?;\n 163 | for member in &members {\n 164 | new_cache.insert(member.clone());\n 165 | }\n 166 | \n 167 | Ok(())\n 168 | }\n 169 | \n 170 | pub fn allowed(&self, username: &str, user_id: u64) -> Fallible<bool> {\n 171 | if self.rust_teams {\n 172 | let url = format!(\"{}/permissions/crater.json\", team_data::BASE_URL);\n 173 | let members: team_data::Permission = crate::utils::http::get_sync(&url)?.json()?;\n 174 | if members.github_ids.contains(&user_id) {\n 175 | return Ok(true);\n 176 | }\n 177 | }\n 178 | Ok(self.cached_usernames.read().unwrap().contains(username))\n 179 | }","lines":59,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/auth.rs#L121-L179"}]},{"id":"ARCH_4","type":"architecture","type_label":"Architecture","severity":"low","easy":false,"description":"Crater::run is a single ~265-line match arm dispatcher that inlines the full business logic of every subcommand (config loading, DB opening, status transitions, report generation, runner orchestration). This mixes CLI argument parsing with application logic, makes the function hard to test in isolation, and concentrates many responsibilities in cli.rs. Each arm repeats the `Config::load()? / Database::open()? / ActionsCtx::new(...)` boilerplate.","fix":"Extract each subcommand body into its own function (or a method on a command struct) and factor the repeated config/db/ctx setup into a helper, leaving run() as a thin dispatcher.","locations":[{"ref":"src/cli.rs:265-533","code":" 265 | impl Crater {\n 266 | pub fn run(&self) -> Result<()> {\n 267 | match *self {\n 268 | Crater::CreateLists { ref lists } => {\n 269 | let mut lists: HashSet<_> = lists.iter().map(|s| s.as_str()).collect();\n 270 | \n 271 | let config = Config::load()?;\n 272 | let db = Database::open()?;\n 273 | let ctx = ActionsCtx::new(&db, &config);\n 274 | \n 275 | let action = if lists.is_empty() {\n 276 | actions::UpdateLists::default()\n 277 | } else {\n 278 | actions::UpdateLists {\n 279 | github: lists.remove(\"github\"),\n 280 | registry: lists.remove(\"registry\"),\n 281 | local: lists.remove(\"local\"),\n 282 | }\n 283 | };\n 284 | \n 285 | if let Some(unknown) = lists.iter().next() {\n 286 | bail!(\"unknown list: {}\", unknown);\n 287 | } else {\n 288 | action.apply(&ctx)?;\n 289 | }\n 290 | }\n 291 | Crater::PrepareLocal => {\n 292 | let config = Config::load()?;\n 293 | let db = Database::open()?;\n 294 | let ctx = ActionsCtx::new(&db, &config);\n 295 | actions::UpdateLists::default().apply(&ctx)?;\n 296 | }\n 297 | Crater::DefineEx {\n 298 | ref ex,\n 299 | ref tc1,\n 300 | ref tc2,\n 301 | ref mode,\n 302 | ref crates,\n 303 | ref cap_lints,\n 304 | ref priority,\n 305 | ref ignore_blacklist,\n 306 | ref assign,\n 307 | ref requirement,\n 308 | } => {\n 309 | let config = Config::load()?;\n 310 | let db = Database::open()?;\n 311 | let ctx = ActionsCtx::new(&db, &config);\n 312 | \n 313 | actions::CreateExperiment {\n 314 | name: ex.0.clone(),\n 315 | toolchains: [tc1.clone(), tc2.clone()],\n 316 | mode: *mode,\n 317 | crates: crates.clone().resolve()?,\n 318 | cap_lints: *cap_lints,\n 319 | priority: *priority,\n 320 | github_issue: None,\n 321 | ignore_blacklist: *ignore_blacklist,\n 322 | assign: assign.clone(),\n 323 | requirement: requirement.clone(),\n 324 | }\n 325 | .apply(&ctx)?;\n 326 | }\n 327 | Crater::Edit {\n 328 | ref name,\n 329 | ref tc1,\n 330 | ref tc2,\n 331 | ref mode,\n 332 | ref crates,\n 333 | ref cap_lints,\n 334 | ref priority,\n 335 | ref ignore_blacklist,\n 336 | ref no_ignore_blacklist,\n 337 | ref assign,\n 338 | ref requirement,\n 339 | } => {\n 340 | let config = Config::load()?;\n 341 | let db = Database::open()?;\n 342 | let ctx = ActionsCtx::new(&db, &config);\n 343 | \n 344 | let ignore_blacklist = if *ignore_blacklist {\n 345 | Some(true)\n 346 | } else if *no_ignore_blacklist {\n 347 | Some(false)\n 348 | } else {\n 349 | None\n 350 | };\n 351 | \n 352 | actions::EditExperiment {\n 353 | name: name.clone(),\n 354 | toolchains: [tc1.clone(), tc2.clone()],\n 355 | mode: *mode,\n 356 | crates: crates.clone().map(|cs| cs.resolve()).transpose()?,\n 357 | cap_lints: *cap_lints,\n 358 | priority: *priority,\n 359 | ignore_blacklist,\n 360 | assign: assign.clone(),\n 361 | requirement: requirement.clone(),\n 362 | }\n 363 | .apply(&ctx)?;\n 364 | }\n 365 | Crater::DeleteEx { ref ex } => {\n 366 | let config = Config::load()?;\n 367 | let db = Database::open()?;\n 368 | let ctx = ActionsCtx::new(&db, &config);\n 369 | \n 370 | actions::DeleteExperiment { name: ex.0.clone() }.apply(&ctx)?;\n 371 | }\n 372 | Crater::DeleteAllResults { ref ex } => {\n 373 | let db = Database::open()?;\n 374 | let result_db = DatabaseDB::new(&db);\n 375 | \n 376 | if let Some(mut experiment) = Experiment::get(&db, &ex.0)? {\n 377 | result_db.delete_all_results(&experiment)?;\n 378 | experiment.set_status(&db, Status::Queued)?;\n 379 | } else {\n 380 | bail!(\"missing experiment {}\", ex.0);\n 381 | }\n 382 | }\n 383 | Crater::DeleteResult {\n 384 | ref ex,\n 385 | ref tc,\n 386 | ref krate,\n 387 | } => {\n 388 | let db = Database::open()?;\n 389 | let result_db = DatabaseDB::new(&db);\n 390 | \n 391 | if let Some(mut experiment) = Experiment::get(&db, &ex.0)? {\n 392 | if let Some(tc) = tc {\n 393 | result_db.delete_result(&experiment, tc, krate)?;\n 394 | } else {\n 395 | for tc in &experiment.toolchains {\n 396 | result_db.delete_result(&experiment, tc, krate)?;\n 397 | }\n 398 | }\n 399 | \n 400 | experiment.set_status(&db, Status::Queued)?;\n 401 | } else {\n 402 | bail!(\"missing experiment {}\", ex.0);\n 403 | }\n 404 | }\n 405 | Crater::RunGraph {\n 406 | ref ex,\n 407 | threads,\n 408 | ref docker_env,\n 409 | fast_workspace_init,\n 410 | } => {\n 411 | let config = Config::load()?;\n 412 | let db = Database::open()?;\n 413 | \n 414 | if let Some(mut experiment) = Experiment::get(&db, &ex.0)? {\n 415 | // Ensure the experiment is properly assigned\n 416 | match experiment.assigned_to {\n 417 | None => experiment.set_assigned_to(&db, Some(&Assignee::CLI))?,\n 418 | Some(Assignee::CLI) => {}\n 419 | Some(a) => bail!(\"experiment {} is assigned to {}\", ex.0, a),\n 420 | }\n 421 | \n 422 | // Update the status\n 423 | match experiment.status {\n 424 | Status::Queued => experiment.set_status(&db, Status::Running)?,\n 425 | Status::Running => {}\n 426 | other => bail!(\"can't run an experiment with status {}\", other.to_str()),\n 427 | }\n 428 | \n 429 | let result_db = DatabaseDB::new(&db);\n 430 | \n 431 | let workspace = self\n 432 | .workspace(docker_env.as_ref().map(|s| s.as_str()), fast_workspace_init)?;\n 433 | workspace.purge_all_build_dirs()?;\n 434 | \n 435 | let crates =\n 436 | std::sync::Mutex::new(experiment.get_uncompleted_crates(&db, None)?);\n 437 | let res = runner::run_ex(\n 438 | &experiment,\n 439 | &workspace,\n 440 | &result_db,\n 441 | threads,\n 442 | &config,\n 443 | &|| Ok(crates.lock().unwrap().pop()),\n 444 | );\n 445 | workspace.purge_all_build_dirs()?;\n 446 | res?;\n 447 | } else {\n 448 | bail!(\"missing experiment {}\", ex.0);\n 449 | }\n 450 | }\n 451 | Crater::GenReport {\n 452 | ref ex,\n 453 | ref dest,\n 454 | force,\n 455 | output_templates,\n 456 | } => {\n 457 | let config = Config::load()?;\n 458 | let db = Database::open()?;\n 459 | \n 460 | if let Some(mut experiment) = Experiment::get(&db, &ex.0)? {\n 461 | let (completed, all) = experiment.raw_progress(&db)?;\n 462 | if !force && completed != all {\n 463 | bail!(\n 464 | \"can't generate the report of an incomplete experiment: {}/{} results \\\n 465 | (use --force to override)\",\n 466 | completed,\n 467 | all,\n 468 | );\n 469 | }\n 470 | \n 471 | experiment.set_status(&db, Status::GeneratingReport)?;\n 472 | \n 473 | let result_db = DatabaseDB::new(&db);\n 474 | let res = report::gen(\n 475 | &result_db,\n 476 | &experiment,\n 477 | &experiment.get_crates(&db)?,\n 478 | &report::FileWriter::create(dest.0.clone())?,\n 479 | &config,\n 480 | output_templates,\n 481 | );\n 482 | \n 483 | if let Err(err) = res {\n 484 | experiment.set_status(&db, Status::ReportFailed)?;\n 485 | return Err(err);\n 486 | } else {\n 487 | experiment.set_status(&db, Status::Completed)?;\n 488 | }\n 489 | } else {\n 490 | bail!(\"missing experiment: {}\", ex.0);\n 491 | }\n 492 | }\n 493 | Crater::Server { bind } => {\n 494 | let config = Config::load()?;\n 495 | server::run(\n 496 | config,\n 497 | bind.unwrap_or_else(|| ([127, 0, 0, 1], 8000).into()),\n 498 | )?;\n 499 | }\n 500 | Crater::Agent {\n 501 | ref url,\n 502 | ref token,\n 503 | threads,\n 504 | ref docker_env,\n 505 | fast_workspace_init,\n 506 | ref capabilities,\n 507 | no_default_capabilities,\n 508 | } => {\n 509 | let mut caps = if no_default_capabilities {\n 510 | Capabilities::default()\n 511 | } else {\n 512 | default_capabilities_for_target()\n 513 | };\n 514 | caps.extend(capabilities.clone());\n 515 | \n 516 | agent::run(\n 517 | url,\n 518 | token,\n 519 | threads,\n 520 | &caps,\n 521 | &self\n 522 | .workspace(docker_env.as_ref().map(|s| s.as_str()), fast_workspace_init)?,\n 523 | )?;\n 524 | }\n 525 | Crater::CheckConfig { ref filename } => {\n 526 | if let Err(ref e) = Config::check(filename) {\n 527 | bail!(\"check-config failed: {}\", e);\n 528 | }\n 529 | }\n 530 | }\n 531 | \n 532 | Ok(())\n 533 | }","lines":269,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/cli.rs#L265-L533"}]},{"id":"ARCH_5","type":"architecture","type_label":"Architecture","severity":"low","easy":false,"description":"Crate::try_from is a ~160-line function mixing two completely different parsing strategies (legacy whitespace pkgid string surgery vs PackageIdSpec parsing) with deeply nested match/if-let chains and duplicated GitHub-URL-extraction logic appearing three times (github.com special-casing in the Git branch, and twice in the None/url branch).","fix":"Extract the repeated 'parse github.com org/repo from url' logic into a helper, and split the two parsing strategies into separate functions to reduce nesting and duplication.","locations":[{"ref":"src/crates/mod.rs:74-237","code":" 74 | impl TryFrom<&'_ PackageId> for Crate {\n 75 | type Error = anyhow::Error;\n 76 | \n 77 | fn try_from(pkgid: &PackageId) -> Fallible<Crate> {\n 78 | if pkgid.repr.contains(|c: char| c.is_ascii_whitespace()) {\n 79 | let parts = &pkgid\n 80 | .repr\n 81 | .split_ascii_whitespace()\n 82 | .flat_map(|s| {\n 83 | // remove ()\n 84 | s.trim_matches(|c: char| c.is_ascii_punctuation())\n 85 | // split resource and protocol\n 86 | .split('+')\n 87 | })\n 88 | .collect::<Vec<_>>();\n 89 | \n 90 | match parts[..] {\n 91 | [name, version, \"registry\", _] => Ok(Crate::Registry(RegistryCrate {\n 92 | name: name.to_smolstr(),\n 93 | version: version.to_smolstr(),\n 94 | })),\n 95 | [_, _, \"path\", path] => Ok(Crate::Path(path.to_string())),\n 96 | [_, _, \"git\", repo] => {\n 97 | if repo.starts_with(\"https://github.com\") {\n 98 | Ok(Crate::GitHub(repo.replace('#', \"/\").parse()?))\n 99 | } else {\n 100 | let mut parts = repo.split('#').rev().collect::<Vec<_>>();\n 101 | let url = parts.pop();\n 102 | let sha = parts.pop();\n 103 | \n 104 | match (url, sha) {\n 105 | (Some(url), None) => Ok(Crate::Git(GitRepo {\n 106 | url: url.to_string(),\n 107 | sha: None,\n 108 | })),\n 109 | (Some(url), Some(sha)) => Ok(Crate::Git(GitRepo {\n 110 | // remove additional queries if the sha is present\n 111 | // as the crate version is already uniquely determined\n 112 | url: url.split('?').next().unwrap().to_string(),\n 113 | sha: Some(sha.to_string()),\n 114 | })),\n 115 | _ => bail!(\"malformed git repo: {}\", repo),\n 116 | }\n 117 | }\n 118 | }\n 119 | _ => bail!(\n 120 | \"malformed pkgid format: {}\\n maybe the representation has changed?\",\n 121 | pkgid.repr\n 122 | ),\n 123 | }\n 124 | } else {\n 125 | use cargo_util_schemas::core::*;\n 126 | \n 127 | let package_id = PackageIdSpec::parse(&pkgid.repr)?;\n 128 | \n 129 | match package_id.kind() {\n 130 | Some(SourceKind::LocalRegistry) => Ok(Crate::Local(package_id.name().to_string())),\n 131 | Some(SourceKind::Git(rev)) => {\n 132 | if let Some(url) = package_id.url() {\n 133 | if url.domain() == Some(\"github.com\") {\n 134 | if let Some(mut path) = url.path_segments() {\n 135 | let Some(org) = path.next() else {\n 136 | bail!(\"Github URL path is too short\")\n 137 | };\n 138 | \n 139 | let Some(repo_name) = path.next() else {\n 140 | bail!(\"Github URL path is too short\")\n 141 | };\n 142 | \n 143 | Ok(Crate::GitHub(GitHubRepo {\n 144 | org: org.to_string(),\n 145 | name: repo_name.trim_end_matches(\".git\").to_string(),\n 146 | sha: match rev {\n 147 | GitReference::Rev(rev)\n 148 | if rev.chars().all(|c| c.is_ascii_hexdigit()) =>\n 149 | {\n 150 | Some(rev.to_string())\n 151 | }\n 152 | _ => None,\n 153 | },\n 154 | }))\n 155 | } else {\n 156 | bail!(\"Github Git URL doesn't have a valid path\")\n 157 | }\n 158 | } else {\n 159 | Ok(Crate::Git(GitRepo {\n 160 | url: url.to_string(),\n 161 | sha: match rev {\n 162 | GitReference::Rev(rev)\n 163 | if rev.chars().all(|c| c.is_ascii_hexdigit()) =>\n 164 | {\n 165 | Some(rev.to_string())\n 166 | }\n 167 | _ => None,\n 168 | },\n 169 | }))\n 170 | }\n 171 | } else {\n 172 | bail!(\"Package Id with SourceKind Git should have a URL\")\n 173 | }\n 174 | }\n 175 | Some(SourceKind::Path) => {\n 176 | if let Some(url) = package_id.url() {\n 177 | Ok(Crate::Path(url.path().to_string()))\n 178 | } else {\n 179 | bail!(\"Package Id with SourceKind Path should have a URL\")\n 180 | }\n 181 | }\n 182 | Some(SourceKind::Registry | SourceKind::SparseRegistry) => {\n 183 | Ok(Crate::Registry(RegistryCrate {\n 184 | name: package_id.name().into(),\n 185 | version: package_id\n 186 | .version()\n 187 | .ok_or_else(|| anyhow!(\"missing version for registry crate\"))?\n 188 | .to_smolstr(),\n 189 | }))\n 190 | }\n 191 | Some(SourceKind::Directory) => {\n 192 | bail!(\"Unsupported SourceKind Directory\")\n 193 | }\n 194 | None => match package_id.url() {\n 195 | None => Ok(Crate::Registry(RegistryCrate {\n 196 | name: package_id.name().into(),\n 197 | version: package_id\n 198 | .version()\n 199 | .ok_or_else(|| anyhow!(\"missing version for registry crate\"))?\n 200 | .to_smolstr(),\n 201 | })),\n 202 | Some(url) => match url.scheme() {\n 203 | \"http\" | \"https\" | \"git\" | \"ssh\" => {\n 204 | if url.domain() == Some(\"github.com\") {\n 205 | if let Some(mut path) = url.path_segments() {\n 206 | let Some(org) = path.next() else {\n 207 | bail!(\"Github URL path is too short\")\n 208 | };\n 209 | \n 210 | let Some(repo_name) = path.next() else {\n 211 | bail!(\"Github URL path is too short\")\n 212 | };\n 213 | \n 214 | Ok(Crate::GitHub(GitHubRepo {\n 215 | org: org.to_string(),\n 216 | name: repo_name.trim_end_matches(\".git\").to_string(),\n 217 | sha: None,\n 218 | }))\n 219 | } else {\n 220 | bail!(\"Github Git URL doesn't have a valid path\")\n 221 | }\n 222 | } else {\n 223 | Ok(Crate::Git(GitRepo {\n 224 | url: url.to_string(),\n 225 | sha: None,\n 226 | }))\n 227 | }\n 228 | }\n 229 | \"file\" => Ok(Crate::Path(url.path().to_string())),\n 230 | other => {\n 231 | bail!(format!(\"Unsuported Protocol: {other}\"))\n 232 | }\n 233 | },\n 234 | },\n 235 | }\n 236 | }\n 237 | }","lines":164,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/crates/mod.rs#L74-L237"}]},{"id":"ARCH_6","type":"architecture","type_label":"Architecture","severity":"low","easy":false,"description":"src/report/mod.rs is a 1071-line module mixing many responsibilities: data models (CrateResult, Comparison, BuildTestResult), crates.io index querying (get_crate_version_status), path/url/name formatting, the comparison state machine, report orchestration (gen/generate_report/write_logs), the ReportWriter trait, and two writer impls (FileWriter, DummyWriter). The non-test code is ~650 lines. This concentration makes the comparison logic, network I/O, and filesystem writer hard to test/evolve independently.","fix":"Split into submodules: e.g. `model.rs` (CrateResult/Comparison), `index.rs` (get_crate_version_status), `paths.rs` (crate_to_* and sanitization), `writer.rs` (ReportWriter + FileWriter), keeping mod.rs as orchestration.","locations":[{"ref":"src/report/mod.rs:1-650","code":" 1 | use crate::config::Config;\n 2 | use crate::crates::Crate;\n 3 | use crate::dirs::WORK_DIR;\n 4 | use crate::experiments::Experiment;\n 5 | use crate::prelude::*;\n 6 | use crate::report::analyzer::{analyze_report, ReportConfig, ToolchainSelect};\n 7 | use crate::results::{EncodedLog, EncodingType, FailureReason, ReadResults, TestResult};\n 8 | use crate::toolchain::Toolchain;\n 9 | use crate::utils;\n 10 | use crates_index::SparseIndex;\n 11 | use mime::Mime;\n 12 | use percent_encoding::{utf8_percent_encode, AsciiSet};\n 13 | use std::borrow::Cow;\n 14 | #[cfg(test)]\n 15 | use std::collections::HashMap;\n 16 | use std::fmt::{self, Display};\n 17 | use std::fs;\n 18 | use std::path::{Path, PathBuf};\n 19 | \n 20 | mod analyzer;\n 21 | mod archives;\n 22 | mod display;\n 23 | mod html;\n 24 | mod markdown;\n 25 | mod s3;\n 26 | \n 27 | pub use self::display::{Color, ResultColor, ResultName};\n 28 | pub use self::s3::{S3Prefix, S3Writer};\n 29 | pub use analyzer::TestResults;\n 30 | \n 31 | pub(crate) const REPORT_ENCODE_SET: AsciiSet = percent_encoding::CONTROLS\n 32 | .add(b' ')\n 33 | .add(b'\"')\n 34 | .add(b'#')\n 35 | .add(b'<')\n 36 | .add(b'>')\n 37 | .add(b'`')\n 38 | .add(b'?')\n 39 | .add(b'{')\n 40 | .add(b'}')\n 41 | .add(b'+');\n 42 | \n 43 | #[derive(Serialize, Deserialize)]\n 44 | pub struct RawTestResults {\n 45 | pub crates: Vec<CrateResult>,\n 46 | }\n 47 | \n 48 | #[cfg_attr(test, derive(Debug))]\n 49 | #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n 50 | pub struct CrateResult {\n 51 | name: String,\n 52 | url: String,\n 53 | krate: Crate,\n 54 | #[serde(skip_serializing_if = \"Option::is_none\")]\n 55 | status: Option<CrateVersionStatus>,\n 56 | pub res: Comparison,\n 57 | runs: [Option<BuildTestResult>; 2],\n 58 | }\n 59 | \n 60 | string_enum!(enum CrateVersionStatus {\n 61 | Yanked => \"yanked\",\n 62 | Outdated => \"outdated\",\n 63 | UpToDate => \"\",\n 64 | MissingFromIndex => \"missing from the index\",\n 65 | });\n 66 | \n 67 | string_enum!(pub enum Comparison {\n 68 | Regressed => \"regressed\",\n 69 | Fixed => \"fixed\",\n 70 | Skipped => \"skipped\",\n 71 | Unknown => \"unknown\",\n 72 | Error => \"error\",\n 73 | Broken => \"broken\",\n 74 | PrepareFail => \"prepare-fail\",\n 75 | SameBuildFail => \"build-fail\",\n 76 | SameTestFail => \"test-fail\",\n 77 | SameTestSkipped => \"test-skipped\",\n 78 | SameTestPass => \"test-pass\",\n 79 | SpuriousRegressed => \"spurious-regressed\",\n 80 | SpuriousFixed => \"spurious-fixed\",\n 81 | });\n 82 | \n 83 | impl Comparison {\n 84 | pub fn show_in_summary(self) -> bool {\n 85 | match self {\n 86 | Comparison::Regressed\n 87 | | Comparison::Fixed\n 88 | | Comparison::Unknown\n 89 | | Comparison::Error\n 90 | | Comparison::SpuriousRegressed\n 91 | | Comparison::SpuriousFixed\n 92 | | Comparison::PrepareFail => true,\n 93 | Comparison::Skipped\n 94 | | Comparison::Broken\n 95 | | Comparison::SameBuildFail\n 96 | | Comparison::SameTestFail\n 97 | | Comparison::SameTestSkipped\n 98 | | Comparison::SameTestPass => false,\n 99 | }\n 100 | }\n 101 | \n 102 | pub fn report_config(self) -> ReportConfig {\n 103 | match self {\n 104 | Comparison::Regressed => ReportConfig::Complete(ToolchainSelect::End),\n 105 | Comparison::Fixed => ReportConfig::Complete(ToolchainSelect::Start),\n 106 | Comparison::Unknown\n 107 | | Comparison::Error\n 108 | | Comparison::SpuriousRegressed\n 109 | | Comparison::SpuriousFixed\n 110 | | Comparison::Skipped\n 111 | | Comparison::Broken\n 112 | | Comparison::PrepareFail\n 113 | | Comparison::SameBuildFail\n 114 | | Comparison::SameTestFail\n 115 | | Comparison::SameTestSkipped\n 116 | | Comparison::SameTestPass => ReportConfig::Simple,\n 117 | }\n 118 | }\n 119 | }\n 120 | \n 121 | #[cfg_attr(test, derive(Debug))]\n 122 | #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n 123 | struct BuildTestResult {\n 124 | res: TestResult,\n 125 | log: String,\n 126 | }\n 127 | \n 128 | /// The type of sanitization required for a string.\n 129 | #[derive(Debug, Clone, Copy)]\n 130 | enum SanitizationContext {\n 131 | Url,\n 132 | Path,\n 133 | }\n 134 | \n 135 | impl SanitizationContext {\n 136 | fn sanitize(self, input: &str) -> Cow<'_, str> {\n 137 | match self {\n 138 | SanitizationContext::Url => utf8_percent_encode(input, &REPORT_ENCODE_SET).into(),\n 139 | \n 140 | SanitizationContext::Path => {\n 141 | utf8_percent_encode(input, &utils::FILENAME_ENCODE_SET).into()\n 142 | }\n 143 | }\n 144 | }\n 145 | }\n 146 | \n 147 | fn crate_to_path_fragment(\n 148 | toolchain: &Toolchain,\n 149 | krate: &Crate,\n 150 | dest: SanitizationContext,\n 151 | ) -> PathBuf {\n 152 | let mut path = PathBuf::new();\n 153 | path.push(dest.sanitize(&toolchain.to_string()).into_owned());\n 154 | \n 155 | match *krate {\n 156 | Crate::Registry(ref details) => {\n 157 | path.push(\"reg\");\n 158 | \n 159 | let name = format!(\"{}-{}\", details.name, details.version);\n 160 | path.push(dest.sanitize(&name).into_owned());\n 161 | }\n 162 | Crate::GitHub(ref repo) => {\n 163 | path.push(\"gh\");\n 164 | \n 165 | let name = format!(\"{}.{}\", repo.org, repo.name);\n 166 | path.push(dest.sanitize(&name).into_owned());\n 167 | }\n 168 | Crate::Local(ref name) => {\n 169 | path.push(\"local\");\n 170 | path.push(name);\n 171 | }\n 172 | Crate::Path(ref krate_path) => {\n 173 | path.push(\"path\");\n 174 | path.push(dest.sanitize(krate_path).into_owned());\n 175 | }\n 176 | Crate::Git(ref repo) => {\n 177 | path.push(\"git\");\n 178 | path.push(dest.sanitize(&repo.url).into_owned());\n 179 | }\n 180 | }\n 181 | \n 182 | path\n 183 | }\n 184 | \n 185 | fn get_crate_version_status(\n 186 | index: &SparseIndex,\n 187 | krate: &Crate,\n 188 | ) -> Fallible<Option<CrateVersionStatus>> {\n 189 | if let Crate::Registry(krate) = krate {\n 190 | let req = index.make_cache_request(&krate.name)?;\n 191 | let req: reqwest::blocking::Request = req\n 192 | .body(reqwest::blocking::Body::from(vec![]))\n 193 | .unwrap()\n 194 | .try_into()?;\n 195 | \n 196 | let resp = crate::utils::http::HTTP_SYNC_CLIENT.execute(req)?;\n 197 | let mut builder = crates_index::http::Response::builder()\n 198 | .status(resp.status())\n 199 | .version(resp.version());\n 200 | builder\n 201 | .headers_mut()\n 202 | .unwrap()\n 203 | .extend(resp.headers().iter().map(|(k, v)| (k.clone(), v.clone())));\n 204 | let body = resp.bytes()?;\n 205 | let res = builder.body(Vec::from(body)).unwrap();\n 206 | \n 207 | let Some(index_krate) = index.parse_cache_response(&krate.name, res, false)? else {\n 208 | bail!(\"crate {krate:?} not found in index\")\n 209 | };\n 210 | \n 211 | let outdated = index_krate.most_recent_version().version() != krate.version;\n 212 | \n 213 | for version in index_krate.versions().iter().rev() {\n 214 | // Check if the tested version is yanked\n 215 | if version.version() == krate.version {\n 216 | if version.is_yanked() {\n 217 | return Ok(Some(CrateVersionStatus::Yanked));\n 218 | } else if outdated {\n 219 | return Ok(Some(CrateVersionStatus::Outdated));\n 220 | } else {\n 221 | return Ok(Some(CrateVersionStatus::UpToDate));\n 222 | }\n 223 | }\n 224 | }\n 225 | \n 226 | bail!(\"crate version {krate:?} not found in sparse index\");\n 227 | } else {\n 228 | // we do not check versions for other crates\n 229 | Ok(None)\n 230 | }\n 231 | }\n 232 | \n 233 | pub fn generate_report<DB: ReadResults>(\n 234 | db: &DB,\n 235 | config: &Config,\n 236 | ex: &Experiment,\n 237 | crates: &[Crate],\n 238 | ) -> Fallible<RawTestResults> {\n 239 | let mut crates = crates.to_vec();\n 240 | let index = SparseIndex::with_path(\n 241 | WORK_DIR.join(\"crates.io-sparse-index\"),\n 242 | crates_index::sparse::URL,\n 243 | )?;\n 244 | //crate ids are unique so unstable sort is equivalent to stable sort but is generally faster\n 245 | crates.sort_unstable_by_key(|a| a.id());\n 246 | let res = crates\n 247 | .iter()\n 248 | .map(|krate| {\n 249 | // Any errors here will turn into unknown results\n 250 | let mut crate_results = ex.toolchains.iter().map(|tc| -> Option<BuildTestResult> {\n 251 | // Convert errors to None with ok()\n 252 | let res = db.load_test_result(ex, tc, krate).ok()??;\n 253 | \n 254 | Some(BuildTestResult {\n 255 | res,\n 256 | log: crate_to_path_fragment(tc, krate, SanitizationContext::Url)\n 257 | .to_str()\n 258 | .unwrap()\n 259 | .replace('\\'', \"/\"), // Normalize paths in reports generated on Windows\n 260 | })\n 261 | });\n 262 | let crate1 = crate_results.next().unwrap();\n 263 | let crate2 = crate_results.next().unwrap();\n 264 | let comp = compare(\n 265 | config,\n 266 | krate,\n 267 | crate1.as_ref().map(|b| &b.res),\n 268 | crate2.as_ref().map(|b| &b.res),\n 269 | );\n 270 | \n 271 | Ok(CrateResult {\n 272 | name: crate_to_name(krate),\n 273 | url: crate_to_url(krate),\n 274 | status: get_crate_version_status(&index, krate)\n 275 | .unwrap_or(Some(CrateVersionStatus::MissingFromIndex)),\n 276 | krate: krate.clone(),\n 277 | res: comp,\n 278 | runs: [crate1, crate2],\n 279 | })\n 280 | })\n 281 | .collect::<Fallible<Vec<_>>>()?;\n 282 | \n 283 | Ok(RawTestResults { crates: res })\n 284 | }\n 285 | \n 286 | const PROGRESS_FRACTION: usize = 50; // write progress every ~1/N crates\n 287 | \n 288 | fn write_logs<DB: ReadResults, W: ReportWriter>(\n 289 | db: &DB,\n 290 | ex: &Experiment,\n 291 | crates: &[Crate],\n 292 | dest: &W,\n 293 | config: &Config,\n 294 | ) -> Fallible<()> {\n 295 | let num_crates = crates.len();\n 296 | let progress_every = (num_crates / PROGRESS_FRACTION) + 1;\n 297 | \n 298 | let errors = std::sync::Mutex::new(vec![]);\n 299 | std::thread::scope(|s| {\n 300 | let mut channels = vec![];\n 301 | // This isn't really related to the number of cores on the system, since these threads are\n 302 | // mostly driving network-related traffic. 8 is a reasonable number to not overwhelm\n 303 | // systems while keeping things moving much faster than fully serial uploads.\n 304 | for _ in 0..8 {\n 305 | let (tx, rx) = std::sync::mpsc::sync_channel::<(PathBuf, Vec<u8>, EncodingType)>(32);\n 306 | channels.push(tx);\n 307 | let errors = &errors;\n 308 | s.spawn(move || {\n 309 | while let Ok((log_path, data, encoding)) = rx.recv() {\n 310 | if let Err(e) =\n 311 | dest.write_bytes(log_path, &data, &mime::TEXT_PLAIN_UTF_8, encoding)\n 312 | {\n 313 | errors.lock().unwrap().push(e);\n 314 | }\n 315 | }\n 316 | });\n 317 | }\n 318 | \n 319 | for (i, krate) in crates.iter().enumerate() {\n 320 | if i.is_multiple_of(progress_every) {\n 321 | info!(\"wrote logs for {i}/{num_crates} crates\")\n 322 | }\n 323 | \n 324 | if config.should_skip(krate) {\n 325 | continue;\n 326 | }\n 327 | \n 328 | for tc in &ex.toolchains {\n 329 | let log_path =\n 330 | crate_to_path_fragment(tc, krate, SanitizationContext::Path).join(\"log.txt\");\n 331 | let content = db\n 332 | .load_log(ex, tc, krate)\n 333 | .and_then(|c| c.ok_or_else(|| anyhow!(\"missing logs\")))\n 334 | .with_context(|| format!(\"failed to read log of {krate} on {tc}\"));\n 335 | let content = match content {\n 336 | Ok(c) => c,\n 337 | Err(e) => {\n 338 | utils::report_failure(&e);\n 339 | continue;\n 340 | }\n 341 | };\n 342 | \n 343 | match content {\n 344 | EncodedLog::Plain(data) => {\n 345 | channels[i % channels.len()]\n 346 | .send((log_path, data, EncodingType::Plain))\n 347 | .unwrap();\n 348 | }\n 349 | EncodedLog::Gzip(data) => {\n 350 | channels[i % channels.len()]\n 351 | .send((log_path, data, EncodingType::Gzip))\n 352 | .unwrap();\n 353 | }\n 354 | }\n 355 | }\n 356 | }\n 357 | });\n 358 | \n 359 | let mut errors = errors.into_inner().unwrap();\n 360 | for error in errors.iter() {\n 361 | utils::report_failure(&anyhow!(\"Logging upload failed: {:?}\", error));\n 362 | }\n 363 | if !errors.is_empty() {\n 364 | return Err(errors.remove(0));\n 365 | }\n 366 | \n 367 | Ok(())\n 368 | }\n 369 | \n 370 | pub fn gen<DB: ReadResults, W: ReportWriter + Display>(\n 371 | db: &DB,\n 372 | ex: &Experiment,\n 373 | crates: &[Crate],\n 374 | dest: &W,\n 375 | config: &Config,\n 376 | output_templates: bool,\n 377 | ) -> Fallible<TestResults> {\n 378 | let raw = generate_report(db, config, ex, crates)?;\n 379 | \n 380 | info!(\"writing results to {dest}\");\n 381 | info!(\"writing metadata\");\n 382 | dest.write_string(\n 383 | \"results.json\",\n 384 | serde_json::to_string(&raw)?.into(),\n 385 | &mime::APPLICATION_JSON,\n 386 | )?;\n 387 | dest.write_string(\n 388 | \"config.json\",\n 389 | serde_json::to_string(&ex)?.into(),\n 390 | &mime::APPLICATION_JSON,\n 391 | )?;\n 392 | dest.write_string(\n 393 | \"retry-regressed-list.txt\",\n 394 | gen_retry_list(&raw).into(),\n 395 | &mime::TEXT_PLAIN_UTF_8,\n 396 | )?;\n 397 | \n 398 | let res = analyze_report(raw);\n 399 | info!(\"writing archives\");\n 400 | let available_archives = archives::write_logs_archives(db, ex, crates, dest, config)?;\n 401 | info!(\"writing html files\");\n 402 | html::write_html_report(\n 403 | ex,\n 404 | crates.len(),\n 405 | &res,\n 406 | available_archives,\n 407 | dest,\n 408 | output_templates,\n 409 | )?;\n 410 | info!(\"writing markdown files\");\n 411 | markdown::write_markdown_report(ex, crates.len(), &res, dest, output_templates)?;\n 412 | info!(\"writing logs\");\n 413 | write_logs(db, ex, crates, dest, config)?;\n 414 | \n 415 | Ok(res)\n 416 | }\n 417 | \n 418 | pub(crate) const SPURIOUS_RETRY: &[Comparison] =\n 419 | &[Comparison::SpuriousRegressed, Comparison::PrepareFail];\n 420 | \n 421 | /// Generates a list of regressed crate names that can be passed to crater via\n 422 | /// `crates=list:...` to retry those.\n 423 | fn gen_retry_list(res: &RawTestResults) -> String {\n 424 | use std::fmt::Write;\n 425 | \n 426 | let mut out = String::new();\n 427 | \n 428 | let regressed_crates = res\n 429 | .crates\n 430 | .iter()\n 431 | .filter(|crate_res| {\n 432 | crate_res.res == Comparison::Regressed || SPURIOUS_RETRY.contains(&crate_res.res)\n 433 | })\n 434 | .map(|crate_res| &crate_res.krate);\n 435 | \n 436 | for krate in regressed_crates {\n 437 | match krate {\n 438 | Crate::Registry(details) => writeln!(out, \"{}\", details.name).unwrap(),\n 439 | Crate::GitHub(repo) => writeln!(out, \"{}/{}\", repo.org, repo.name).unwrap(),\n 440 | Crate::Local(_) | Crate::Git(_) | Crate::Path(_) => {}\n 441 | }\n 442 | }\n 443 | \n 444 | out\n 445 | }\n 446 | \n 447 | fn crate_to_name(c: &Crate) -> String {\n 448 | match *c {\n 449 | Crate::Registry(ref details) => format!(\"{}-{}\", details.name, details.version),\n 450 | Crate::GitHub(ref repo) => {\n 451 | if let Some(ref sha) = repo.sha {\n 452 | format!(\"{}.{}.{sha}\", repo.org, repo.name)\n 453 | } else {\n 454 | format!(\"{}.{}\", repo.org, repo.name)\n 455 | }\n 456 | }\n 457 | Crate::Local(ref name) => format!(\"{name} (local)\"),\n 458 | Crate::Path(ref path) => utf8_percent_encode(path, &REPORT_ENCODE_SET).to_string(),\n 459 | Crate::Git(ref repo) => {\n 460 | if let Some(ref sha) = repo.sha {\n 461 | format!(\n 462 | \"{}.{}\",\n 463 | utf8_percent_encode(&repo.url, &REPORT_ENCODE_SET),\n 464 | sha\n 465 | )\n 466 | } else {\n 467 | utf8_percent_encode(&repo.url, &REPORT_ENCODE_SET).to_string()\n 468 | }\n 469 | }\n 470 | }\n 471 | }\n 472 | \n 473 | fn crate_to_url(c: &Crate) -> String {\n 474 | match *c {\n 475 | Crate::Registry(ref details) => format!(\n 476 | \"https://crates.io/crates/{}/{}\",\n 477 | details.name, details.version\n 478 | ),\n 479 | Crate::GitHub(ref repo) => {\n 480 | if let Some(ref sha) = repo.sha {\n 481 | format!(\"https://github.com/{}/{}/tree/{sha}\", repo.org, repo.name)\n 482 | } else {\n 483 | format!(\"https://github.com/{}/{}\", repo.org, repo.name)\n 484 | }\n 485 | }\n 486 | Crate::Local(ref name) => {\n 487 | format!(\"{}/tree/HEAD/local-crates/{}\", crate::CRATER_REPO_URL, name)\n 488 | }\n 489 | Crate::Path(ref path) => utf8_percent_encode(path, &REPORT_ENCODE_SET).to_string(),\n 490 | Crate::Git(ref repo) => repo.url.clone(),\n 491 | }\n 492 | }\n 493 | \n 494 | fn compare(\n 495 | config: &Config,\n 496 | krate: &Crate,\n 497 | r1: Option<&TestResult>,\n 498 | r2: Option<&TestResult>,\n 499 | ) -> Comparison {\n 500 | use crate::results::TestResult::*;\n 501 | \n 502 | match (r1, r2) {\n 503 | (Some(res1), Some(res2)) => match (res1, res2) {\n 504 | // ICE -> ICE is not a regression, but anything else to an ICE is.\n 505 | (BuildFail(FailureReason::ICE), BuildFail(FailureReason::ICE)) => {\n 506 | Comparison::SameBuildFail\n 507 | }\n 508 | (BuildFail(_), BuildFail(FailureReason::ICE)) => Comparison::Regressed,\n 509 | \n 510 | // same\n 511 | (BuildFail(_), BuildFail(_)) => Comparison::SameBuildFail,\n 512 | (TestSkipped, TestSkipped) => Comparison::SameTestSkipped,\n 513 | (TestFail(_), TestFail(_)) => Comparison::SameTestFail,\n 514 | (TestPass, TestPass) => Comparison::SameTestPass,\n 515 | \n 516 | // (spurious) fixed\n 517 | (BuildFail(reason), TestSkipped | TestFail(_) | TestPass)\n 518 | | (TestFail(reason), TestPass) => {\n 519 | if reason.is_spurious() {\n 520 | Comparison::SpuriousFixed\n 521 | } else {\n 522 | Comparison::Fixed\n 523 | }\n 524 | }\n 525 | \n 526 | // (spurious) regressed\n 527 | (TestSkipped | TestFail(_) | TestPass, BuildFail(reason))\n 528 | | (TestPass, TestFail(reason)) => {\n 529 | if reason.is_spurious() {\n 530 | Comparison::SpuriousRegressed\n 531 | } else {\n 532 | Comparison::Regressed\n 533 | }\n 534 | }\n 535 | \n 536 | (Skipped, _) | (_, Skipped) => Comparison::Skipped,\n 537 | (BrokenCrate(_), _) | (_, BrokenCrate(_)) => Comparison::Broken,\n 538 | (PrepareFail(_), _) | (_, PrepareFail(_)) => Comparison::PrepareFail,\n 539 | (Error, _) | (_, Error) => Comparison::Error,\n 540 | (TestFail(_) | TestPass, TestSkipped) | (TestSkipped, TestFail(_) | TestPass) => {\n 541 | panic!(\"can't compare {res1} and {res2}\");\n 542 | }\n 543 | },\n 544 | _ if config.should_skip(krate) => Comparison::Skipped,\n 545 | _ => Comparison::Unknown,\n 546 | }\n 547 | }\n 548 | \n 549 | pub trait ReportWriter: Send + Sync {\n 550 | fn write_bytes<P: AsRef<Path>>(\n 551 | &self,\n 552 | path: P,\n 553 | b: &[u8],\n 554 | mime: &Mime,\n 555 | encoding_type: EncodingType,\n 556 | ) -> Fallible<()>;\n 557 | fn write_string<P: AsRef<Path>>(&self, path: P, s: Cow<str>, mime: &Mime) -> Fallible<()>;\n 558 | }\n 559 | \n 560 | pub struct FileWriter(PathBuf);\n 561 | \n 562 | impl FileWriter {\n 563 | pub fn create(dest: PathBuf) -> Fallible<FileWriter> {\n 564 | fs::create_dir_all(&dest)?;\n 565 | Ok(FileWriter(dest))\n 566 | }\n 567 | fn create_prefix(&self, path: &Path) -> Fallible<()> {\n 568 | if let Some(parent) = path.parent() {\n 569 | fs::create_dir_all(self.0.join(parent))?;\n 570 | }\n 571 | Ok(())\n 572 | }\n 573 | }\n 574 | \n 575 | impl ReportWriter for FileWriter {\n 576 | fn write_bytes<P: AsRef<Path>>(\n 577 | &self,\n 578 | path: P,\n 579 | b: &[u8],\n 580 | _: &Mime,\n 581 | _: EncodingType,\n 582 | ) -> Fallible<()> {\n 583 | self.create_prefix(path.as_ref())?;\n 584 | fs::write(self.0.join(path.as_ref()), b)?;\n 585 | Ok(())\n 586 | }\n 587 | \n 588 | fn write_string<P: AsRef<Path>>(&self, path: P, s: Cow<str>, _: &Mime) -> Fallible<()> {\n 589 | self.create_prefix(path.as_ref())?;\n 590 | fs::write(self.0.join(path.as_ref()), s.as_ref().as_bytes())?;\n 591 | Ok(())\n 592 | }\n 593 | }\n 594 | \n 595 | impl Display for FileWriter {\n 596 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n 597 | self.0.display().fmt(f)\n 598 | }\n 599 | }\n 600 | \n 601 | #[cfg(test)]\n 602 | #[derive(Default)]\n 603 | pub struct DummyWriter {\n 604 | results: std::sync::Mutex<HashMap<(PathBuf, Mime), Vec<u8>>>,\n 605 | }\n 606 | \n 607 | #[cfg(test)]\n 608 | impl DummyWriter {\n 609 | pub fn get<P: AsRef<Path>>(&self, path: P, mime: &Mime) -> Vec<u8> {\n 610 | self.results\n 611 | .lock()\n 612 | .unwrap()\n 613 | .get(&(path.as_ref().to_path_buf(), mime.clone()))\n 614 | .unwrap()\n 615 | .clone()\n 616 | }\n 617 | }\n 618 | \n 619 | #[cfg(test)]\n 620 | impl ReportWriter for DummyWriter {\n 621 | fn write_bytes<P: AsRef<Path>>(\n 622 | &self,\n 623 | path: P,\n 624 | b: &[u8],\n 625 | mime: &Mime,\n 626 | _: EncodingType,\n 627 | ) -> Fallible<()> {\n 628 | self.results\n 629 | .lock()\n 630 | .unwrap()\n 631 | .insert((path.as_ref().to_path_buf(), mime.clone()), b.to_vec());\n 632 | Ok(())\n 633 | }\n 634 | \n 635 | fn write_string<P: AsRef<Path>>(&self, path: P, s: Cow<str>, mime: &Mime) -> Fallible<()> {\n 636 | self.results.lock().unwrap().insert(\n 637 | (path.as_ref().to_path_buf(), mime.clone()),\n 638 | s.bytes().collect(),\n 639 | );\n 640 | Ok(())\n 641 | }\n 642 | }\n 643 | \n 644 | #[cfg(test)]\n 645 | impl Display for DummyWriter {\n 646 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n 647 | write!(f, \":dummy:\")\n 648 | }\n 649 | }\n 650 | ","lines":650,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/report/mod.rs#L1-L650"}]},{"id":"ARCH_7","type":"architecture","type_label":"Architecture","severity":"low","easy":false,"description":"Data is cloned and wrapped in two ways: a `Mutex<Data>` (mutex) passed only to the agent-api routes, plus an `Arc<Data>` for everything else, plus the original `data` used to spawn workers. Having both a shared-immutable Arc<Data> and a separate Mutex<Data> clone of the same struct is confusing and error-prone: mutations through the mutex are invisible to the Arc copy and vice versa, since they are independent clones.","fix":"Clarify ownership: avoid cloning Data into both an Arc and a Mutex; share a single Arc and put only the genuinely-mutable sub-state behind interior mutability (as agents/uncompleted_cache already are).","locations":[{"ref":"src/server/mod.rs:104-132","code":" 104 | let mutex = Arc::new(Mutex::new(data.clone()));\n 105 | \n 106 | data.reports_worker.spawn(data.clone(), github_data.clone());\n 107 | info!(\"spawned reports worker...\");\n 108 | cronjobs::spawn(data.clone());\n 109 | \n 110 | info!(\"running server on {bind}...\");\n 111 | \n 112 | let data = Arc::new(data);\n 113 | let github_data = github_data.map(Arc::new);\n 114 | \n 115 | let record_progress_worker = data.record_progress_worker.clone();\n 116 | let routes = warp::any()\n 117 | .and(warp::any().map(move || record_progress_worker.clone().start_request()))\n 118 | .and(\n 119 | warp::any()\n 120 | .and(\n 121 | warp::path(\"webhooks\")\n 122 | .and(routes::webhooks::routes(data.clone(), github_data.clone())),\n 123 | )\n 124 | .or(warp::path(\"agent-api\").and(routes::agent::routes(\n 125 | data.clone(),\n 126 | mutex,\n 127 | github_data,\n 128 | )))\n 129 | .unify()\n 130 | .or(warp::path(\"metrics\").and(routes::metrics::routes(data.clone())))\n 131 | .unify()\n 132 | .or(routes::ui::routes(data))","lines":29,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/mod.rs#L104-L132"}]},{"id":"ARCH_8","type":"architecture","type_label":"Architecture","severity":"low","easy":false,"description":"agent.rs mixes many responsibilities in one file: warp route wiring, six endpoint handlers, a background worker thread (RecordProgressThread) with its own concurrency primitives, a RequestGuard RAII type, and two error/result adapters. The RecordProgressThread/in-flight-request machinery is a distinct concern from HTTP endpoint definitions and would be clearer in its own module.","fix":"Move RecordProgressThread, RequestGuard, and block_until_idle logic into a dedicated module, leaving agent.rs focused on route/endpoint definitions.","locations":[{"ref":"src/server/routes/agent.rs:186-344","code":" 186 | #[derive(Clone)]\n 187 | pub struct RecordProgressThread {\n 188 | // String is the worker name\n 189 | queue: Sender<ExperimentData<ProgressData>>,\n 190 | in_flight_requests: Arc<(Mutex<usize>, Condvar)>,\n 191 | }\n 192 | \n 193 | impl RecordProgressThread {\n 194 | pub fn new(\n 195 | db: crate::db::Database,\n 196 | metrics: crate::server::metrics::Metrics,\n 197 | ) -> RecordProgressThread {\n 198 | // 64 message queue, after which we start load shedding automatically.\n 199 | let (tx, rx) = crossbeam_channel::bounded(64);\n 200 | let in_flight_requests = Arc::new((Mutex::new(0), Condvar::new()));\n 201 | \n 202 | let this = RecordProgressThread {\n 203 | queue: tx,\n 204 | in_flight_requests,\n 205 | };\n 206 | let ret = this.clone();\n 207 | std::thread::Builder::new()\n 208 | .name(String::from(\"record-prog-crater\"))\n 209 | .spawn(move || loop {\n 210 | // Panics should already be logged and otherwise there's not much we\n 211 | // can/should do.\n 212 | let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {\n 213 | let result = rx.recv().unwrap();\n 214 | this.block_until_idle();\n 215 | \n 216 | let start = std::time::Instant::now();\n 217 | \n 218 | if let Some(ex) = Experiment::get(&db, &result.experiment_name).unwrap() {\n 219 | let db = DatabaseDB::new(&db);\n 220 | if let Err(e) = db.store(&ex, &result.data, EncodingType::Plain) {\n 221 | // Failing to record a result is basically fine -- this\n 222 | // just means that we'll have to re-try this job.\n 223 | log::error!(\"Failed to store result into database: {e:?}\");\n 224 | crate::utils::report_failure(&e);\n 225 | }\n 226 | \n 227 | metrics.record_completed_jobs(&ex.name, 1);\n 228 | \n 229 | if let Err(e) = db.clear_stale_records() {\n 230 | // Not a hard failure. We can continue even if we failed\n 231 | // to clear records from already completed runs...\n 232 | log::error!(\"Failed to clear stale records: {e:?}\");\n 233 | crate::utils::report_failure(&e);\n 234 | }\n 235 | \n 236 | metrics\n 237 | .crater_endpoint_time\n 238 | .with_label_values(&[\"record_progress_worker\"])\n 239 | .observe(start.elapsed().as_secs_f64());\n 240 | \n 241 | let to_metric = |f: &FailureReason| match f {\n 242 | FailureReason::Unknown => \"unknown\",\n 243 | FailureReason::OOM => \"oom\",\n 244 | FailureReason::NoSpace => \"no-space\",\n 245 | FailureReason::Timeout => \"timeout\",\n 246 | FailureReason::ICE => \"ice\",\n 247 | FailureReason::NetworkAccess => \"network-access\",\n 248 | FailureReason::Docker => \"docker\",\n 249 | FailureReason::CompilerDiagnosticChange => \"compiler-diagnostic-change\",\n 250 | FailureReason::CompilerError(_) => \"compiler-error\",\n 251 | FailureReason::DependsOn(_) => \"dependency\",\n 252 | };\n 253 | \n 254 | metrics\n 255 | .crater_progress_report\n 256 | .with_label_values(&[\n 257 | ex.name.as_str(),\n 258 | // Reduce cardinality on the error kind to reduce # of distinct\n 259 | // metrics created.\n 260 | &match &result.data.result.result {\n 261 | TestResult::BrokenCrate(r) => format!(\"broken-crate:{}\", r),\n 262 | TestResult::PrepareFail(r) => {\n 263 | format!(\"prepare-fail:{}\", to_metric(r))\n 264 | }\n 265 | TestResult::BuildFail(r) => {\n 266 | format!(\"build-fail:{}\", to_metric(r))\n 267 | }\n 268 | TestResult::TestFail(r) => {\n 269 | format!(\"test-fail:{}\", to_metric(r))\n 270 | }\n 271 | TestResult::TestSkipped => \"test-skipped\".to_owned(),\n 272 | TestResult::TestPass => \"test-pass\".to_owned(),\n 273 | TestResult::Skipped => \"skipped\".to_owned(),\n 274 | TestResult::Error => \"error\".to_owned(),\n 275 | },\n 276 | ])\n 277 | .inc();\n 278 | }\n 279 | }));\n 280 | })\n 281 | .unwrap();\n 282 | \n 283 | ret\n 284 | }\n 285 | \n 286 | pub fn block_until_idle(&self) {\n 287 | // Wait until there are zero in-flight requests.\n 288 | //\n 289 | // Note: We do **not** keep the lock here for the subsequent\n 290 | // computation. That means that if we ever observe zero, then we're\n 291 | // going to kick off the below computation; obviously requests may keep\n 292 | // coming in -- we don't want to block those requests.\n 293 | //\n 294 | // The expectation that we will see zero here also implies that\n 295 | // the server is *sometimes* idle (i.e., we are not constantly\n 296 | // processing requests at 100% load). It's not clear that's 100%\n 297 | // a valid assumption, but if we are at 100% load in terms of\n 298 | // requests coming in, that's a problem in and of itself (since\n 299 | // the majority of expected requests are record-progress, which\n 300 | // should be *very* fast now that the work for them is async and\n 301 | // offloaded to this thread).\n 302 | //\n 303 | // Ignore the mutex guard (see above).\n 304 | drop(\n 305 | self.in_flight_requests\n 306 | .1\n 307 | .wait_while(\n 308 | self.in_flight_requests\n 309 | .0\n 310 | .lock()\n 311 | .unwrap_or_else(|l| l.into_inner()),\n 312 | |g| *g != 0,\n 313 | )\n 314 | .unwrap_or_else(|g| g.into_inner()),\n 315 | );\n 316 | }\n 317 | \n 318 | pub fn start_request(&self) -> RequestGuard {\n 319 | *self\n 320 | .in_flight_requests\n 321 | .0\n 322 | .lock()\n 323 | .unwrap_or_else(|l| l.into_inner()) += 1;\n 324 | RequestGuard {\n 325 | thread: self.clone(),\n 326 | }\n 327 | }\n 328 | }\n 329 | \n 330 | pub struct RequestGuard {\n 331 | thread: RecordProgressThread,\n 332 | }\n 333 | \n 334 | impl Drop for RequestGuard {\n 335 | fn drop(&mut self) {\n 336 | *self\n 337 | .thread\n 338 | .in_flight_requests\n 339 | .0\n 340 | .lock()\n 341 | .unwrap_or_else(|l| l.into_inner()) -= 1;\n 342 | self.thread.in_flight_requests.1.notify_one();\n 343 | }\n 344 | }","lines":159,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/routes/agent.rs#L186-L344"}]},{"id":"OTHER_1","type":"other","type_label":"Other","severity":"low","easy":false,"description":"Code migrations stringify_toolchain_names and stringify_crate_names disable foreign_keys with `PRAGMA foreign_keys = OFF;` inside a transaction. Per SQLite docs, PRAGMA foreign_keys is a no-op inside a transaction (it can only be changed when no transaction is active), so these statements do not actually disable FK enforcement. The UPDATEs happen to be safe here only because they rewrite both sides, but the developers' apparent intent (suspend FK checks during the rewrite) is silently not honored, which is a latent trap if a future migration relies on it.","fix":"Remove the misleading in-transaction PRAGMA foreign_keys statements, or use `PRAGMA defer_foreign_keys = ON;` (which does work within a transaction) if deferred FK checking is actually needed.","locations":[{"ref":"src/db/migrations.rs:162-175","code":" 162 | t.execute(\"PRAGMA foreign_keys = OFF;\", [])?;\n 163 | t.execute(\n 164 | &format!(\"UPDATE experiments SET toolchain_start = {fn_name}(toolchain_start);\",),\n 165 | [],\n 166 | )?;\n 167 | t.execute(\n 168 | &format!(\"UPDATE experiments SET toolchain_end = {fn_name}(toolchain_end);\",),\n 169 | [],\n 170 | )?;\n 171 | t.execute(\n 172 | &format!(\"UPDATE results SET toolchain = {fn_name}(toolchain);\"),\n 173 | [],\n 174 | )?;\n 175 | t.execute(\"PRAGMA foreign_keys = ON;\", [])?;","lines":14,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/db/migrations.rs#L162-L175"},{"ref":"src/db/migrations.rs:344-351","code":" 344 | t.execute(\"PRAGMA foreign_keys = OFF;\", [])?;\n 345 | t.execute(\n 346 | &format!(\"UPDATE experiment_crates SET crate = {fn_name}(crate);\"),\n 347 | [],\n 348 | )?;\n 349 | t.execute(&format!(\"UPDATE results SET crate = {fn_name}(crate);\"), [])?;\n 350 | t.execute(&format!(\"UPDATE crates SET crate = {fn_name}(crate);\"), [])?;\n 351 | t.execute(\"PRAGMA foreign_keys = ON;\", [])?;","lines":8,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/db/migrations.rs#L344-L351"}]},{"id":"OTHER_2","type":"other","type_label":"Other","severity":"low","easy":false,"description":"DiskSpaceWatcher::worker_idle with permanent=true increments cache_in_use.0 but never decrements it (no matching guard.0 -= 1). This is intentional for shutdown, but it permanently inflates the idle count: if a worker exits permanently while others are still running, a subsequent clean() waits for c.0 == worker_count and may now be satisfied by the leaked permanent count, allowing a purge while a live worker still holds caches in use. The 'global is-the-cache-in-use synchronization point' comment relies on the count reflecting only currently-idle workers.","fix":"On permanent idle, decrement worker_count (or track exited workers) so the clean() barrier compares against the number of still-live workers rather than letting a permanently-incremented counter satisfy the barrier.","locations":[{"ref":"src/runner/worker.rs:441-453","code":" 441 | pub(super) fn worker_idle(&self, permanent: bool) {\n 442 | log::trace!(\"worker at idle point\");\n 443 | let mut guard = self.cache_in_use.lock().unwrap();\n 444 | log::trace!(\"worker declared idle\");\n 445 | // note that we're not running right now.\n 446 | guard.0 += 1;\n 447 | self.cache_waiter.notify_all();\n 448 | if !permanent {\n 449 | let mut guard = self.cache_waiter.wait_while(guard, |c| c.1).unwrap();\n 450 | // Then set ourselves as running.\n 451 | guard.0 -= 1;\n 452 | self.cache_waiter.notify_all();\n 453 | }","lines":13,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/worker.rs#L441-L453"},{"ref":"src/runner/worker.rs:158-173","code":" 158 | pub(super) fn run(&self) -> Fallible<()> {\n 159 | loop {\n 160 | let krate = if let Some(next) = (self.next_crate)()? {\n 161 | next\n 162 | } else {\n 163 | // Backoff from calling the server again, to reduce load when we're spinning until\n 164 | // the next experiment is ready.\n 165 | std::thread::sleep(Duration::from_secs(rand::random_range(60..120)));\n 166 | \n 167 | if let Some(cb) = self.between_crates.get() {\n 168 | cb(true);\n 169 | }\n 170 | \n 171 | // We're done if no more crates left.\n 172 | return Ok(());\n 173 | };","lines":16,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/runner/worker.rs#L158-L173"}]},{"id":"OTHER_3","type":"other","type_label":"Other","severity":"low","easy":false,"description":"GitHubApi stores the bot API token in a plain String and embeds it into the Authorization header on every request; GitHubApi derives Clone but not a redacting Debug. The Tokens/BotTokens structs derive Debug, so the api_token and webhooks_secret can be logged in full if a Tokens value is ever {:?}-formatted (e.g. in an error context), leaking secrets to logs.","fix":"Avoid deriving Debug on secret-bearing structs, or implement a custom Debug that redacts api_token/webhooks_secret/access_key/secret_key.","locations":[{"ref":"src/server/tokens.rs:8-20","code":" 8 | #[derive(Debug, Clone, Deserialize)]\n 9 | #[serde(tag = \"type\", rename_all = \"kebab-case\")]\n 10 | pub enum BucketRegion {\n 11 | S3 { region: String },\n 12 | Custom { url: String },\n 13 | }\n 14 | \n 15 | #[derive(Debug, Clone, Deserialize)]\n 16 | #[serde(rename_all = \"kebab-case\")]\n 17 | pub struct BotTokens {\n 18 | pub webhooks_secret: String,\n 19 | pub api_token: String,\n 20 | }","lines":13,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/tokens.rs#L8-L20"},{"ref":"src/server/github.rs:28-38","code":" 28 | #[derive(Clone)]\n 29 | pub struct GitHubApi {\n 30 | token: String,\n 31 | }\n 32 | \n 33 | impl GitHubApi {\n 34 | pub fn new(tokens: &BotTokens) -> Self {\n 35 | GitHubApi {\n 36 | token: tokens.api_token.clone(),\n 37 | }\n 38 | }","lines":11,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/server/github.rs#L28-L38"}]},{"id":"OTHER_4","type":"other","type_label":"Other","severity":"negligible","easy":false,"description":"Database::open_at and Database::temp create the directory `WORK_DIR` (std::fs::create_dir_all(&*WORK_DIR)) even though the database file path passed in may be entirely outside WORK_DIR. open_at takes an arbitrary `path` but still mkdir's WORK_DIR rather than path.parent(), so opening a DB at a custom location silently also creates the global work dir and does not ensure the target's parent directory exists.","fix":"Create the parent directory of the actual `path` argument (path.parent()) in open_at/temp instead of (or in addition to) WORK_DIR.","locations":[{"ref":"src/db/mod.rs:100-108","code":" 100 | pub fn open_at(path: &Path) -> Fallible<Self> {\n 101 | std::fs::create_dir_all(&*WORK_DIR)?;\n 102 | Database::new(\n 103 | SqliteConnectionManager {\n 104 | file: path.to_owned(),\n 105 | },\n 106 | None,\n 107 | )\n 108 | }","lines":9,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/db/mod.rs#L100-L108"},{"ref":"src/db/mod.rs:110-119","code":" 110 | #[cfg(test)]\n 111 | pub fn temp() -> Fallible<Self> {\n 112 | let tempfile = NamedTempFile::new()?;\n 113 | Database::new(\n 114 | SqliteConnectionManager {\n 115 | file: tempfile.path().to_owned(),\n 116 | },\n 117 | Some(tempfile),\n 118 | )\n 119 | }","lines":10,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/db/mod.rs#L110-L119"}]},{"id":"OTHER_5","type":"other","type_label":"Other","severity":"negligible","easy":false,"description":"test_failed_experiment sleeps for 80 real seconds (std::thread::sleep(Duration::from_secs(80))) to wait out the RUN_TIMEOUT. This makes the unit test suite take well over a minute and is wall-clock dependent / flaky on slow CI. The test-mode RUN_TIMEOUT is 1 minute but the sleep is 80s.","fix":"Inject a controllable clock or make RUN_TIMEOUT/'now' offset configurable in tests so the timeout can be simulated without sleeping 80 seconds.","locations":[{"ref":"src/experiments.rs:1028-1028","code":" 1028 | std::thread::sleep(std::time::Duration::from_secs(80)); // need to wait for at least 60 seconds for timeout to fire","lines":1,"link":"https://github.com/rust-lang/crater/blob/637f5fbb37daaf0799205cbf10f4a247abeef1c1/src/experiments.rs#L1028"}]}]</script>
<script type="application/json" id="PI">{"name":"crater","description":"","git_url":"https://github.com/rust-lang/crater.git","repo_url":"https://github.com/rust-lang/crater","commit":"637f5fbb37daaf0799205cbf10f4a247abeef1c1","commit_short":"637f5fbb37","commit_url":"https://github.com/rust-lang/crater/commit/637f5fbb37daaf0799205cbf10f4a247abeef1c1","branch":"master","generated":"2026-06-21"}</script>
<div class="app">
<div class="top">
<div>
<h1>Code Audit Report — crater</h1>
<div class="meta">Generated 2026-06-21 — <span id="stats"></span></div>
</div>
<div class="top-right">
<input type="search" id="search" placeholder="Search findings…" autocomplete="off">
<label class="group-label">Group by
<select id="groupby">
<option value="type">Type</option>
<option value="severity">Severity</option>
<option value="file">File</option>
<option value="none">None (flat)</option>
</select>
</label>
</div>
</div>
<div class="project-info"><div class="pi-name">crater</div><div class="pi-row"><a class="pi-repo-btn" href="https://github.com/rust-lang/crater" target="_blank" rel="noopener">🔗 https://github.com/rust-lang/crater</a><span><span class="pi-k">branch</span> <code>master</code></span><span><span class="pi-k">commit</span> <a href="https://github.com/rust-lang/crater/commit/637f5fbb37daaf0799205cbf10f4a247abeef1c1" target="_blank" rel="noopener"><code>637f5fbb37</code></a></span></div></div>
<div class="filterbar" id="filterbar"></div>
<div id="progress-wrap">
<div id="progress-bar"><div id="progress-fill"></div></div>
<span id="progress-text"></span>
<button class="action-btn" data-action="collapse-all">⊟ Collapse all</button>
<button class="action-btn" data-action="expand-all">⊞ Expand all</button>
<button class="action-btn" data-action="download-md">⬇ MD</button>
<button class="action-btn" data-action="download-md-short">⬇ MD Short</button>
<button class="action-btn" data-action="download-toml">⬇ TOML</button>
</div>
<div id="hidden-bar"></div>
<div id="content"></div>
</div>
<script>
(function(){
'use strict';
// Data
const FINDINGS = JSON.parse(document.getElementById('D').textContent);
const PROJECT_INFO = JSON.parse(document.getElementById('PI').textContent);
const SEVS = ['critical','high','medium','low','negligible'];
const STATUSES = ['open','fixed','wont-fix','false-positive'];
const ALL_TYPES = [...new Set(FINDINGS.map(f => f.type))].sort();
// Build lookup map for O(1) access
const FINDING_BY_ID = new Map(FINDINGS.map(f => [f.id, f]));
// Only severities actually present in the report — the filter bar hides the rest.
const PRESENT_SEVS = SEVS.filter(s => FINDINGS.some(f => (f.severity || 'medium') === s));
// Whether any finding is flagged easy — the "Quick wins" filter row is hidden otherwise.
const HAS_EASY = FINDINGS.some(f => f.easy);
const STORE_KEY = 'audit-status:' + location.href;
const STATUS_CLS = {open:'st-open', fixed:'st-fixed', 'wont-fix':'st-wont', 'false-positive':'st-fp'};
const STATUS_LBL = {open:'● open', fixed:'✓ fixed', 'wont-fix':"— won't fix", 'false-positive':'✗ false pos.'};
const STATUS_NEXT = {open:'fixed', fixed:'wont-fix', 'wont-fix':'false-positive', 'false-positive':'open'};
// State
const S = {
search: '', groupBy: 'type',
hiddenSev: new Set(), hiddenType: new Set(),
hiddenStatus: new Set(), hiddenGroup: new Set(),
collapsed: new Set(), easyOnly: false,
};
const statuses = {};
// Persistence
function loadStatuses() {
try { Object.assign(statuses, JSON.parse(localStorage.getItem(STORE_KEY) || '{}')); } catch {}
}
function saveStatuses() {
try { localStorage.setItem(STORE_KEY, JSON.stringify(statuses)); } catch {}
}
function statusOf(id) { return statuses[id] || 'open'; }
// URL hash — debounced so rapid filter changes don't thrash history
let _hashTimer = 0;
function syncHash() {
clearTimeout(_hashTimer);
_hashTimer = setTimeout(() => {
const p = new URLSearchParams();
if (S.groupBy !== 'type') p.set('g', S.groupBy);
if (S.hiddenSev.size) p.set('hs', [...S.hiddenSev].join(','));
if (S.hiddenType.size) p.set('ht', [...S.hiddenType].join(','));
if (S.hiddenStatus.size) p.set('hst', [...S.hiddenStatus].join(','));
if (S.easyOnly) p.set('e', '1');
if (S.search) p.set('q', S.search);
const str = p.toString();
history.replaceState(null, '', str ? '#' + str : location.pathname + location.search);
}, 300);
}
function loadHash() {
const p = new URLSearchParams(location.hash.slice(1));
if (p.has('g')) { S.groupBy = p.get('g'); document.getElementById('groupby').value = S.groupBy; }
if (p.has('q')) { S.search = p.get('q'); document.getElementById('search').value = S.search; }
if (p.has('hs')) p.get('hs') .split(',').filter(Boolean).forEach(v => S.hiddenSev.add(v));
if (p.has('ht')) p.get('ht') .split(',').filter(Boolean).forEach(v => S.hiddenType.add(v));
if (p.has('hst')) p.get('hst').split(',').filter(Boolean).forEach(v => S.hiddenStatus.add(v));
if (p.has('e')) S.easyOnly = p.get('e') === '1';
}
// Helpers
function esc(s) {
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function highlight(text, q) {
if (!q) return esc(text);
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')', 'gi');
return String(text).split(re).map((p, i) => i % 2 ? '<mark>' + esc(p) + '</mark>' : esc(p)).join('');
}
// Render text with ```fenced``` code blocks and `inline` backticks,
// also paragraph-splitting on blank lines. Output is HTML-safe.
function renderMd(text, q) {
const src = String(text == null ? '' : text);
const FENCE = /```[ \t]*([A-Za-z0-9_+-]*)[ \t]*\r?\n([\s\S]*?)```/g;
const parts = [];
let last = 0, m;
while ((m = FENCE.exec(src)) !== null) {
parts.push({kind: 'text', value: src.slice(last, m.index)});
parts.push({kind: 'code', value: m[2].replace(/\r?\n$/, '')});
last = m.index + m[0].length;
}
parts.push({kind: 'text', value: src.slice(last)});
function renderInline(s) {
const re = /`([^`\n]+)`/g;
let out = '', i = 0, mm;
while ((mm = re.exec(s)) !== null) {
out += highlight(s.slice(i, mm.index), q);
out += '<code class="md-inline">' + highlight(mm[1], q) + '</code>';
i = mm.index + mm[0].length;
}
out += highlight(s.slice(i), q);
return out;
}
function renderTextChunk(s) {
const paragraphs = s.split(/\r?\n[ \t]*\r?\n/);
const out = [];
for (const p of paragraphs) {
const trimmed = p.replace(/^\s+|\s+$/g, '');
if (!trimmed) continue;
out.push('<p>' + renderInline(trimmed).replace(/\r?\n/g, '<br>') + '</p>');
}
return out.join('');
}
let html = '';
for (const p of parts) {
if (p.kind === 'text') html += renderTextChunk(p.value);
else html += '<pre class="md-code">' + highlight(p.value, q) + '</pre>';
}
return html;
}
function sevCls(s) { return SEVS.includes(s) ? 'sev-'+s : 'sev-negligible'; }
function sevLbl(s) { return s ? s[0].toUpperCase()+s.slice(1) : 'Medium'; }
function typeLabel(t) { return t.replace(/_/g,' ').replace(/\b\w/g, c => c.toUpperCase()); }
// First sentence of text (up to and including the first period).
function firstSentence(text) {
const m = String(text).match(/\.(\s|$)/);
return m ? text.slice(0, text.indexOf(m[0]) + 1) : text;
}
// Build full MD report (mirrors to_markdown in Python).
function buildMdFull() {
const SORD = ['critical','high','medium','low','negligible'];
const today = new Date().toISOString().slice(0, 10);
let out = '# Code Audit Report\n\n_Generated ' + today + '_\n\n';
const typeMap = new Map();
for (const f of FINDINGS) {
if (!typeMap.has(f.type)) typeMap.set(f.type, []);
typeMap.get(f.type).push(f);
}
const sortedTypes = [...typeMap.keys()].sort();
out += '## Summary\n\n| Type | Severity | Count |\n|------|----------|-------|\n';
for (const t of sortedTypes) {
const items = typeMap.get(t);
const topSev = items.map(f => f.severity || 'medium').reduce((best, s) => {
const bi = SORD.indexOf(best), si = SORD.indexOf(s);
return (si >= 0 && (bi < 0 || si < bi)) ? s : best;
}, 'negligible');
out += '| ' + typeLabel(t) + ' | ' + topSev + ' | ' + items.length + ' |\n';
}
for (const t of sortedTypes) {
out += '\n## ' + typeLabel(t) + '\n\n';
for (const f of typeMap.get(t)) {
const sevStr = f.severity ? ' `' + f.severity.toUpperCase() + '`' : '';
const easyStr = f.easy ? ' ⚡ easy' : '';
out += '### ' + f.id + sevStr + easyStr + '\n\n';
out += '**Description:** ' + (f.description || '') + '\n\n';
if (f.locations.length) {
out += '**Locations:**\n\n';
for (const l of f.locations) {
if (l.code != null) out += '```\n' + l.ref + '\n' + l.code + '\n```\n\n';
else out += '- `' + l.ref + '` _(file not found)_\n\n';
}
}
out += '**Fix:** ' + (f.fix || '') + '\n\n---\n\n';
}
}
return out;
}
// Build MD short checklist (mirrors to_md_short in Python).
function buildMdShort() {
const lines = [];
const ids = [];
const sorted = [...FINDINGS].sort((a, b) => a.type.localeCompare(b.type));
for (const f of sorted) {
ids.push(f.id);
const desc = firstSentence(f.description || '');
const locs = f.locations.map(l => l.ref);
const locStr = locs.length ? ' - [' + locs.join(', ') + ']' : '';
const sevStr = f.severity ? ' [' + f.severity.toUpperCase() + ']' : '';
const easyStr = f.easy ? ' ⚡' : '';
lines.push('- [ ] - ' + f.id + sevStr + easyStr + ' - ' + desc + locStr);
}
lines.push('', '## IDs only', '');
ids.forEach(id => lines.push('- [ ] - ' + id));
return lines.join('\n') + '\n';
}
function downloadText(text, filename, mime) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], {type: mime || 'text/plain'}));
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
function tomlStr(s) {
return '"' + String(s == null ? '' : s)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t') + '"';
}
// Build TOML matching the original findings.toml format (re-parseable by toml_to_report.py).
function buildFindingsToml() {
function pad(key) { return (key + ' ').slice(0, 12); }
const chunks = [];
for (const f of FINDINGS) {
const lns = ['[[finding]]'];
lns.push(pad('type') + '= ' + tomlStr(f.type));
lns.push(pad('severity') + '= ' + tomlStr(f.severity || 'medium'));
if (f.easy) lns.push(pad('easy') + '= true');
lns.push(pad('description') + '= ' + tomlStr(f.description || ''));
const refs = f.locations.map(l => tomlStr(l.ref));
lns.push(pad('locations') + '= [' + refs.join(', ') + ']');
lns.push(pad('fix') + '= ' + tomlStr(f.fix || ''));
chunks.push(lns.join('\n'));
}
return chunks.join('\n\n') + '\n';
}
// Build project-info.toml from embedded PROJECT_INFO (same format as gen_project_info.py).
function buildProjectInfoToml() {
const ORDER = ['name','description','git_url','repo_url','commit','commit_short','commit_url','branch','generated'];
const pi = Object.assign({}, PROJECT_INFO);
if (!('description' in pi)) pi.description = '';
const lines = [
'# Project metadata for the code audit report (all fields optional).',
'# Hand-edit freely (e.g. fill in description); re-run the audit without --force to keep edits.',
'', '[project]'
];
for (const k of ORDER) {
if (k in pi) lines.push(k + ' = ' + tomlStr(pi[k]));
}
return lines.join('\n') + '\n';
}
// Build the GitHub-ready Markdown for one finding (mirrors findings.md).
function findingToMd(f) {
let out = '### ' + f.id + (f.severity ? ' `' + f.severity.toUpperCase() + '`' : '') + (f.easy ? ' ⚡ easy' : '') + '\n\n';
out += '**Description:** ' + (f.description || '') + '\n\n';
if (f.locations.length) {
out += '**Locations:**\n\n';
for (const l of f.locations) {
if (l.code != null) out += '```\n' + l.ref + '\n' + l.code + '\n```\n\n';
else out += '- `' + l.ref + '` _(file not found)_\n\n';
}
}
out += '**Fix:** ' + (f.fix || '') + '\n';
return out;
}
function flashBtn(btn, msg) {
btn.textContent = msg;
setTimeout(() => { btn.textContent = '⧉ Copy'; }, 1200);
}
function copyText(text, btn) {
const ok = () => flashBtn(btn, '✓ Copied');
const no = () => flashBtn(btn, '✗ Failed');
const fallback = () => {
try {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.top = '-9999px';
document.body.appendChild(ta); ta.select();
const r = document.execCommand('copy');
document.body.removeChild(ta);
r ? ok() : no();
} catch { no(); }
};
if (navigator.clipboard && navigator.clipboard.writeText)
navigator.clipboard.writeText(text).then(ok, fallback);
else fallback();
}
// Filter logic
function getKey(f) {
if (S.groupBy === 'severity') return f.severity || 'medium';
if (S.groupBy === 'file') return f.locations.length ? f.locations[0].ref.replace(/:.*/,'') : '(no location)';
if (S.groupBy === 'none') return '';
return f.type_label;
}
// Pre-compute search haystack per finding (cleared on full re-render)
const _hay = new Map();
function hayOf(f) {
if (!_hay.has(f.id)) {
_hay.set(f.id, [f.id, f.type_label, f.severity, f.description, f.fix,
f.easy ? 'easy quick win' : '',
...f.locations.map(l => l.ref)].join(' ').toLowerCase());
}
return _hay.get(f.id);
}
function matches(f) {
if (S.easyOnly && !f.easy) return false;
if (S.hiddenSev.has(f.severity || 'medium')) return false;
if (S.hiddenType.has(f.type)) return false;
const st = statusOf(f.id);
if (S.hiddenStatus.has(st)) return false;
if (S.search) {
const q = S.search.toLowerCase();
if (!hayOf(f).includes(q) && !st.includes(q)) return false;
}
return true;
}
function sortKeys(keys) {
if (S.groupBy === 'severity') {
return [...keys].sort((a, b) => {
const ai = SEVS.indexOf(a), bi = SEVS.indexOf(b);
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
});
}
return [...keys].sort();
}
// Build body HTML for an expanded card (called lazily on first expand).
function renderCardBody(f, q) {
let locs = '';
for (const l of f.locations) {
const lineInfo = l.lines != null
? ' <span class="loc-lines">(' + l.lines + ' line' + (l.lines === 1 ? '' : 's') + ')</span>'
: '';
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 type-badge" style="cursor:default">' + esc(f.type_label) + '</span>' +
(f.easy ? '<span class="badge easy-badge" style="cursor:default" title="Easy to verify & fix - no deep knowledge of the code, SDK or technology required">\u26a1 Easy</span>' : '') +
(locSummary ? '<span class="card-loc" title="' + esc(locSummary) + '">' + esc(locSummary) + '</span>' : '') +
'<button class="status-btn ' + sc2 + '" data-action="cycle-status" data-id="' + esc(f.id) + '">' + sl2 + '</button>' +
'<button class="copy-btn" data-action="copy-md" data-id="' + esc(f.id) + '" title="Copy as Markdown for GitHub">\u29c9 Copy</button>' +
'</div>' +
'<div class="card-body" data-body-id="' + esc(f.id) + '"' + (isCollapsed ? ' style="display:none"' : '') + '>' +
bodyInner +
'</div></div>'
);
}
function initFilterBar() {
const fb = document.getElementById('filterbar');
let h = '<div class="filter-row"><span class="filter-lbl">Severity:</span>';
for (const s of PRESENT_SEVS)
h += '<button class="badge ' + sevCls(s) + '" data-sev="' + s + '">' + sevLbl(s) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="sev"></button></div>';
h += '<div class="filter-row"><span class="filter-lbl">Type:</span>';
for (const t of ALL_TYPES)
h += '<button class="badge type-badge" data-type="' + esc(t) + '">' + esc(typeLabel(t)) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="type"></button></div>';
h += '<div class="filter-row"><span class="filter-lbl">Status:</span>';
for (const st of STATUSES)
h += '<button class="badge ' + (STATUS_CLS[st]||'st-open') + '" data-status="' + st + '">' + (STATUS_LBL[st]||st) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="status"></button></div>';
if (HAS_EASY)
h += '<div class="filter-row"><span class="filter-lbl">Quick wins:</span>'
+ '<button class="badge easy-badge" data-easy="1">\u26a1 Easy only</button></div>';
fb.innerHTML = h;
}
function updateToggles() {
for (const b of document.querySelectorAll('[data-sev]')) b.classList.toggle('off', S.hiddenSev.has(b.dataset.sev));
for (const b of document.querySelectorAll('[data-type]')) b.classList.toggle('off', S.hiddenType.has(b.dataset.type));
for (const b of document.querySelectorAll('[data-status]')) b.classList.toggle('off', S.hiddenStatus.has(b.dataset.status));
for (const b of document.querySelectorAll('[data-easy]')) b.classList.toggle('off', !S.easyOnly);
for (const b of document.querySelectorAll('[data-toggle-all]')) {
const k = b.dataset.toggleAll;
const set = k === 'sev' ? S.hiddenSev : k === 'type' ? S.hiddenType : S.hiddenStatus;
b.textContent = set.size === 0 ? 'Hide all' : 'Show all';
}
}
function updateProgress() {
const total = FINDINGS.length;
const done = FINDINGS.filter(f => statusOf(f.id) !== 'open').length;
const pct = total ? Math.round(done / total * 100) : 0;
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('progress-text').textContent = done + ' / ' + total + ' resolved (' + pct + '%)';
}
// Surgical toggle: flip one card without touching the rest of the DOM.
function toggleCard(id) {
const wasCollapsed = S.collapsed.has(id);
if (wasCollapsed) S.collapsed.delete(id); else S.collapsed.add(id);
const body = document.querySelector('[data-body-id="' + id + '"]');
const arrow = document.querySelector('[data-card-id="' + id + '"] .card-arrow');
if (!body) { render(); return; }
if (wasCollapsed) {
if (!body.firstChild) {
const f = FINDING_BY_ID.get(id);
if (f) body.innerHTML = renderCardBody(f, S.search);
}
body.style.display = '';
if (arrow) arrow.textContent = '\u25bc';
} else {
body.style.display = 'none';
if (arrow) arrow.textContent = '\u25b6';
}
}
// Surgical status update: update one card's button + resolved class.
function cycleStatus(id) {
statuses[id] = STATUS_NEXT[statusOf(id)] || 'open';
saveStatuses();
const st = statusOf(id);
const btn = document.querySelector('.status-btn[data-id="' + id + '"]');
if (!btn) { render(); return; }
btn.className = 'status-btn ' + (STATUS_CLS[st] || 'st-open');
btn.textContent = STATUS_LBL[st] || '\u25cf open';
const card = document.querySelector('[data-card-id="' + id + '"]');
if (card) card.classList.toggle('resolved', st !== 'open');
updateProgress();
}
// Surgical collapse/expand for a group: toggle all bodies in the group without full re-render.
function collapseGroup(key, collapse) {
FINDINGS.forEach(f => {
if (getKey(f) !== key) return;
if (collapse) S.collapsed.add(f.id); else S.collapsed.delete(f.id);
const body = document.querySelector('[data-body-id="' + f.id + '"]');
const arrow = document.querySelector('[data-card-id="' + f.id + '"] .card-arrow');
if (!body) return;
if (collapse) {
body.style.display = 'none';
if (arrow) arrow.textContent = '\u25b6';
} else {
if (!body.firstChild) {
const ff = FINDING_BY_ID.get(f.id);
if (ff) body.innerHTML = renderCardBody(ff, S.search);
}
body.style.display = '';
if (arrow) arrow.textContent = '\u25bc';
}
});
}
// Full re-render: used for filter/group-by/search changes.
// Batched via requestAnimationFrame.
let _rafPending = false;
function scheduleRender() {
if (_rafPending) return;
_rafPending = true;
requestAnimationFrame(() => { _rafPending = false; render(); });
}
function render() {
_hay.clear();
updateToggles();
updateProgress();
const q = S.search;
const visible = FINDINGS.filter(matches);
document.getElementById('stats').textContent = visible.length + ' / ' + FINDINGS.length + ' findings';
// Build groups
const map = new Map();
for (const f of visible) {
const k = getKey(f);
if (!map.has(k)) map.set(k, []);
map.get(k).push(f);
}
// Hidden-groups bar
const hbar = document.getElementById('hidden-bar');
if (S.hiddenGroup.size > 0) {
let hh = '<div class="hidden-inner"><span class="hidden-lbl">Hidden:</span>';
for (const g of [...S.hiddenGroup].sort())
hh += '<button class="show-btn" data-action="show-group" data-key="' + esc(g) + '">' + esc(g || '(all findings)') + '</button>';
hh += '<button class="show-all-btn" data-action="show-all">Show all</button></div>';
hbar.innerHTML = hh;
} else {
hbar.innerHTML = '';
}
// Content
const content = document.getElementById('content');
if (!visible.length) {
content.innerHTML = '<div class="no-results">No findings match the current filters.</div>';
syncHash();
return;
}
let hh = '';
for (const k of sortKeys([...map.keys()])) {
if (S.hiddenGroup.has(k)) continue;
const items = map.get(k);
const title = k || 'All Findings';
const done = items.filter(f => statusOf(f.id) !== 'open').length;
const allDone = done === items.length;
hh += '<div class="group-section">' +
'<div class="group-header">' +
'<span class="group-title' + (allDone ? ' all-done' : '') + '">' + esc(title) + '</span>' +
'<span class="group-count">' + done + '/' + items.length + '</span>' +
'<button class="icon-btn" data-action="collapse-group" data-key="' + esc(k) + '" title="Collapse all">\u229f</button>' +
'<button class="icon-btn" data-action="expand-group" data-key="' + esc(k) + '" title="Expand all">\u229e</button>' +
'<button class="hide-btn" data-action="hide-group" data-key="' + esc(k) + '">Hide</button>' +
'</div>';
for (const f of items) hh += renderCard(f, q);
hh += '</div>';
}
content.innerHTML = hh;
syncHash();
}
// Event delegation
document.getElementById('filterbar').addEventListener('click', e => {
const b = e.target.closest('[data-sev],[data-type],[data-status],[data-toggle-all],[data-easy]');
if (!b) return;
if (b.dataset.easy) {
S.easyOnly = !S.easyOnly;
}
else if (b.dataset.toggleAll) {
const k = b.dataset.toggleAll;
const set = k === 'sev' ? S.hiddenSev : k === 'type' ? S.hiddenType : S.hiddenStatus;
const full = k === 'sev' ? PRESENT_SEVS : k === 'type' ? ALL_TYPES : STATUSES;
if (set.size === 0) full.forEach(v => set.add(v));
else set.clear();
}
else if (b.dataset.sev) { const s = S.hiddenSev; s.has(b.dataset.sev) ? s.delete(b.dataset.sev) : s.add(b.dataset.sev); }
else if (b.dataset.type) { const s = S.hiddenType; s.has(b.dataset.type) ? s.delete(b.dataset.type) : s.add(b.dataset.type); }
else if (b.dataset.status) { const s = S.hiddenStatus; s.has(b.dataset.status) ? s.delete(b.dataset.status) : s.add(b.dataset.status); }
scheduleRender();
});
document.addEventListener('click', e => {
const b = e.target.closest('[data-action]');
if (!b) return;
const { action, key, id } = b.dataset;
if (action === 'hide-group') { S.hiddenGroup.add(key); scheduleRender(); }
else if (action === 'show-group') { S.hiddenGroup.delete(key); scheduleRender(); }
else if (action === 'show-all') { S.hiddenGroup.clear(); scheduleRender(); }
else if (action === 'collapse-all') { FINDINGS.forEach(f => S.collapsed.add(f.id)); scheduleRender(); }
else if (action === 'expand-all') { S.collapsed.clear(); scheduleRender(); }
else if (action === 'copy-md') {
e.stopPropagation();
const f = FINDING_BY_ID.get(id);
if (f) copyText(findingToMd(f), b);
}
else if (action === 'download-md') { downloadText(buildMdFull(), 'findings.md'); }
else if (action === 'download-md-short') { downloadText(buildMdShort(), 'findings-short.md'); }
else if (action === 'download-toml') {
downloadText(buildFindingsToml(), 'findings.toml', 'application/toml');
downloadText(buildProjectInfoToml(), 'project-info.toml', 'application/toml');
}
else if (action === 'cycle-status') {
e.stopPropagation();
cycleStatus(id);
}
else if (action === 'toggle-card') {
toggleCard(id);
}
else if (action === 'collapse-group') {
collapseGroup(key, true);
}
else if (action === 'expand-group') {
collapseGroup(key, false);
}
});
// Debounced search — 180 ms after last keystroke
let _searchTimer = 0;
document.getElementById('search').addEventListener('input', e => {
S.search = e.target.value;
clearTimeout(_searchTimer);
_searchTimer = setTimeout(scheduleRender, 180);
});
document.getElementById('groupby').addEventListener('change', e => {
S.groupBy = e.target.value;
S.hiddenGroup.clear();
scheduleRender();
});
// Init
loadStatuses();
initFilterBar();
loadHash();
FINDINGS.forEach(f => S.collapsed.add(f.id));
render();
})();
</script>
</body></html>