lenso-platform-provider 0.1.27

Provider Service host transport for the Lenso backend framework.
Documentation
use platform_core::ProviderHttpBodyEvidence;
use serde_json::Value;

pub(crate) const MAX_PROVIDER_HTTP_BODY_EVIDENCE_BYTES: usize = 64 * 1024;

pub(crate) fn capture_json_body(
    body: Option<&Value>,
    absent_reason: &'static str,
) -> ProviderHttpBodyEvidence {
    let Some(body) = body else {
        return ProviderHttpBodyEvidence::not_applicable(absent_reason);
    };

    let Ok(encoded) = serde_json::to_vec(body) else {
        return ProviderHttpBodyEvidence::not_captured("serialization_failed", None);
    };
    let raw_bytes = encoded.len();
    if raw_bytes > MAX_PROVIDER_HTTP_BODY_EVIDENCE_BYTES {
        return ProviderHttpBodyEvidence::not_captured("evidence_limit_exceeded", Some(raw_bytes));
    }

    let redacted = redact_json_value(body.clone());
    let Ok(redacted_encoded) = serde_json::to_vec(&redacted) else {
        return ProviderHttpBodyEvidence::not_captured("serialization_failed", None);
    };
    if redacted_encoded.len() > MAX_PROVIDER_HTTP_BODY_EVIDENCE_BYTES {
        return ProviderHttpBodyEvidence::not_captured(
            "evidence_limit_exceeded",
            Some(redacted_encoded.len()),
        );
    }

    ProviderHttpBodyEvidence::captured(redacted, raw_bytes)
}

fn redact_json_value(value: Value) -> Value {
    match value {
        Value::Array(items) => Value::Array(items.into_iter().map(redact_json_value).collect()),
        Value::Object(map) => Value::Object(
            map.into_iter()
                .map(|(key, value)| {
                    if is_sensitive_json_key(&key) {
                        (key, Value::String("[redacted]".to_owned()))
                    } else {
                        (key, redact_json_value(value))
                    }
                })
                .collect(),
        ),
        value => value,
    }
}

fn is_sensitive_json_key(key: &str) -> bool {
    let lower = key.to_ascii_lowercase();
    [
        "authorization",
        "cookie",
        "password",
        "passwd",
        "secret",
        "token",
        "api_key",
        "apikey",
        "access_key",
        "credential",
        "email",
    ]
    .iter()
    .any(|unsafe_part| lower.contains(unsafe_part))
}

#[cfg(test)]
mod tests {
    use super::*;
    use platform_core::ProviderHttpBodyCaptureStatus;
    use serde_json::json;

    #[test]
    fn recursively_redacts_sensitive_fields_before_capture() {
        let body = json!({
            "profile": {
                "email": "alex@example.com",
                "credentials": [{ "access_token": "secret" }],
                "display_name": "Alex"
            }
        });

        let evidence = capture_json_body(Some(&body), "method_without_body");

        assert_eq!(
            evidence.capture_status(),
            ProviderHttpBodyCaptureStatus::Captured
        );
        let captured = evidence.body().expect("body should be captured");
        assert_eq!(captured["profile"]["email"], "[redacted]");
        assert_eq!(captured["profile"]["credentials"], "[redacted]");
        assert_eq!(captured["profile"]["display_name"], "Alex");
    }

    #[test]
    fn omits_body_larger_than_the_evidence_limit() {
        let body = json!({ "value": "x".repeat(MAX_PROVIDER_HTTP_BODY_EVIDENCE_BYTES) });
        let evidence_limit = i64::try_from(MAX_PROVIDER_HTTP_BODY_EVIDENCE_BYTES)
            .expect("body evidence limit should fit in i64");

        let evidence = capture_json_body(Some(&body), "method_without_body");

        assert_eq!(
            evidence.capture_status(),
            ProviderHttpBodyCaptureStatus::NotCaptured
        );
        assert_eq!(evidence.capture_reason(), Some("evidence_limit_exceeded"));
        assert!(
            evidence
                .observed_bytes()
                .is_some_and(|bytes| bytes > evidence_limit)
        );
    }

    #[test]
    fn redaction_cannot_expand_the_persisted_copy_beyond_the_limit() {
        let evidence_limit = i64::try_from(MAX_PROVIDER_HTTP_BODY_EVIDENCE_BYTES)
            .expect("body evidence limit should fit in i64");
        let body = Value::Object(
            (0..3_000)
                .map(|index| (format!("secret_{index}"), Value::String(String::new())))
                .collect(),
        );
        assert!(serde_json::to_vec(&body).expect("JSON should encode").len() < 64 * 1024);

        let evidence = capture_json_body(Some(&body), "method_without_body");

        assert_eq!(
            evidence.capture_status(),
            ProviderHttpBodyCaptureStatus::NotCaptured
        );
        assert_eq!(evidence.capture_reason(), Some("evidence_limit_exceeded"));
        assert!(
            evidence
                .observed_bytes()
                .is_some_and(|bytes| bytes > evidence_limit)
        );
    }

    #[test]
    fn records_an_explicit_absence_reason() {
        let evidence = capture_json_body(None, "method_without_body");

        assert_eq!(
            evidence.capture_status(),
            ProviderHttpBodyCaptureStatus::NotApplicable
        );
        assert_eq!(evidence.capture_reason(), Some("method_without_body"));
        assert_eq!(evidence.observed_bytes(), None);
    }
}