1use serde::{Deserialize, Serialize};
111use std::collections::{BTreeMap, HashSet};
112
113pub const SCENARIO_SCHEMA_VERSION: u32 = 1;
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct Scenario {
123 #[serde(default = "default_schema_version")]
125 pub schema_version: u32,
126
127 pub id: String,
129
130 #[serde(default)]
132 pub description: String,
133
134 #[serde(default)]
136 pub lab: LabSection,
137
138 #[serde(default)]
140 pub chaos: ChaosSection,
141
142 #[serde(default)]
144 pub network: NetworkSection,
145
146 #[serde(default)]
148 pub faults: Vec<FaultEvent>,
149
150 #[serde(default)]
152 pub participants: Vec<Participant>,
153
154 #[serde(default = "default_oracles")]
156 pub oracles: Vec<String>,
157
158 #[serde(default)]
160 pub cancellation: Option<CancellationSection>,
161
162 #[serde(default)]
164 pub resource_caps: ResourceCapsSection,
165
166 #[serde(default = "default_expected_invariants")]
168 pub expected_invariants: Vec<String>,
169
170 #[serde(default)]
172 pub minimization: MinimizationSection,
173
174 #[serde(default)]
176 pub golden_projection: GoldenProjectionSection,
177
178 #[serde(default)]
180 pub include: Vec<IncludeRef>,
181
182 #[serde(default)]
184 pub metadata: BTreeMap<String, String>,
185}
186
187fn default_schema_version() -> u32 {
188 SCENARIO_SCHEMA_VERSION
189}
190
191fn default_oracles() -> Vec<String> {
192 vec!["all".to_string()]
193}
194
195pub const SUPPORTED_EXPECTED_INVARIANTS: &[&str] = &[
197 "quiescence",
198 "losers_drained",
199 "no_obligation_leaks",
200 "bounded_artifact_output",
201 "deterministic_replay",
202];
203
204fn default_expected_invariants() -> Vec<String> {
205 [
206 "quiescence",
207 "losers_drained",
208 "no_obligation_leaks",
209 "deterministic_replay",
210 ]
211 .into_iter()
212 .map(str::to_string)
213 .collect()
214}
215
216impl Default for Scenario {
217 fn default() -> Self {
218 Self {
219 schema_version: default_schema_version(),
220 id: String::new(),
221 description: String::new(),
222 lab: LabSection::default(),
223 chaos: ChaosSection::default(),
224 network: NetworkSection::default(),
225 faults: Vec::new(),
226 participants: Vec::new(),
227 oracles: default_oracles(),
228 cancellation: None,
229 resource_caps: ResourceCapsSection::default(),
230 expected_invariants: default_expected_invariants(),
231 minimization: MinimizationSection::default(),
232 golden_projection: GoldenProjectionSection::default(),
233 include: Vec::new(),
234 metadata: BTreeMap::new(),
235 }
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct LabSection {
246 #[serde(default = "default_seed")]
248 pub seed: u64,
249
250 pub entropy_seed: Option<u64>,
252
253 #[serde(default = "default_worker_count")]
255 pub worker_count: usize,
256
257 #[serde(default = "default_trace_capacity")]
259 pub trace_capacity: usize,
260
261 #[serde(default = "default_max_steps")]
263 pub max_steps: Option<u64>,
264
265 #[serde(default = "default_true")]
267 pub panic_on_obligation_leak: bool,
268
269 #[serde(default = "default_true")]
271 pub panic_on_futurelock: bool,
272
273 #[serde(default = "default_futurelock_max_idle")]
275 pub futurelock_max_idle_steps: u64,
276
277 #[serde(default)]
279 pub replay_recording: bool,
280}
281
282impl Default for LabSection {
283 fn default() -> Self {
284 Self {
285 seed: 42,
286 entropy_seed: None,
287 worker_count: 1,
288 trace_capacity: 4096,
289 max_steps: Some(100_000),
290 panic_on_obligation_leak: true,
291 panic_on_futurelock: true,
292 futurelock_max_idle_steps: 10_000,
293 replay_recording: false,
294 }
295 }
296}
297
298fn default_seed() -> u64 {
299 42
300}
301fn default_worker_count() -> usize {
302 1
303}
304fn default_trace_capacity() -> usize {
305 4096
306}
307#[allow(clippy::unnecessary_wraps)]
308fn default_max_steps() -> Option<u64> {
309 Some(100_000)
310}
311fn default_true() -> bool {
312 true
313}
314fn default_futurelock_max_idle() -> u64 {
315 10_000
316}
317
318#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
324#[serde(tag = "preset", rename_all = "snake_case")]
325pub enum ChaosSection {
326 #[default]
328 Off,
329 Light,
331 Heavy,
333 Custom {
335 #[serde(default)]
337 cancel_probability: f64,
338 #[serde(default)]
340 delay_probability: f64,
341 #[serde(default)]
343 delay_min_ms: u64,
344 #[serde(default = "default_delay_max_ms")]
346 delay_max_ms: u64,
347 #[serde(default)]
349 io_error_probability: f64,
350 #[serde(default)]
352 wakeup_storm_probability: f64,
353 #[serde(default)]
355 budget_exhaustion_probability: f64,
356 },
357}
358
359fn default_delay_max_ms() -> u64 {
360 10
361}
362
363#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
369pub struct NetworkSection {
370 #[serde(default)]
372 pub preset: NetworkPreset,
373
374 #[serde(default)]
376 pub links: BTreeMap<String, LinkConditions>,
377}
378
379#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
381#[serde(rename_all = "snake_case")]
382pub enum NetworkPreset {
383 #[default]
385 Ideal,
386 Local,
388 Lan,
390 Wan,
392 Satellite,
394 Congested,
396 Lossy,
398}
399
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
402pub struct LinkConditions {
403 #[serde(default)]
405 pub latency: Option<LatencySpec>,
406 #[serde(default)]
408 pub packet_loss: Option<f64>,
409 #[serde(default)]
411 pub packet_corrupt: Option<f64>,
412 #[serde(default)]
414 pub packet_duplicate: Option<f64>,
415 #[serde(default)]
417 pub packet_reorder: Option<f64>,
418 #[serde(default)]
420 pub bandwidth: Option<u64>,
421}
422
423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
425#[serde(tag = "model", rename_all = "snake_case")]
426pub enum LatencySpec {
427 Fixed {
429 ms: u64,
431 },
432 Uniform {
434 min_ms: u64,
436 max_ms: u64,
438 },
439 Normal {
441 mean_ms: u64,
443 stddev_ms: u64,
445 },
446}
447
448#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
454pub struct FaultEvent {
455 pub at_ms: u64,
457
458 pub action: FaultAction,
460
461 #[serde(default)]
463 pub args: BTreeMap<String, serde_json::Value>,
464}
465
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(rename_all = "snake_case")]
469pub enum FaultAction {
470 Partition,
472 Heal,
474 DiskPressure,
476 DiskRecovered,
478 DelayedCleanup,
480 ProcessStall,
482 ProcessResume,
484 HostCrash,
486 HostRestart,
488 ClockSkew,
490 ClockReset,
492}
493
494#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
500pub struct Participant {
501 pub name: String,
503
504 #[serde(default)]
506 pub role: String,
507
508 #[serde(default)]
510 pub properties: BTreeMap<String, serde_json::Value>,
511}
512
513#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
519pub struct CancellationSection {
520 pub strategy: CancellationStrategy,
522
523 #[serde(default)]
525 pub count: Option<usize>,
526
527 #[serde(default)]
529 pub probability: Option<f64>,
530}
531
532#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
534#[serde(rename_all = "snake_case")]
535pub enum CancellationStrategy {
536 Never,
538 AllPoints,
540 RandomSample,
542 FirstN,
544 LastN,
546 EveryNth,
548 Probabilistic,
550}
551
552#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
558pub struct ResourceCapsSection {
559 #[serde(default)]
561 pub max_artifact_bytes: Option<u64>,
562
563 #[serde(default)]
565 pub max_fault_events: Option<usize>,
566
567 #[serde(default)]
569 pub max_counterexample_events: Option<usize>,
570}
571
572#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
574pub struct MinimizationSection {
575 #[serde(default)]
577 pub enabled: bool,
578
579 #[serde(default)]
581 pub max_evaluations: Option<usize>,
582
583 #[serde(default)]
585 pub max_counterexample_events: Option<usize>,
586}
587
588#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
590#[serde(rename_all = "snake_case")]
591pub enum GoldenProjectionFormat {
592 #[default]
594 Json,
595 Markdown,
597}
598
599#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601pub struct GoldenProjectionSection {
602 #[serde(default)]
604 pub format: GoldenProjectionFormat,
605
606 #[serde(default = "default_true")]
608 pub canonicalized: bool,
609
610 #[serde(default = "default_true")]
612 pub redacted: bool,
613}
614
615impl Default for GoldenProjectionSection {
616 fn default() -> Self {
617 Self {
618 format: GoldenProjectionFormat::Json,
619 canonicalized: true,
620 redacted: true,
621 }
622 }
623}
624
625#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
631pub struct IncludeRef {
632 pub path: String,
634}
635
636#[derive(Debug, Clone)]
642pub struct ValidationError {
643 pub field: String,
645 pub message: String,
647}
648
649impl std::fmt::Display for ValidationError {
650 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
651 write!(f, "{}: {}", self.field, self.message)
652 }
653}
654
655impl std::error::Error for ValidationError {}
656
657impl Scenario {
658 #[must_use]
662 pub fn validate(&self) -> Vec<ValidationError> {
663 let mut errors = Vec::new();
664 self.validate_header(&mut errors);
665 self.validate_chaos(&mut errors);
666 self.validate_network(&mut errors);
667 self.validate_faults(&mut errors);
668 self.validate_participants(&mut errors);
669 self.validate_cancellation(&mut errors);
670 self.validate_resource_caps(&mut errors);
671 self.validate_expected_invariants(&mut errors);
672 self.validate_minimization(&mut errors);
673 self.validate_golden_projection(&mut errors);
674 self.validate_includes(&mut errors);
675 errors
676 }
677
678 fn validate_header(&self, errors: &mut Vec<ValidationError>) {
679 if self.schema_version != SCENARIO_SCHEMA_VERSION {
680 errors.push(ValidationError {
681 field: "schema_version".into(),
682 message: format!(
683 "unsupported version {}, expected {SCENARIO_SCHEMA_VERSION}",
684 self.schema_version
685 ),
686 });
687 }
688 if self.id.is_empty() {
689 errors.push(ValidationError {
690 field: "id".into(),
691 message: "scenario id must not be empty".into(),
692 });
693 }
694 if self.lab.worker_count == 0 {
695 errors.push(ValidationError {
696 field: "lab.worker_count".into(),
697 message: "worker_count must be >= 1".into(),
698 });
699 }
700 if self.lab.trace_capacity == 0 {
701 errors.push(ValidationError {
702 field: "lab.trace_capacity".into(),
703 message: "trace_capacity must be > 0".into(),
704 });
705 }
706 }
707
708 fn validate_chaos(&self, errors: &mut Vec<ValidationError>) {
709 if let ChaosSection::Custom {
710 cancel_probability,
711 delay_probability,
712 delay_min_ms,
713 delay_max_ms,
714 io_error_probability,
715 wakeup_storm_probability,
716 budget_exhaustion_probability,
717 } = &self.chaos
718 {
719 for (name, val) in [
720 ("chaos.cancel_probability", cancel_probability),
721 ("chaos.delay_probability", delay_probability),
722 ("chaos.io_error_probability", io_error_probability),
723 ("chaos.wakeup_storm_probability", wakeup_storm_probability),
724 (
725 "chaos.budget_exhaustion_probability",
726 budget_exhaustion_probability,
727 ),
728 ] {
729 if !val.is_finite() {
740 errors.push(ValidationError {
741 field: name.into(),
742 message: format!(
743 "probability must be a finite number in [0.0, 1.0], got {val}"
744 ),
745 });
746 } else if !(0.0..=1.0).contains(val) {
747 errors.push(ValidationError {
748 field: name.into(),
749 message: format!("probability must be in [0.0, 1.0], got {val}"),
750 });
751 }
752 }
753 if *delay_min_ms > *delay_max_ms {
754 errors.push(ValidationError {
755 field: "chaos.delay_min_ms".into(),
756 message: format!(
757 "delay_min_ms ({delay_min_ms}) must be <= delay_max_ms ({delay_max_ms})"
758 ),
759 });
760 }
761 }
762 }
763
764 fn validate_network(&self, errors: &mut Vec<ValidationError>) {
765 for (key, link) in &self.network.links {
766 let key_valid = key
767 .split_once("->")
768 .is_some_and(|(from, to)| !from.is_empty() && !to.is_empty() && !to.contains("->"));
769 if !key_valid {
770 errors.push(ValidationError {
771 field: format!("network.links.{key}"),
772 message: "link key must be in format \"from->to\"".into(),
773 });
774 }
775
776 for (name, value) in [
777 ("packet_loss", link.packet_loss),
778 ("packet_corrupt", link.packet_corrupt),
779 ("packet_duplicate", link.packet_duplicate),
780 ("packet_reorder", link.packet_reorder),
781 ] {
782 if let Some(probability) = value {
783 if !probability.is_finite() || !(0.0..=1.0).contains(&probability) {
784 errors.push(ValidationError {
785 field: format!("network.links.{key}.{name}"),
786 message: format!(
787 "probability must be finite and in [0.0, 1.0], got {probability}"
788 ),
789 });
790 }
791 }
792 }
793
794 if let Some(LatencySpec::Uniform { min_ms, max_ms }) = &link.latency {
795 if min_ms > max_ms {
796 errors.push(ValidationError {
797 field: format!("network.links.{key}.latency"),
798 message: format!(
799 "uniform latency min_ms ({min_ms}) must be <= max_ms ({max_ms})"
800 ),
801 });
802 }
803 }
804 }
805 }
806
807 fn validate_faults(&self, errors: &mut Vec<ValidationError>) {
808 let participant_names: HashSet<&str> =
809 self.participants.iter().map(|p| p.name.as_str()).collect();
810
811 for (index, fault) in self.faults.iter().enumerate() {
812 Self::validate_fault_args(index, fault, &participant_names, errors);
813 }
814
815 for window in self.faults.windows(2) {
816 if window[1].at_ms < window[0].at_ms {
817 errors.push(ValidationError {
818 field: "faults".into(),
819 message: format!(
820 "fault events must be ordered by at_ms: {} comes before {}",
821 window[0].at_ms, window[1].at_ms
822 ),
823 });
824 }
825 }
826 }
827
828 fn validate_fault_args(
829 fault_index: usize,
830 fault: &FaultEvent,
831 participant_names: &HashSet<&str>,
832 errors: &mut Vec<ValidationError>,
833 ) {
834 match &fault.action {
835 FaultAction::Partition | FaultAction::Heal => {
836 let from =
837 Self::required_fault_string_arg(fault_index, &fault.args, "from", errors);
838 let to = Self::required_fault_string_arg(fault_index, &fault.args, "to", errors);
839
840 if let (Some(from), Some(to)) = (from, to) {
841 if from == to {
842 errors.push(ValidationError {
843 field: format!("faults[{fault_index}].args.to"),
844 message: "partition/heal endpoints must be distinct".into(),
845 });
846 }
847 Self::validate_fault_participant_ref(
848 fault_index,
849 "from",
850 from,
851 participant_names,
852 errors,
853 );
854 Self::validate_fault_participant_ref(
855 fault_index,
856 "to",
857 to,
858 participant_names,
859 errors,
860 );
861 }
862 }
863 FaultAction::DiskPressure => {
864 Self::required_fault_string_arg(fault_index, &fault.args, "path", errors);
865 Self::required_fault_u64_arg(fault_index, &fault.args, "bytes", errors);
866 }
867 FaultAction::DiskRecovered => {
868 Self::required_fault_string_arg(fault_index, &fault.args, "path", errors);
869 }
870 FaultAction::DelayedCleanup => {
871 Self::required_fault_string_arg(fault_index, &fault.args, "phase", errors);
872 Self::required_fault_u64_arg(fault_index, &fault.args, "delay_ms", errors);
873 }
874 FaultAction::ProcessStall => {
875 if let Some(host) =
876 Self::required_fault_string_arg(fault_index, &fault.args, "host", errors)
877 {
878 Self::validate_fault_participant_ref(
879 fault_index,
880 "host",
881 host,
882 participant_names,
883 errors,
884 );
885 }
886 Self::required_fault_u64_arg(fault_index, &fault.args, "duration_ms", errors);
887 }
888 FaultAction::ProcessResume => {
889 if let Some(host) =
890 Self::required_fault_string_arg(fault_index, &fault.args, "host", errors)
891 {
892 Self::validate_fault_participant_ref(
893 fault_index,
894 "host",
895 host,
896 participant_names,
897 errors,
898 );
899 }
900 }
901 FaultAction::HostCrash | FaultAction::HostRestart | FaultAction::ClockReset => {
902 if let Some(host) =
903 Self::required_fault_string_arg(fault_index, &fault.args, "host", errors)
904 {
905 Self::validate_fault_participant_ref(
906 fault_index,
907 "host",
908 host,
909 participant_names,
910 errors,
911 );
912 }
913 }
914 FaultAction::ClockSkew => {
915 if let Some(host) =
916 Self::required_fault_string_arg(fault_index, &fault.args, "host", errors)
917 {
918 Self::validate_fault_participant_ref(
919 fault_index,
920 "host",
921 host,
922 participant_names,
923 errors,
924 );
925 }
926 Self::required_fault_i64_arg(fault_index, &fault.args, "skew_ms", errors);
927 }
928 }
929 }
930
931 fn required_fault_u64_arg(
932 fault_index: usize,
933 args: &BTreeMap<String, serde_json::Value>,
934 key: &str,
935 errors: &mut Vec<ValidationError>,
936 ) {
937 let value = args.get(key).and_then(serde_json::Value::as_u64);
938 if value.is_none_or(|value| value == 0) {
939 errors.push(ValidationError {
940 field: format!("faults[{fault_index}].args.{key}"),
941 message: format!("fault action requires positive integer arg `{key}`"),
942 });
943 }
944 }
945
946 fn required_fault_string_arg<'a>(
947 fault_index: usize,
948 args: &'a BTreeMap<String, serde_json::Value>,
949 key: &str,
950 errors: &mut Vec<ValidationError>,
951 ) -> Option<&'a str> {
952 let value = args
953 .get(key)
954 .and_then(serde_json::Value::as_str)
955 .map(str::trim)
956 .filter(|value| !value.is_empty());
957
958 if value.is_none() {
959 errors.push(ValidationError {
960 field: format!("faults[{fault_index}].args.{key}"),
961 message: format!("fault action requires non-empty string arg `{key}`"),
962 });
963 }
964
965 value
966 }
967
968 fn required_fault_i64_arg(
969 fault_index: usize,
970 args: &BTreeMap<String, serde_json::Value>,
971 key: &str,
972 errors: &mut Vec<ValidationError>,
973 ) {
974 if args.get(key).and_then(serde_json::Value::as_i64).is_none() {
975 errors.push(ValidationError {
976 field: format!("faults[{fault_index}].args.{key}"),
977 message: format!("fault action requires integer arg `{key}`"),
978 });
979 }
980 }
981
982 fn validate_fault_participant_ref(
983 fault_index: usize,
984 key: &str,
985 value: &str,
986 participant_names: &HashSet<&str>,
987 errors: &mut Vec<ValidationError>,
988 ) {
989 if participant_names.is_empty() || participant_names.contains(value) {
990 return;
991 }
992
993 errors.push(ValidationError {
994 field: format!("faults[{fault_index}].args.{key}"),
995 message: format!("unknown participant `{value}`"),
996 });
997 }
998
999 fn validate_participants(&self, errors: &mut Vec<ValidationError>) {
1000 let mut seen_names = std::collections::HashSet::new();
1001 for p in &self.participants {
1002 if !seen_names.insert(&p.name) {
1003 errors.push(ValidationError {
1004 field: format!("participants.{}", p.name),
1005 message: "duplicate participant name".into(),
1006 });
1007 }
1008 }
1009 }
1010
1011 fn validate_cancellation(&self, errors: &mut Vec<ValidationError>) {
1012 let Some(ref cancel) = self.cancellation else {
1013 return;
1014 };
1015 match cancel.strategy {
1016 CancellationStrategy::RandomSample
1017 | CancellationStrategy::FirstN
1018 | CancellationStrategy::LastN
1019 | CancellationStrategy::EveryNth => {
1020 if cancel.count.is_none() {
1021 errors.push(ValidationError {
1022 field: "cancellation.count".into(),
1023 message: format!(
1024 "strategy {:?} requires a count parameter",
1025 cancel.strategy
1026 ),
1027 });
1028 } else if cancel.count == Some(0) {
1029 errors.push(ValidationError {
1030 field: "cancellation.count".into(),
1031 message: "count must be >= 1".into(),
1032 });
1033 }
1034 }
1035 CancellationStrategy::Probabilistic => {
1036 if let Some(p) = cancel.probability {
1037 if !p.is_finite() || !(0.0..=1.0).contains(&p) {
1038 errors.push(ValidationError {
1039 field: "cancellation.probability".into(),
1040 message: format!("probability must be in [0.0, 1.0], got {p}"),
1041 });
1042 }
1043 } else {
1044 errors.push(ValidationError {
1045 field: "cancellation.probability".into(),
1046 message: "strategy probabilistic requires a probability parameter".into(),
1047 });
1048 }
1049 }
1050 CancellationStrategy::Never | CancellationStrategy::AllPoints => {}
1051 }
1052 }
1053
1054 fn validate_resource_caps(&self, errors: &mut Vec<ValidationError>) {
1055 if self.resource_caps.max_artifact_bytes == Some(0) {
1056 errors.push(ValidationError {
1057 field: "resource_caps.max_artifact_bytes".into(),
1058 message: "max_artifact_bytes must be >= 1 when set".into(),
1059 });
1060 }
1061
1062 if let Some(max_fault_events) = self.resource_caps.max_fault_events {
1063 if max_fault_events == 0 {
1064 errors.push(ValidationError {
1065 field: "resource_caps.max_fault_events".into(),
1066 message: "max_fault_events must be >= 1 when set".into(),
1067 });
1068 } else if self.faults.len() > max_fault_events {
1069 errors.push(ValidationError {
1070 field: "resource_caps.max_fault_events".into(),
1071 message: format!(
1072 "scenario defines {} fault event(s), exceeding cap {max_fault_events}",
1073 self.faults.len()
1074 ),
1075 });
1076 }
1077 }
1078
1079 if self.resource_caps.max_counterexample_events == Some(0) {
1080 errors.push(ValidationError {
1081 field: "resource_caps.max_counterexample_events".into(),
1082 message: "max_counterexample_events must be >= 1 when set".into(),
1083 });
1084 }
1085 }
1086
1087 fn validate_expected_invariants(&self, errors: &mut Vec<ValidationError>) {
1088 if self.expected_invariants.is_empty() {
1089 errors.push(ValidationError {
1090 field: "expected_invariants".into(),
1091 message: "at least one expected invariant is required".into(),
1092 });
1093 return;
1094 }
1095
1096 let mut seen = HashSet::new();
1097 for (index, invariant) in self.expected_invariants.iter().enumerate() {
1098 let invariant = invariant.trim();
1099 if invariant.is_empty() {
1100 errors.push(ValidationError {
1101 field: format!("expected_invariants[{index}]"),
1102 message: "expected invariant name must not be empty".into(),
1103 });
1104 continue;
1105 }
1106
1107 if !SUPPORTED_EXPECTED_INVARIANTS.contains(&invariant) {
1108 errors.push(ValidationError {
1109 field: format!("expected_invariants[{index}]"),
1110 message: format!("unsupported expected invariant `{invariant}`"),
1111 });
1112 }
1113
1114 if !seen.insert(invariant) {
1115 errors.push(ValidationError {
1116 field: format!("expected_invariants[{index}]"),
1117 message: format!("duplicate expected invariant `{invariant}`"),
1118 });
1119 }
1120 }
1121 }
1122
1123 fn validate_minimization(&self, errors: &mut Vec<ValidationError>) {
1124 if self.minimization.enabled {
1125 match self.minimization.max_evaluations {
1126 Some(0) => errors.push(ValidationError {
1127 field: "minimization.max_evaluations".into(),
1128 message: "enabled minimization requires max_evaluations >= 1".into(),
1129 }),
1130 None => errors.push(ValidationError {
1131 field: "minimization.max_evaluations".into(),
1132 message: "enabled minimization requires max_evaluations".into(),
1133 }),
1134 Some(_) => {}
1135 }
1136 }
1137
1138 if self.minimization.max_counterexample_events == Some(0) {
1139 errors.push(ValidationError {
1140 field: "minimization.max_counterexample_events".into(),
1141 message: "max_counterexample_events must be >= 1 when set".into(),
1142 });
1143 }
1144 }
1145
1146 fn validate_golden_projection(&self, errors: &mut Vec<ValidationError>) {
1147 if !self.golden_projection.canonicalized {
1148 errors.push(ValidationError {
1149 field: "golden_projection.canonicalized".into(),
1150 message: "golden projection must be canonicalized".into(),
1151 });
1152 }
1153 if !self.golden_projection.redacted {
1154 errors.push(ValidationError {
1155 field: "golden_projection.redacted".into(),
1156 message: "golden projection must be redacted".into(),
1157 });
1158 }
1159 }
1160
1161 fn validate_includes(&self, errors: &mut Vec<ValidationError>) {
1162 for (index, include) in self.include.iter().enumerate() {
1163 let field = format!("include[{index}].path");
1164
1165 if include.path.is_empty() {
1167 errors.push(ValidationError {
1168 field: field.clone(),
1169 message: "include path must not be empty".into(),
1170 });
1171 continue;
1172 }
1173
1174 if include.path.starts_with('/') || include.path.starts_with('\\') {
1176 errors.push(ValidationError {
1177 field: field.clone(),
1178 message: "include path must not be absolute (no leading / or \\)".into(),
1179 });
1180 continue;
1181 }
1182
1183 if include.path.contains("..") {
1185 errors.push(ValidationError {
1186 field: field.clone(),
1187 message: "include path must not contain '..' (path traversal attack)".into(),
1188 });
1189 continue;
1190 }
1191
1192 if include.path.chars().any(|c| c.is_control() || c == '\0') {
1194 errors.push(ValidationError {
1195 field: field.clone(),
1196 message: "include path must not contain control characters or null bytes"
1197 .into(),
1198 });
1199 continue;
1200 }
1201
1202 let allowed_chars = |c: char| c.is_alphanumeric() || matches!(c, '.' | '_' | '-' | '/');
1204 if !include.path.chars().all(allowed_chars) {
1205 errors.push(ValidationError {
1206 field: field.clone(),
1207 message: "include path contains invalid characters (only alphanumeric, '.', '_', '-', '/' allowed)".into(),
1208 });
1209 continue;
1210 }
1211
1212 if include.path.len() > 255 {
1214 errors.push(ValidationError {
1215 field: field.clone(),
1216 message: "include path too long (maximum 255 characters)".into(),
1217 });
1218 continue;
1219 }
1220
1221 let has_yaml_extension = std::path::Path::new(&include.path)
1223 .extension()
1224 .and_then(|extension| extension.to_str())
1225 .is_some_and(|extension| {
1226 extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml")
1227 });
1228 if !has_yaml_extension {
1229 errors.push(ValidationError {
1230 field,
1231 message: "include path must end with .yaml or .yml extension".into(),
1232 });
1233 }
1234 }
1235 }
1236
1237 #[must_use]
1239 pub fn to_lab_config(&self) -> super::config::LabConfig {
1240 let mut config = super::config::LabConfig::new(self.lab.seed)
1241 .worker_count(self.lab.worker_count)
1242 .trace_capacity(self.lab.trace_capacity)
1243 .panic_on_leak(self.lab.panic_on_obligation_leak)
1244 .panic_on_futurelock(self.lab.panic_on_futurelock)
1245 .futurelock_max_idle_steps(self.lab.futurelock_max_idle_steps);
1246
1247 if let Some(entropy) = self.lab.entropy_seed {
1248 config = config.entropy_seed(entropy);
1249 }
1250
1251 if let Some(max) = self.lab.max_steps {
1252 config = config.max_steps(max);
1253 } else {
1254 config = config.no_step_limit();
1255 }
1256
1257 config = match &self.chaos {
1259 ChaosSection::Off => config,
1260 ChaosSection::Light => config.with_light_chaos(),
1261 ChaosSection::Heavy => config.with_heavy_chaos(),
1262 ChaosSection::Custom {
1263 cancel_probability,
1264 delay_probability,
1265 delay_min_ms,
1266 delay_max_ms,
1267 io_error_probability,
1268 wakeup_storm_probability,
1269 budget_exhaustion_probability,
1270 } => {
1271 use std::time::Duration;
1272 let chaos_seed = self.lab.entropy_seed.unwrap_or(self.lab.seed);
1273 let chaos = crate::lab::chaos::ChaosConfig::new(chaos_seed)
1274 .with_cancel_probability(*cancel_probability)
1275 .with_delay_probability(*delay_probability)
1276 .with_delay_range(
1277 Duration::from_millis(*delay_min_ms)..Duration::from_millis(*delay_max_ms),
1278 )
1279 .with_io_error_probability(*io_error_probability)
1280 .with_wakeup_storm_probability(*wakeup_storm_probability)
1281 .with_budget_exhaust_probability(*budget_exhaustion_probability);
1282 config.with_chaos(chaos)
1283 }
1284 };
1285
1286 if self.lab.replay_recording {
1287 config = config.with_default_replay_recording();
1288 }
1289
1290 config
1291 }
1292
1293 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
1299 serde_json::from_str(json)
1300 }
1301
1302 pub fn to_json(&self) -> Result<String, serde_json::Error> {
1315 let value = serde_json::to_value(self)?;
1316 serde_json::to_string(&canonicalize_json_value(value))
1317 }
1318}
1319
1320fn canonicalize_json_value(value: serde_json::Value) -> serde_json::Value {
1321 match value {
1322 serde_json::Value::Array(values) => {
1323 serde_json::Value::Array(values.into_iter().map(canonicalize_json_value).collect())
1324 }
1325 serde_json::Value::Object(values) => {
1326 let mut entries: Vec<_> = values.into_iter().collect();
1327 entries.sort_unstable_by(|left, right| left.0.cmp(&right.0));
1328
1329 let mut canonical = serde_json::Map::new();
1330 for (key, value) in entries {
1331 canonical.insert(key, canonicalize_json_value(value));
1332 }
1333 serde_json::Value::Object(canonical)
1334 }
1335 scalar => scalar,
1336 }
1337}
1338
1339#[cfg(test)]
1344mod tests {
1345 #![allow(
1346 clippy::pedantic,
1347 clippy::nursery,
1348 clippy::expect_fun_call,
1349 clippy::map_unwrap_or,
1350 clippy::cast_possible_wrap,
1351 clippy::future_not_send
1352 )]
1353 use super::*;
1354
1355 fn minimal_json() -> &'static str {
1356 r#"{
1357 "id": "test-scenario",
1358 "description": "minimal test"
1359 }"#
1360 }
1361
1362 #[test]
1363 fn parse_minimal_scenario() {
1364 let s: Scenario = serde_json::from_str(minimal_json()).unwrap();
1365 assert_eq!(s.id, "test-scenario");
1366 assert_eq!(s.schema_version, 1);
1367 assert_eq!(s.lab.seed, 42);
1368 assert_eq!(s.lab.worker_count, 1);
1369 assert!(s.faults.is_empty());
1370 assert!(s.participants.is_empty());
1371 assert_eq!(s.oracles, vec!["all"]);
1372 assert_eq!(s.resource_caps, ResourceCapsSection::default());
1373 assert_eq!(s.expected_invariants, default_expected_invariants());
1374 assert_eq!(s.minimization, MinimizationSection::default());
1375 assert_eq!(s.golden_projection, GoldenProjectionSection::default());
1376 }
1377
1378 #[test]
1379 fn validate_minimal_scenario() {
1380 let s: Scenario = serde_json::from_str(minimal_json()).unwrap();
1381 let errors = s.validate();
1382 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1383 }
1384
1385 #[test]
1386 fn validate_empty_id_rejected() {
1387 let json = r#"{"id": "", "description": "bad"}"#;
1388 let s: Scenario = serde_json::from_str(json).unwrap();
1389 let errors = s.validate();
1390 assert!(errors.iter().any(|e| e.field == "id"));
1391 }
1392
1393 #[test]
1394 fn validate_bad_schema_version() {
1395 let json = r#"{"schema_version": 99, "id": "x"}"#;
1396 let s: Scenario = serde_json::from_str(json).unwrap();
1397 let errors = s.validate();
1398 assert!(errors.iter().any(|e| e.field == "schema_version"));
1399 }
1400
1401 #[test]
1402 fn parse_chaos_preset_light() {
1403 let json = r#"{"id": "x", "chaos": {"preset": "light"}}"#;
1404 let s: Scenario = serde_json::from_str(json).unwrap();
1405 assert!(matches!(s.chaos, ChaosSection::Light));
1406 }
1407
1408 #[test]
1409 fn parse_chaos_custom() {
1410 let json = r#"{
1411 "id": "x",
1412 "chaos": {
1413 "preset": "custom",
1414 "cancel_probability": 0.05,
1415 "delay_probability": 0.3,
1416 "io_error_probability": 0.1
1417 }
1418 }"#;
1419 let s: Scenario = serde_json::from_str(json).unwrap();
1420 match s.chaos {
1421 ChaosSection::Custom {
1422 cancel_probability,
1423 delay_probability,
1424 io_error_probability,
1425 ..
1426 } => {
1427 assert!((cancel_probability - 0.05).abs() < f64::EPSILON);
1428 assert!((delay_probability - 0.3).abs() < f64::EPSILON);
1429 assert!((io_error_probability - 0.1).abs() < f64::EPSILON);
1430 }
1431 other => panic!("expected Custom, got {other:?}"), }
1433 }
1434
1435 #[test]
1436 fn validate_chaos_bad_probability() {
1437 let json = r#"{
1438 "id": "x",
1439 "chaos": {"preset": "custom", "cancel_probability": 1.5}
1440 }"#;
1441 let s: Scenario = serde_json::from_str(json).unwrap();
1442 let errors = s.validate();
1443 assert!(errors.iter().any(|e| e.field == "chaos.cancel_probability"));
1444 }
1445
1446 #[test]
1454 fn validate_chaos_rejects_nan_probability_with_finite_error() {
1455 let mut s: Scenario = serde_json::from_str(r#"{"id":"x"}"#).unwrap();
1458 s.chaos = ChaosSection::Custom {
1459 cancel_probability: f64::NAN,
1460 delay_probability: 0.0,
1461 delay_min_ms: 0,
1462 delay_max_ms: 1,
1463 io_error_probability: 0.0,
1464 wakeup_storm_probability: 0.0,
1465 budget_exhaustion_probability: 0.0,
1466 };
1467 let errors = s.validate();
1468 let nan_error = errors
1469 .iter()
1470 .find(|e| e.field == "chaos.cancel_probability")
1471 .expect("chaos.cancel_probability NaN must be flagged");
1472 assert!(
1473 nan_error.message.contains("finite"),
1474 "NaN error message must say 'finite', got: {}",
1475 nan_error.message
1476 );
1477 assert!(
1478 nan_error.message.contains("NaN"),
1479 "NaN error message must include 'NaN', got: {}",
1480 nan_error.message
1481 );
1482 }
1483
1484 #[test]
1485 fn validate_chaos_rejects_infinity_probability_with_finite_error() {
1486 let mut s: Scenario = serde_json::from_str(r#"{"id":"x"}"#).unwrap();
1487 s.chaos = ChaosSection::Custom {
1488 cancel_probability: 0.0,
1489 delay_probability: 0.0,
1490 delay_min_ms: 0,
1491 delay_max_ms: 1,
1492 io_error_probability: 0.0,
1493 wakeup_storm_probability: f64::INFINITY,
1494 budget_exhaustion_probability: f64::NEG_INFINITY,
1495 };
1496 let errors = s.validate();
1497 let inf_storm = errors
1498 .iter()
1499 .find(|e| e.field == "chaos.wakeup_storm_probability")
1500 .expect("chaos.wakeup_storm_probability +Inf must be flagged");
1501 assert!(
1502 inf_storm.message.contains("finite"),
1503 "+Inf error must say 'finite', got: {}",
1504 inf_storm.message
1505 );
1506 let neg_inf_budget = errors
1507 .iter()
1508 .find(|e| e.field == "chaos.budget_exhaustion_probability")
1509 .expect("chaos.budget_exhaustion_probability -Inf must be flagged");
1510 assert!(
1511 neg_inf_budget.message.contains("finite"),
1512 "-Inf error must say 'finite', got: {}",
1513 neg_inf_budget.message
1514 );
1515 }
1516
1517 #[test]
1518 fn parse_network_preset_wan() {
1519 let json = r#"{"id": "x", "network": {"preset": "wan"}}"#;
1520 let s: Scenario = serde_json::from_str(json).unwrap();
1521 assert_eq!(s.network.preset, NetworkPreset::Wan);
1522 }
1523
1524 #[test]
1525 fn parse_network_link_override() {
1526 let json = r#"{
1527 "id": "x",
1528 "network": {
1529 "preset": "lan",
1530 "links": {
1531 "alice->bob": { "packet_loss": 0.5 }
1532 }
1533 }
1534 }"#;
1535 let s: Scenario = serde_json::from_str(json).unwrap();
1536 let link = s.network.links.get("alice->bob").unwrap();
1537 assert!((link.packet_loss.unwrap() - 0.5).abs() < f64::EPSILON);
1538 }
1539
1540 #[test]
1541 fn validate_bad_link_key() {
1542 let json = r#"{
1543 "id": "x",
1544 "network": {"links": {"alice_bob": {}}}
1545 }"#;
1546 let s: Scenario = serde_json::from_str(json).unwrap();
1547 let errors = s.validate();
1548 assert!(errors.iter().any(|e| e.field.contains("network.links")));
1549 }
1550
1551 #[test]
1552 fn validate_link_probability_out_of_range() {
1553 let json = r#"{
1554 "id": "x",
1555 "network": {
1556 "links": {
1557 "alice->bob": { "packet_loss": 1.5 }
1558 }
1559 }
1560 }"#;
1561 let s: Scenario = serde_json::from_str(json).unwrap();
1562 let errors = s.validate();
1563 assert!(
1564 errors
1565 .iter()
1566 .any(|e| e.field == "network.links.alice->bob.packet_loss")
1567 );
1568 }
1569
1570 #[test]
1571 fn validate_uniform_latency_min_max_order() {
1572 let json = r#"{
1573 "id": "x",
1574 "network": {
1575 "links": {
1576 "alice->bob": {
1577 "latency": { "model": "uniform", "min_ms": 20, "max_ms": 10 }
1578 }
1579 }
1580 }
1581 }"#;
1582 let s: Scenario = serde_json::from_str(json).unwrap();
1583 let errors = s.validate();
1584 assert!(
1585 errors
1586 .iter()
1587 .any(|e| e.field == "network.links.alice->bob.latency")
1588 );
1589 }
1590
1591 #[test]
1592 fn parse_fault_events() {
1593 let json = r#"{
1594 "id": "x",
1595 "faults": [
1596 {"at_ms": 100, "action": "partition", "args": {"from": "a", "to": "b"}},
1597 {"at_ms": 500, "action": "heal", "args": {"from": "a", "to": "b"}}
1598 ]
1599 }"#;
1600 let s: Scenario = serde_json::from_str(json).unwrap();
1601 assert_eq!(s.faults.len(), 2);
1602 assert_eq!(s.faults[0].at_ms, 100);
1603 assert!(matches!(s.faults[0].action, FaultAction::Partition));
1604 assert_eq!(s.faults[1].at_ms, 500);
1605 assert!(matches!(s.faults[1].action, FaultAction::Heal));
1606 }
1607
1608 #[test]
1609 fn validate_unordered_faults() {
1610 let json = r#"{
1611 "id": "x",
1612 "faults": [
1613 {"at_ms": 500, "action": "partition"},
1614 {"at_ms": 100, "action": "heal"}
1615 ]
1616 }"#;
1617 let s: Scenario = serde_json::from_str(json).unwrap();
1618 let errors = s.validate();
1619 assert!(errors.iter().any(|e| e.field == "faults"));
1620 }
1621
1622 #[test]
1623 fn validate_fault_action_args_fail_closed() {
1624 let json = r#"{
1625 "id": "x",
1626 "faults": [
1627 {"at_ms": 1, "action": "partition"},
1628 {"at_ms": 2, "action": "host_crash", "args": {"host": ""}},
1629 {"at_ms": 3, "action": "clock_skew", "args": {"host": "alice", "skew_ms": "fast"}},
1630 {"at_ms": 4, "action": "disk_pressure", "args": {"path": "", "bytes": 0}},
1631 {"at_ms": 5, "action": "delayed_cleanup", "args": {"phase": "", "delay_ms": 0}},
1632 {"at_ms": 6, "action": "process_stall", "args": {"host": "", "duration_ms": 0}}
1633 ]
1634 }"#;
1635 let s: Scenario = serde_json::from_str(json).unwrap();
1636 let errors = s.validate();
1637
1638 assert!(errors.iter().any(|e| e.field == "faults[0].args.from"));
1639 assert!(errors.iter().any(|e| e.field == "faults[0].args.to"));
1640 assert!(errors.iter().any(|e| e.field == "faults[1].args.host"));
1641 assert!(errors.iter().any(|e| e.field == "faults[2].args.skew_ms"));
1642 assert!(errors.iter().any(|e| e.field == "faults[3].args.path"));
1643 assert!(errors.iter().any(|e| e.field == "faults[3].args.bytes"));
1644 assert!(errors.iter().any(|e| e.field == "faults[4].args.phase"));
1645 assert!(errors.iter().any(|e| e.field == "faults[4].args.delay_ms"));
1646 assert!(errors.iter().any(|e| e.field == "faults[5].args.host"));
1647 assert!(
1648 errors
1649 .iter()
1650 .any(|e| e.field == "faults[5].args.duration_ms")
1651 );
1652 }
1653
1654 #[test]
1655 fn validate_fault_args_reference_declared_participants() {
1656 let json = r#"{
1657 "id": "x",
1658 "participants": [
1659 {"name": "alice"},
1660 {"name": "bob"}
1661 ],
1662 "faults": [
1663 {"at_ms": 1, "action": "partition", "args": {"from": "alice", "to": "mallory"}},
1664 {"at_ms": 2, "action": "heal", "args": {"from": "bob", "to": "bob"}},
1665 {"at_ms": 3, "action": "clock_reset", "args": {"host": "mallory"}},
1666 {"at_ms": 4, "action": "process_stall", "args": {"host": "mallory", "duration_ms": 10}}
1667 ]
1668 }"#;
1669 let s: Scenario = serde_json::from_str(json).unwrap();
1670 let errors = s.validate();
1671
1672 assert!(errors.iter().any(|e| {
1673 e.field == "faults[0].args.to" && e.message.contains("unknown participant")
1674 }));
1675 assert!(
1676 errors
1677 .iter()
1678 .any(|e| { e.field == "faults[1].args.to" && e.message.contains("distinct") })
1679 );
1680 assert!(errors.iter().any(|e| {
1681 e.field == "faults[2].args.host" && e.message.contains("unknown participant")
1682 }));
1683 assert!(errors.iter().any(|e| {
1684 e.field == "faults[3].args.host" && e.message.contains("unknown participant")
1685 }));
1686 }
1687
1688 #[test]
1689 fn parse_disk_process_and_cleanup_fault_events() {
1690 let json = r#"{
1691 "id": "x",
1692 "participants": [{"name": "worker-a"}],
1693 "faults": [
1694 {"at_ms": 10, "action": "disk_pressure", "args": {"path": "target/proof", "bytes": 4096}},
1695 {"at_ms": 20, "action": "delayed_cleanup", "args": {"phase": "finalizers", "delay_ms": 25}},
1696 {"at_ms": 30, "action": "process_stall", "args": {"host": "worker-a", "duration_ms": 40}},
1697 {"at_ms": 80, "action": "process_resume", "args": {"host": "worker-a"}},
1698 {"at_ms": 90, "action": "disk_recovered", "args": {"path": "target/proof"}}
1699 ]
1700 }"#;
1701 let s: Scenario = serde_json::from_str(json).unwrap();
1702 assert_eq!(s.faults.len(), 5);
1703 assert!(matches!(s.faults[0].action, FaultAction::DiskPressure));
1704 assert!(matches!(s.faults[1].action, FaultAction::DelayedCleanup));
1705 assert!(matches!(s.faults[2].action, FaultAction::ProcessStall));
1706 assert!(matches!(s.faults[3].action, FaultAction::ProcessResume));
1707 assert!(matches!(s.faults[4].action, FaultAction::DiskRecovered));
1708 assert!(
1709 s.validate().is_empty(),
1710 "new DSL fault actions must validate"
1711 );
1712 }
1713
1714 #[test]
1715 fn parse_participants() {
1716 let json = r#"{
1717 "id": "x",
1718 "participants": [
1719 {"name": "alice", "role": "sender"},
1720 {"name": "bob", "role": "receiver"}
1721 ]
1722 }"#;
1723 let s: Scenario = serde_json::from_str(json).unwrap();
1724 assert_eq!(s.participants.len(), 2);
1725 assert_eq!(s.participants[0].name, "alice");
1726 assert_eq!(s.participants[1].role, "receiver");
1727 }
1728
1729 #[test]
1730 fn validate_duplicate_participant() {
1731 let json = r#"{
1732 "id": "x",
1733 "participants": [
1734 {"name": "alice"},
1735 {"name": "alice"}
1736 ]
1737 }"#;
1738 let s: Scenario = serde_json::from_str(json).unwrap();
1739 let errors = s.validate();
1740 assert!(errors.iter().any(|e| e.message.contains("duplicate")));
1741 }
1742
1743 #[test]
1744 fn parse_cancellation_strategy() {
1745 let json = r#"{
1746 "id": "x",
1747 "cancellation": {
1748 "strategy": "random_sample",
1749 "count": 100
1750 }
1751 }"#;
1752 let s: Scenario = serde_json::from_str(json).unwrap();
1753 let cancel = s.cancellation.as_ref().unwrap();
1754 assert!(matches!(
1755 cancel.strategy,
1756 CancellationStrategy::RandomSample
1757 ));
1758 assert_eq!(cancel.count, Some(100));
1759 }
1760
1761 #[test]
1762 fn validate_missing_count() {
1763 let json = r#"{
1764 "id": "x",
1765 "cancellation": {"strategy": "random_sample"}
1766 }"#;
1767 let s: Scenario = serde_json::from_str(json).unwrap();
1768 let errors = s.validate();
1769 assert!(errors.iter().any(|e| e.field == "cancellation.count"));
1770 }
1771
1772 #[test]
1773 fn parse_source_backed_dsl_fields() {
1774 let json = r#"{
1775 "id": "chaos-partition-cancel-storm",
1776 "description": "partition plus cancellation storm",
1777 "lab": {"seed": 340334, "worker_count": 2, "max_steps": 1000},
1778 "participants": [
1779 {"name": "alice", "role": "sender"},
1780 {"name": "bob", "role": "receiver"}
1781 ],
1782 "faults": [
1783 {"at_ms": 100, "action": "partition", "args": {"from": "alice", "to": "bob"}},
1784 {"at_ms": 500, "action": "heal", "args": {"from": "alice", "to": "bob"}}
1785 ],
1786 "cancellation": {"strategy": "random_sample", "count": 8},
1787 "resource_caps": {
1788 "max_artifact_bytes": 65536,
1789 "max_fault_events": 8,
1790 "max_counterexample_events": 16
1791 },
1792 "expected_invariants": [
1793 "quiescence",
1794 "losers_drained",
1795 "no_obligation_leaks",
1796 "deterministic_replay"
1797 ],
1798 "minimization": {
1799 "enabled": true,
1800 "max_evaluations": 64,
1801 "max_counterexample_events": 16
1802 },
1803 "golden_projection": {
1804 "format": "json",
1805 "canonicalized": true,
1806 "redacted": true
1807 }
1808 }"#;
1809
1810 let s: Scenario = serde_json::from_str(json).unwrap();
1811 assert_eq!(s.lab.seed, 340_334);
1812 assert_eq!(s.resource_caps.max_artifact_bytes, Some(65_536));
1813 assert_eq!(s.resource_caps.max_fault_events, Some(8));
1814 assert_eq!(s.resource_caps.max_counterexample_events, Some(16));
1815 assert_eq!(
1816 s.expected_invariants,
1817 vec![
1818 "quiescence".to_string(),
1819 "losers_drained".to_string(),
1820 "no_obligation_leaks".to_string(),
1821 "deterministic_replay".to_string()
1822 ]
1823 );
1824 assert!(s.minimization.enabled);
1825 assert_eq!(s.minimization.max_evaluations, Some(64));
1826 assert_eq!(s.minimization.max_counterexample_events, Some(16));
1827 assert_eq!(s.golden_projection.format, GoldenProjectionFormat::Json);
1828 assert!(s.golden_projection.canonicalized);
1829 assert!(s.golden_projection.redacted);
1830 assert!(
1831 s.validate().is_empty(),
1832 "source-backed scenario must validate"
1833 );
1834 }
1835
1836 #[test]
1837 fn validate_resource_caps_bound_fault_count() {
1838 let json = r#"{
1839 "id": "x",
1840 "participants": [{"name": "alice"}, {"name": "bob"}],
1841 "faults": [
1842 {"at_ms": 1, "action": "partition", "args": {"from": "alice", "to": "bob"}},
1843 {"at_ms": 2, "action": "heal", "args": {"from": "alice", "to": "bob"}}
1844 ],
1845 "resource_caps": {
1846 "max_artifact_bytes": 0,
1847 "max_fault_events": 1,
1848 "max_counterexample_events": 0
1849 }
1850 }"#;
1851
1852 let s: Scenario = serde_json::from_str(json).unwrap();
1853 let errors = s.validate();
1854 assert!(
1855 errors
1856 .iter()
1857 .any(|e| e.field == "resource_caps.max_artifact_bytes")
1858 );
1859 assert!(
1860 errors
1861 .iter()
1862 .any(|e| e.field == "resource_caps.max_fault_events")
1863 );
1864 assert!(
1865 errors
1866 .iter()
1867 .any(|e| e.field == "resource_caps.max_counterexample_events")
1868 );
1869 }
1870
1871 #[test]
1872 fn validate_expected_invariants_fail_closed() {
1873 let json = r#"{
1874 "id": "x",
1875 "expected_invariants": ["quiescence", "", "quiescence", "mystery"]
1876 }"#;
1877
1878 let s: Scenario = serde_json::from_str(json).unwrap();
1879 let errors = s.validate();
1880 assert!(errors.iter().any(|e| {
1881 e.field == "expected_invariants[1]" && e.message.contains("must not be empty")
1882 }));
1883 assert!(
1884 errors.iter().any(|e| {
1885 e.field == "expected_invariants[2]" && e.message.contains("duplicate")
1886 })
1887 );
1888 assert!(
1889 errors.iter().any(|e| {
1890 e.field == "expected_invariants[3]" && e.message.contains("unsupported")
1891 })
1892 );
1893 }
1894
1895 #[test]
1896 fn validate_minimization_requires_positive_budget_when_enabled() {
1897 let json = r#"{
1898 "id": "x",
1899 "minimization": {
1900 "enabled": true,
1901 "max_counterexample_events": 0
1902 }
1903 }"#;
1904
1905 let s: Scenario = serde_json::from_str(json).unwrap();
1906 let errors = s.validate();
1907 assert!(
1908 errors
1909 .iter()
1910 .any(|e| e.field == "minimization.max_evaluations")
1911 );
1912 assert!(
1913 errors
1914 .iter()
1915 .any(|e| e.field == "minimization.max_counterexample_events")
1916 );
1917 }
1918
1919 #[test]
1920 fn validate_golden_projection_requires_canonical_redacted_output() {
1921 let json = r#"{
1922 "id": "x",
1923 "golden_projection": {
1924 "format": "markdown",
1925 "canonicalized": false,
1926 "redacted": false
1927 }
1928 }"#;
1929
1930 let s: Scenario = serde_json::from_str(json).unwrap();
1931 let errors = s.validate();
1932 assert!(
1933 errors
1934 .iter()
1935 .any(|e| e.field == "golden_projection.canonicalized")
1936 );
1937 assert!(
1938 errors
1939 .iter()
1940 .any(|e| e.field == "golden_projection.redacted")
1941 );
1942 }
1943
1944 #[test]
1945 fn validate_includes_path_traversal_security() {
1946 let json = r#"{
1948 "id": "test",
1949 "include": [{"path": ""}]
1950 }"#;
1951 let s: Scenario = serde_json::from_str(json).unwrap();
1952 let errors = s.validate();
1953 assert!(
1954 errors
1955 .iter()
1956 .any(|e| e.field == "include[0].path" && e.message.contains("empty"))
1957 );
1958
1959 let json = r#"{
1961 "id": "test",
1962 "include": [{"path": "/etc/passwd"}]
1963 }"#;
1964 let s: Scenario = serde_json::from_str(json).unwrap();
1965 let errors = s.validate();
1966 assert!(
1967 errors
1968 .iter()
1969 .any(|e| e.field == "include[0].path" && e.message.contains("absolute"))
1970 );
1971
1972 let json = r#"{
1974 "id": "test",
1975 "include": [{"path": "\\windows\\system32\\config\\sam"}]
1976 }"#;
1977 let s: Scenario = serde_json::from_str(json).unwrap();
1978 let errors = s.validate();
1979 assert!(
1980 errors
1981 .iter()
1982 .any(|e| e.field == "include[0].path" && e.message.contains("absolute"))
1983 );
1984
1985 let json = r#"{
1987 "id": "test",
1988 "include": [{"path": "../../../etc/passwd.yaml"}]
1989 }"#;
1990 let s: Scenario = serde_json::from_str(json).unwrap();
1991 let errors = s.validate();
1992 assert!(
1993 errors
1994 .iter()
1995 .any(|e| e.field == "include[0].path" && e.message.contains("path traversal"))
1996 );
1997
1998 let json = r#"{
2000 "id": "test",
2001 "include": [{"path": "configs/../secrets.yaml"}]
2002 }"#;
2003 let s: Scenario = serde_json::from_str(json).unwrap();
2004 let errors = s.validate();
2005 assert!(
2006 errors
2007 .iter()
2008 .any(|e| e.field == "include[0].path" && e.message.contains("path traversal"))
2009 );
2010
2011 let json = r#"{
2013 "id": "test",
2014 "include": [{"path": "config\u0000.yaml"}]
2015 }"#;
2016 let s: Scenario = serde_json::from_str(json).unwrap();
2017 let errors = s.validate();
2018 assert!(
2019 errors
2020 .iter()
2021 .any(|e| e.field == "include[0].path" && e.message.contains("control characters"))
2022 );
2023
2024 let json = r#"{
2026 "id": "test",
2027 "include": [{"path": "config$evil.yaml"}]
2028 }"#;
2029 let s: Scenario = serde_json::from_str(json).unwrap();
2030 let errors = s.validate();
2031 assert!(
2032 errors
2033 .iter()
2034 .any(|e| e.field == "include[0].path" && e.message.contains("invalid characters"))
2035 );
2036
2037 let long_path = "a".repeat(256) + ".yaml";
2039 let json = format!(
2040 r#"{{"id": "test", "include": [{{"path": "{}"}}]}}"#,
2041 long_path
2042 );
2043 let s: Scenario = serde_json::from_str(&json).unwrap();
2044 let errors = s.validate();
2045 assert!(
2046 errors
2047 .iter()
2048 .any(|e| e.field == "include[0].path" && e.message.contains("too long"))
2049 );
2050
2051 let json = r#"{
2053 "id": "test",
2054 "include": [{"path": "config.txt"}]
2055 }"#;
2056 let s: Scenario = serde_json::from_str(json).unwrap();
2057 let errors = s.validate();
2058 assert!(
2059 errors.iter().any(|e| e.field == "include[0].path"
2060 && e.message.contains("must end with .yaml or .yml"))
2061 );
2062
2063 let json = r#"{
2065 "id": "test",
2066 "include": [{"path": "config/base.yaml"}]
2067 }"#;
2068 let s: Scenario = serde_json::from_str(json).unwrap();
2069 let errors = s.validate();
2070 assert!(
2071 errors
2072 .iter()
2073 .all(|e| !e.field.starts_with("include[0].path"))
2074 );
2075 }
2076
2077 #[test]
2078 fn to_lab_config_defaults() {
2079 let s: Scenario = serde_json::from_str(minimal_json()).unwrap();
2080 let config = s.to_lab_config();
2081 assert_eq!(config.seed, 42);
2082 assert_eq!(config.worker_count, 1);
2083 assert_eq!(config.trace_capacity, 4096);
2084 assert!(config.panic_on_obligation_leak);
2085 }
2086
2087 #[test]
2088 fn to_lab_config_chaos_light() {
2089 let json = r#"{"id": "x", "chaos": {"preset": "light"}}"#;
2090 let s: Scenario = serde_json::from_str(json).unwrap();
2091 let config = s.to_lab_config();
2092 assert!(config.has_chaos());
2093 }
2094
2095 #[test]
2096 fn to_lab_config_custom_seed() {
2097 let json = r#"{"id": "x", "lab": {"seed": 12345, "worker_count": 4}}"#;
2098 let s: Scenario = serde_json::from_str(json).unwrap();
2099 let config = s.to_lab_config();
2100 assert_eq!(config.seed, 12345);
2101 assert_eq!(config.worker_count, 4);
2102 }
2103
2104 #[test]
2105 fn canonical_contract_full_json_roundtrip() {
2106 let json = r#"{
2107 "id": "roundtrip-test",
2108 "description": "full roundtrip",
2109 "lab": {"seed": 99, "worker_count": 2},
2110 "chaos": {"preset": "heavy"},
2111 "network": {"preset": "wan"},
2112 "participants": [
2113 {"name": "alice", "role": "sender"},
2114 {"name": "bob", "role": "receiver"}
2115 ],
2116 "faults": [{
2117 "at_ms": 100,
2118 "action": "partition",
2119 "args": {"from": "alice", "to": "bob"}
2120 }],
2121 "resource_caps": {"max_artifact_bytes": 1024, "max_fault_events": 2},
2122 "expected_invariants": ["quiescence", "deterministic_replay"],
2123 "minimization": {"enabled": false, "max_counterexample_events": 8},
2124 "golden_projection": {"format": "markdown", "canonicalized": true, "redacted": true}
2125 }"#;
2126 let s1: Scenario = serde_json::from_str(json).unwrap();
2127 assert!(s1.validate().is_empty());
2128 let serialized = s1.to_json().unwrap();
2129 let s2: Scenario = Scenario::from_json(&serialized).unwrap();
2130 assert_eq!(s1, s2);
2131 }
2132
2133 #[test]
2134 fn canonical_contract_matches_byte_golden() {
2135 let scenario = Scenario::from_json(minimal_json()).unwrap();
2136 let canonical = scenario.to_json().unwrap();
2137
2138 assert_eq!(
2139 canonical,
2140 r#"{"cancellation":null,"chaos":{"preset":"off"},"description":"minimal test","expected_invariants":["quiescence","losers_drained","no_obligation_leaks","deterministic_replay"],"faults":[],"golden_projection":{"canonicalized":true,"format":"json","redacted":true},"id":"test-scenario","include":[],"lab":{"entropy_seed":null,"futurelock_max_idle_steps":10000,"max_steps":100000,"panic_on_futurelock":true,"panic_on_obligation_leak":true,"replay_recording":false,"seed":42,"trace_capacity":4096,"worker_count":1},"metadata":{},"minimization":{"enabled":false,"max_counterexample_events":null,"max_evaluations":null},"network":{"links":{},"preset":"ideal"},"oracles":["all"],"participants":[],"resource_caps":{"max_artifact_bytes":null,"max_counterexample_events":null,"max_fault_events":null},"schema_version":1}"#
2141 );
2142 }
2143
2144 #[test]
2145 fn canonical_contract_orders_dynamic_objects_recursively() {
2146 let value = serde_json::json!({
2147 "z": {"beta": 2, "alpha": 1},
2148 "a": [{"delta": 4, "charlie": 3}],
2149 });
2150 let canonical = canonicalize_json_value(value);
2151
2152 assert_eq!(
2153 serde_json::to_string(&canonical).unwrap(),
2154 r#"{"a":[{"charlie":3,"delta":4}],"z":{"alpha":1,"beta":2}}"#
2155 );
2156 }
2157
2158 #[test]
2159 fn canonical_contract_migrates_missing_version_without_meaning_change() {
2160 let implicit = Scenario::from_json(r#"{"id":"legacy-defaulted-version"}"#).unwrap();
2161 let explicit =
2162 Scenario::from_json(r#"{"schema_version":1,"id":"legacy-defaulted-version"}"#).unwrap();
2163
2164 assert_eq!(implicit, explicit);
2165 assert_eq!(implicit.to_json().unwrap(), explicit.to_json().unwrap());
2166 assert!(implicit.validate().is_empty());
2167 }
2168
2169 #[test]
2170 fn parse_metadata() {
2171 let json = r#"{
2172 "id": "x",
2173 "metadata": {"git_sha": "abc123", "author": "bot"}
2174 }"#;
2175 let s: Scenario = serde_json::from_str(json).unwrap();
2176 assert_eq!(s.metadata.get("git_sha").unwrap(), "abc123");
2177 }
2178
2179 #[test]
2180 fn parse_latency_models() {
2181 let json = r#"{
2182 "id": "x",
2183 "network": {
2184 "preset": "ideal",
2185 "links": {
2186 "a->b": {"latency": {"model": "fixed", "ms": 5}},
2187 "b->c": {"latency": {"model": "uniform", "min_ms": 1, "max_ms": 10}},
2188 "c->d": {"latency": {"model": "normal", "mean_ms": 50, "stddev_ms": 10}}
2189 }
2190 }
2191 }"#;
2192 let s: Scenario = serde_json::from_str(json).unwrap();
2193 assert_eq!(s.network.links.len(), 3);
2194 let ab = s.network.links.get("a->b").unwrap();
2195 assert!(matches!(ab.latency, Some(LatencySpec::Fixed { ms: 5 })));
2196 }
2197
2198 #[test]
2199 fn parse_include() {
2200 let json = r#"{
2201 "id": "x",
2202 "include": [{"path": "base.yaml"}]
2203 }"#;
2204 let s: Scenario = serde_json::from_str(json).unwrap();
2205 assert_eq!(s.include.len(), 1);
2206 assert_eq!(s.include[0].path, "base.yaml");
2207 }
2208
2209 #[test]
2210 fn network_preset_debug_clone_copy_eq() {
2211 let p = NetworkPreset::Wan;
2212 let dbg = format!("{p:?}");
2213 assert!(dbg.contains("Wan"));
2214
2215 let p2 = p;
2216 assert_eq!(p, p2);
2217
2218 let p3 = p;
2219 assert_eq!(p, p3);
2220
2221 assert_ne!(NetworkPreset::Ideal, NetworkPreset::Lossy);
2222 }
2223
2224 #[test]
2225 fn chaos_section_debug_clone_default() {
2226 let c = ChaosSection::default();
2227 let dbg = format!("{c:?}");
2228 assert!(dbg.contains("Off"));
2229
2230 let c2 = c;
2231 let dbg2 = format!("{c2:?}");
2232 assert_eq!(dbg, dbg2);
2233 }
2234
2235 #[test]
2236 fn fault_action_debug_clone() {
2237 let a = FaultAction::Partition;
2238 let dbg = format!("{a:?}");
2239 assert!(dbg.contains("Partition"));
2240
2241 let a2 = a;
2242 let dbg2 = format!("{a2:?}");
2243 assert_eq!(dbg, dbg2);
2244 }
2245
2246 #[test]
2247 fn validation_error_debug_clone() {
2248 let e = ValidationError {
2249 field: "lab.seed".into(),
2250 message: "must be positive".into(),
2251 };
2252 let dbg = format!("{e:?}");
2253 assert!(dbg.contains("lab.seed"));
2254
2255 let e2 = e;
2256 assert_eq!(e2.field, "lab.seed");
2257 assert_eq!(e2.message, "must be positive");
2258 }
2259}