use async_trait::async_trait;
use boundgraph_core::{Digest, SchemaId, SlicerId};
use serde_json::{Map, Value, json};
use crate::slicer::{Raw, Slice, SliceError, Slicer};
pub const JSON_SCHEMA: &str = "boundgraph.json.v1";
pub const REDACTED: &str = "<redacted>";
const SECRET_KEYS: &[&str] = &[
"password",
"passwd",
"secret",
"token",
"api_key",
"apikey",
"access_key",
"accesskey",
"private_key",
"privatekey",
"authorization",
"auth",
"credential",
"session_id",
"sessionid",
"cookie",
"signature",
"client_secret",
"refresh",
"bearer",
"otp",
"pin",
];
pub fn is_secret_key(key: &str) -> bool {
let lower = key.to_ascii_lowercase().replace('-', "_");
SECRET_KEYS.iter().any(|k| lower.contains(k))
}
pub fn looks_like_secret(value: &str) -> bool {
const PREFIXES: &[&str] = &[
"sk-",
"sk_live_",
"sk_test_",
"pk_live_",
"ghp_",
"gho_",
"ghs_",
"github_pat_",
"xoxb-",
"xoxp-",
"AKIA",
"ASIA",
"AIza",
"-----BEGIN",
];
if PREFIXES.iter().any(|p| value.starts_with(p)) {
return true;
}
if value.starts_with("eyJ") && value.matches('.').count() == 2 {
return true;
}
false
}
pub fn infer(value: &Value) -> Value {
match value {
Value::Null => json!({ "type": "null" }),
Value::Bool(_) => json!({ "type": "boolean" }),
Value::Number(n) => {
if n.is_i64() || n.is_u64() {
json!({ "type": "integer" })
} else {
json!({ "type": "number" })
}
}
Value::String(_) => json!({ "type": "string" }),
Value::Array(items) => {
let mut schema = json!({ "type": "array" });
if let Some(merged) = items.iter().map(infer).reduce(|a, b| merge(&a, &b)) {
schema["items"] = merged;
}
schema
}
Value::Object(map) => {
let mut properties = Map::new();
for (key, child) in map {
properties.insert(key.clone(), infer(child));
}
json!({ "type": "object", "properties": Value::Object(properties) })
}
}
}
fn merge(a: &Value, b: &Value) -> Value {
if a == b {
return a.clone();
}
let (ta, tb) = (a["type"].as_str(), b["type"].as_str());
if ta == Some("null") {
return b.clone();
}
if tb == Some("null") {
return a.clone();
}
if ta == Some("object") && tb == Some("object") {
let mut properties = a["properties"].as_object().cloned().unwrap_or_default();
if let Some(other) = b["properties"].as_object() {
for (key, schema) in other {
let merged = match properties.get(key) {
Some(existing) => merge(existing, schema),
None => schema.clone(),
};
properties.insert(key.clone(), merged);
}
}
return json!({ "type": "object", "properties": Value::Object(properties) });
}
if ta == Some("array") && tb == Some("array") {
let mut schema = json!({ "type": "array" });
match (a.get("items"), b.get("items")) {
(Some(x), Some(y)) => schema["items"] = merge(x, y),
(Some(x), None) => schema["items"] = x.clone(),
(None, Some(y)) => schema["items"] = y.clone(),
(None, None) => {}
}
return schema;
}
json!({ "type": [ta.unwrap_or("unknown"), tb.unwrap_or("unknown")] })
}
#[derive(Debug, Clone)]
pub struct JsonSlicer {
max_depth: usize,
array_sample: usize,
extra_secret_keys: Vec<String>,
}
impl Default for JsonSlicer {
fn default() -> Self {
Self::new()
}
}
impl JsonSlicer {
pub fn new() -> Self {
Self {
max_depth: 4,
array_sample: 3,
extra_secret_keys: Vec::new(),
}
}
#[must_use]
pub fn sampling(mut self, elements: usize, max_depth: usize) -> Self {
self.array_sample = elements;
self.max_depth = max_depth.max(1);
self
}
#[must_use]
pub fn redacting<S: Into<String>>(mut self, keys: impl IntoIterator<Item = S>) -> Self {
self.extra_secret_keys
.extend(keys.into_iter().map(|k| k.into().to_ascii_lowercase()));
self
}
fn is_secret(&self, key: &str) -> bool {
if is_secret_key(key) {
return true;
}
let lower = key.to_ascii_lowercase();
self.extra_secret_keys.iter().any(|k| lower.contains(k))
}
fn sample(
&self,
value: &Value,
depth: usize,
elements: usize,
path: &str,
elided: &mut Vec<String>,
counts: &mut Map<String, Value>,
) -> Value {
match value {
Value::Array(items) => {
if !items.is_empty() {
counts.insert(path.to_string(), json!(items.len()));
}
if depth == 0 {
elided.push(path.to_string());
return json!(format!("<array of {}>", items.len()));
}
let taken: Vec<Value> = items
.iter()
.take(elements)
.enumerate()
.map(|(i, item)| {
self.sample(
item,
depth - 1,
elements,
&format!("{path}[{i}]"),
elided,
counts,
)
})
.collect();
if taken.len() < items.len() {
elided.push(format!("{path}[]"));
}
Value::Array(taken)
}
Value::Object(map) => {
if depth == 0 {
elided.push(path.to_string());
return json!(format!("<object with {} fields>", map.len()));
}
let mut out = Map::new();
for (key, child) in map {
if self.is_secret(key) {
out.insert(key.clone(), json!(REDACTED));
continue;
}
if let Value::String(text) = child
&& looks_like_secret(text)
{
out.insert(key.clone(), json!(REDACTED));
continue;
}
out.insert(
key.clone(),
self.sample(
child,
depth - 1,
elements,
&format!("{path}.{key}"),
elided,
counts,
),
);
}
Value::Object(out)
}
Value::String(text) if looks_like_secret(text) => json!(REDACTED),
other => other.clone(),
}
}
fn payload(&self, value: &Value, schema: &Value, fidelity: Option<(usize, usize)>) -> Value {
let mut payload = json!({
"schema": schema,
"shape": format!("b3:{}", &Digest::of(schema.to_string().as_bytes()).to_string()[..16]),
});
let Some((depth, elements)) = fidelity else {
return payload;
};
let mut elided = Vec::new();
let mut counts = Map::new();
let sample = self.sample(value, depth, elements, "$", &mut elided, &mut counts);
payload["sample"] = sample;
if !counts.is_empty() {
payload["counts"] = Value::Object(counts);
}
if !elided.is_empty() {
elided.sort();
elided.dedup();
payload["elided"] = json!(elided);
}
payload
}
}
#[async_trait]
impl Slicer for JsonSlicer {
fn id(&self) -> SlicerId {
SlicerId::new("json", 1)
}
async fn slice(&self, raw: &Raw, cap: usize) -> Result<Slice, SliceError> {
let text = raw.text();
let value: Value = serde_json::from_str(&text).map_err(|e| SliceError::Unsuitable {
slicer: "json".to_string(),
detail: format!("not JSON: {e}"),
})?;
let schema = infer(&value);
let out_schema = SchemaId::new(JSON_SCHEMA, 1);
let counter = self.counter();
let fits = |v: &Value| counter.count(&serde_json::to_string(v).unwrap_or_default()) <= cap;
let floor = self.payload(&value, &schema, None);
if !fits(&floor) {
return Err(SliceError::CapTooSmall {
cap,
minimum: counter.count(&serde_json::to_string(&floor).unwrap_or_default()),
});
}
for depth in (1..=self.max_depth).rev() {
for elements in (0..=self.array_sample).rev() {
let candidate = self.payload(&value, &schema, Some((depth, elements)));
if fits(&candidate) {
let lossy = candidate.get("elided").is_some()
|| candidate["sample"] != value
|| depth < self.max_depth
|| elements < self.array_sample;
return Ok(Slice {
schema: out_schema,
payload: candidate,
lossy,
});
}
}
}
Ok(Slice {
schema: out_schema,
payload: floor,
lossy: true,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::slicer::MemoryStore;
fn raw_of(value: &Value) -> Raw {
Raw::new(value.to_string())
}
async fn slice(value: &Value, cap: usize) -> Slice {
JsonSlicer::new()
.slice(&raw_of(value), cap)
.await
.expect("a satisfiable cap")
}
fn big_response() -> Value {
json!({
"users": (0..5_000)
.map(|i| json!({ "id": i, "email": format!("user{i}@example.test"), "admin": false }))
.collect::<Vec<_>>(),
"next_cursor": "abc123",
"total": 5_000,
})
}
fn rendered(slice: &Slice) -> String {
serde_json::to_string(&slice.payload).unwrap()
}
#[tokio::test]
async fn the_schema_survives_a_cap_that_fits_almost_nothing() {
for cap in [100, 120, 200, 400, 2_000] {
let slice = slice(&big_response(), cap).await;
let schema = &slice.payload["schema"];
assert_eq!(schema["type"], "object", "at cap {cap}");
assert_eq!(
schema["properties"]["users"]["items"]["properties"]["email"]["type"], "string",
"the nested element shape survives cap {cap}"
);
assert_eq!(schema["properties"]["next_cursor"]["type"], "string");
}
}
#[test]
fn scalars_infer_their_own_types() {
assert_eq!(infer(&json!(null))["type"], "null");
assert_eq!(infer(&json!(true))["type"], "boolean");
assert_eq!(infer(&json!(42))["type"], "integer");
assert_eq!(infer(&json!(1.5))["type"], "number");
assert_eq!(infer(&json!("x"))["type"], "string");
}
#[test]
fn an_array_schema_merges_every_element_rather_than_trusting_the_first() {
let value = json!([{ "a": 1 }, { "b": "two" }]);
let items = &infer(&value)["items"]["properties"];
assert_eq!(items["a"]["type"], "integer");
assert_eq!(items["b"]["type"], "string");
}
#[test]
fn a_nullable_field_reports_the_type_it_has_when_it_has_one() {
let value = json!([{ "cursor": null }, { "cursor": "abc" }]);
assert_eq!(
infer(&value)["items"]["properties"]["cursor"]["type"],
"string"
);
}
#[test]
fn a_genuinely_mixed_field_says_so_rather_than_picking_one() {
let value = json!([{ "x": 1 }, { "x": "one" }]);
let x = &infer(&value)["items"]["properties"]["x"]["type"];
assert!(x.is_array(), "expected a union, got {x}");
}
#[tokio::test]
async fn two_responses_of_the_same_shape_share_a_fingerprint() {
let a = json!({ "users": [{ "id": 1 }], "total": 1 });
let b = json!({ "users": [{ "id": 999 }], "total": 999 });
let c = json!({ "users": [{ "id": 1, "extra": true }], "total": 1 });
async fn shape(value: &Value) -> Value {
slice(value, 500).await.payload["shape"].clone()
}
assert_eq!(shape(&a).await, shape(&b).await, "same shape, other values");
assert_ne!(
shape(&a).await,
shape(&c).await,
"an extra field is a different shape"
);
}
#[tokio::test]
async fn the_condensate_declares_its_schema_id() {
let store = MemoryStore::new();
let condensate = JsonSlicer::new()
.condense(&raw_of(&big_response()), 500, &store)
.await
.unwrap();
assert_eq!(condensate.schema, SchemaId::new(JSON_SCHEMA, 1));
assert_eq!(condensate.slicer, SlicerId::new("json", 1));
}
#[tokio::test]
async fn a_long_array_keeps_its_count_and_a_sample() {
let slice = slice(&big_response(), 800).await;
assert_eq!(
slice.payload["counts"]["$.users"], 5_000,
"the count survives whatever the sample"
);
let sample = slice.payload["sample"]["users"].as_array();
if let Some(sample) = sample {
assert!(sample.len() < 5_000, "it is a sample, not the array");
}
assert!(slice.lossy);
}
#[tokio::test]
async fn the_element_count_survives_even_when_no_element_does() {
let slice = slice(&big_response(), 200).await;
assert_eq!(slice.payload["counts"]["$.users"], 5_000);
}
#[tokio::test]
async fn a_short_array_is_kept_whole_and_is_not_lossy() {
let value = json!({ "items": [1, 2] });
let slice = slice(&value, 400).await;
assert_eq!(slice.payload["sample"], value);
assert!(!slice.lossy);
assert!(slice.payload.get("elided").is_none());
}
#[tokio::test]
async fn the_sample_width_is_configurable() {
let value = json!({ "xs": (0..50).collect::<Vec<_>>() });
let wide = JsonSlicer::new()
.sampling(10, 4)
.slice(&raw_of(&value), 2_000)
.await
.unwrap();
assert_eq!(wide.payload["sample"]["xs"].as_array().unwrap().len(), 10);
}
#[tokio::test]
async fn deep_nesting_is_elided_and_the_paths_are_named() {
let deep = json!({ "a": { "b": { "c": { "d": { "e": { "f": 1 } } } } } });
let slice = JsonSlicer::new()
.sampling(3, 2)
.slice(&raw_of(&deep), 400)
.await
.unwrap();
let elided = slice.payload["elided"].as_array().unwrap();
assert!(
elided.iter().any(|p| p.as_str().unwrap().contains("$.a.b")),
"the elided path should be named: {elided:?}"
);
assert!(rendered(&slice).contains("<object with"));
assert!(slice.lossy);
}
#[tokio::test]
async fn an_elided_object_says_how_many_fields_it_had() {
let value = json!({ "outer": { "a": 1, "b": 2, "c": 3 } });
let slice = JsonSlicer::new()
.sampling(3, 1)
.slice(&raw_of(&value), 400)
.await
.unwrap();
assert_eq!(slice.payload["sample"]["outer"], "<object with 3 fields>");
}
#[tokio::test]
async fn secret_looking_fields_are_redacted_rather_than_sampled() {
let value = json!({
"user": "alice",
"password": "hunter2",
"api_key": "kx-1234",
"apiKey": "kx-5678",
"X-Api-Key": "kx-9012",
"access_token": "at-abcd",
"refresh_token": "rt-efgh",
"client_secret": "cs-ijkl",
"authorization": "Basic dXNlcjpwYXNz",
"session_id": "sess-mnop",
"signature": "sig-qrst",
});
let slice = slice(&value, 2_000).await;
let text = rendered(&slice);
for leaked in [
"hunter2",
"kx-1234",
"kx-5678",
"kx-9012",
"at-abcd",
"rt-efgh",
"cs-ijkl",
"dXNlcjpwYXNz",
"sess-mnop",
"sig-qrst",
] {
assert!(
!text.contains(leaked),
"{leaked} reached the payload: {text}"
);
}
assert_eq!(
slice.payload["sample"]["user"], "alice",
"and the rest survives"
);
}
#[tokio::test]
async fn redaction_happens_regardless_of_the_cap() {
let value = json!({ "api_key": "sk-live-secret-value" });
for cap in [40, 80, 200, 5_000] {
let slice = slice(&value, cap).await;
assert!(
!rendered(&slice).contains("secret-value"),
"leaked at cap {cap}"
);
}
}
#[tokio::test]
async fn the_key_survives_even_though_its_value_does_not() {
let slice = slice(&json!({ "api_key": "sk-live-abc" }), 400).await;
assert_eq!(
slice.payload["schema"]["properties"]["api_key"]["type"],
"string"
);
assert_eq!(slice.payload["sample"]["api_key"], REDACTED);
}
#[tokio::test]
async fn a_credential_shaped_value_is_redacted_under_an_innocent_name() {
let value = json!({
"note": "sk-live-51H8yZ",
"header": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc",
"key_material": "-----BEGIN RSA PRIVATE KEY-----",
"aws": "AKIAIOSFODNN7EXAMPLE",
});
let slice = slice(&value, 2_000).await;
let text = rendered(&slice);
for leaked in ["51H8yZ", "eyJhbGci", "BEGIN RSA", "AKIAIOSFODNN7EXAMPLE"] {
assert!(!text.contains(leaked), "{leaked} survived: {text}");
}
}
#[tokio::test]
async fn an_ordinary_long_string_is_not_mistaken_for_a_credential() {
let digest = "a".repeat(64);
let value = json!({ "digest": digest, "message": "a fairly long ordinary sentence" });
let slice = slice(&value, 2_000).await;
assert_eq!(slice.payload["sample"]["digest"], digest);
assert!(rendered(&slice).contains("ordinary sentence"));
}
#[tokio::test]
async fn extra_secret_names_can_be_declared() {
let value = json!({ "house_key": "front-door-code" });
let default = slice(&value, 400).await;
assert_eq!(default.payload["sample"]["house_key"], "front-door-code");
let strict = JsonSlicer::new()
.redacting(["house_key"])
.slice(&raw_of(&value), 400)
.await
.unwrap();
assert_eq!(strict.payload["sample"]["house_key"], REDACTED);
}
#[test]
fn the_key_conventions_are_matched_however_they_are_wrapped() {
for key in [
"password",
"Password",
"api_key",
"apiKey",
"X-API-KEY",
"client_secret",
"refresh_token",
"AUTHORIZATION",
"sessionId",
"Cookie",
] {
assert!(is_secret_key(key), "{key} should be secret");
}
for key in ["username", "id", "email", "created_at", "digest", "count"] {
assert!(!is_secret_key(key), "{key} should not be");
}
}
#[tokio::test]
async fn the_output_never_exceeds_the_cap() {
let store = MemoryStore::new();
let raw = raw_of(&big_response());
for cap in [100, 120, 200, 400, 800, 2_000, 8_000] {
let condensate = JsonSlicer::new()
.condense(&raw, cap, &store)
.await
.unwrap_or_else(|e| panic!("cap {cap} should be satisfiable: {e}"));
assert_eq!(condensate.cap, cap);
}
}
#[tokio::test]
async fn slicing_is_deterministic() {
let raw = raw_of(&big_response());
let first = JsonSlicer::new().slice(&raw, 600).await.unwrap();
for _ in 0..8 {
assert_eq!(JsonSlicer::new().slice(&raw, 600).await.unwrap(), first);
}
}
#[tokio::test]
async fn input_that_is_not_json_is_unsuitable_rather_than_fatal() {
let err = JsonSlicer::new()
.slice(&Raw::new("this is not json at all"), 400)
.await
.unwrap_err();
assert!(matches!(err, SliceError::Unsuitable { .. }));
assert!(err.can_fall_back());
}
#[tokio::test]
async fn a_response_that_fits_entirely_is_not_marked_lossy() {
let value = json!({ "ok": true, "count": 2 });
let slice = slice(&value, 400).await;
assert!(!slice.lossy);
assert_eq!(slice.payload["sample"], value);
}
#[tokio::test]
async fn a_top_level_array_is_handled_as_readily_as_an_object() {
let value = json!((0..100).map(|i| json!({ "i": i })).collect::<Vec<_>>());
let slice = slice(&value, 400).await;
assert_eq!(slice.payload["schema"]["type"], "array");
assert_eq!(slice.payload["counts"]["$"], 100);
}
}