Skip to main content

asupersync/lab/
scenario.rs

1//! FrankenLab scenario format (bd-1hu19.1).
2//!
3//! A scenario file declares typed lab configuration, participants, faults,
4//! oracle names, and author metadata. YAML is the accepted CLI input format;
5//! [`Scenario::from_json`] and [`Scenario::to_json`] provide a library-only
6//! JSON round trip. Parsing a field does not by itself mean that the current
7//! runner gives that field a simulated runtime effect.
8//!
9//! # Format overview
10//!
11//! ```yaml
12//! schema_version: 1
13//! id: smoke-sendpermit-ack
14//! description: Happy-path SendPermit/Ack under light chaos
15//!
16//! lab:
17//!   seed: 42
18//!   worker_count: 2
19//!   trace_capacity: 8192
20//!   max_steps: 100000
21//!   panic_on_obligation_leak: true
22//!   panic_on_futurelock: true
23//!   futurelock_max_idle_steps: 10000
24//!
25//! chaos:
26//!   preset: light           # off | light | heavy | custom
27//!
28//! network:
29//!   preset: lan             # ideal | local | lan | wan | satellite | congested | lossy
30//!
31//! faults:
32//!   - at_ms: 100
33//!     action: partition
34//!     args: { from: alice, to: bob }
35//!   - at_ms: 500
36//!     action: heal
37//!     args: { from: alice, to: bob }
38//!
39//! participants:
40//!   - name: alice
41//!     role: sender
42//!   - name: bob
43//!     role: receiver
44//!
45//! oracles:
46//!   - all
47//!
48//! cancellation:
49//!   strategy: random_sample
50//!   count: 100
51//!
52//! resource_caps:
53//!   max_artifact_bytes: 65536
54//!   max_fault_events: 8
55//!   max_counterexample_events: 16
56//!
57//! expected_invariants:
58//!   - quiescence
59//!   - losers_drained
60//!   - no_obligation_leaks
61//!   - deterministic_replay
62//!
63//! minimization:
64//!   enabled: true
65//!   max_evaluations: 64
66//!   max_counterexample_events: 16
67//!
68//! golden_projection:
69//!   format: json
70//!   canonicalized: true
71//!   redacted: true
72//! ```
73//!
74//! # Include references
75//!
76//! Scenarios may declare paths in `include`:
77//!
78//! ```yaml
79//! include:
80//!   - path: base_config.yaml
81//! ```
82//!
83//! The current loaders validate each include path but do not read, resolve, or
84//! merge the referenced document. Authors must keep every effective field in
85//! the file passed to the CLI until include resolution is implemented.
86//!
87//! # Determinism
88//!
89//! `lab.seed` feeds the deterministic scheduler, and the replay command compares
90//! two executions of the loaded scenario. This is a scoped replay invariant,
91//! not a cross-build or cross-platform guarantee. The schema itself schedules
92//! no application workload, and several fields are currently validation-only.
93//!
94//! [`Scenario::to_json`] is the canonical machine representation for the
95//! typed schema. It emits compact UTF-8 JSON, orders every object key
96//! lexicographically, preserves array order, and uses serde_json's stable
97//! shortest representation for finite numbers. Duration fields use integer
98//! milliseconds, as indicated by their `_ms` suffix. Documents that omit
99//! `schema_version` migrate additively to the current version through the
100//! typed default; canonical output always writes the explicit version and
101//! every typed field.
102//!
103//! YAML remains an accepted authoring format. The canonical encoder only
104//! emits fields owned by the typed schema; free-form values belong in the
105//! explicit `metadata`, participant `properties`, and fault `args` maps.
106//! Fault `args` keys and values are copied into trace text and JSON run-result
107//! fault logs. They are not scrubbed by `golden_projection.redacted`, so scenario
108//! documents must not contain credentials, tokens, or other private values.
109
110use serde::{Deserialize, Serialize};
111use std::collections::{BTreeMap, HashSet};
112
113// ---------------------------------------------------------------------------
114// Top-level scenario
115// ---------------------------------------------------------------------------
116
117/// Current scenario schema version.
118pub const SCENARIO_SCHEMA_VERSION: u32 = 1;
119
120/// A complete FrankenLab test scenario.
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct Scenario {
123    /// Schema version (must be 1).
124    #[serde(default = "default_schema_version")]
125    pub schema_version: u32,
126
127    /// Stable, unique scenario identifier (e.g. `"smoke-sendpermit-ack"`).
128    pub id: String,
129
130    /// Human-readable description.
131    #[serde(default)]
132    pub description: String,
133
134    /// Lab runtime configuration.
135    #[serde(default)]
136    pub lab: LabSection,
137
138    /// Chaos injection configuration.
139    #[serde(default)]
140    pub chaos: ChaosSection,
141
142    /// Network simulation configuration.
143    #[serde(default)]
144    pub network: NetworkSection,
145
146    /// Timed fault injection events.
147    #[serde(default)]
148    pub faults: Vec<FaultEvent>,
149
150    /// Named participants (actors/tasks).
151    #[serde(default)]
152    pub participants: Vec<Participant>,
153
154    /// Oracle names to enable.  `["all"]` enables every oracle.
155    #[serde(default = "default_oracles")]
156    pub oracles: Vec<String>,
157
158    /// Cancellation injection strategy.
159    #[serde(default)]
160    pub cancellation: Option<CancellationSection>,
161
162    /// Resource caps for bounded artifact and counterexample emission.
163    #[serde(default)]
164    pub resource_caps: ResourceCapsSection,
165
166    /// Invariants the scenario expects the runner to enforce or report.
167    #[serde(default = "default_expected_invariants")]
168    pub expected_invariants: Vec<String>,
169
170    /// Counterexample minimization settings.
171    #[serde(default)]
172    pub minimization: MinimizationSection,
173
174    /// Golden projection settings for stable, redacted scenario output.
175    #[serde(default)]
176    pub golden_projection: GoldenProjectionSection,
177
178    /// Optional includes (for composability).
179    #[serde(default)]
180    pub include: Vec<IncludeRef>,
181
182    /// Arbitrary key-value metadata (git sha, author, tags).
183    #[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
195/// Source-owned invariant names understood by the chaos scenario DSL.
196pub 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// ---------------------------------------------------------------------------
240// Lab section
241// ---------------------------------------------------------------------------
242
243/// Lab runtime knobs.
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct LabSection {
246    /// PRNG seed for deterministic scheduling.
247    #[serde(default = "default_seed")]
248    pub seed: u64,
249
250    /// Optional separate entropy seed (defaults to `seed`).
251    pub entropy_seed: Option<u64>,
252
253    /// Number of virtual workers.
254    #[serde(default = "default_worker_count")]
255    pub worker_count: usize,
256
257    /// Trace event buffer capacity.
258    #[serde(default = "default_trace_capacity")]
259    pub trace_capacity: usize,
260
261    /// Maximum scheduler steps before forced termination.
262    #[serde(default = "default_max_steps")]
263    pub max_steps: Option<u64>,
264
265    /// Panic on obligation leak.
266    #[serde(default = "default_true")]
267    pub panic_on_obligation_leak: bool,
268
269    /// Panic on futurelock detection.
270    #[serde(default = "default_true")]
271    pub panic_on_futurelock: bool,
272
273    /// Idle steps before futurelock fires.
274    #[serde(default = "default_futurelock_max_idle")]
275    pub futurelock_max_idle_steps: u64,
276
277    /// Enable replay recording.
278    #[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// ---------------------------------------------------------------------------
319// Chaos section
320// ---------------------------------------------------------------------------
321
322/// Chaos injection configuration.
323#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
324#[serde(tag = "preset", rename_all = "snake_case")]
325pub enum ChaosSection {
326    /// Chaos disabled.
327    #[default]
328    Off,
329    /// CI-friendly defaults (1% cancel, 5% delay, 2% I/O error).
330    Light,
331    /// Thorough testing (10% cancel, 20% delay, 15% I/O error).
332    Heavy,
333    /// Fully specified probabilities.
334    Custom {
335        /// Cancellation injection probability (0.0-1.0).
336        #[serde(default)]
337        cancel_probability: f64,
338        /// Delay injection probability (0.0-1.0).
339        #[serde(default)]
340        delay_probability: f64,
341        /// Minimum injected delay (milliseconds).
342        #[serde(default)]
343        delay_min_ms: u64,
344        /// Maximum injected delay (milliseconds).
345        #[serde(default = "default_delay_max_ms")]
346        delay_max_ms: u64,
347        /// I/O error injection probability (0.0-1.0).
348        #[serde(default)]
349        io_error_probability: f64,
350        /// Wakeup storm probability (0.0-1.0).
351        #[serde(default)]
352        wakeup_storm_probability: f64,
353        /// Budget exhaustion probability (0.0-1.0).
354        #[serde(default)]
355        budget_exhaustion_probability: f64,
356    },
357}
358
359fn default_delay_max_ms() -> u64 {
360    10
361}
362
363// ---------------------------------------------------------------------------
364// Network section
365// ---------------------------------------------------------------------------
366
367/// Network simulation configuration.
368#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
369pub struct NetworkSection {
370    /// Preset network conditions.
371    #[serde(default)]
372    pub preset: NetworkPreset,
373
374    /// Per-link overrides (key = "alice->bob").
375    #[serde(default)]
376    pub links: BTreeMap<String, LinkConditions>,
377}
378
379/// Named network condition presets.
380#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
381#[serde(rename_all = "snake_case")]
382pub enum NetworkPreset {
383    /// No latency, loss, or corruption.
384    #[default]
385    Ideal,
386    /// ~1ms latency.
387    Local,
388    /// 1-5ms latency, 0.01% loss.
389    Lan,
390    /// 20-100ms latency, 0.1% loss.
391    Wan,
392    /// ~600ms latency, 1% loss.
393    Satellite,
394    /// ~100ms latency with congestion effects.
395    Congested,
396    /// 10% packet loss.
397    Lossy,
398}
399
400/// Per-link network condition overrides.
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
402pub struct LinkConditions {
403    /// Latency model.
404    #[serde(default)]
405    pub latency: Option<LatencySpec>,
406    /// Packet loss probability (0.0-1.0).
407    #[serde(default)]
408    pub packet_loss: Option<f64>,
409    /// Packet corruption probability (0.0-1.0).
410    #[serde(default)]
411    pub packet_corrupt: Option<f64>,
412    /// Packet duplication probability (0.0-1.0).
413    #[serde(default)]
414    pub packet_duplicate: Option<f64>,
415    /// Packet reordering probability (0.0-1.0).
416    #[serde(default)]
417    pub packet_reorder: Option<f64>,
418    /// Bandwidth limit (bytes/second).
419    #[serde(default)]
420    pub bandwidth: Option<u64>,
421}
422
423/// Latency model specification.
424#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
425#[serde(tag = "model", rename_all = "snake_case")]
426pub enum LatencySpec {
427    /// Fixed latency.
428    Fixed {
429        /// Latency in milliseconds.
430        ms: u64,
431    },
432    /// Uniform distribution \[min_ms, max_ms\].
433    Uniform {
434        /// Minimum latency in milliseconds.
435        min_ms: u64,
436        /// Maximum latency in milliseconds.
437        max_ms: u64,
438    },
439    /// Normal distribution (mean +/- stddev), clamped to \[0, inf).
440    Normal {
441        /// Mean latency in milliseconds.
442        mean_ms: u64,
443        /// Standard deviation in milliseconds.
444        stddev_ms: u64,
445    },
446}
447
448// ---------------------------------------------------------------------------
449// Fault events
450// ---------------------------------------------------------------------------
451
452/// A timed fault injection event.
453#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
454pub struct FaultEvent {
455    /// Virtual time (milliseconds) at which the fault fires.
456    pub at_ms: u64,
457
458    /// The fault action.
459    pub action: FaultAction,
460
461    /// Action arguments.
462    #[serde(default)]
463    pub args: BTreeMap<String, serde_json::Value>,
464}
465
466/// Fault action types.
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(rename_all = "snake_case")]
469pub enum FaultAction {
470    /// Network partition between two participants.
471    Partition,
472    /// Heal a previously applied partition.
473    Heal,
474    /// Apply disk-pressure accounting for an artifact or scratch path.
475    DiskPressure,
476    /// Clear disk-pressure accounting for an artifact or scratch path.
477    DiskRecovered,
478    /// Delay cleanup/finalizer progress for a named phase.
479    DelayedCleanup,
480    /// Stall a participant process for a bounded virtual duration.
481    ProcessStall,
482    /// Resume a previously stalled participant process.
483    ProcessResume,
484    /// Crash a host (stop processing).
485    HostCrash,
486    /// Restart a previously crashed host.
487    HostRestart,
488    /// Inject clock skew on a participant.
489    ClockSkew,
490    /// Reset clock skew to zero on a participant.
491    ClockReset,
492}
493
494// ---------------------------------------------------------------------------
495// Participants
496// ---------------------------------------------------------------------------
497
498/// A named participant in the scenario.
499#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
500pub struct Participant {
501    /// Unique name within the scenario.
502    pub name: String,
503
504    /// Role hint (free-form: "sender", "receiver", "coordinator", ...).
505    #[serde(default)]
506    pub role: String,
507
508    /// Arbitrary properties for the participant.
509    #[serde(default)]
510    pub properties: BTreeMap<String, serde_json::Value>,
511}
512
513// ---------------------------------------------------------------------------
514// Cancellation injection
515// ---------------------------------------------------------------------------
516
517/// Cancellation injection configuration.
518#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
519pub struct CancellationSection {
520    /// The injection strategy.
521    pub strategy: CancellationStrategy,
522
523    /// Parameter for strategies that take a count.
524    #[serde(default)]
525    pub count: Option<usize>,
526
527    /// Probability parameter (for `probabilistic` strategy).
528    #[serde(default)]
529    pub probability: Option<f64>,
530}
531
532/// Cancellation injection strategies.
533#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
534#[serde(rename_all = "snake_case")]
535pub enum CancellationStrategy {
536    /// No cancellation injection (recording only).
537    Never,
538    /// Test all await points (N+1 runs).
539    AllPoints,
540    /// Random sample of await points.
541    RandomSample,
542    /// First N await points.
543    FirstN,
544    /// Last N await points.
545    LastN,
546    /// Every Nth await point.
547    EveryNth,
548    /// Probabilistic per-point injection.
549    Probabilistic,
550}
551
552// ---------------------------------------------------------------------------
553// Resource caps, invariants, and golden projection
554// ---------------------------------------------------------------------------
555
556/// Resource bounds applied to scenario execution and emitted artifacts.
557#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
558pub struct ResourceCapsSection {
559    /// Maximum bytes allowed for scenario artifacts.
560    #[serde(default)]
561    pub max_artifact_bytes: Option<u64>,
562
563    /// Maximum fault events permitted by the scenario definition.
564    #[serde(default)]
565    pub max_fault_events: Option<usize>,
566
567    /// Maximum events retained in minimized counterexample output.
568    #[serde(default)]
569    pub max_counterexample_events: Option<usize>,
570}
571
572/// Counterexample minimization policy for failing scenario runs.
573#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
574pub struct MinimizationSection {
575    /// Whether counterexample minimization is required for this scenario.
576    #[serde(default)]
577    pub enabled: bool,
578
579    /// Maximum minimizer evaluations before fail-closed exhaustion.
580    #[serde(default)]
581    pub max_evaluations: Option<usize>,
582
583    /// Maximum events retained in minimized counterexample output.
584    #[serde(default)]
585    pub max_counterexample_events: Option<usize>,
586}
587
588/// Stable projection formats for chaos scenario goldens.
589#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
590#[serde(rename_all = "snake_case")]
591pub enum GoldenProjectionFormat {
592    /// Canonical JSON projection.
593    #[default]
594    Json,
595    /// Markdown summary projection.
596    Markdown,
597}
598
599/// Golden-output policy for deterministic chaos scenario artifacts.
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601pub struct GoldenProjectionSection {
602    /// Projection format.
603    #[serde(default)]
604    pub format: GoldenProjectionFormat,
605
606    /// Projection must use stable canonical ordering.
607    #[serde(default = "default_true")]
608    pub canonicalized: bool,
609
610    /// Projection must redact host/user/coordination-sensitive data.
611    #[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// ---------------------------------------------------------------------------
626// Include
627// ---------------------------------------------------------------------------
628
629/// Reference to an included scenario file.
630#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
631pub struct IncludeRef {
632    /// Relative path to the included YAML.
633    pub path: String,
634}
635
636// ---------------------------------------------------------------------------
637// Validation
638// ---------------------------------------------------------------------------
639
640/// Validation error for a scenario file.
641#[derive(Debug, Clone)]
642pub struct ValidationError {
643    /// Path within the scenario (e.g. "lab.seed").
644    pub field: String,
645    /// What is wrong.
646    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    /// Validate the scenario for structural correctness.
659    ///
660    /// Returns an empty `Vec` if valid.
661    #[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                // br-asupersync-cb440b: the existing `(0.0..=1.0).contains(val)`
730                // check did already reject NaN (every NaN comparison returns
731                // false), but it produced an opaque
732                // "probability must be in [0.0, 1.0], got NaN" error and
733                // silently treated +Inf the same way. Match the explicit
734                // `is_finite()` pattern used in the network validator
735                // (validate_network below) so that NaN/Inf are rejected with
736                // a clear, descriptive error message and the validator's
737                // intent is unambiguous to future readers and to anyone
738                // diffing the chaos surface against the network surface.
739                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            // Security: Reject empty paths
1166            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            // Security: Reject absolute paths
1175            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            // Security: Reject path traversal attempts
1184            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            // Security: Reject paths with null bytes or control characters
1193            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            // Security: Restrict to reasonable filename characters
1203            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            // Security: Reject excessively long paths
1213            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            // Security: Require .yaml or .yml extension
1222            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    /// Convert this scenario to a [`super::config::LabConfig`].
1238    #[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        // Apply chaos preset
1258        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    /// Parse a scenario from a JSON string.
1294    ///
1295    /// # Errors
1296    ///
1297    /// Returns a `serde_json::Error` if the JSON is malformed.
1298    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
1299        serde_json::from_str(json)
1300    }
1301
1302    /// Serialize this scenario to canonical JSON.
1303    ///
1304    /// The encoding is compact and recursively orders object keys
1305    /// lexicographically. Array order is preserved because it is part of the
1306    /// typed scenario meaning. Call [`Self::validate`] before encoding when
1307    /// the bytes will be used as replay evidence; semantic validation is kept
1308    /// separate so callers can still serialize invalid scenarios for
1309    /// diagnostics.
1310    ///
1311    /// # Errors
1312    ///
1313    /// Returns a `serde_json::Error` if serialization fails.
1314    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// ---------------------------------------------------------------------------
1340// Tests
1341// ---------------------------------------------------------------------------
1342
1343#[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:?}"), // ubs:ignore - test helper
1432        }
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    // br-asupersync-cb440b: NaN/Inf chaos probabilities must be rejected
1447    // with an explicit "must be a finite number" error rather than the
1448    // generic out-of-range message. This is the regression that proves
1449    // the chaos validator now matches the network validator's
1450    // is_finite-first pattern, instead of relying on the subtle fact
1451    // that NaN comparisons against a RangeInclusive happen to return
1452    // false.
1453    #[test]
1454    fn validate_chaos_rejects_nan_probability_with_finite_error() {
1455        // NaN cannot be expressed as a JSON literal, so we parse a
1456        // minimal scenario then mutate the chaos section directly.
1457        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        // Test empty path rejection
1947        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        // Test absolute path rejection (Unix style)
1960        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        // Test absolute path rejection (Windows style)
1973        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        // Test path traversal rejection
1986        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        // Test subtler path traversal
1999        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        // Test control character rejection (null byte)
2012        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        // Test invalid character rejection
2025        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        // Test path length limit
2038        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        // Test extension requirement (missing extension)
2052        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        // Test valid path passes all checks
2064        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}