synthiaresearch 0.1.2

Rust SDK for the Synthia API
Documentation
//! Client-side PII redaction for production traces.
//!
//! REQUIRED and always on: it runs before a trace leaves this process, so raw
//! PII never reaches the server or its LLM. Structured PII is caught
//! deterministically here; the server re-scans as a backstop. Names and
//! addresses need a caller-supplied redactor.
//!
//! Kept in step with the JS `PII_DETECTORS` / `SENSITIVE_KEY` and the Python
//! `_redact_*` helpers — same patterns, same order, same replacement labels.

use std::sync::OnceLock;

use regex::Regex;
use serde_json::{Map, Value};

/// Order matters: card (13–19 digits) is matched BEFORE phone (9–15) so a card
/// number isn't partially eaten and mislabeled as a phone.
fn detectors() -> &'static [(Regex, &'static str)] {
    static DETECTORS: OnceLock<Vec<(Regex, &'static str)>> = OnceLock::new();
    DETECTORS.get_or_init(|| {
        vec![
            (
                Regex::new(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}").unwrap(),
                "<redacted:email>",
            ),
            // Rust's regex crate has no lookaround, so the digit-boundary guards
            // the JS patterns express with (?<!\d)/(?!\d) are applied by
            // rejecting matches whose neighbours are digits — see `replace_all`.
            (Regex::new(r"\d{3}-\d{2}-\d{4}").unwrap(), "<redacted:ssn>"),
            (
                Regex::new(r"(?:\d[ -]?){13,19}").unwrap(),
                "<redacted:card>",
            ),
            (
                Regex::new(r"(?:\+?\d[\s.\-]?){9,14}\d").unwrap(),
                "<redacted:phone>",
            ),
            (
                Regex::new(r"(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)")
                    .unwrap(),
                "<redacted:ip>",
            ),
        ]
    })
}

/// A value under a sensitive key is masked wholesale — more reliable than a
/// free-text regex over structured JSON.
fn sensitive_key() -> &'static Regex {
    static KEY: OnceLock<Regex> = OnceLock::new();
    KEY.get_or_init(|| {
        Regex::new(r"(?i)email|phone|ssn|dob|birth|address|name|card|account|token|secret|password")
            .unwrap()
    })
}

/// Replace every match, but skip matches whose immediate neighbours are digits.
/// That reproduces the `(?<!\d)…(?!\d)` guards the JS detectors use, which the
/// `regex` crate cannot express directly.
fn replace_guarded(input: &str, re: &Regex, repl: &str) -> String {
    let bytes = input.as_bytes();
    let mut out = String::with_capacity(input.len());
    let mut last = 0;
    for m in re.find_iter(input) {
        let before_is_digit = m.start() > 0 && bytes[m.start() - 1].is_ascii_digit();
        let after_is_digit = m.end() < bytes.len() && bytes[m.end()].is_ascii_digit();
        if before_is_digit || after_is_digit {
            continue;
        }
        out.push_str(&input[last..m.start()]);
        out.push_str(repl);
        last = m.end();
    }
    out.push_str(&input[last..]);
    out
}

/// A caller-supplied redactor, composed AFTER the built-in detectors. Names and
/// addresses need this; the structured detectors cannot catch them.
pub type Extra<'a> = Option<&'a (dyn Fn(&str) -> String + Send + Sync)>;

pub fn redact_string(s: &str) -> String {
    redact_string_with(s, None)
}

pub fn redact_string_with(s: &str, extra: Extra<'_>) -> String {
    let mut out = s.to_string();
    for (re, repl) in detectors() {
        out = replace_guarded(&out, re, repl);
    }
    match extra {
        Some(f) => f(&out),
        None => out,
    }
}

pub fn redact_value(value: &Value) -> Value {
    redact_value_with(value, None)
}

pub fn redact_value_with(value: &Value, extra: Extra<'_>) -> Value {
    redact_with_key(value, None, extra)
}

fn redact_with_key(value: &Value, key_hint: Option<&str>, extra: Extra<'_>) -> Value {
    match value {
        Value::String(s) => {
            if key_hint.is_some_and(|k| sensitive_key().is_match(k)) {
                Value::String("<redacted>".to_string())
            } else {
                Value::String(redact_string_with(s, extra))
            }
        }
        Value::Array(items) => Value::Array(
            items
                .iter()
                .map(|v| redact_with_key(v, None, extra))
                .collect(),
        ),
        Value::Object(map) => {
            let mut out = Map::new();
            for (k, v) in map {
                out.insert(k.clone(), redact_with_key(v, Some(k), extra));
            }
            Value::Object(out)
        }
        other => other.clone(),
    }
}