1use crate::lab::config::LabConfig;
8use crate::lab::replay::normalize_for_replay;
9use crate::lab::runtime::{InvariantViolation, LabRuntime};
10use std::collections::BTreeMap;
11
12#[derive(Debug, Clone)]
14pub struct FuzzConfig {
15 pub base_seed: u64,
17 pub entropy_seed: u64,
23 pub iterations: usize,
25 pub max_steps: u64,
27 pub worker_count: usize,
29 pub minimize: bool,
31 pub minimize_attempts: usize,
33}
34
35impl FuzzConfig {
36 #[must_use]
38 pub fn new(base_seed: u64, iterations: usize) -> Self {
39 Self {
40 base_seed,
41 entropy_seed: base_seed,
42 iterations,
43 max_steps: 100_000,
44 worker_count: 1,
45 minimize: true,
46 minimize_attempts: 96,
47 }
48 }
49
50 #[must_use]
52 pub fn worker_count(mut self, count: usize) -> Self {
53 self.worker_count = count;
54 self
55 }
56
57 #[must_use]
59 pub fn entropy_seed(mut self, seed: u64) -> Self {
60 self.entropy_seed = seed;
61 self
62 }
63
64 #[must_use]
66 pub fn max_steps(mut self, max: u64) -> Self {
67 self.max_steps = max;
68 self
69 }
70
71 #[must_use]
73 pub fn minimize(mut self, enabled: bool) -> Self {
74 self.minimize = enabled;
75 self
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct FuzzFinding {
82 pub seed: u64,
84 pub entropy_seed: u64,
86 pub steps: u64,
91 pub violations: Vec<InvariantViolation>,
97 pub certificate_hash: u64,
99 pub trace_fingerprint: u64,
101 pub minimized_seed: Option<u64>,
103}
104
105#[derive(Debug)]
107pub struct FuzzReport {
108 pub iterations: usize,
110 pub entropy_seed: u64,
112 pub findings: Vec<FuzzFinding>,
114 pub violation_counts: BTreeMap<String, usize>,
116 pub unique_certificates: usize,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
122pub struct FuzzRegressionCase {
123 pub seed: u64,
125 pub replay_seed: u64,
127 pub entropy_seed: u64,
129 pub certificate_hash: u64,
131 pub trace_fingerprint: u64,
133 pub violation_categories: Vec<String>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
139pub struct FuzzRegressionCorpus {
140 pub schema_version: u32,
142 pub base_seed: u64,
144 pub entropy_seed: u64,
146 pub iterations: usize,
148 pub cases: Vec<FuzzRegressionCase>,
150}
151
152impl FuzzFinding {
153 #[must_use]
155 pub fn to_promoted_scenario(
156 &self,
157 surface_id: &str,
158 contract_version: &str,
159 ) -> crate::lab::dual_run::PromotedFuzzScenario {
160 crate::lab::dual_run::promote_fuzz_finding(self, surface_id, contract_version)
161 }
162}
163
164impl FuzzRegressionCase {
165 #[must_use]
167 pub fn to_promoted_scenario(
168 &self,
169 surface_id: &str,
170 contract_version: &str,
171 ) -> crate::lab::dual_run::PromotedFuzzScenario {
172 crate::lab::dual_run::promote_regression_case(self, surface_id, contract_version)
173 }
174}
175
176impl FuzzRegressionCorpus {
177 #[must_use]
179 pub fn to_promoted_scenarios(
180 &self,
181 surface_id: &str,
182 contract_version: &str,
183 ) -> Vec<crate::lab::dual_run::PromotedFuzzScenario> {
184 crate::lab::dual_run::promote_regression_corpus(self, surface_id, contract_version)
185 }
186}
187
188impl FuzzReport {
189 #[must_use]
191 pub fn has_findings(&self) -> bool {
192 !self.findings.is_empty()
193 }
194
195 #[must_use]
197 pub fn finding_seeds(&self) -> Vec<u64> {
198 self.findings.iter().map(|f| f.seed).collect()
199 }
200
201 #[must_use]
203 pub fn minimized_seeds(&self) -> Vec<u64> {
204 self.findings
205 .iter()
206 .filter_map(|f| f.minimized_seed)
207 .collect()
208 }
209
210 #[must_use]
215 pub fn to_regression_corpus(&self, base_seed: u64) -> FuzzRegressionCorpus {
216 let mut cases: Vec<FuzzRegressionCase> = self
217 .findings
218 .iter()
219 .map(|finding| {
220 let replay_seed = finding.minimized_seed.unwrap_or(finding.seed);
221 FuzzRegressionCase {
222 seed: finding.seed,
223 replay_seed,
224 entropy_seed: finding.entropy_seed,
225 certificate_hash: finding.certificate_hash,
226 trace_fingerprint: finding.trace_fingerprint,
227 violation_categories: sorted_violation_categories(&finding.violations),
228 }
229 })
230 .collect();
231
232 cases.sort_by_key(|case| {
233 (
234 case.replay_seed,
235 case.seed,
236 case.trace_fingerprint,
237 case.certificate_hash,
238 )
239 });
240
241 FuzzRegressionCorpus {
242 schema_version: 1,
243 base_seed,
244 entropy_seed: self.entropy_seed,
245 iterations: self.iterations,
246 cases,
247 }
248 }
249
250 #[must_use]
252 pub fn to_promoted_findings(
253 &self,
254 surface_id: &str,
255 contract_version: &str,
256 ) -> Vec<crate::lab::dual_run::PromotedFuzzScenario> {
257 self.findings
258 .iter()
259 .map(|finding| finding.to_promoted_scenario(surface_id, contract_version))
260 .collect()
261 }
262
263 #[must_use]
268 pub fn to_promoted_regression_scenarios(
269 &self,
270 base_seed: u64,
271 surface_id: &str,
272 contract_version: &str,
273 ) -> Vec<crate::lab::dual_run::PromotedFuzzScenario> {
274 self.to_regression_corpus(base_seed)
275 .to_promoted_scenarios(surface_id, contract_version)
276 }
277}
278
279pub struct FuzzHarness {
285 config: FuzzConfig,
286}
287
288impl FuzzHarness {
289 #[must_use]
291 pub fn new(config: FuzzConfig) -> Self {
292 Self { config }
293 }
294
295 pub fn run<F>(&self, test: F) -> FuzzReport
300 where
301 F: Fn(&mut LabRuntime),
302 {
303 let mut findings = Vec::new();
304 let mut violation_counts: BTreeMap<String, usize> = BTreeMap::new();
305 let mut certificate_hashes = std::collections::BTreeSet::new();
306
307 for i in 0..self.config.iterations {
308 let seed = self.config.base_seed.wrapping_add(i as u64);
309 let result = self.run_single(seed, &test);
310
311 certificate_hashes.insert(result.certificate_hash);
312
313 if !result.violations.is_empty() {
314 for v in &result.violations {
315 let key = violation_category(v);
316 *violation_counts.entry(key).or_insert(0) += 1;
317 }
318
319 let minimized = if self.config.minimize {
320 self.minimize_seed(seed, &test)
321 } else {
322 None
323 };
324
325 let (minimized_seed, steps, violations, certificate_hash, trace_fingerprint) =
326 match minimized {
327 Some((min_seed, ref min_res)) => (
328 Some(min_seed),
329 min_res.steps,
330 min_res.violations.clone(),
331 min_res.certificate_hash,
332 min_res.trace_fingerprint,
333 ),
334 None => (
335 None,
336 result.steps,
337 result.violations.clone(),
338 result.certificate_hash,
339 result.trace_fingerprint,
340 ),
341 };
342
343 findings.push(FuzzFinding {
344 seed,
345 entropy_seed: self.config.entropy_seed,
346 steps,
347 violations,
348 certificate_hash,
349 trace_fingerprint,
350 minimized_seed,
351 });
352 }
353 }
354
355 FuzzReport {
356 iterations: self.config.iterations,
357 entropy_seed: self.config.entropy_seed,
358 findings,
359 violation_counts,
360 unique_certificates: certificate_hashes.len(),
361 }
362 }
363
364 fn run_single<F>(&self, seed: u64, test: &F) -> SingleRunResult
365 where
366 F: Fn(&mut LabRuntime),
367 {
368 let mut lab_config = LabConfig::new(seed);
369 lab_config = lab_config.worker_count(self.config.worker_count);
370 lab_config = lab_config.entropy_seed(self.config.entropy_seed);
371 lab_config = lab_config.max_steps(self.config.max_steps);
372
373 let mut runtime = LabRuntime::new(lab_config);
374
375 let panic_message = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
383 test(&mut runtime);
384 }))
385 .err()
386 .map(|payload| {
387 if let Some(s) = payload.downcast_ref::<&'static str>() {
389 (*s).to_string()
390 } else if let Some(s) = payload.downcast_ref::<String>() {
391 s.clone()
392 } else {
393 "<unknown panic payload>".to_string()
394 }
395 });
396
397 let steps = runtime.steps();
398 let certificate_hash = runtime.certificate().hash();
399 let trace_events = runtime.trace().snapshot();
400 let normalized = normalize_for_replay(&trace_events);
401 let trace_fingerprint =
402 crate::trace::canonicalize::trace_fingerprint(&normalized.normalized);
403 let mut violations = runtime.check_invariants();
404 if let Some(message) = panic_message {
405 violations.push(InvariantViolation::TestPanic { message });
406 }
407
408 SingleRunResult {
409 steps,
410 violations,
411 certificate_hash,
412 trace_fingerprint,
413 }
414 }
415
416 fn minimize_seed<F>(&self, original_seed: u64, test: &F) -> Option<(u64, SingleRunResult)>
421 where
422 F: Fn(&mut LabRuntime),
423 {
424 let original_result = self.run_single(original_seed, test);
425 if original_result.violations.is_empty() {
426 return None;
427 }
428 let target_categories = sorted_violation_categories(&original_result.violations);
429
430 let mut best_seed = original_seed;
431 let mut best_result = None;
432
433 for attempt in 0..self.config.minimize_attempts {
435 let candidate = match attempt {
436 0..=15 => attempt as u64,
438 16..=31 => original_seed.wrapping_sub((attempt - 15) as u64),
440 _ => original_seed ^ (1u64 << ((attempt - 32) % 64)),
442 };
443
444 if candidate == original_seed {
445 continue;
446 }
447
448 let result = self.run_single(candidate, test);
449 if result.violations.is_empty() {
450 continue;
451 }
452
453 let categories = sorted_violation_categories(&result.violations);
454 if categories == target_categories && candidate < best_seed {
455 best_seed = candidate;
456 best_result = Some(result);
457 }
458 }
459
460 if best_seed == original_seed {
461 None
462 } else {
463 Some((
464 best_seed,
465 best_result.expect("best result should exist when updating best_seed"),
466 ))
467 }
468 }
469}
470
471#[derive(Clone, Debug, PartialEq)]
472struct SingleRunResult {
473 steps: u64,
474 violations: Vec<InvariantViolation>,
475 certificate_hash: u64,
476 trace_fingerprint: u64,
477}
478
479fn violation_category(v: &InvariantViolation) -> String {
480 match v {
481 InvariantViolation::ObligationLeak { .. } => "obligation_leak".to_string(),
482 InvariantViolation::TaskLeak { .. } => "task_leak".to_string(),
483 InvariantViolation::ActorLeak { .. } => "actor_leak".to_string(),
484 InvariantViolation::QuiescenceViolation => "quiescence_violation".to_string(),
485 InvariantViolation::Futurelock { .. } => "futurelock".to_string(),
486 InvariantViolation::CancellationProtocol { .. } => "cancellation_protocol".to_string(),
487 InvariantViolation::TestPanic { .. } => "test_panic".to_string(),
488 }
489}
490
491fn sorted_violation_categories(violations: &[InvariantViolation]) -> Vec<String> {
492 let mut categories: Vec<String> = violations.iter().map(violation_category).collect();
493 categories.sort_unstable();
494 categories.dedup();
495 categories
496}
497
498pub fn fuzz_quick<F>(seed: u64, iterations: usize, test: F) -> FuzzReport
500where
501 F: Fn(&mut LabRuntime),
502{
503 let harness = FuzzHarness::new(FuzzConfig::new(seed, iterations));
504 harness.run(test)
505}
506
507#[cfg(test)]
508mod tests {
509 #![allow(
510 clippy::pedantic,
511 clippy::nursery,
512 clippy::expect_fun_call,
513 clippy::map_unwrap_or,
514 clippy::cast_possible_wrap,
515 clippy::future_not_send
516 )]
517 use super::*;
518 use crate::types::Budget;
519
520 #[test]
521 fn fuzz_no_violations_with_simple_task() {
522 let report = fuzz_quick(42, 10, |runtime| {
523 let region = runtime.state.create_root_region(Budget::INFINITE);
524 let (t, _) = runtime
525 .state
526 .create_task(region, Budget::INFINITE, async { 1 })
527 .expect("t");
528 runtime.scheduler.lock().schedule(t, 0);
529 runtime.run_until_quiescent();
530 });
531
532 assert!(!report.has_findings());
533 assert_eq!(report.iterations, 10);
534 assert!(report.unique_certificates >= 1);
535 }
536
537 #[test]
538 fn fuzz_config_builder() {
539 let config = FuzzConfig::new(0, 100)
540 .worker_count(4)
541 .max_steps(5000)
542 .minimize(false);
543 assert_eq!(config.worker_count, 4);
544 assert_eq!(config.max_steps, 5000);
545 assert!(!config.minimize);
546 }
547
548 #[test]
549 fn fuzz_two_tasks_no_violations() {
550 let report = fuzz_quick(0, 20, |runtime| {
551 let region = runtime.state.create_root_region(Budget::INFINITE);
552 let (t1, _) = runtime
553 .state
554 .create_task(region, Budget::INFINITE, async {})
555 .expect("t1");
556 let (t2, _) = runtime
557 .state
558 .create_task(region, Budget::INFINITE, async {})
559 .expect("t2");
560 {
561 let mut sched = runtime.scheduler.lock();
562 sched.schedule(t1, 0);
563 sched.schedule(t2, 0);
564 }
565 runtime.run_until_quiescent();
566 });
567
568 assert!(!report.has_findings());
569 }
570
571 #[test]
572 fn fuzz_report_seed_accessors() {
573 let report = FuzzReport {
574 iterations: 5,
575 entropy_seed: 99,
576 findings: vec![FuzzFinding {
577 seed: 42,
578 entropy_seed: 99,
579 steps: 10,
580 violations: vec![],
581 certificate_hash: 123,
582 trace_fingerprint: 456,
583 minimized_seed: Some(3),
584 }],
585 violation_counts: BTreeMap::new(),
586 unique_certificates: 1,
587 };
588
589 assert_eq!(report.finding_seeds(), vec![42]);
590 assert_eq!(report.minimized_seeds(), vec![3]);
591 assert!(report.has_findings());
592 }
593
594 #[test]
595 fn fuzz_deterministic_same_seed_same_result() {
596 let run = |seed: u64| -> usize {
597 let report = fuzz_quick(seed, 5, |runtime| {
598 let region = runtime.state.create_root_region(Budget::INFINITE);
599 let (t, _) = runtime
600 .state
601 .create_task(region, Budget::INFINITE, async { 42 })
602 .expect("t");
603 runtime.scheduler.lock().schedule(t, 0);
604 runtime.run_until_quiescent();
605 });
606 report.unique_certificates
607 };
608
609 let r1 = run(77);
610 let r2 = run(77);
611 assert_eq!(r1, r2);
612 }
613
614 #[test]
619 fn fuzz_config_debug_clone_defaults() {
620 let cfg = FuzzConfig::new(42, 100);
621 let dbg = format!("{cfg:?}");
622 assert!(dbg.contains("FuzzConfig"), "{dbg}");
623 assert_eq!(cfg.base_seed, 42);
624 assert_eq!(cfg.entropy_seed, 42);
625 assert_eq!(cfg.iterations, 100);
626 assert_eq!(cfg.max_steps, 100_000);
627 assert_eq!(cfg.worker_count, 1);
628 assert!(cfg.minimize);
629 assert_eq!(cfg.minimize_attempts, 96);
630 let cloned = cfg.clone();
631 assert_eq!(cloned.base_seed, cfg.base_seed);
632 assert_eq!(cloned.iterations, cfg.iterations);
633 }
634
635 #[test]
636 fn fuzz_finding_debug_clone() {
637 let finding = FuzzFinding {
638 seed: 99,
639 entropy_seed: 7,
640 steps: 500,
641 violations: vec![],
642 certificate_hash: 12345,
643 trace_fingerprint: 67890,
644 minimized_seed: Some(7),
645 };
646 let dbg = format!("{finding:?}");
647 assert!(dbg.contains("FuzzFinding"), "{dbg}");
648 let cloned = finding;
649 assert_eq!(cloned.seed, 99);
650 assert_eq!(cloned.entropy_seed, 7);
651 assert_eq!(cloned.steps, 500);
652 assert_eq!(cloned.certificate_hash, 12345);
653 assert_eq!(cloned.trace_fingerprint, 67890);
654 assert_eq!(cloned.minimized_seed, Some(7));
655 }
656
657 #[test]
658 fn fuzz_harness_keeps_entropy_seed_stable_across_iterations() {
659 let observed = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
660 let captured = std::sync::Arc::clone(&observed);
661 let harness = FuzzHarness::new(FuzzConfig::new(7, 3));
662
663 harness.run(move |runtime| {
664 captured
665 .lock()
666 .expect("lock observed seeds")
667 .push((runtime.config().seed, runtime.config().entropy_seed));
668 });
669
670 let observed = observed.lock().expect("lock observed seeds");
671 assert_eq!(
672 observed.as_slice(),
673 &[(7, 7), (8, 7), (9, 7)],
674 "campaign iterations must vary schedule seed without mutating entropy seed"
675 );
676 }
677
678 #[test]
679 fn fuzz_report_debug_empty() {
680 let report = FuzzReport {
681 iterations: 0,
682 entropy_seed: 55,
683 findings: vec![],
684 violation_counts: BTreeMap::new(),
685 unique_certificates: 0,
686 };
687 let dbg = format!("{report:?}");
688 assert!(dbg.contains("FuzzReport"), "{dbg}");
689 assert!(!report.has_findings());
690 assert!(report.finding_seeds().is_empty());
691 assert!(report.minimized_seeds().is_empty());
692 }
693
694 #[test]
695 fn regression_corpus_is_sorted_and_minimized() {
696 let report = FuzzReport {
697 iterations: 3,
698 entropy_seed: 0x7777,
699 findings: vec![
700 FuzzFinding {
701 seed: 44,
702 entropy_seed: 0x7777,
703 steps: 100,
704 violations: vec![
705 InvariantViolation::QuiescenceViolation,
706 InvariantViolation::QuiescenceViolation,
707 ],
708 certificate_hash: 0xB,
709 trace_fingerprint: 0xBB,
710 minimized_seed: Some(3),
711 },
712 FuzzFinding {
713 seed: 13,
714 entropy_seed: 0x7777,
715 steps: 200,
716 violations: vec![InvariantViolation::Futurelock {
717 task: crate::types::TaskId::new_for_test(1, 0),
718 region: crate::types::RegionId::new_for_test(1, 0),
719 idle_steps: 1,
720 held: Vec::new(),
721 last_checkpoint_message: None,
722 }],
723 certificate_hash: 0xA,
724 trace_fingerprint: 0xAA,
725 minimized_seed: None,
726 },
727 ],
728 violation_counts: BTreeMap::new(),
729 unique_certificates: 2,
730 };
731
732 let corpus = report.to_regression_corpus(1234);
733 assert_eq!(corpus.schema_version, 1);
734 assert_eq!(corpus.base_seed, 1234);
735 assert_eq!(corpus.entropy_seed, 0x7777);
736 assert_eq!(corpus.iterations, 3);
737 assert_eq!(corpus.cases.len(), 2);
738
739 assert_eq!(corpus.cases[0].seed, 44);
741 assert_eq!(corpus.cases[0].replay_seed, 3);
742 assert_eq!(
743 corpus.cases[0].violation_categories,
744 vec!["quiescence_violation"]
745 );
746
747 assert_eq!(corpus.cases[1].seed, 13);
748 assert_eq!(corpus.cases[1].replay_seed, 13);
749 assert_eq!(corpus.cases[1].violation_categories, vec!["futurelock"]);
750 }
751
752 #[test]
753 fn regression_corpus_replay_seeds_preserve_violation_categories() {
754 let config = FuzzConfig::new(0x6C6F_7265_6D71_6505, 4)
755 .worker_count(2)
756 .max_steps(256)
757 .minimize(true);
758 let harness = FuzzHarness::new(config.clone());
759
760 let scenario = |runtime: &mut LabRuntime| {
761 let root = runtime.state.create_root_region(Budget::INFINITE);
762 for _ in 0..3 {
763 let (task_id, _) = runtime
764 .state
765 .create_task(root, Budget::INFINITE, async {})
766 .expect("create scheduled task");
767 runtime.scheduler.lock().schedule(task_id, 0);
768 }
769 let _unscheduled = runtime
770 .state
771 .create_task(root, Budget::INFINITE, async {})
772 .expect("create unscheduled task");
773 runtime.run_until_quiescent();
774 };
775
776 let report = harness.run(scenario);
777 assert!(report.has_findings(), "expected minimized fuzz findings");
778 let corpus = report.to_regression_corpus(config.base_seed);
779 assert!(
780 !corpus.cases.is_empty(),
781 "regression corpus must include failing replay seeds"
782 );
783
784 for case in &corpus.cases {
785 let first_replay = harness.run_single(case.replay_seed, &scenario);
786 assert!(
787 !first_replay.violations.is_empty(),
788 "replay seed {} should still violate an invariant",
789 case.replay_seed
790 );
791 let replay_categories = sorted_violation_categories(&first_replay.violations);
792 assert_eq!(
793 replay_categories, case.violation_categories,
794 "replay seed {} changed violation categories",
795 case.replay_seed
796 );
797
798 let second_replay = harness.run_single(case.replay_seed, &scenario);
800 assert_eq!(
801 first_replay.certificate_hash,
802 second_replay.certificate_hash
803 );
804 assert_eq!(
805 first_replay.trace_fingerprint,
806 second_replay.trace_fingerprint
807 );
808 }
809 }
810
811 #[test]
812 fn minimize_seed_requires_full_violation_category_match() {
813 let harness = FuzzHarness::new(FuzzConfig::new(20, 1));
814 let scenario = |runtime: &mut LabRuntime| {
815 let seed = runtime.config().seed;
816 let region = runtime.state.create_root_region(Budget::INFINITE);
817
818 let _leaked = runtime
820 .state
821 .create_task(region, Budget::INFINITE, async {})
822 .expect("create leaked task");
823
824 if seed >= 20 {
828 runtime
829 .state
830 .region(region)
831 .expect("region exists")
832 .set_state(crate::record::region::RegionState::Closed);
833 }
834 };
835
836 let original = harness.run_single(20, &scenario);
837 assert_eq!(
838 sorted_violation_categories(&original.violations),
839 vec!["quiescence_violation", "task_leak"]
840 );
841
842 let smaller = harness.run_single(19, &scenario);
843 assert_eq!(
844 sorted_violation_categories(&smaller.violations),
845 vec!["task_leak"]
846 );
847
848 let minimized = harness.minimize_seed(20, &scenario);
849 assert_eq!(
850 minimized, None,
851 "smaller seeds do not preserve the original full violation category set"
852 );
853 }
854
855 #[test]
856 fn fuzz_report_promotes_findings_into_replayable_scenarios() {
857 let report = FuzzReport {
858 iterations: 1,
859 entropy_seed: 0x44,
860 findings: vec![FuzzFinding {
861 seed: 0xABCD,
862 entropy_seed: 0x44,
863 steps: 10,
864 violations: vec![InvariantViolation::TaskLeak { count: 1 }],
865 certificate_hash: 0x101,
866 trace_fingerprint: 0x202,
867 minimized_seed: Some(0x55),
868 }],
869 violation_counts: BTreeMap::from([("task_leak".to_string(), 1)]),
870 unique_certificates: 1,
871 };
872
873 let promoted = report.to_promoted_findings("scheduler.surface", "v1");
874 assert_eq!(promoted.len(), 1);
875 assert_eq!(promoted[0].original_seed, 0xABCD);
876 assert_eq!(promoted[0].replay_seed, 0x55);
877 assert_eq!(promoted[0].trace_fingerprint, 0x202);
878 assert_eq!(promoted[0].violation_categories, vec!["task_leak"]);
879 }
880
881 #[test]
882 fn regression_corpus_promotes_cases_with_campaign_lineage() {
883 let corpus = FuzzRegressionCorpus {
884 schema_version: 1,
885 base_seed: 0xCAFE,
886 entropy_seed: 0x77,
887 iterations: 2,
888 cases: vec![FuzzRegressionCase {
889 seed: 0x10,
890 replay_seed: 0x08,
891 entropy_seed: 0x77,
892 certificate_hash: 0x111,
893 trace_fingerprint: 0x222,
894 violation_categories: vec!["task_leak".to_string()],
895 }],
896 };
897
898 let promoted = corpus.to_promoted_scenarios("scheduler.surface", "v1");
899 assert_eq!(promoted.len(), 1);
900 assert_eq!(promoted[0].campaign_base_seed, Some(0xCAFE));
901 assert_eq!(promoted[0].campaign_iteration, Some(0));
902 assert_eq!(promoted[0].original_seed, 0x10);
903 assert_eq!(promoted[0].replay_seed, 0x08);
904 assert_eq!(
905 promoted[0].identity.seed_plan.entropy_seed_override,
906 Some(0x77)
907 );
908 assert_eq!(
909 promoted[0].violation_categories,
910 vec!["task_leak".to_string()]
911 );
912 }
913
914 #[test]
915 fn minimized_findings_keep_violation_payload_consistent_with_replay_seed() {
916 let harness = FuzzHarness::new(FuzzConfig::new(20, 1));
917 let scenario = |runtime: &mut LabRuntime| {
918 let seed = runtime.config().seed;
919 let region = runtime.state.create_root_region(Budget::INFINITE);
920
921 let leak_count = if seed >= 20 { 2 } else { 1 };
922 for _ in 0..leak_count {
923 let _leaked = runtime
924 .state
925 .create_task(region, Budget::INFINITE, async {})
926 .expect("create leaked task");
927 }
928 };
929
930 let report = harness.run(scenario);
931 let finding = report
932 .findings
933 .first()
934 .expect("campaign should surface a minimized finding");
935 assert_eq!(finding.minimized_seed, Some(0));
936
937 let replay = harness.run_single(0, &scenario);
938 assert_eq!(finding.steps, replay.steps);
939 assert_eq!(finding.violations, replay.violations);
940 assert_eq!(finding.certificate_hash, replay.certificate_hash);
941 assert_eq!(finding.trace_fingerprint, replay.trace_fingerprint);
942 assert_eq!(
943 finding.violations,
944 vec![InvariantViolation::TaskLeak { count: 1 }]
945 );
946 }
947
948 #[test]
949 fn promoted_regression_scenarios_preserve_entropy_seed_override() {
950 let report = FuzzReport {
951 iterations: 1,
952 entropy_seed: 0xBADA,
953 findings: vec![FuzzFinding {
954 seed: 0x20,
955 entropy_seed: 0xBADA,
956 steps: 4,
957 violations: vec![InvariantViolation::TaskLeak { count: 1 }],
958 certificate_hash: 0xAB,
959 trace_fingerprint: 0xCD,
960 minimized_seed: Some(0x02),
961 }],
962 violation_counts: BTreeMap::from([("task_leak".to_string(), 1)]),
963 unique_certificates: 1,
964 };
965
966 let promoted = report.to_promoted_regression_scenarios(0x20, "scheduler.surface", "v1");
967 assert_eq!(promoted.len(), 1);
968 assert_eq!(
969 promoted[0].identity.seed_plan.entropy_seed_override,
970 Some(0xBADA)
971 );
972 }
973
974 #[test]
975 fn ipejce_panicking_test_closure_recorded_as_finding_not_aborting_campaign() {
976 let cfg = FuzzConfig::new(0xDEADBEEF, 1).worker_count(1).max_steps(16);
981 let campaign = super::FuzzHarness::new(cfg);
982 let panic_message = "deliberate test failure";
983 let result = campaign.run_single(0xDEADBEEF, &|_runtime: &mut LabRuntime| {
984 panic!("{}", panic_message); });
986 let saw_panic = result.violations.iter().any(|v| {
990 matches!(
991 v,
992 InvariantViolation::TestPanic { message } if message.contains(panic_message)
993 )
994 });
995 assert!(
996 saw_panic,
997 "TestPanic.message should preserve the panic payload: {:?}",
998 result.violations
999 );
1000 }
1001}