wallfacer-core 0.1.0

Dynamic validation harness for MCP servers
Documentation
use rand::RngCore;
use serde_json::{json, Map, Number, Value};
use tracing::warn;

use super::strategies;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GenMode {
    Conform,
    Adversarial,
    Mixed,
}

pub fn generate_payload(schema: &Value, rng: &mut impl RngCore, mode: GenMode) -> Value {
    match generate_value(schema, rng, mode) {
        Value::Object(map) => Value::Object(map),
        _ => Value::Object(Map::new()),
    }
}

pub fn generate_value(schema: &Value, rng: &mut impl RngCore, mode: GenMode) -> Value {
    if contains_unsupported_keyword(schema) {
        warn!("schema contains unsupported composition keyword; falling back");
        return fallback_for(schema);
    }

    if let Some(value) = schema.get("const") {
        return value.clone();
    }

    if let Some(values) = schema.get("enum").and_then(Value::as_array) {
        if values.is_empty() {
            return Value::Null;
        }
        return strategies::pick(rng, values).clone();
    }

    let effective_mode = match mode {
        GenMode::Mixed if strategies::chance(rng, 1, 5) => GenMode::Adversarial,
        GenMode::Mixed => GenMode::Conform,
        other => other,
    };

    let schema_type = schema_type(schema);
    match schema_type.as_deref() {
        Some("string") => string_value(schema, rng, effective_mode),
        Some("integer") => integer_value(schema, rng, effective_mode),
        Some("number") => number_value(schema, rng, effective_mode),
        Some("boolean") => Value::Bool(rng.next_u64() % 2 == 0),
        Some("array") => array_value(schema, rng, effective_mode),
        Some("object") | None => object_value(schema, rng, effective_mode),
        Some("null") => Value::Null,
        Some(other) => {
            warn!(
                schema_type = other,
                "unsupported schema type; returning null"
            );
            Value::Null
        }
    }
}

fn string_value(schema: &Value, rng: &mut impl RngCore, mode: GenMode) -> Value {
    if mode == GenMode::Adversarial {
        return Value::String(strategies::long_or_tricky_string(rng));
    }

    let min = number_keyword(schema, "minLength").unwrap_or(0).max(0) as usize;
    let max = number_keyword(schema, "maxLength")
        .unwrap_or(32)
        .max(min as i64) as usize;
    let max = max.min(256);
    let span = max.saturating_sub(min) + 1;
    let len = min + ((rng.next_u64() as usize) % span);
    let value = (0..len)
        .map(|_| {
            let offset = (rng.next_u64() % 26) as u8;
            char::from(b'a' + offset)
        })
        .collect::<String>();
    Value::String(value)
}

fn integer_value(schema: &Value, rng: &mut impl RngCore, mode: GenMode) -> Value {
    if mode == GenMode::Adversarial {
        return json!(strategies::boundary_int(rng));
    }

    let mut min = number_keyword(schema, "minimum").unwrap_or(-1000);
    let mut max = number_keyword(schema, "maximum").unwrap_or(1000);
    if number_keyword(schema, "exclusiveMinimum").is_some() {
        min = min.saturating_add(1);
    }
    if number_keyword(schema, "exclusiveMaximum").is_some() {
        max = max.saturating_sub(1);
    }
    if min > max {
        return json!(min);
    }

    let span = (max as i128 - min as i128 + 1).max(1) as u64;
    let mut value = min.saturating_add((rng.next_u64() % span) as i64);
    if let Some(multiple_of) = number_keyword(schema, "multipleOf").filter(|value| *value != 0) {
        value -= value.rem_euclid(multiple_of);
    }
    json!(value)
}

fn number_value(schema: &Value, rng: &mut impl RngCore, mode: GenMode) -> Value {
    if mode == GenMode::Adversarial {
        return Number::from_f64(strategies::boundary_float(rng))
            .map(Value::Number)
            .unwrap_or(Value::Null);
    }

    let min = schema
        .get("minimum")
        .and_then(Value::as_f64)
        .unwrap_or(-1000.0);
    let max = schema
        .get("maximum")
        .and_then(Value::as_f64)
        .unwrap_or(1000.0)
        .max(min);
    let unit = (rng.next_u64() as f64) / (u64::MAX as f64);
    let value = min + ((max - min) * unit);
    Number::from_f64(value)
        .map(Value::Number)
        .unwrap_or(Value::Null)
}

fn array_value(schema: &Value, rng: &mut impl RngCore, mode: GenMode) -> Value {
    if mode == GenMode::Adversarial && strategies::chance(rng, 1, 10) {
        return strategies::deep_nesting(1000);
    }

    let item_schema = schema.get("items").unwrap_or(&Value::Null);
    let min = number_keyword(schema, "minItems").unwrap_or(0).max(0) as usize;
    let default_max = if mode == GenMode::Adversarial { 128 } else { 8 };
    let max = number_keyword(schema, "maxItems")
        .unwrap_or(default_max)
        .max(min as i64) as usize;
    let max = max.min(default_max as usize);
    let span = max.saturating_sub(min) + 1;
    let len = min + ((rng.next_u64() as usize) % span);
    Value::Array(
        (0..len)
            .map(|_| generate_value(item_schema, rng, mode))
            .collect(),
    )
}

fn object_value(schema: &Value, rng: &mut impl RngCore, mode: GenMode) -> Value {
    let properties = schema
        .get("properties")
        .and_then(Value::as_object)
        .cloned()
        .unwrap_or_default();
    let required = schema
        .get("required")
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(Value::as_str)
                .map(ToOwned::to_owned)
                .collect::<Vec<_>>()
        })
        .unwrap_or_else(|| properties.keys().cloned().collect());

    let mut object = Map::new();
    for (key, property_schema) in &properties {
        if mode == GenMode::Adversarial && required.contains(key) && strategies::chance(rng, 1, 20)
        {
            continue;
        }

        let value = if mode == GenMode::Adversarial && strategies::chance(rng, 1, 20) {
            strategies::wrong_type_for(schema_type(property_schema).as_deref().unwrap_or("null"))
        } else {
            generate_value(property_schema, rng, mode)
        };
        object.insert(key.clone(), value);
    }

    if mode == GenMode::Adversarial
        && schema
            .get("additionalProperties")
            .and_then(Value::as_bool)
            .is_some_and(|allowed| !allowed)
        && strategies::chance(rng, 1, 5)
    {
        object.insert("unexpected_extra".to_string(), json!("extra"));
    }

    Value::Object(object)
}

fn schema_type(schema: &Value) -> Option<String> {
    match schema.get("type") {
        Some(Value::String(value)) => Some(value.clone()),
        Some(Value::Array(values)) => values
            .iter()
            .filter_map(Value::as_str)
            .find(|value| *value != "null")
            .map(ToOwned::to_owned),
        _ => {
            if schema.get("properties").is_some() {
                Some("object".to_string())
            } else {
                None
            }
        }
    }
}

fn number_keyword(schema: &Value, key: &str) -> Option<i64> {
    schema.get(key).and_then(Value::as_i64)
}

fn contains_unsupported_keyword(schema: &Value) -> bool {
    const UNSUPPORTED: &[&str] = &[
        "$ref",
        "$defs",
        "oneOf",
        "anyOf",
        "allOf",
        "if",
        "then",
        "else",
        "not",
        "dependentRequired",
    ];

    schema
        .as_object()
        .is_some_and(|object| UNSUPPORTED.iter().any(|key| object.contains_key(*key)))
}

fn fallback_for(schema: &Value) -> Value {
    match schema_type(schema).as_deref() {
        Some("object") => Value::Object(Map::new()),
        _ => Value::Null,
    }
}