use serde_json::{Map, Value};
use crate::{client::CallOutcome, finding::FindingKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShrinkTargetKind {
Crash,
Hang,
ProtocolError,
AnyNonOk,
}
impl ShrinkTargetKind {
pub fn from_finding_kind(kind: &FindingKind) -> Self {
match kind {
FindingKind::Crash => Self::Crash,
FindingKind::Hang { .. } => Self::Hang,
FindingKind::ProtocolError => Self::ProtocolError,
_ => Self::AnyNonOk,
}
}
pub fn matches_outcome(self, outcome: &CallOutcome) -> bool {
match (self, outcome) {
(Self::Crash, CallOutcome::Crash(_)) => true,
(Self::Hang, CallOutcome::Hang(_)) => true,
(Self::ProtocolError, CallOutcome::ProtocolError(_)) => true,
(Self::AnyNonOk, CallOutcome::Ok(_)) => false,
(Self::AnyNonOk, _) => true,
_ => false,
}
}
}
pub const MAX_STEPS: usize = 256;
#[derive(Debug, Clone)]
pub struct ShrinkResult {
pub minimised: Value,
pub steps: usize,
pub byte_size: (usize, usize),
}
pub fn shrink<F>(original: &Value, mut still_fires: F) -> ShrinkResult
where
F: FnMut(&Value) -> bool,
{
let original_bytes = canonical_bytes(original);
let mut candidate = original.clone();
let mut steps = 0usize;
let mut progress = true;
while progress && steps < MAX_STEPS {
progress = false;
let trials = enumerate_trials(&candidate);
for trial in trials {
if steps >= MAX_STEPS {
break;
}
steps += 1;
if still_fires(&trial) {
candidate = trial;
progress = true;
break; }
}
}
let minimised_bytes = canonical_bytes(&candidate);
ShrinkResult {
minimised: candidate,
steps,
byte_size: (original_bytes, minimised_bytes),
}
}
pub async fn shrink_async<F, Fut>(original: &Value, mut still_fires: F) -> ShrinkResult
where
F: FnMut(Value) -> Fut,
Fut: std::future::Future<Output = bool>,
{
let original_bytes = canonical_bytes(original);
let mut candidate = original.clone();
let mut steps = 0usize;
let mut progress = true;
while progress && steps < MAX_STEPS {
progress = false;
let trials = enumerate_trials(&candidate);
for trial in trials {
if steps >= MAX_STEPS {
break;
}
steps += 1;
if still_fires(trial.clone()).await {
candidate = trial;
progress = true;
break;
}
}
}
let minimised_bytes = canonical_bytes(&candidate);
ShrinkResult {
minimised: candidate,
steps,
byte_size: (original_bytes, minimised_bytes),
}
}
pub fn enumerate_trials(value: &Value) -> Vec<Value> {
let mut out = Vec::new();
enumerate_at(value, &mut out, &[]);
out
}
fn enumerate_at(value: &Value, out: &mut Vec<Value>, _path: &[String]) {
match value {
Value::Object(map) => {
for key in map.keys() {
let mut clone = map.clone();
clone.shift_remove(key);
out.push(Value::Object(clone));
}
for (key, child) in map {
let mut child_trials = Vec::new();
enumerate_at(child, &mut child_trials, _path);
for trial in child_trials {
let mut clone = map.clone();
clone.insert(key.clone(), trial);
out.push(Value::Object(clone));
}
}
}
Value::Array(arr) => {
for i in 0..arr.len() {
let mut clone = arr.clone();
clone.remove(i);
out.push(Value::Array(clone));
}
for (i, child) in arr.iter().enumerate() {
let mut child_trials = Vec::new();
enumerate_at(child, &mut child_trials, _path);
for trial in child_trials {
let mut clone = arr.clone();
clone[i] = trial;
out.push(Value::Array(clone));
}
}
}
Value::String(s) => {
if !s.is_empty() {
out.push(Value::String(String::new()));
if s.len() > 1 {
let mid = s.len() / 2;
let safe = (0..=mid)
.rev()
.find(|i| s.is_char_boundary(*i))
.unwrap_or(0);
out.push(Value::String(s[..safe].to_string()));
}
}
}
Value::Number(n) => {
if n.as_f64() != Some(0.0) {
out.push(Value::Number(serde_json::Number::from(0)));
}
}
Value::Bool(b) => {
if *b {
out.push(Value::Bool(false));
}
}
Value::Null => {}
}
}
fn canonical_bytes(v: &Value) -> usize {
serde_json::to_vec(v).map(|b| b.len()).unwrap_or(0)
}
trait MapShiftRemove {
fn shift_remove(&mut self, key: &str);
}
impl MapShiftRemove for Map<String, Value> {
fn shift_remove(&mut self, key: &str) {
self.remove(key);
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn shrink_drops_unrelated_keys() {
let original = json!({
"trigger": "boom",
"noise1": "ignored",
"noise2": [1, 2, 3, 4, 5],
"noise3": {"deep": {"deeper": "still"}}
});
let result = shrink(&original, |v| {
v.get("trigger").and_then(Value::as_str) == Some("boom")
});
assert_eq!(result.minimised.get("trigger"), Some(&json!("boom")));
let obj = result.minimised.as_object().expect("obj");
assert_eq!(obj.len(), 1, "minimised must have only the trigger key");
assert!(result.byte_size.1 < result.byte_size.0);
}
#[test]
fn shrink_empties_unconstrained_strings() {
let original = json!({"path": "a/b/c/very/deep/nested/file.txt"});
let result = shrink(&original, |v| v.get("path").is_some());
assert_eq!(result.minimised.get("path"), Some(&json!("")));
}
#[test]
fn shrink_drops_array_elements() {
let original = json!({"items": [1, 2, 3, 4, 5, 6, 7, 8]});
let result = shrink(&original, |v| {
v.get("items")
.and_then(Value::as_array)
.is_some_and(|arr| arr.contains(&json!(7)))
});
let arr = result
.minimised
.get("items")
.and_then(Value::as_array)
.expect("array");
assert_eq!(arr, &vec![json!(7)]);
}
#[test]
fn shrink_terminates_on_fixed_point() {
let original = json!({"a": 1});
let result = shrink(&original, |_| true);
assert_eq!(result.minimised, json!({}));
}
#[test]
fn shrink_respects_step_cap() {
let original = json!({"a": 1, "b": "hello", "c": [1, 2, 3]});
let mut count = 0;
let result = shrink(&original, |_| {
count += 1;
false });
assert!(
result.steps <= MAX_STEPS,
"should respect MAX_STEPS cap; got {}",
result.steps
);
assert_eq!(result.minimised, original);
}
#[test]
fn shrink_handles_unicode_strings_safely() {
let original = json!({"emoji": "🤖🌟⭐"});
let result = shrink(&original, |v| v.get("emoji").is_some());
assert_eq!(result.minimised.get("emoji"), Some(&json!("")));
}
#[test]
fn shrink_preserves_structurally_required_keys() {
let original = json!({"id": "abc", "extra": "noise", "more": "noise2"});
let result = shrink(&original, |v| {
v.get("id")
.and_then(Value::as_str)
.is_some_and(|s| !s.is_empty())
});
let obj = result.minimised.as_object().expect("obj");
assert!(obj.contains_key("id"), "id must remain");
assert!(!obj.contains_key("extra"), "extra must go");
assert!(!obj.contains_key("more"), "more must go");
}
}