parlov-elicit 0.3.0

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
//! Internal utility helpers.
//!
//! Pure, stateless functions with no business logic. Grows as strategies are
//! implemented.

use bytes::Bytes;
use http::{HeaderMap, HeaderName, HeaderValue, Method};
use parlov_core::{ProbeDefinition, Technique};

use crate::context::ScanContext;
use crate::types::{ProbePair, StrategyMetadata};

/// Substitutes `{id}` in the URL template with `id`.
///
/// Only the first occurrence is replaced.
#[must_use]
pub fn substitute_url(template: &str, id: &str) -> String {
    template.replacen("{id}", id, 1)
}

/// Substitutes `{id}` in a body template, returning `None` when no template is provided.
#[must_use]
pub fn substitute_body(template: Option<&str>, id: &str) -> Option<Bytes> {
    template.map(|t| Bytes::from(t.replace("{id}", id)))
}

/// Builds a `ProbePair` from a `ScanContext`, method, and explicit header/body overrides.
///
/// `baseline_headers` and `probe_headers` are the final headers to use — this
/// function does not merge with `ctx.headers`. When `body` is `None` and
/// `ctx.body_template` is set, the template is substituted for each side
/// independently. Strategies that pass an explicit body are unaffected.
/// The caller supplies `metadata` and `technique` describing the strategy.
#[must_use]
pub fn build_pair(
    ctx: &ScanContext,
    method: Method,
    baseline_headers: HeaderMap,
    probe_headers: HeaderMap,
    body: Option<Bytes>,
    metadata: StrategyMetadata,
    technique: Technique,
) -> ProbePair {
    let baseline_url = substitute_url(&ctx.target, &ctx.baseline_id);
    let probe_url = substitute_url(&ctx.target, &ctx.probe_id);

    let baseline_body = body
        .clone()
        .or_else(|| substitute_body(ctx.body_template.as_deref(), &ctx.baseline_id));
    let probe_body = body
        .or_else(|| substitute_body(ctx.body_template.as_deref(), &ctx.probe_id));

    ProbePair {
        baseline: ProbeDefinition {
            url: baseline_url,
            method: method.clone(),
            headers: baseline_headers,
            body: baseline_body,
        },
        probe: ProbeDefinition {
            url: probe_url,
            method,
            headers: probe_headers,
            body: probe_body,
        },
        metadata,
        technique,
    }
}

/// Returns a clone of `base` with `key` set to `value`, overwriting any existing value.
///
/// # Panics
///
/// Panics if `key` is not a valid HTTP header name or `value` is not a valid header
/// value. Both are caller-guaranteed — this function is intended for call sites where
/// names and values are known-good string literals or programmatically validated values.
#[must_use]
pub fn clone_headers_with(base: &HeaderMap, key: &str, value: &str) -> HeaderMap {
    let mut map = base.clone();
    let name = HeaderName::from_bytes(key.as_bytes())
        .expect("caller guarantees valid header name");
    let val = HeaderValue::from_str(value)
        .expect("caller guarantees valid header value");
    map.insert(name, val);
    map
}

/// Returns a clone of `base` with the header named `key` removed.
///
/// No-op when `key` is not present.
///
/// # Panics
///
/// Panics if `key` is not a valid HTTP header name. This function is intended for
/// call sites where the name is a known-good literal.
#[must_use]
pub fn clone_headers_without(base: &HeaderMap, key: &str) -> HeaderMap {
    let mut map = base.clone();
    let name = HeaderName::from_bytes(key.as_bytes())
        .expect("caller guarantees valid header name");
    map.remove(name);
    map
}

/// Builds a JSON object body from a slice of `(field, value)` string pairs.
///
/// Returns serialized bytes. The caller is responsible for setting the
/// `Content-Type: application/json` header separately.
///
/// Example: `json_body(&[("email", "alice@example.com")])` → `{"email":"alice@example.com"}`
///
/// # Panics
///
/// Does not panic in practice. `serde_json::to_vec` on a string-keyed map with string
/// values is infallible; the `expect` is a compile-time guard against signature changes.
#[must_use]
pub fn json_body(fields: &[(&str, &str)]) -> Bytes {
    let map: serde_json::Map<String, serde_json::Value> = fields
        .iter()
        .map(|(k, v)| ((*k).to_owned(), serde_json::Value::String((*v).to_owned())))
        .collect();
    let vec = serde_json::to_vec(&map).expect("serializing string-keyed map is infallible");
    Bytes::from(vec)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::ScanContext;
    use crate::types::RiskLevel;
    use parlov_core::{NormativeStrength, OracleClass, Vector};

    fn make_metadata() -> StrategyMetadata {
        StrategyMetadata {
            strategy_id: "test",
            strategy_name: "Test",
            risk: RiskLevel::Safe,
        }
    }

    fn make_technique() -> Technique {
        Technique {
            id: "test",
            name: "Test technique",
            oracle_class: OracleClass::Existence,
            vector: Vector::StatusCodeDiff,
            strength: NormativeStrength::Should,
        }
    }

    fn make_ctx() -> ScanContext {
        ScanContext {
            target: "https://api.example.com/users/{id}".to_owned(),
            baseline_id: "1001".to_owned(),
            probe_id: "9999".to_owned(),
            headers: HeaderMap::new(),
            max_risk: RiskLevel::Safe,
            known_duplicate: None,
            state_field: None,
            alt_credential: None,
            body_template: None,
        }
    }

    // --- substitute_url ---

    #[test]
    fn substitute_url_replaces_placeholder() {
        let result = substitute_url("https://api.example.com/users/{id}", "1001");
        assert_eq!(result, "https://api.example.com/users/1001");
    }

    #[test]
    fn substitute_url_replaces_only_first_occurrence() {
        let result = substitute_url("https://api.example.com/{id}/things/{id}", "42");
        assert_eq!(result, "https://api.example.com/42/things/{id}");
    }

    #[test]
    fn substitute_url_no_placeholder_returns_unchanged() {
        let template = "https://api.example.com/users/all";
        let result = substitute_url(template, "99");
        assert_eq!(result, template);
    }

    // --- build_pair ---

    #[test]
    fn build_pair_baseline_url_uses_baseline_id() {
        let ctx = make_ctx();
        let pair = build_pair(&ctx, Method::GET, HeaderMap::new(), HeaderMap::new(), None, make_metadata(), make_technique());
        assert_eq!(pair.baseline.url, "https://api.example.com/users/1001");
    }

    #[test]
    fn build_pair_probe_url_uses_probe_id() {
        let ctx = make_ctx();
        let pair = build_pair(&ctx, Method::GET, HeaderMap::new(), HeaderMap::new(), None, make_metadata(), make_technique());
        assert_eq!(pair.probe.url, "https://api.example.com/users/9999");
    }

    #[test]
    fn build_pair_method_set_on_both_definitions() {
        let ctx = make_ctx();
        let pair = build_pair(&ctx, Method::POST, HeaderMap::new(), HeaderMap::new(), None, make_metadata(), make_technique());
        assert_eq!(pair.baseline.method, Method::POST);
        assert_eq!(pair.probe.method, Method::POST);
    }

    #[test]
    fn build_pair_headers_assigned_to_correct_side() {
        let ctx = make_ctx();
        let baseline_hdrs = clone_headers_with(&HeaderMap::new(), "x-role", "admin");
        let probe_hdrs = clone_headers_with(&HeaderMap::new(), "x-role", "guest");
        let pair = build_pair(&ctx, Method::GET, baseline_hdrs, probe_hdrs, None, make_metadata(), make_technique());
        assert_eq!(pair.baseline.headers.get("x-role").unwrap(), "admin");
        assert_eq!(pair.probe.headers.get("x-role").unwrap(), "guest");
    }

    #[test]
    fn build_pair_body_none_when_none() {
        let ctx = make_ctx();
        let pair = build_pair(&ctx, Method::GET, HeaderMap::new(), HeaderMap::new(), None, make_metadata(), make_technique());
        assert!(pair.baseline.body.is_none());
        assert!(pair.probe.body.is_none());
    }

    #[test]
    fn build_pair_body_present_when_some() {
        let ctx = make_ctx();
        let body = Bytes::from_static(b"{\"x\":1}");
        let pair = build_pair(&ctx, Method::POST, HeaderMap::new(), HeaderMap::new(), Some(body.clone()), make_metadata(), make_technique());
        assert_eq!(pair.baseline.body.as_ref().unwrap(), &body);
        assert_eq!(pair.probe.body.as_ref().unwrap(), &body);
    }

    #[test]
    fn build_pair_body_template_substituted_when_no_explicit_body() {
        let ctx = ScanContext {
            body_template: Some(r#"{"id":"{id}"}"#.to_owned()),
            ..make_ctx()
        };
        let pair = build_pair(&ctx, Method::POST, HeaderMap::new(), HeaderMap::new(), None, make_metadata(), make_technique());
        assert_eq!(pair.baseline.body.unwrap(), Bytes::from(r#"{"id":"1001"}"#));
        assert_eq!(pair.probe.body.unwrap(), Bytes::from(r#"{"id":"9999"}"#));
    }

    #[test]
    fn build_pair_explicit_body_overrides_template() {
        let ctx = ScanContext {
            body_template: Some(r#"{"id":"{id}"}"#.to_owned()),
            ..make_ctx()
        };
        let explicit = Bytes::from_static(b"explicit");
        let pair = build_pair(&ctx, Method::POST, HeaderMap::new(), HeaderMap::new(), Some(explicit.clone()), make_metadata(), make_technique());
        assert_eq!(pair.baseline.body.unwrap(), explicit);
        assert_eq!(pair.probe.body.unwrap(), explicit);
    }

    // --- substitute_body ---

    #[test]
    fn substitute_body_none_template_returns_none() {
        assert!(substitute_body(None, "42").is_none());
    }

    #[test]
    fn substitute_body_replaces_placeholder() {
        let result = substitute_body(Some(r#"{"id":"{id}"}"#), "42").unwrap();
        assert_eq!(result, Bytes::from(r#"{"id":"42"}"#));
    }

    #[test]
    fn substitute_body_no_placeholder_returns_template_unchanged() {
        let result = substitute_body(Some(r#"{"static":true}"#), "99").unwrap();
        assert_eq!(result, Bytes::from(r#"{"static":true}"#));
    }

    // --- clone_headers_with ---

    #[test]
    fn clone_headers_with_new_header_present() {
        let result = clone_headers_with(&HeaderMap::new(), "x-custom", "hello");
        assert_eq!(result.get("x-custom").unwrap(), "hello");
    }

    #[test]
    fn clone_headers_with_preserves_existing_headers() {
        let base = clone_headers_with(&HeaderMap::new(), "authorization", "Bearer tok");
        let result = clone_headers_with(&base, "x-custom", "hello");
        assert_eq!(result.get("authorization").unwrap(), "Bearer tok");
        assert_eq!(result.get("x-custom").unwrap(), "hello");
    }

    #[test]
    fn clone_headers_with_overwrites_existing_key() {
        let base = clone_headers_with(&HeaderMap::new(), "x-role", "user");
        let result = clone_headers_with(&base, "x-role", "admin");
        assert_eq!(result.get("x-role").unwrap(), "admin");
        assert_eq!(result.get_all("x-role").iter().count(), 1);
    }

    // --- clone_headers_without ---

    #[test]
    fn clone_headers_without_removes_named_header() {
        let base = clone_headers_with(&HeaderMap::new(), "x-remove-me", "gone");
        let result = clone_headers_without(&base, "x-remove-me");
        assert!(result.get("x-remove-me").is_none());
    }

    #[test]
    fn clone_headers_without_preserves_other_headers() {
        let base = clone_headers_with(&HeaderMap::new(), "authorization", "Bearer tok");
        let base = clone_headers_with(&base, "x-remove-me", "gone");
        let result = clone_headers_without(&base, "x-remove-me");
        assert_eq!(result.get("authorization").unwrap(), "Bearer tok");
    }

    #[test]
    fn clone_headers_without_noop_when_key_absent() {
        let base = clone_headers_with(&HeaderMap::new(), "authorization", "Bearer tok");
        let result = clone_headers_without(&base, "x-nonexistent");
        assert_eq!(result.get("authorization").unwrap(), "Bearer tok");
        assert!(result.get("x-nonexistent").is_none());
    }

    // --- json_body ---

    #[test]
    fn json_body_single_field() {
        let body = json_body(&[("status", "active")]);
        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert_eq!(parsed["status"], "active");
    }

    #[test]
    fn json_body_multiple_fields() {
        let body = json_body(&[("email", "alice@example.com"), ("role", "admin")]);
        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert_eq!(parsed["email"], "alice@example.com");
        assert_eq!(parsed["role"], "admin");
    }

    #[test]
    fn json_body_empty_slice_produces_empty_object() {
        let body = json_body(&[]);
        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert!(parsed.as_object().unwrap().is_empty());
    }

    #[test]
    fn json_body_roundtrips_through_serde() {
        let body = json_body(&[("k", "v")]);
        let result = serde_json::from_slice::<serde_json::Value>(&body);
        assert!(result.is_ok());
    }
}