puffin/fuzzer/
mutations.rs

1use std::borrow::Cow;
2
3use libafl::prelude::*;
4use libafl_bolts::prelude::*;
5
6use super::utils::{
7    choose, choose_iter, choose_term, choose_term_filtered_mut, choose_term_path_filtered,
8    find_all_sub_term_filtered, find_all_term_filtered, find_term, find_term_mut, reservoir_sample,
9    Choosable, TermConstraints, TracePath,
10};
11use crate::algebra::atoms::Function;
12use crate::algebra::signature::Signature;
13use crate::algebra::{DYTerm, Subterms, Term, TermType};
14use crate::fuzzer::term_zoo::TermZoo;
15use crate::protocol::{ProtocolBehavior, ProtocolTypes};
16use crate::put_registry::PutRegistry;
17use crate::trace::{Action, Spawner, Trace, TraceContext};
18
19#[derive(Clone, Copy, Debug)]
20pub struct MutationConfig {
21    pub fresh_zoo_after: u64,
22    pub max_result_trace_length: usize,
23    pub min_trace_length: usize,
24    /// Below this term size we no longer mutate. Note that it is possible to reach
25    /// smaller terms by having a mutation which removes all symbols in a single mutation.
26    /// Above this term size we no longer mutate.
27    pub term_constraints: TermConstraints,
28    pub with_bit_level: bool,
29    pub with_dy: bool,
30    /// Focus on one payload at a time for a whole StdMutationalStage
31    pub with_focus: bool,
32    /// Relative weights for the scope at which a *replacement* mutation is applied, see
33    /// [`ScopeWeights`] and [`MutationScope`].
34    pub scope_weights: ScopeWeights,
35}
36
37impl Default for MutationConfig {
38    //  TODO:EVAL: evaluate modif to this config
39    fn default() -> Self {
40        Self {
41            fresh_zoo_after: 100000,
42            max_result_trace_length: 20,
43            min_trace_length: 2,
44            term_constraints: TermConstraints::default(),
45            with_bit_level: false,
46            with_dy: true,
47            with_focus: true,
48            scope_weights: ScopeWeights::default(),
49        }
50    }
51}
52
53/// Relative weights for picking a [`MutationScope`] when applying a replacement mutation.
54///
55/// The default weights the scope inverse to its breadth (`global:1, step:2, individual:3`) so that
56/// surgical edits dominate. Setting them allows ablation studies and reproducing historical
57/// behaviours, e.g. `(0, 0, 1)` is purely individual replacements (behaviour before global
58/// mutations existed).
59#[derive(Clone, Copy, Debug)]
60pub struct ScopeWeights {
61    pub global: usize,
62    pub step: usize,
63    pub individual: usize,
64}
65
66impl Default for ScopeWeights {
67    fn default() -> Self {
68        Self {
69            // Weights INVERSE to the semantic breadth of the scope: a Global replace rewrites the
70            // matched sub-term EVERYWHERE in the trace (broadest, most destructive for structured
71            // inputs), Step within one step, Individual a single occurrence (most surgical). For
72            // grammar/DY fuzzing we want surgical edits to dominate, so global is the least likely.
73            // (AFL-style broad stacking suits flat byte inputs, not deep protocol structures.)
74            global: 1,
75            step: 2,
76            individual: 3,
77        }
78    }
79}
80
81impl ScopeWeights {
82    #[must_use]
83    pub const fn new(global: usize, step: usize, individual: usize) -> Self {
84        Self {
85            global,
86            step,
87            individual,
88        }
89    }
90
91    /// Saturating, so that absurdly large weights keep a meaningful (if degenerate) distribution
92    /// instead of overflowing: the dominant weight simply wins.
93    const fn total(self) -> usize {
94        self.global
95            .saturating_add(self.step)
96            .saturating_add(self.individual)
97    }
98}
99
100/// Scope over which a chosen replacement is applied, see [`apply_scoped_mutation`].
101#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
102pub enum MutationScope {
103    /// Replace *every* occurrence structurally equal to the chosen term, in the whole trace.
104    Global,
105    /// Replace every such occurrence, but only within the step of the chosen term.
106    Step,
107    /// Replace only the single chosen occurrence.
108    Individual,
109}
110
111impl MutationScope {
112    /// Draw a scope at random according to `weights`. Falls back to [`Self::Individual`] when all
113    /// weights are zero.
114    fn choose<R: Rand>(weights: ScopeWeights, rand: &mut R) -> Self {
115        let total = weights.total();
116        if total == 0 {
117            return Self::Individual;
118        }
119        let draw = rand.between(0, total - 1);
120        if draw < weights.global {
121            Self::Global
122        } else if draw < weights.global.saturating_add(weights.step) {
123            Self::Step
124        } else {
125            Self::Individual
126        }
127    }
128}
129
130/// Apply `new_term` in place of the term at `to_mutate_path`, generalising the replacement to
131/// structurally equal terms according to a randomly drawn [`MutationScope`].
132///
133/// This is the shared engine behind the *replacement* DY mutators (generate / replace-match /
134/// replace-reuse): they all have the shape "pick a sub-term, compute a replacement for it, write it
135/// back", so broadcasting that replacement to the other occurrences of the *same* term is
136/// well-defined. Doing so collapses the cost of goals that require the same edit in several places
137/// (e.g. turning every `fn_seq_2` of one action into `fn_seq_5`) from one lucky draw *per
138/// occurrence* down to one lucky draw *per distinct sub-term*.
139///
140/// The generalisation is deliberately performed without any term constraint: skipping a large
141/// recipe here would silently turn "replace all occurrences" into a partial replacement, which is
142/// precisely what a broader scope is meant to avoid; and the cost stays linear in the size of the
143/// trace, i.e. the same order as the selection pass that already ran.
144///
145/// The chosen occurrence is always part of the targets, so that a broader scope is a strict
146/// superset of [`MutationScope::Individual`] whatever the search returns.
147// TODO: middle-ground scope: instead of the whole trace or the whole step, pick a position between
148// the chosen term and the root of the recipe and replace all occurrences of the chosen term in that
149// sub-term only.
150pub fn apply_scoped_mutation<PT: ProtocolTypes, R: Rand>(
151    trace: &mut Trace<PT>,
152    to_mutate_path: &TracePath,
153    representative: &Term<PT>,
154    new_term: &Term<PT>,
155    config: &MutationConfig,
156    rand: &mut R,
157) -> MutationResult {
158    let targets = scoped_targets(trace, to_mutate_path, representative, config, rand);
159    apply_to_targets(
160        trace,
161        &targets,
162        new_term,
163        representative,
164        config.with_bit_level,
165        &config.term_constraints,
166    )
167}
168
169/// Returns `true` iff replacing a term of size `representative_size` by one of size `new_size` at
170/// every path in `targets` keeps each affected single step recipe within
171/// `constraints.max_result_term_size`.
172///
173/// **Precondition: the input `trace` is already within the caps.** This is a loop invariant, not
174/// something re-checked here: hand-written seeds are authored within the caps, and every
175/// replacement mutation is reject-whole, so a trace that reached the corpus is always `<=
176/// max_result_term_size`. The size-neutral fast path below relies on it — a shrinking or
177/// size-neutral replacement is accepted in O(1) precisely because it cannot push an *already
178/// bounded* trace over a cap. The one way to break the invariant is to configure a result cap
179/// *below* the size of an already-imported seed: such a seed is out of bounds before any mutation,
180/// and a neutral replacement will (correctly, per this contract) keep it that way. Enforcing caps
181/// against oversized inputs is a corpus-boundary concern, out of scope for the mutator.
182///
183/// Efficiency (why we don't recompute every step's size):
184/// - a mutation that does not grow the trace (`new_size <= representative_size`) can never exceed a
185///   cap, so we accept in O(1) without touching any size;
186/// - otherwise we only look at the **affected steps** (those containing a target) — never all steps
187///   — recomputing each such step's recipe size once, and derive the new whole-trace size as
188///   `old_trace_size + n_targets * delta`. `old_trace_size` is read from the (already bounded, `<=
189///   already-bounded) trace, so it is cheap.
190fn replacement_within_caps<PT: ProtocolTypes>(
191    trace: &Trace<PT>,
192    targets: &[TracePath],
193    representative_size: usize,
194    new_size: usize,
195    constraints: &TermConstraints,
196) -> bool {
197    if new_size <= representative_size {
198        return true; // shrink or size-neutral: cannot exceed any cap
199    }
200    let delta = new_size - representative_size;
201
202    // Per-affected-step check: count targets per step, only recompute those steps' sizes.
203    let mut per_step: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
204    for (step_index, _) in targets {
205        *per_step.entry(*step_index).or_insert(0) += 1;
206    }
207    for (step_index, count) in &per_step {
208        let cur = match trace.steps.get(*step_index).map(|s| &s.action) {
209            Some(Action::Input(input)) => input.recipe.size(),
210            _ => 0,
211        };
212        if cur + count * delta > constraints.max_result_term_size {
213            return false;
214        }
215    }
216
217    // We intentionally cap only the per-step recipe size here. Whole-trace growth is bounded by the
218    // number of steps (`max_result_trace_length`, enforced when steps are added) times this cap,
219    // which is enough; we therefore avoid the extra whole-trace `Trace::size()` recomputation.
220    true
221}
222
223/// Returns `true` iff replacing a term carrying `representative_payloads` payloads by one carrying
224/// `new_payloads`, at every path in `targets`, keeps each *affected* step recipe within
225/// `constraints.threshold_max_payloads_per_term`.
226///
227/// The budget is enforced **per term** (per step recipe), not as an average over the whole trace:
228/// a broad scope that rewrites several occurrences within a single recipe is charged the full
229/// growth for that recipe, so one dense recipe cannot hide behind many payload-free ones. Mirrors
230/// [`replacement_within_caps`]: only a growing replacement (`new_payloads >
231/// representative_payloads`) can exceed the budget, so a payload-neutral or shrinking replacement
232/// is accepted in O(1) without inspecting any recipe. Enforced uniformly for every replacement
233/// mutator via [`apply_to_targets`] (previously only [`ReplaceReuseMutator`] checked it, and only
234/// against a trace-wide average).
235fn payloads_within_caps<PT: ProtocolTypes>(
236    trace: &Trace<PT>,
237    targets: &[TracePath],
238    representative_payloads: usize,
239    new_payloads: usize,
240    constraints: &TermConstraints,
241) -> bool {
242    if new_payloads <= representative_payloads {
243        return true; // no payload growth: cannot exceed the budget
244    }
245    let delta = new_payloads - representative_payloads;
246
247    // Per-affected-step check: count targets per step, only inspect those steps' recipes. `cur`
248    // already includes the payloads of the occurrences about to be replaced, so the projected
249    // recipe payload count is `cur + count * delta`.
250    let mut per_step: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
251    for (step_index, _) in targets {
252        *per_step.entry(*step_index).or_insert(0) += 1;
253    }
254    for (step_index, count) in &per_step {
255        let cur = match trace.steps.get(*step_index).map(|s| &s.action) {
256            Some(Action::Input(input)) => input.recipe.count_payloads(),
257            _ => 0,
258        };
259        if cur + count * delta > constraints.threshold_max_payloads_per_term {
260            return false;
261        }
262    }
263    true
264}
265
266/// Replace the term at each of `targets` (each currently structurally equal to `representative`) by
267/// `new_term`, enforcing the result-size caps (`constraints.max_result_{term,trace}_size`) and,
268/// when `with_bit` is set, the per-term payload budget
269/// (`constraints.threshold_max_payloads_per_term`). If applying to all targets would exceed a cap,
270/// the mutation is rejected wholesale (returns [`MutationResult::Skipped`]) so the "replace all
271/// equal occurrences" atomicity is preserved. See `replacement_within_caps` and
272/// `payloads_within_caps`.
273///
274/// `with_bit` mirrors whether bit-level mutations are enabled: when they are not, no term carries a
275/// payload, so the whole payload accounting is skipped without even walking `new_term`.
276pub fn apply_to_targets<PT: ProtocolTypes>(
277    trace: &mut Trace<PT>,
278    targets: &[TracePath],
279    new_term: &Term<PT>,
280    representative: &Term<PT>,
281    with_bit: bool,
282    constraints: &TermConstraints,
283) -> MutationResult {
284    if !replacement_within_caps(
285        trace,
286        targets,
287        representative.size(),
288        new_term.size(),
289        constraints,
290    ) {
291        log::debug!("[Mutation] rejected: would exceed result-size caps");
292        return MutationResult::Skipped;
293    }
294    // Payloads only exist under bit-level mutations; when those are off, skip the accounting
295    // entirely (no `new_term` walk). Even under bit-level, only a replacement that *adds* payloads
296    // can exceed the budget, so a payload-free `new_term` short-circuits before the per-recipe
297    // walks.
298    if with_bit {
299        let new_payloads = new_term.count_payloads();
300        if new_payloads > 0
301            && !payloads_within_caps(
302                trace,
303                targets,
304                representative.count_payloads(),
305                new_payloads,
306                constraints,
307            )
308        {
309            log::debug!("[Mutation] rejected: would exceed per-term payload budget");
310            return MutationResult::Skipped;
311        }
312    }
313
314    let mut mutated = false;
315    for target in targets {
316        if let Some(term_mut) = find_term_mut(trace, target) {
317            term_mut.mutate(new_term.clone());
318            mutated = true;
319        }
320    }
321
322    if mutated {
323        MutationResult::Mutated
324    } else {
325        MutationResult::Skipped
326    }
327}
328
329/// Draw a [`MutationScope`] and return *all* the occurrences to replace, see
330/// [`apply_scoped_mutation`]. The search is unbounded (finds every structurally-equal occurrence);
331/// runaway is prevented at *application* time by the result-size caps in [`apply_to_targets`], not
332/// by cutting the search short.
333///
334/// Exposed separately from [`apply_to_targets`] so a caller can inspect *how many* occurrences
335/// would be replaced before committing: [`ReplaceReuseMutator`] needs it to bound the payloads it
336/// adds.
337pub fn scoped_targets<PT: ProtocolTypes, R: Rand>(
338    trace: &Trace<PT>,
339    to_mutate_path: &TracePath,
340    representative: &Term<PT>,
341    config: &MutationConfig,
342    rand: &mut R,
343) -> Vec<TracePath> {
344    let scope = MutationScope::choose(config.scope_weights, rand);
345    let (chosen_step, _) = to_mutate_path;
346
347    // the type shape is only compared first to speed up the comparison
348    let filter = |term: &Term<PT>| {
349        term.get_type_shape() == representative.get_type_shape() && term == representative
350    };
351    // The result-size caps enforced in `apply_to_targets` bound growth, so the search for the
352    // other occurrences is itself unconstrained.
353    let constraints = TermConstraints::no_constraint();
354
355    let mut targets: Vec<TracePath> = match scope {
356        // no search needed, the chosen occurrence is added below
357        MutationScope::Individual => vec![],
358        // only the recipe of the chosen step is explored, rather than filtering the whole trace
359        MutationScope::Step => match trace.steps.get(*chosen_step).map(|step| &step.action) {
360            Some(Action::Input(input)) => {
361                find_all_sub_term_filtered(&input.recipe, filter, &constraints)
362                    .into_iter()
363                    .map(|term_path| (*chosen_step, term_path))
364                    .collect()
365            }
366            _ => vec![],
367        },
368        MutationScope::Global => find_all_term_filtered(trace, filter, &constraints),
369    };
370    // the chosen occurrence is added last, make sure it is not replaced twice
371    targets.retain(|path| path != to_mutate_path);
372    targets.push(to_mutate_path.clone());
373
374    log::debug!(
375        "[Mutation] scope {scope:?} selected {} occurrence(s)",
376        targets.len()
377    );
378    targets
379}
380
381impl MutationConfig {
382    pub fn default_with_bit() -> Self {
383        MutationConfig {
384            with_bit_level: true,
385            with_focus: false,
386            ..Self::default()
387        }
388    }
389}
390pub type DyMutations<'harness, PT, PB, S> = tuple_list_type!(
391// DY mutations
392    RepeatMutator<S>,
393    SkipMutator<S>,
394    ReplaceReuseMutator<S>,
395    ReplaceMatchMutator<S, PT>,
396    RemoveAndLiftMutator<S>,
397    GenerateMutator<'harness, S, PB>,
398    SwapMutator<S>,
399);
400
401#[must_use]
402pub fn dy_mutations<'harness, S, PT: ProtocolTypes, PB>(
403    mutation_config: MutationConfig,
404    signature: &'static Signature<PT>,
405    put_registry: &'harness PutRegistry<PB>,
406) -> DyMutations<'harness, PT, PB, S>
407where
408    S: HasCorpus<Trace<PT>> + HasMetadata + HasMaxSize + HasRand,
409    PB: ProtocolBehavior<ProtocolTypes = PT>,
410{
411    let MutationConfig {
412        fresh_zoo_after,
413        max_result_trace_length,
414        min_trace_length,
415        term_constraints,
416        with_dy,
417        ..
418    } = mutation_config;
419
420    tuple_list!(
421        RepeatMutator::new(max_result_trace_length, with_dy),
422        SkipMutator::new(min_trace_length, with_dy),
423        ReplaceReuseMutator::new(mutation_config),
424        ReplaceMatchMutator::new(mutation_config, signature),
425        RemoveAndLiftMutator::new(term_constraints, with_dy),
426        GenerateMutator::new(
427            0,
428            fresh_zoo_after,
429            None,
430            signature,
431            put_registry,
432            mutation_config,
433        ), /* Refresh zoo after 100000M mutations */
434        SwapMutator::new(term_constraints, with_dy),
435    )
436}
437
438/// SWAP: Swaps a sub-term with a different sub-term which is part of the trace
439
440/// (such that types match).
441///
442/// Note: this mutation is deliberately *not* scoped (see [`MutationScope`]): it exchanges two
443/// sub-terms, so "replace all equal occurrences" has no single well-defined replacement.
444// TODO: we might later give it its own scoped variant: swap *all* occurrences of A with *all*
445// occurrences of B (with probability 1/3), or only within the current step (with probability 1/3).
446pub struct SwapMutator<S>
447where
448    S: HasRand,
449{
450    constraints: TermConstraints,
451    phantom_s: std::marker::PhantomData<S>,
452    with_dy: bool,
453}
454
455impl<S> SwapMutator<S>
456where
457    S: HasRand,
458{
459    #[must_use]
460    pub const fn new(constraints: TermConstraints, with_dy: bool) -> Self {
461        Self {
462            constraints,
463            phantom_s: std::marker::PhantomData,
464            with_dy,
465        }
466    }
467}
468
469impl<S, PT: ProtocolTypes> Mutator<Trace<PT>, S> for SwapMutator<S>
470where
471    S: HasRand,
472{
473    fn mutate(&mut self, state: &mut S, trace: &mut Trace<PT>) -> Result<MutationResult, Error> {
474        log::debug!("[DY] Start mutate with {}", self.name());
475        if !self.with_dy {
476            return Ok(MutationResult::Skipped);
477        }
478        let _a = BytesInsertMutator;
479        let rand = state.rand_mut();
480        if let Some((term_a, trace_path_a)) = choose(trace, &self.constraints, rand) {
481            if let Some(trace_path_b) = choose_term_path_filtered(
482                trace,
483                |term: &Term<PT>| term.get_type_shape() == term_a.get_type_shape(),
484                &self.constraints,
485                rand,
486            ) {
487                let step_a_size = match &trace.steps[trace_path_a.0].action {
488                    crate::trace::Action::Input(input) => input.recipe.size(),
489                    crate::trace::Action::Output(_) => 0,
490                };
491                let step_b_size = match &trace.steps[trace_path_b.0].action {
492                    crate::trace::Action::Input(input) => input.recipe.size(),
493                    crate::trace::Action::Output(_) => 0,
494                };
495
496                let term_a_cloned = term_a.clone();
497                let term_a_size = term_a.size();
498
499                if let Some(term_b_mut) = find_term_mut(trace, &trace_path_b) {
500                    let term_b_size = term_b_mut.size();
501
502                    // Post-mutation hard cap of 500 to prevent runaway growth while allowing
503                    // natural overshoot (pre-PR 472 behavior).
504                    if step_b_size + term_a_size <= 500 + term_b_size
505                        && step_a_size + term_b_size <= 500 + term_a_size
506                    {
507                        log::debug!(
508                            "[Mutation] Mutate SwapMutator on terms\n{} and\n {}",
509                            term_a_cloned,
510                            term_b_mut
511                        );
512                        let term_b_cloned = term_b_mut.clone();
513                        term_b_mut.mutate(term_a_cloned);
514                        if let Some(trace_a_mut) = find_term_mut(trace, &trace_path_a) {
515                            trace_a_mut.mutate(term_b_cloned);
516                        }
517                        return Ok(MutationResult::Mutated);
518                    } else {
519                        log::debug!("[SwapMutator] Skipped as it would exceed max_term_size.");
520                    }
521                }
522            }
523        }
524        log::debug!("       Skipped {}", self.name());
525        Ok(MutationResult::Skipped)
526    }
527
528    fn post_exec(&mut self, _state: &mut S, _new_corpus_id: Option<CorpusId>) -> Result<(), Error> {
529        Ok(())
530    }
531}
532impl<S> Named for SwapMutator<S>
533where
534    S: HasRand,
535{
536    fn name(&self) -> &Cow<'static, str> {
537        &Cow::Borrowed("SwapMutator")
538    }
539}
540
541/// REMOVE AND LIFT: Removes a sub-term from a term and attaches orphaned children to the parent
542
543/// (such that types match). This only works if there is only a single child.
544///
545/// Note: this mutation is deliberately *not* scoped (see [`MutationScope`]): the replacement is a
546/// grand-sub-term of the mutated term, i.e. position-dependent, so there is no single replacement
547/// to broadcast.
548// TODO: a scoped variant would instead apply the *same transformation* to all terms (which are
549// `make_list` terms) that are equal to the impacted term *before* the RemoveAndLift.
550// Note: this mutation will eventually be removed in favour of more scoped list-only mutations.
551pub struct RemoveAndLiftMutator<S>
552where
553    S: HasRand,
554{
555    constraints: TermConstraints,
556    phantom_s: std::marker::PhantomData<S>,
557    with_dy: bool,
558}
559
560impl<S> RemoveAndLiftMutator<S>
561where
562    S: HasRand,
563{
564    #[must_use]
565    pub const fn new(constraints: TermConstraints, with_dy: bool) -> Self {
566        Self {
567            constraints,
568            phantom_s: std::marker::PhantomData,
569            with_dy,
570        }
571    }
572}
573
574impl<S, PT: ProtocolTypes> Mutator<Trace<PT>, S> for RemoveAndLiftMutator<S>
575where
576    S: HasRand,
577{
578    fn mutate(&mut self, state: &mut S, trace: &mut Trace<PT>) -> Result<MutationResult, Error> {
579        log::debug!("[DY] Start mutate with {}", self.name());
580        if !self.with_dy {
581            return Ok(MutationResult::Skipped);
582        }
583        let rand = state.rand_mut();
584        let filter = |term: &Term<PT>| {
585            term.is_symbolic() && // exclude terms with payloads since we aim to modify its internal structure
586            match &term.term {
587                DYTerm::Variable(_) => false,
588                DYTerm::Application(_, subterms) =>
589                    {
590                        subterms
591                            .find_subterm(|subterm| match &subterm.term {
592                                DYTerm::Variable(_) => false,
593                                DYTerm::Application(_, grand_subterms) => {
594                                    grand_subterms.find_subterm_same_shape(subterm).is_some()
595                                }
596                            })
597                            .is_some()
598                    }
599            }
600        };
601        if let Some(to_mutate) = choose_term_filtered_mut(trace, filter, &self.constraints, rand) {
602            log::debug!(
603                "[Mutation] Mutate RemoveAndLiftMutator on term\n{}",
604                to_mutate
605            );
606            match &mut to_mutate.term {
607                // TODO-bitlevel: maybe also SKIP if not(to_mutate.is_symbolic())
608                DYTerm::Variable(_) => {
609                    log::debug!("       Skipped {}", self.name());
610                    Ok(MutationResult::Skipped)
611                }
612                DYTerm::Application(_, ref mut subterms) => {
613                    if let Some(((subterm_index, _), grand_subterm)) = choose_iter(
614                        subterms.filter_grand_subterms(|subterm, grand_subterm| {
615                            subterm.get_type_shape() == grand_subterm.get_type_shape()
616                        }),
617                        rand,
618                    ) {
619                        let grand_subterm_cloned = grand_subterm.clone();
620                        subterms.push(grand_subterm_cloned);
621                        subterms.swap_remove(subterm_index);
622                        return Ok(MutationResult::Mutated);
623                    }
624                    log::debug!("       Skipped {}", self.name());
625                    Ok(MutationResult::Skipped)
626                }
627            }
628        } else {
629            log::debug!("       Skipped {}", self.name());
630            Ok(MutationResult::Skipped)
631        }
632    }
633
634    fn post_exec(&mut self, _state: &mut S, _new_corpus_id: Option<CorpusId>) -> Result<(), Error> {
635        Ok(())
636    }
637}
638
639impl<S> Named for RemoveAndLiftMutator<S>
640where
641    S: HasRand,
642{
643    fn name(&self) -> &Cow<'static, str> {
644        &Cow::Borrowed("RemoveAndLiftMutator")
645    }
646}
647
648/// REPLACE-MATCH: Replaces a function symbol with a different one (such that types match).
649///
650/// An example would be to replace a constant with another constant or the binary function
651/// `fn_add` with `fn_sub`.
652/// It can also replace any variable with a constant.
653pub struct ReplaceMatchMutator<S, PT: ProtocolTypes>
654where
655    S: HasRand,
656{
657    config: MutationConfig,
658    signature: &'static Signature<PT>,
659    phantom_s: std::marker::PhantomData<S>,
660}
661
662impl<S, PT: ProtocolTypes> ReplaceMatchMutator<S, PT>
663where
664    S: HasRand,
665{
666    #[must_use]
667    pub const fn new(config: MutationConfig, signature: &'static Signature<PT>) -> Self {
668        Self {
669            config: MutationConfig {
670                term_constraints: TermConstraints {
671                    // forbid replacing function symbols in terms with payloads
672                    must_be_symbolic: true,
673                    ..config.term_constraints
674                },
675                ..config
676            },
677            signature,
678            phantom_s: std::marker::PhantomData,
679        }
680    }
681}
682
683impl<S, PT: ProtocolTypes> Mutator<Trace<PT>, S> for ReplaceMatchMutator<S, PT>
684where
685    S: HasRand,
686{
687    fn mutate(&mut self, state: &mut S, trace: &mut Trace<PT>) -> Result<MutationResult, Error> {
688        log::debug!("[DY] Start mutate with {}", self.name());
689        if !self.config.with_dy {
690            return Ok(MutationResult::Skipped);
691        }
692        let rand = state.rand_mut();
693        let Some(to_mutate_path) =
694            choose_term_path_filtered(trace, |_| true, &self.config.term_constraints, rand)
695        else {
696            log::debug!("       Skipped {}", self.name());
697            return Ok(MutationResult::Skipped);
698        };
699        let Some(to_mutate) = find_term(trace, &to_mutate_path).cloned() else {
700            log::debug!("       Skipped {}", self.name());
701            return Ok(MutationResult::Skipped);
702        };
703        log::debug!("[Mutation] ReplaceMatchMutator on term\n{}", to_mutate);
704
705        // Build the fully-formed replacement, of the same type as the chosen term.
706        let new_term = match &to_mutate.term {
707            DYTerm::Variable(variable) => {
708                let Some((shape, dynamic_fn)) = self.signature.functions.choose_filtered(
709                    |(shape, _)| variable.typ == shape.return_type && shape.is_constant(),
710                    rand,
711                ) else {
712                    log::debug!("       Skipped {}", self.name());
713                    return Ok(MutationResult::Skipped);
714                };
715                Term::from(DYTerm::Application(
716                    Function::new(shape.clone(), dynamic_fn.clone()),
717                    Vec::new(),
718                ))
719            }
720            DYTerm::Application(func, _) => {
721                let Some((shape, dynamic_fn)) = self.signature.functions.choose_filtered(
722                    |(shape, _)| {
723                        func.shape() != shape
724                            && func.shape().return_type == shape.return_type
725                            && func.shape().argument_types == shape.argument_types
726                    },
727                    rand,
728                ) else {
729                    log::debug!("       Skipped {}", self.name());
730                    return Ok(MutationResult::Skipped);
731                };
732                // Only the function symbol changes, the sub-terms are kept.
733                let mut new_term = to_mutate.clone();
734                if let DYTerm::Application(new_func, _) = &mut new_term.term {
735                    new_func.change_function(shape.clone(), dynamic_fn.clone());
736                }
737                new_term
738            }
739        };
740
741        Ok(apply_scoped_mutation(
742            trace,
743            &to_mutate_path,
744            &to_mutate,
745            &new_term,
746            &self.config,
747            rand,
748        ))
749    }
750
751    fn post_exec(&mut self, _state: &mut S, _new_corpus_id: Option<CorpusId>) -> Result<(), Error> {
752        Ok(())
753    }
754}
755
756impl<S, PT: ProtocolTypes> Named for ReplaceMatchMutator<S, PT>
757where
758    S: HasRand,
759{
760    fn name(&self) -> &Cow<'static, str> {
761        &Cow::Borrowed("ReplaceMatchMutator")
762    }
763}
764
765/// REPLACE-REUSE: Replaces a sub-term with a different sub-term which is part of the trace
766/// (such that types match). The new sub-term could come from another step which has a different
767/// recipe term.
768pub struct ReplaceReuseMutator<S>
769where
770    S: HasRand,
771{
772    config: MutationConfig,
773    phantom_s: std::marker::PhantomData<S>,
774}
775
776impl<S> ReplaceReuseMutator<S>
777where
778    S: HasRand,
779{
780    #[must_use]
781    pub const fn new(config: MutationConfig) -> Self {
782        Self {
783            config,
784            phantom_s: std::marker::PhantomData,
785        }
786    }
787}
788
789impl<S, PT: ProtocolTypes> Mutator<Trace<PT>, S> for ReplaceReuseMutator<S>
790where
791    S: HasRand,
792{
793    fn mutate(&mut self, state: &mut S, trace: &mut Trace<PT>) -> Result<MutationResult, Error> {
794        log::debug!("[DY] Start mutate with {}", self.name());
795        if !self.config.with_dy {
796            return Ok(MutationResult::Skipped);
797        }
798        let rand = state.rand_mut();
799        if let Some(replacement) = choose_term(trace, &self.config.term_constraints, rand).cloned()
800        {
801            if let Some(to_replace_path) = choose_term_path_filtered(
802                trace,
803                |term: &Term<PT>| term.get_type_shape() == replacement.get_type_shape(),
804                &self.config.term_constraints,
805                rand,
806            ) {
807                let Some(to_replace) = find_term(trace, &to_replace_path).cloned() else {
808                    log::debug!("       Skipped {}", self.name());
809                    return Ok(MutationResult::Skipped);
810                };
811                // the scope is drawn first: a broader scope replaces several occurrences, and each
812                // of them contributes to the payload budget checked below
813                let targets =
814                    scoped_targets(trace, &to_replace_path, &to_replace, &self.config, rand);
815                // The per-term payload budget (threshold_max_payloads_per_term) is enforced
816                // wholesale in `apply_to_targets`, uniformly across all replacement mutators, and
817                // skipped entirely when bit-level mutations are off (`self.with_bit`).
818                log::debug!(
819                    "[Mutation] Mutate ReplaceReuseMutator on terms\n {} and\n{}",
820                    to_replace,
821                    replacement
822                );
823                return Ok(apply_to_targets(
824                    trace,
825                    &targets,
826                    &replacement,
827                    &to_replace,
828                    self.config.with_bit_level,
829                    &self.config.term_constraints,
830                ));
831            }
832        }
833        log::debug!("       Skipped {}", self.name());
834        Ok(MutationResult::Skipped)
835    }
836
837    fn post_exec(&mut self, _state: &mut S, _new_corpus_id: Option<CorpusId>) -> Result<(), Error> {
838        Ok(())
839    }
840}
841
842impl<S> Named for ReplaceReuseMutator<S>
843where
844    S: HasRand,
845{
846    fn name(&self) -> &Cow<'static, str> {
847        &Cow::Borrowed("ReplaceReuseMutator")
848    }
849}
850
851/// SKIP:  Removes an input step
852pub struct SkipMutator<S>
853where
854    S: HasRand,
855{
856    min_trace_length: usize,
857    phantom_s: std::marker::PhantomData<S>,
858    with_dy: bool,
859}
860
861impl<S> SkipMutator<S>
862where
863    S: HasRand,
864{
865    #[must_use]
866    pub const fn new(min_trace_length: usize, with_dy: bool) -> Self {
867        Self {
868            min_trace_length,
869            phantom_s: std::marker::PhantomData,
870            with_dy,
871        }
872    }
873}
874impl<S, PT: ProtocolTypes> Mutator<Trace<PT>, S> for SkipMutator<S>
875where
876    S: HasRand,
877{
878    fn mutate(&mut self, state: &mut S, trace: &mut Trace<PT>) -> Result<MutationResult, Error> {
879        log::debug!("[DY] Start mutate with {}", self.name());
880        if !self.with_dy {
881            return Ok(MutationResult::Skipped);
882        }
883        let steps = &mut trace.steps;
884        let length = steps.len();
885        if length <= self.min_trace_length {
886            log::debug!("       Skipped {}", self.name());
887            return Ok(MutationResult::Skipped);
888        }
889        if length == 0 {
890            log::debug!("       Skipped {}", self.name());
891            return Ok(MutationResult::Skipped);
892        }
893        let remove_index = state.rand_mut().between(0, length - 1);
894        log::debug!("[Mutation] Mutate SkipMutator on step {remove_index}");
895        steps.remove(remove_index);
896        Ok(MutationResult::Mutated)
897    }
898
899    fn post_exec(&mut self, _state: &mut S, _new_corpus_id: Option<CorpusId>) -> Result<(), Error> {
900        Ok(())
901    }
902}
903impl<S> Named for SkipMutator<S>
904where
905    S: HasRand,
906{
907    fn name(&self) -> &Cow<'static, str> {
908        &Cow::Borrowed("SkipMutator")
909    }
910}
911
912/// REPEAT: Repeats an input which is already part of the trace
913pub struct RepeatMutator<S>
914where
915    S: HasRand,
916{
917    max_result_trace_length: usize,
918    phantom_s: std::marker::PhantomData<S>,
919    with_dy: bool,
920}
921
922impl<S> RepeatMutator<S>
923where
924    S: HasRand,
925{
926    #[must_use]
927    pub const fn new(max_result_trace_length: usize, with_dy: bool) -> Self {
928        Self {
929            max_result_trace_length,
930            phantom_s: std::marker::PhantomData,
931            with_dy,
932        }
933    }
934}
935impl<S, PT: ProtocolTypes> Mutator<Trace<PT>, S> for RepeatMutator<S>
936where
937    S: HasRand,
938{
939    fn mutate(&mut self, state: &mut S, trace: &mut Trace<PT>) -> Result<MutationResult, Error> {
940        log::debug!("[DY] Start mutate with {}", self.name());
941        if !self.with_dy {
942            return Ok(MutationResult::Skipped);
943        }
944        let steps = &trace.steps;
945        let length = steps.len();
946        if length >= self.max_result_trace_length {
947            log::debug!("       Skipped {}", self.name());
948            return Ok(MutationResult::Skipped);
949        }
950        if length == 0 {
951            log::debug!("       Skipped {}", self.name());
952            return Ok(MutationResult::Skipped);
953        }
954        let insert_index = state.rand_mut().between(0, length);
955        let Some(step) = state.rand_mut().choose(steps) else {
956            return Ok(MutationResult::Skipped);
957        };
958        let step = step.clone();
959        log::debug!("[Mutation] Mutate RepeatMutator on step {insert_index}");
960        trace.steps.insert(insert_index, step);
961        Ok(MutationResult::Mutated)
962    }
963
964    fn post_exec(&mut self, _state: &mut S, _new_corpus_id: Option<CorpusId>) -> Result<(), Error> {
965        Ok(())
966    }
967}
968impl<S> Named for RepeatMutator<S>
969where
970    S: HasRand,
971{
972    fn name(&self) -> &Cow<'static, str> {
973        &Cow::Borrowed("RepeatMutator")
974    }
975}
976
977/// GENERATE: Generates a previously-unseen term using a term zoo
978pub struct GenerateMutator<'a, S, PB: ProtocolBehavior>
979where
980    S: HasRand,
981{
982    mutation_counter: u64,
983    refresh_zoo_after: u64,
984    config: MutationConfig,
985    zoo: Option<TermZoo<PB>>,
986    signature: &'static Signature<PB::ProtocolTypes>,
987    put_registry: &'a PutRegistry<PB>,
988    phantom_s: std::marker::PhantomData<S>,
989}
990impl<'a, S, PB: ProtocolBehavior> GenerateMutator<'a, S, PB>
991where
992    S: HasRand,
993{
994    #[must_use]
995    #[allow(clippy::too_many_arguments)]
996    pub const fn new(
997        mutation_counter: u64,
998        refresh_zoo_after: u64,
999        zoo: Option<TermZoo<PB>>,
1000        signature: &'static Signature<PB::ProtocolTypes>,
1001        put_registry: &'a PutRegistry<PB>,
1002        config: MutationConfig,
1003    ) -> Self {
1004        Self {
1005            mutation_counter,
1006            refresh_zoo_after,
1007            config,
1008            zoo,
1009            signature,
1010            put_registry,
1011            phantom_s: std::marker::PhantomData,
1012        }
1013    }
1014}
1015impl<'a, S, PB: ProtocolBehavior> Mutator<Trace<PB::ProtocolTypes>, S>
1016    for GenerateMutator<'a, S, PB>
1017where
1018    S: HasRand,
1019{
1020    fn mutate(
1021        &mut self,
1022        state: &mut S,
1023        trace: &mut Trace<PB::ProtocolTypes>,
1024    ) -> Result<MutationResult, Error> {
1025        log::debug!("[DY] Start mutate with {}", self.name());
1026        if !self.config.with_dy {
1027            return Ok(MutationResult::Skipped);
1028        }
1029        let rand = state.rand_mut();
1030        let (to_mutate_path, to_mutate, new_term) = {
1031            if let Some((to_mutate, to_mutate_path)) =
1032                reservoir_sample(trace, |_| true, &self.config.term_constraints, rand)
1033            {
1034                log::debug!("[Mutation] Mutate GenerateMutator on term\n{}", to_mutate);
1035                self.mutation_counter += 1;
1036                let zoo = if self.mutation_counter % self.refresh_zoo_after == 0 {
1037                    log::debug!("[Mutation] Mutate GenerateMutator: refresh zoo");
1038                    let spawner = Spawner::new(self.put_registry.clone());
1039                    let ctx = TraceContext::new(spawner); // zoo generate symbolic terms
1040                    self.zoo.insert(TermZoo::generate(
1041                        &ctx,
1042                        self.signature,
1043                        rand,
1044                        self.config.term_constraints.zoo_gen_how_many,
1045                        self.config.term_constraints.zoo_max_depth,
1046                    ))
1047                } else {
1048                    self.zoo.get_or_insert_with(|| {
1049                        let spawner = Spawner::new(self.put_registry.clone());
1050                        let ctx = TraceContext::new(spawner); // zoo generate symbolic terms
1051                        TermZoo::generate(
1052                            &ctx,
1053                            self.signature,
1054                            rand,
1055                            self.config.term_constraints.zoo_gen_how_many,
1056                            self.config.term_constraints.zoo_max_depth,
1057                        )
1058                    })
1059                };
1060                if let Some(new_term) = zoo.choose_filtered(
1061                    |term| {
1062                        // We seek for a different term with a matching type
1063                        to_mutate.get_type_shape() == term.get_type_shape() && *to_mutate != **term
1064                    },
1065                    rand,
1066                ) {
1067                    log::debug!(
1068                        "Found to_mutate and new_term: {}\n ----------------\n{}",
1069                        to_mutate,
1070                        new_term
1071                    );
1072                    // clone so that the immutable borrow of `trace` ends here
1073                    (to_mutate_path, to_mutate.clone(), new_term.clone())
1074                } else {
1075                    return Ok(MutationResult::Skipped);
1076                }
1077            } else {
1078                return Ok(MutationResult::Skipped);
1079            }
1080        };
1081
1082        // Apply the replacement at a randomly drawn scope (global / step / individual)
1083        Ok(apply_scoped_mutation(
1084            trace,
1085            &to_mutate_path,
1086            &to_mutate,
1087            &new_term,
1088            &self.config,
1089            rand,
1090        ))
1091    }
1092
1093    fn post_exec(&mut self, _state: &mut S, _new_corpus_id: Option<CorpusId>) -> Result<(), Error> {
1094        Ok(())
1095    }
1096}
1097
1098impl<'a, S, PB: ProtocolBehavior> Named for GenerateMutator<'a, S, PB>
1099where
1100    S: HasRand,
1101{
1102    fn name(&self) -> &Cow<'static, str> {
1103        &Cow::Borrowed("GenerateMutator")
1104    }
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109    use std::collections::{HashMap, HashSet};
1110
1111    use libafl::corpus::InMemoryCorpus;
1112    use libafl::mutators::{MutationResult, Mutator};
1113    use libafl::state::StdState;
1114    use libafl_bolts::rands::{RomuDuoJrRand, StdRand};
1115
1116    use super::*;
1117    use crate::agent::AgentName;
1118    use crate::algebra::dynamic_function::DescribableFunction;
1119    use crate::algebra::test_signature::{TestTrace, *};
1120    use crate::algebra::DYTerm;
1121    use crate::fuzzer::utils::{choose_term_path, TracePath};
1122    use crate::term;
1123    use crate::trace::{Action, InputAction, Step};
1124
1125    fn create_state(
1126    ) -> StdState<InMemoryCorpus<TestTrace>, TestTrace, RomuDuoJrRand, InMemoryCorpus<TestTrace>>
1127    {
1128        let rand = StdRand::with_seed(1235);
1129        let corpus: InMemoryCorpus<TestTrace> = InMemoryCorpus::new();
1130        StdState::new(rand, corpus, InMemoryCorpus::new(), &mut (), &mut ()).unwrap()
1131    }
1132
1133    /// Checks whether repeat can repeat the last step
1134    #[test_log::test]
1135    fn test_repeat_mutator() {
1136        let mut state = create_state();
1137
1138        let mut mutator = RepeatMutator::new(15, true);
1139
1140        fn check_is_encrypt12(step: &Step<TestProtocolTypes>) -> bool {
1141            if let Action::Input(input) = &step.action {
1142                if input.recipe.name() == fn_encrypt12.name() {
1143                    return true;
1144                }
1145            }
1146            false
1147        }
1148
1149        loop {
1150            let mut trace = setup_simple_trace();
1151            mutator.mutate(&mut state, &mut trace).unwrap();
1152
1153            let length = trace.steps.len();
1154            if let Some(last) = trace.steps.get(length - 1) {
1155                if check_is_encrypt12(last) {
1156                    if let Some(step) = trace.steps.get(length - 2) {
1157                        if check_is_encrypt12(step) {
1158                            break;
1159                        }
1160                    }
1161                }
1162            }
1163        }
1164    }
1165
1166    /// The defining behaviour of each scope, on a trace that contains the *same* sub-term several
1167    /// times inside one step and also in another step:
1168    /// - `Individual` replaces exactly the chosen occurrence,
1169    /// - `Step` replaces every occurrence of the chosen step, and only those,
1170    /// - `Global` replaces every occurrence of the whole trace.
1171    #[test_log::test]
1172    fn test_scope_extent() {
1173        let mut rand = StdRand::with_seed(11);
1174
1175        // a term that appears 3 times in step 0 and 2 times in step 1
1176        let duplicated: Term<TestProtocolTypes> = term! { fn_signature_algorithm_extension };
1177        let recipe_a: Term<TestProtocolTypes> = term! {
1178            fn_client_extensions_append(
1179                (fn_client_extensions_append(
1180                    (fn_client_extensions_append(
1181                        fn_client_extensions_new,
1182                        fn_signature_algorithm_extension
1183                    )),
1184                    fn_signature_algorithm_extension
1185                )),
1186                fn_signature_algorithm_extension
1187            )
1188        };
1189        let recipe_b: Term<TestProtocolTypes> = term! {
1190            fn_client_extensions_append(
1191                (fn_client_extensions_append(
1192                    fn_client_extensions_new,
1193                    fn_signature_algorithm_extension
1194                )),
1195                fn_signature_algorithm_extension
1196            )
1197        };
1198        let build_trace = || Trace {
1199            steps: vec![
1200                Step {
1201                    agent: AgentName::first(),
1202                    action: Action::Input(InputAction {
1203                        recipe: recipe_a.clone(),
1204                        precomputations: vec![],
1205                    }),
1206                },
1207                Step {
1208                    agent: AgentName::first(),
1209                    action: Action::Input(InputAction {
1210                        recipe: recipe_b.clone(),
1211                        precomputations: vec![],
1212                    }),
1213                },
1214            ],
1215            ..setup_simple_trace()
1216        };
1217        let replacement: Term<TestProtocolTypes> = term! { fn_ec_point_formats_extension };
1218
1219        // count the occurrences of `duplicated` left in each step
1220        let remaining = |trace: &Trace<TestProtocolTypes>| {
1221            (0..2)
1222                .map(|step| match &trace.steps[step].action {
1223                    Action::Input(input) => (&input.recipe)
1224                        .into_iter()
1225                        .filter(|t| **t == duplicated)
1226                        .count(),
1227                    Action::Output(_) => 0,
1228                })
1229                .collect::<Vec<_>>()
1230        };
1231
1232        let chosen_path: TracePath = (0, vec![1]); // outermost duplicated term of step 0
1233        let sanity = build_trace();
1234        assert_eq!(remaining(&sanity), vec![3, 2], "test fixture");
1235
1236        for (weights, expected, label) in [
1237            (ScopeWeights::new(0, 0, 1), vec![2, 2], "individual"),
1238            (ScopeWeights::new(0, 1, 0), vec![0, 2], "step"),
1239            (ScopeWeights::new(1, 0, 0), vec![0, 0], "global"),
1240        ] {
1241            let mut trace = build_trace();
1242            let targets = scoped_targets(
1243                &trace,
1244                &chosen_path,
1245                &duplicated,
1246                &MutationConfig {
1247                    scope_weights: weights,
1248                    ..MutationConfig::default()
1249                },
1250                &mut rand,
1251            );
1252            assert_eq!(
1253                apply_to_targets(
1254                    &mut trace,
1255                    &targets,
1256                    &replacement,
1257                    &duplicated,
1258                    true,
1259                    &TermConstraints::no_constraint(),
1260                ),
1261                MutationResult::Mutated,
1262                "{label}: should have mutated"
1263            );
1264            assert_eq!(
1265                remaining(&trace),
1266                expected,
1267                "{label}: unexpected extent of the replacement (per-step occurrences left)"
1268            );
1269        }
1270    }
1271
1272    /// The payload budget (`threshold_max_payloads_per_term`) is enforced **per term** (per step
1273    /// recipe), not as a trace-wide average: concentrating the payload growth of a broad scope in a
1274    /// single recipe is rejected wholesale, even when the same growth spread over the whole trace
1275    /// would stay under budget on average. Payload-neutral or shrinking replacements are always
1276    /// accepted. Exercises [`payloads_within_caps`] directly (the guard folded into
1277    /// [`apply_to_targets`]).
1278    #[test_log::test]
1279    fn test_payload_budget_is_per_term_not_average() {
1280        let trace = setup_simple_trace();
1281        let step = trace
1282            .steps
1283            .iter()
1284            .position(|s| matches!(s.action, Action::Input(_)))
1285            .expect("the fixture has at least one input step");
1286        // baseline payloads already in that recipe (0 for a symbolic seed, but read it to stay
1287        // robust if the fixture changes)
1288        let base = match &trace.steps[step].action {
1289            Action::Input(input) => input.recipe.count_payloads(),
1290            Action::Output(_) => unreachable!(),
1291        };
1292
1293        // one growing replacement (+1 payload) for each of four occurrences, all in ONE recipe
1294        let concentrated: Vec<TracePath> = (0..4).map(|i| (step, vec![i])).collect();
1295        let single: Vec<TracePath> = vec![(step, vec![0])];
1296        // budget leaves room for +3 payloads in a single recipe
1297        let constraints = TermConstraints {
1298            threshold_max_payloads_per_term: base + 3,
1299            ..TermConstraints::no_constraint()
1300        };
1301
1302        // per-term: this recipe would reach base + 4 > base + 3 -> reject the whole mutation. A
1303        // trace-wide average (4 payloads over several steps) would have stayed under budget; that
1304        // dilution is exactly what this check no longer allows.
1305        assert!(
1306            !payloads_within_caps(&trace, &concentrated, base, base + 1, &constraints),
1307            "four +1-payload replacements in one recipe must exceed the per-term budget"
1308        );
1309        // a single occurrence only reaches base + 1 <= base + 3 -> accepted
1310        assert!(
1311            payloads_within_caps(&trace, &single, base, base + 1, &constraints),
1312            "a single growing replacement stays within the per-term budget"
1313        );
1314        // payload-neutral and shrinking replacements are accepted regardless of scope width
1315        assert!(payloads_within_caps(
1316            &trace,
1317            &concentrated,
1318            7,
1319            7,
1320            &constraints
1321        ));
1322        assert!(payloads_within_caps(
1323            &trace,
1324            &concentrated,
1325            9,
1326            2,
1327            &constraints
1328        ));
1329    }
1330
1331    /// `Term::count_payloads` is the alloc-free equivalent of `all_payloads().len()`: it must agree
1332    /// with it node-for-node, both in the all-symbolic case (0) and once a payload is attached.
1333    #[test_log::test]
1334    fn test_count_payloads_matches_all_payloads_len() {
1335        let trace = setup_simple_trace();
1336        for step in &trace.steps {
1337            let Action::Input(input) = &step.action else {
1338                continue;
1339            };
1340            // every sub-term of the recipe: the two traversals must return the same count
1341            for sub in &input.recipe {
1342                assert_eq!(sub.count_payloads(), sub.all_payloads().len());
1343            }
1344            // and once a payload is attached at the root, both see exactly one
1345            let mut with_payload = input.recipe.clone();
1346            with_payload.add_payload(vec![0u8; 4]);
1347            assert_eq!(
1348                with_payload.count_payloads(),
1349                with_payload.all_payloads().len()
1350            );
1351            assert_eq!(with_payload.count_payloads(), 1);
1352        }
1353    }
1354
1355    /// The result-size caps are reject-whole: a *growing* replacement that would push either the
1356    /// affected step recipe over `max_result_term_size` is refused. The size-neutral / shrinking
1357    /// fast path is accepted in
1358    /// O(1) even when the input already exceeds the caps — that is the documented bounded-input
1359    /// precondition of [`replacement_within_caps`] (a cap set below an imported seed is a
1360    /// corpus-boundary misconfiguration, not the mutator's job to repair).
1361    #[test_log::test]
1362    fn test_replacement_within_caps_rejects_growth() {
1363        let trace = setup_simple_trace();
1364        let step = trace
1365            .steps
1366            .iter()
1367            .position(|s| matches!(s.action, Action::Input(_)))
1368            .expect("the fixture has at least one input step");
1369        let cur = match &trace.steps[step].action {
1370            Action::Input(input) => input.recipe.size(),
1371            Action::Output(_) => unreachable!(),
1372        };
1373        let targets: Vec<TracePath> = vec![(step, vec![0])]; // one target in this step
1374
1375        // representative_size/new_size only fix the delta (+5 here); `cur` is read from the recipe.
1376        let (repr, new) = (1, 6);
1377
1378        // generous caps: +5 fits in both -> accepted
1379        let generous = TermConstraints {
1380            max_result_term_size: cur + 100,
1381            ..TermConstraints::no_constraint()
1382        };
1383        assert!(replacement_within_caps(
1384            &trace, &targets, repr, new, &generous
1385        ));
1386
1387        // per-step cap allows only +2 in this recipe -> +5 rejected
1388        let tight_step = TermConstraints {
1389            max_result_term_size: cur + 2,
1390            ..TermConstraints::no_constraint()
1391        };
1392        assert!(!replacement_within_caps(
1393            &trace,
1394            &targets,
1395            repr,
1396            new,
1397            &tight_step
1398        ));
1399
1400        // NOTE: we no longer enforce a whole-trace node cap here — growth is bounded by the
1401        // per-step recipe cap times the number of steps (`max_result_trace_length`).
1402
1403        // documented precondition: with the caps set *below* the already-imported seed, a
1404        // size-neutral or shrinking replacement is still accepted (the input is out of bounds
1405        // before any mutation; the mutator does not retroactively enforce the cap).
1406        let below_seed = TermConstraints {
1407            max_result_term_size: 0,
1408            ..TermConstraints::no_constraint()
1409        };
1410        assert!(replacement_within_caps(&trace, &targets, 4, 4, &below_seed)); // neutral
1411        assert!(replacement_within_caps(&trace, &targets, 9, 2, &below_seed)); // shrink
1412    }
1413
1414    /// Whatever the scope, the *chosen* occurrence is always replaced. In particular a broader
1415    /// scope must not silently skip the mutation when the search for the other occurrences finds
1416    /// nothing.
1417    #[test_log::test]
1418    fn test_scoped_mutation_always_mutates_chosen_occurrence() {
1419        let mut rand = StdRand::with_seed(7);
1420
1421        for weights in [
1422            ScopeWeights::new(1, 0, 0), // global only
1423            ScopeWeights::new(0, 1, 0), // step only
1424            ScopeWeights::new(0, 0, 1), // individual only
1425            ScopeWeights::default(),
1426        ] {
1427            let mut trace = setup_simple_trace();
1428            let path =
1429                choose_term_path_filtered(&trace, |_| true, &TermConstraints::default(), &mut rand)
1430                    .unwrap();
1431            let chosen = find_term(&trace, &path).unwrap().clone();
1432
1433            // any different term of the same type is a valid replacement here
1434            let Some(replacement) = trace
1435                .steps
1436                .iter()
1437                .filter_map(|step| match &step.action {
1438                    Action::Input(input) => Some(&input.recipe),
1439                    Action::Output(_) => None,
1440                })
1441                .flat_map(|recipe| recipe.into_iter())
1442                .find(|term| term.get_type_shape() == chosen.get_type_shape() && **term != chosen)
1443                .cloned()
1444            else {
1445                continue; // no valid replacement in this trace, nothing to assert
1446            };
1447
1448            let targets = scoped_targets(
1449                &trace,
1450                &path,
1451                &chosen,
1452                &MutationConfig {
1453                    scope_weights: weights,
1454                    ..MutationConfig::default()
1455                },
1456                &mut rand,
1457            );
1458            let result = apply_to_targets(
1459                &mut trace,
1460                &targets,
1461                &replacement,
1462                &chosen,
1463                true,
1464                &TermConstraints::no_constraint(),
1465            );
1466
1467            assert_eq!(
1468                result,
1469                MutationResult::Mutated,
1470                "weights {weights:?} should have mutated"
1471            );
1472            assert_eq!(
1473                find_term(&trace, &path).unwrap(),
1474                &replacement,
1475                "weights {weights:?}: the chosen occurrence must carry the replacement"
1476            );
1477        }
1478    }
1479
1480    /// The scope drawn by [`MutationScope::choose`] must follow the configured weights: a zeroed
1481    /// weight is never drawn, and `(0, 0, 0)` degrades gracefully to `Individual`.
1482    #[test_log::test]
1483    fn test_scope_weights() {
1484        let mut rand = StdRand::with_seed(42);
1485
1486        let draw = |w: ScopeWeights, rand: &mut _| {
1487            let mut seen = std::collections::HashSet::new();
1488            for _ in 0..200 {
1489                seen.insert(MutationScope::choose(w, rand));
1490            }
1491            seen
1492        };
1493
1494        // uniform: all three scopes must show up
1495        let all = draw(ScopeWeights::default(), &mut rand);
1496        assert_eq!(all.len(), 3, "uniform weights should draw all three scopes");
1497
1498        // (1, 0, 1): the historical behaviour, never per-step
1499        let no_step = draw(ScopeWeights::new(1, 0, 1), &mut rand);
1500        assert!(!no_step.contains(&MutationScope::Step));
1501        assert!(no_step.contains(&MutationScope::Global));
1502        assert!(no_step.contains(&MutationScope::Individual));
1503
1504        // (0, 0, 1): purely individual replacements
1505        let only_individual = draw(ScopeWeights::new(0, 0, 1), &mut rand);
1506        assert_eq!(only_individual, HashSet::from([MutationScope::Individual]));
1507
1508        // degenerate weights fall back to Individual instead of panicking
1509        let zeroed = draw(ScopeWeights::new(0, 0, 0), &mut rand);
1510        assert_eq!(zeroed, HashSet::from([MutationScope::Individual]));
1511    }
1512
1513    #[test_log::test]
1514    fn test_replace_match_mutator() {
1515        let _server = AgentName::first();
1516        let mut state = create_state();
1517        let mut mutator =
1518            ReplaceMatchMutator::new(MutationConfig::default_with_bit(), &TEST_SIGNATURE);
1519
1520        loop {
1521            let mut trace = setup_simple_trace();
1522            mutator.mutate(&mut state, &mut trace).unwrap();
1523
1524            if let Some(last) = trace.steps.iter().last() {
1525                match &last.action {
1526                    Action::Input(input) => match &input.recipe.term {
1527                        DYTerm::Variable(_) => {}
1528                        DYTerm::Application(_, subterms) => {
1529                            if let Some(last_subterm) = subterms.iter().last() {
1530                                if last_subterm.name() == fn_seq_1.name() {
1531                                    break;
1532                                }
1533                            }
1534                        }
1535                    },
1536                    Action::Output(_) => {}
1537                }
1538            }
1539        }
1540    }
1541
1542    #[test_log::test]
1543    fn test_remove_lift_mutator() {
1544        // Should remove an extension
1545        let mut state = create_state();
1546        let _server = AgentName::first();
1547        let mut mutator = RemoveAndLiftMutator::new(TermConstraints::default(), true);
1548
1549        // Returns the amount of extensions in the trace
1550        fn sum_extension_appends(trace: &TestTrace) -> usize {
1551            trace.count_functions_by_name(fn_client_extensions_append.name())
1552        }
1553
1554        loop {
1555            let mut trace = setup_simple_trace();
1556            let before_mutation = sum_extension_appends(&trace);
1557            let result = mutator.mutate(&mut state, &mut trace).unwrap();
1558
1559            if result == MutationResult::Mutated {
1560                let after_mutation = sum_extension_appends(&trace);
1561                if after_mutation < before_mutation {
1562                    // extension removed
1563                    break;
1564                }
1565            }
1566        }
1567    }
1568
1569    #[test_log::test]
1570    fn test_replace_reuse_mutator() {
1571        let mut state = create_state();
1572        let _server = AgentName::first();
1573        let mut mutator = ReplaceReuseMutator::new(MutationConfig::default_with_bit());
1574
1575        fn count_client_hello(trace: &TestTrace) -> usize {
1576            trace.count_functions_by_name(fn_client_hello.name())
1577        }
1578
1579        fn count_finished(trace: &TestTrace) -> usize {
1580            trace.count_functions_by_name(fn_finished.name())
1581        }
1582
1583        loop {
1584            let mut trace = setup_simple_trace();
1585            let result = mutator.mutate(&mut state, &mut trace).unwrap();
1586
1587            if result == MutationResult::Mutated {
1588                let client_hellos = count_client_hello(&trace);
1589                let finishes = count_finished(&trace);
1590                if client_hellos == 2 && finishes == 0 {
1591                    // finished replaced by client_hello
1592                    break;
1593                }
1594            }
1595        }
1596    }
1597
1598    #[test_log::test]
1599    fn test_skip_mutator() {
1600        let mut state = create_state();
1601        let _server = AgentName::first();
1602        let mut mutator = SkipMutator::new(2, true);
1603
1604        loop {
1605            let mut trace = setup_simple_trace();
1606            let before_len = trace.steps.len();
1607            mutator.mutate(&mut state, &mut trace).unwrap();
1608
1609            if before_len - 1 == trace.steps.len() {
1610                break;
1611            }
1612        }
1613    }
1614
1615    #[test_log::test]
1616    fn test_swap_mutator() {
1617        let mut state = create_state();
1618        let mut mutator = SwapMutator::new(TermConstraints::default(), true);
1619
1620        loop {
1621            let mut trace = setup_simple_trace();
1622            mutator.mutate(&mut state, &mut trace).unwrap();
1623
1624            let is_first_not_ch = if let Some(first) = trace.steps.first() {
1625                match &first.action {
1626                    Action::Input(input) => Some(input.recipe.name() != fn_client_hello.name()),
1627                    Action::Output(_) => None,
1628                }
1629            } else {
1630                None
1631            };
1632
1633            let is_next_not_fn_client_key_exchange = if let Some(next) = trace.steps.get(1) {
1634                match &next.action {
1635                    Action::Input(input) => {
1636                        Some(input.recipe.name() != fn_client_key_exchange.name())
1637                    }
1638                    Action::Output(_) => None,
1639                }
1640            } else {
1641                None
1642            };
1643
1644            if let Some(first) = is_first_not_ch {
1645                if let Some(second) = is_next_not_fn_client_key_exchange {
1646                    if first && second {
1647                        break;
1648                    }
1649                }
1650            }
1651        }
1652    }
1653
1654    #[test_log::test]
1655    fn test_find_term() {
1656        let mut rand = StdRand::with_seed(45);
1657        let mut trace = setup_simple_trace();
1658        let term_size = trace.count_functions();
1659
1660        let mut stats: HashSet<TracePath> = HashSet::new();
1661
1662        for _ in 0..10000 {
1663            let path = choose_term_path(&trace, &TermConstraints::default(), &mut rand).unwrap();
1664            find_term_mut(&mut trace, &path).unwrap();
1665            stats.insert(path);
1666        }
1667
1668        assert_eq!(term_size, stats.len());
1669    }
1670
1671    #[test_log::test]
1672    fn test_reservoir_sample_randomness() {
1673        /// https://rust-lang-nursery.github.io/rust-cookbook/science/mathematics/statistics.html#standard-deviation
1674        fn std_deviation(data: &[u32]) -> Option<f32> {
1675            fn mean(data: &[u32]) -> Option<f32> {
1676                let sum = data.iter().sum::<u32>() as f32;
1677                let count = data.len();
1678
1679                match count {
1680                    positive if positive > 0 => Some(sum / count as f32),
1681                    _ => None,
1682                }
1683            }
1684
1685            match (mean(data), data.len()) {
1686                (Some(data_mean), count) if count > 0 => {
1687                    let variance = data
1688                        .iter()
1689                        .map(|value| {
1690                            let diff = data_mean - (*value as f32);
1691
1692                            diff * diff
1693                        })
1694                        .sum::<f32>()
1695                        / count as f32;
1696
1697                    Some(variance.sqrt())
1698                }
1699                _ => None,
1700            }
1701        }
1702
1703        let trace = setup_simple_trace();
1704        let term_size = trace.count_functions();
1705
1706        let mut rand = StdRand::with_seed(45);
1707        let mut stats: HashMap<u32, u32> = HashMap::new();
1708
1709        for _ in 0..10000 {
1710            let term = choose(&trace, &TermConstraints::default(), &mut rand).unwrap();
1711
1712            let id = term.0.resistant_id();
1713
1714            let count: u32 = *stats.get(&id).unwrap_or(&0);
1715            stats.insert(id, count + 1);
1716        }
1717
1718        let std_dev =
1719            std_deviation(stats.values().cloned().collect::<Vec<u32>>().as_slice()).unwrap();
1720        /*        println!("{:?}", std_dev);
1721        println!("{:?}", stats);*/
1722
1723        assert!(std_dev < 30.0);
1724        assert_eq!(term_size, stats.len());
1725    }
1726}