1use async_trait::async_trait;
42use boundgraph_core::{Digest, SchemaId, SlicerId};
43use serde_json::{Map, Value, json};
44
45use crate::slicer::{Raw, Slice, SliceError, Slicer};
46
47pub const JSON_SCHEMA: &str = "boundgraph.json.v1";
49
50pub const REDACTED: &str = "<redacted>";
52
53const SECRET_KEYS: &[&str] = &[
59 "password",
60 "passwd",
61 "secret",
62 "token",
63 "api_key",
64 "apikey",
65 "access_key",
66 "accesskey",
67 "private_key",
68 "privatekey",
69 "authorization",
70 "auth",
71 "credential",
72 "session_id",
73 "sessionid",
74 "cookie",
75 "signature",
76 "client_secret",
77 "refresh",
78 "bearer",
79 "otp",
80 "pin",
81];
82
83pub fn is_secret_key(key: &str) -> bool {
85 let lower = key.to_ascii_lowercase().replace('-', "_");
86 SECRET_KEYS.iter().any(|k| lower.contains(k))
87}
88
89pub fn looks_like_secret(value: &str) -> bool {
95 const PREFIXES: &[&str] = &[
97 "sk-",
98 "sk_live_",
99 "sk_test_",
100 "pk_live_",
101 "ghp_",
102 "gho_",
103 "ghs_",
104 "github_pat_",
105 "xoxb-",
106 "xoxp-",
107 "AKIA",
108 "ASIA",
109 "AIza",
110 "-----BEGIN",
111 ];
112 if PREFIXES.iter().any(|p| value.starts_with(p)) {
113 return true;
114 }
115
116 if value.starts_with("eyJ") && value.matches('.').count() == 2 {
118 return true;
119 }
120 false
121}
122
123pub fn infer(value: &Value) -> Value {
130 match value {
131 Value::Null => json!({ "type": "null" }),
132 Value::Bool(_) => json!({ "type": "boolean" }),
133 Value::Number(n) => {
134 if n.is_i64() || n.is_u64() {
135 json!({ "type": "integer" })
136 } else {
137 json!({ "type": "number" })
138 }
139 }
140 Value::String(_) => json!({ "type": "string" }),
141 Value::Array(items) => {
142 let mut schema = json!({ "type": "array" });
143 if let Some(merged) = items.iter().map(infer).reduce(|a, b| merge(&a, &b)) {
144 schema["items"] = merged;
145 }
146 schema
147 }
148 Value::Object(map) => {
149 let mut properties = Map::new();
150 for (key, child) in map {
151 properties.insert(key.clone(), infer(child));
154 }
155 json!({ "type": "object", "properties": Value::Object(properties) })
156 }
157 }
158}
159
160fn merge(a: &Value, b: &Value) -> Value {
167 if a == b {
168 return a.clone();
169 }
170 let (ta, tb) = (a["type"].as_str(), b["type"].as_str());
171
172 if ta == Some("null") {
174 return b.clone();
175 }
176 if tb == Some("null") {
177 return a.clone();
178 }
179
180 if ta == Some("object") && tb == Some("object") {
181 let mut properties = a["properties"].as_object().cloned().unwrap_or_default();
182 if let Some(other) = b["properties"].as_object() {
183 for (key, schema) in other {
184 let merged = match properties.get(key) {
185 Some(existing) => merge(existing, schema),
186 None => schema.clone(),
187 };
188 properties.insert(key.clone(), merged);
189 }
190 }
191 return json!({ "type": "object", "properties": Value::Object(properties) });
192 }
193
194 if ta == Some("array") && tb == Some("array") {
195 let mut schema = json!({ "type": "array" });
196 match (a.get("items"), b.get("items")) {
197 (Some(x), Some(y)) => schema["items"] = merge(x, y),
198 (Some(x), None) => schema["items"] = x.clone(),
199 (None, Some(y)) => schema["items"] = y.clone(),
200 (None, None) => {}
201 }
202 return schema;
203 }
204
205 json!({ "type": [ta.unwrap_or("unknown"), tb.unwrap_or("unknown")] })
207}
208
209#[derive(Debug, Clone)]
234pub struct JsonSlicer {
235 max_depth: usize,
236 array_sample: usize,
237 extra_secret_keys: Vec<String>,
238}
239
240impl Default for JsonSlicer {
241 fn default() -> Self {
242 Self::new()
243 }
244}
245
246impl JsonSlicer {
247 pub fn new() -> Self {
249 Self {
250 max_depth: 4,
251 array_sample: 3,
252 extra_secret_keys: Vec::new(),
253 }
254 }
255
256 #[must_use]
258 pub fn sampling(mut self, elements: usize, max_depth: usize) -> Self {
259 self.array_sample = elements;
260 self.max_depth = max_depth.max(1);
261 self
262 }
263
264 #[must_use]
270 pub fn redacting<S: Into<String>>(mut self, keys: impl IntoIterator<Item = S>) -> Self {
271 self.extra_secret_keys
272 .extend(keys.into_iter().map(|k| k.into().to_ascii_lowercase()));
273 self
274 }
275
276 fn is_secret(&self, key: &str) -> bool {
277 if is_secret_key(key) {
278 return true;
279 }
280 let lower = key.to_ascii_lowercase();
281 self.extra_secret_keys.iter().any(|k| lower.contains(k))
282 }
283
284 fn sample(
286 &self,
287 value: &Value,
288 depth: usize,
289 elements: usize,
290 path: &str,
291 elided: &mut Vec<String>,
292 counts: &mut Map<String, Value>,
293 ) -> Value {
294 match value {
295 Value::Array(items) => {
296 if !items.is_empty() {
297 counts.insert(path.to_string(), json!(items.len()));
300 }
301 if depth == 0 {
302 elided.push(path.to_string());
303 return json!(format!("<array of {}>", items.len()));
304 }
305 let taken: Vec<Value> = items
306 .iter()
307 .take(elements)
308 .enumerate()
309 .map(|(i, item)| {
310 self.sample(
311 item,
312 depth - 1,
313 elements,
314 &format!("{path}[{i}]"),
315 elided,
316 counts,
317 )
318 })
319 .collect();
320 if taken.len() < items.len() {
321 elided.push(format!("{path}[]"));
322 }
323 Value::Array(taken)
324 }
325 Value::Object(map) => {
326 if depth == 0 {
327 elided.push(path.to_string());
328 return json!(format!("<object with {} fields>", map.len()));
329 }
330 let mut out = Map::new();
331 for (key, child) in map {
332 if self.is_secret(key) {
334 out.insert(key.clone(), json!(REDACTED));
335 continue;
336 }
337 if let Value::String(text) = child
338 && looks_like_secret(text)
339 {
340 out.insert(key.clone(), json!(REDACTED));
341 continue;
342 }
343 out.insert(
344 key.clone(),
345 self.sample(
346 child,
347 depth - 1,
348 elements,
349 &format!("{path}.{key}"),
350 elided,
351 counts,
352 ),
353 );
354 }
355 Value::Object(out)
356 }
357 Value::String(text) if looks_like_secret(text) => json!(REDACTED),
358 other => other.clone(),
359 }
360 }
361
362 fn payload(&self, value: &Value, schema: &Value, fidelity: Option<(usize, usize)>) -> Value {
364 let mut payload = json!({
365 "schema": schema,
366 "shape": format!("b3:{}", &Digest::of(schema.to_string().as_bytes()).to_string()[..16]),
369 });
370
371 let Some((depth, elements)) = fidelity else {
372 return payload;
373 };
374
375 let mut elided = Vec::new();
376 let mut counts = Map::new();
377 let sample = self.sample(value, depth, elements, "$", &mut elided, &mut counts);
378
379 payload["sample"] = sample;
380 if !counts.is_empty() {
381 payload["counts"] = Value::Object(counts);
382 }
383 if !elided.is_empty() {
384 elided.sort();
385 elided.dedup();
386 payload["elided"] = json!(elided);
387 }
388 payload
389 }
390}
391
392#[async_trait]
393impl Slicer for JsonSlicer {
394 fn id(&self) -> SlicerId {
395 SlicerId::new("json", 1)
396 }
397
398 async fn slice(&self, raw: &Raw, cap: usize) -> Result<Slice, SliceError> {
399 let text = raw.text();
400 let value: Value = serde_json::from_str(&text).map_err(|e| SliceError::Unsuitable {
401 slicer: "json".to_string(),
402 detail: format!("not JSON: {e}"),
403 })?;
404
405 let schema = infer(&value);
406 let out_schema = SchemaId::new(JSON_SCHEMA, 1);
407 let counter = self.counter();
408 let fits = |v: &Value| counter.count(&serde_json::to_string(v).unwrap_or_default()) <= cap;
409
410 let floor = self.payload(&value, &schema, None);
413 if !fits(&floor) {
414 return Err(SliceError::CapTooSmall {
415 cap,
416 minimum: counter.count(&serde_json::to_string(&floor).unwrap_or_default()),
417 });
418 }
419
420 for depth in (1..=self.max_depth).rev() {
422 for elements in (0..=self.array_sample).rev() {
423 let candidate = self.payload(&value, &schema, Some((depth, elements)));
424 if fits(&candidate) {
425 let lossy = candidate.get("elided").is_some()
426 || candidate["sample"] != value
427 || depth < self.max_depth
428 || elements < self.array_sample;
429 return Ok(Slice {
430 schema: out_schema,
431 payload: candidate,
432 lossy,
433 });
434 }
435 }
436 }
437
438 Ok(Slice {
439 schema: out_schema,
440 payload: floor,
441 lossy: true,
442 })
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use crate::slicer::MemoryStore;
450
451 fn raw_of(value: &Value) -> Raw {
452 Raw::new(value.to_string())
453 }
454
455 async fn slice(value: &Value, cap: usize) -> Slice {
456 JsonSlicer::new()
457 .slice(&raw_of(value), cap)
458 .await
459 .expect("a satisfiable cap")
460 }
461
462 fn big_response() -> Value {
463 json!({
464 "users": (0..5_000)
465 .map(|i| json!({ "id": i, "email": format!("user{i}@example.test"), "admin": false }))
466 .collect::<Vec<_>>(),
467 "next_cursor": "abc123",
468 "total": 5_000,
469 })
470 }
471
472 fn rendered(slice: &Slice) -> String {
473 serde_json::to_string(&slice.payload).unwrap()
474 }
475
476 #[tokio::test]
480 async fn the_schema_survives_a_cap_that_fits_almost_nothing() {
481 for cap in [100, 120, 200, 400, 2_000] {
482 let slice = slice(&big_response(), cap).await;
483 let schema = &slice.payload["schema"];
484
485 assert_eq!(schema["type"], "object", "at cap {cap}");
486 assert_eq!(
487 schema["properties"]["users"]["items"]["properties"]["email"]["type"], "string",
488 "the nested element shape survives cap {cap}"
489 );
490 assert_eq!(schema["properties"]["next_cursor"]["type"], "string");
491 }
492 }
493
494 #[test]
495 fn scalars_infer_their_own_types() {
496 assert_eq!(infer(&json!(null))["type"], "null");
497 assert_eq!(infer(&json!(true))["type"], "boolean");
498 assert_eq!(infer(&json!(42))["type"], "integer");
499 assert_eq!(infer(&json!(1.5))["type"], "number");
500 assert_eq!(infer(&json!("x"))["type"], "string");
501 }
502
503 #[test]
506 fn an_array_schema_merges_every_element_rather_than_trusting_the_first() {
507 let value = json!([{ "a": 1 }, { "b": "two" }]);
508 let items = &infer(&value)["items"]["properties"];
509
510 assert_eq!(items["a"]["type"], "integer");
511 assert_eq!(items["b"]["type"], "string");
512 }
513
514 #[test]
515 fn a_nullable_field_reports_the_type_it_has_when_it_has_one() {
516 let value = json!([{ "cursor": null }, { "cursor": "abc" }]);
517 assert_eq!(
518 infer(&value)["items"]["properties"]["cursor"]["type"],
519 "string"
520 );
521 }
522
523 #[test]
524 fn a_genuinely_mixed_field_says_so_rather_than_picking_one() {
525 let value = json!([{ "x": 1 }, { "x": "one" }]);
526 let x = &infer(&value)["items"]["properties"]["x"]["type"];
527 assert!(x.is_array(), "expected a union, got {x}");
528 }
529
530 #[tokio::test]
532 async fn two_responses_of_the_same_shape_share_a_fingerprint() {
533 let a = json!({ "users": [{ "id": 1 }], "total": 1 });
534 let b = json!({ "users": [{ "id": 999 }], "total": 999 });
535 let c = json!({ "users": [{ "id": 1, "extra": true }], "total": 1 });
536
537 async fn shape(value: &Value) -> Value {
538 slice(value, 500).await.payload["shape"].clone()
539 }
540
541 assert_eq!(shape(&a).await, shape(&b).await, "same shape, other values");
542 assert_ne!(
543 shape(&a).await,
544 shape(&c).await,
545 "an extra field is a different shape"
546 );
547 }
548
549 #[tokio::test]
550 async fn the_condensate_declares_its_schema_id() {
551 let store = MemoryStore::new();
552 let condensate = JsonSlicer::new()
553 .condense(&raw_of(&big_response()), 500, &store)
554 .await
555 .unwrap();
556
557 assert_eq!(condensate.schema, SchemaId::new(JSON_SCHEMA, 1));
558 assert_eq!(condensate.slicer, SlicerId::new("json", 1));
559 }
560
561 #[tokio::test]
565 async fn a_long_array_keeps_its_count_and_a_sample() {
566 let slice = slice(&big_response(), 800).await;
567
568 assert_eq!(
569 slice.payload["counts"]["$.users"], 5_000,
570 "the count survives whatever the sample"
571 );
572 let sample = slice.payload["sample"]["users"].as_array();
573 if let Some(sample) = sample {
574 assert!(sample.len() < 5_000, "it is a sample, not the array");
575 }
576 assert!(slice.lossy);
577 }
578
579 #[tokio::test]
582 async fn the_element_count_survives_even_when_no_element_does() {
583 let slice = slice(&big_response(), 200).await;
584 assert_eq!(slice.payload["counts"]["$.users"], 5_000);
585 }
586
587 #[tokio::test]
588 async fn a_short_array_is_kept_whole_and_is_not_lossy() {
589 let value = json!({ "items": [1, 2] });
590 let slice = slice(&value, 400).await;
591
592 assert_eq!(slice.payload["sample"], value);
593 assert!(!slice.lossy);
594 assert!(slice.payload.get("elided").is_none());
595 }
596
597 #[tokio::test]
598 async fn the_sample_width_is_configurable() {
599 let value = json!({ "xs": (0..50).collect::<Vec<_>>() });
600 let wide = JsonSlicer::new()
601 .sampling(10, 4)
602 .slice(&raw_of(&value), 2_000)
603 .await
604 .unwrap();
605
606 assert_eq!(wide.payload["sample"]["xs"].as_array().unwrap().len(), 10);
607 }
608
609 #[tokio::test]
613 async fn deep_nesting_is_elided_and_the_paths_are_named() {
614 let deep = json!({ "a": { "b": { "c": { "d": { "e": { "f": 1 } } } } } });
615 let slice = JsonSlicer::new()
616 .sampling(3, 2)
617 .slice(&raw_of(&deep), 400)
618 .await
619 .unwrap();
620
621 let elided = slice.payload["elided"].as_array().unwrap();
622 assert!(
623 elided.iter().any(|p| p.as_str().unwrap().contains("$.a.b")),
624 "the elided path should be named: {elided:?}"
625 );
626 assert!(rendered(&slice).contains("<object with"));
627 assert!(slice.lossy);
628 }
629
630 #[tokio::test]
631 async fn an_elided_object_says_how_many_fields_it_had() {
632 let value = json!({ "outer": { "a": 1, "b": 2, "c": 3 } });
633 let slice = JsonSlicer::new()
634 .sampling(3, 1)
635 .slice(&raw_of(&value), 400)
636 .await
637 .unwrap();
638
639 assert_eq!(slice.payload["sample"]["outer"], "<object with 3 fields>");
640 }
641
642 #[tokio::test]
646 async fn secret_looking_fields_are_redacted_rather_than_sampled() {
647 let value = json!({
648 "user": "alice",
649 "password": "hunter2",
650 "api_key": "kx-1234",
651 "apiKey": "kx-5678",
652 "X-Api-Key": "kx-9012",
653 "access_token": "at-abcd",
654 "refresh_token": "rt-efgh",
655 "client_secret": "cs-ijkl",
656 "authorization": "Basic dXNlcjpwYXNz",
657 "session_id": "sess-mnop",
658 "signature": "sig-qrst",
659 });
660
661 let slice = slice(&value, 2_000).await;
662 let text = rendered(&slice);
663
664 for leaked in [
665 "hunter2",
666 "kx-1234",
667 "kx-5678",
668 "kx-9012",
669 "at-abcd",
670 "rt-efgh",
671 "cs-ijkl",
672 "dXNlcjpwYXNz",
673 "sess-mnop",
674 "sig-qrst",
675 ] {
676 assert!(
677 !text.contains(leaked),
678 "{leaked} reached the payload: {text}"
679 );
680 }
681 assert_eq!(
682 slice.payload["sample"]["user"], "alice",
683 "and the rest survives"
684 );
685 }
686
687 #[tokio::test]
690 async fn redaction_happens_regardless_of_the_cap() {
691 let value = json!({ "api_key": "sk-live-secret-value" });
692 for cap in [40, 80, 200, 5_000] {
693 let slice = slice(&value, cap).await;
694 assert!(
695 !rendered(&slice).contains("secret-value"),
696 "leaked at cap {cap}"
697 );
698 }
699 }
700
701 #[tokio::test]
704 async fn the_key_survives_even_though_its_value_does_not() {
705 let slice = slice(&json!({ "api_key": "sk-live-abc" }), 400).await;
706
707 assert_eq!(
708 slice.payload["schema"]["properties"]["api_key"]["type"],
709 "string"
710 );
711 assert_eq!(slice.payload["sample"]["api_key"], REDACTED);
712 }
713
714 #[tokio::test]
716 async fn a_credential_shaped_value_is_redacted_under_an_innocent_name() {
717 let value = json!({
718 "note": "sk-live-51H8yZ",
719 "header": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc",
720 "key_material": "-----BEGIN RSA PRIVATE KEY-----",
721 "aws": "AKIAIOSFODNN7EXAMPLE",
722 });
723
724 let slice = slice(&value, 2_000).await;
725 let text = rendered(&slice);
726 for leaked in ["51H8yZ", "eyJhbGci", "BEGIN RSA", "AKIAIOSFODNN7EXAMPLE"] {
727 assert!(!text.contains(leaked), "{leaked} survived: {text}");
728 }
729 }
730
731 #[tokio::test]
734 async fn an_ordinary_long_string_is_not_mistaken_for_a_credential() {
735 let digest = "a".repeat(64);
736 let value = json!({ "digest": digest, "message": "a fairly long ordinary sentence" });
737 let slice = slice(&value, 2_000).await;
738
739 assert_eq!(slice.payload["sample"]["digest"], digest);
740 assert!(rendered(&slice).contains("ordinary sentence"));
741 }
742
743 #[tokio::test]
744 async fn extra_secret_names_can_be_declared() {
745 let value = json!({ "house_key": "front-door-code" });
746
747 let default = slice(&value, 400).await;
748 assert_eq!(default.payload["sample"]["house_key"], "front-door-code");
749
750 let strict = JsonSlicer::new()
751 .redacting(["house_key"])
752 .slice(&raw_of(&value), 400)
753 .await
754 .unwrap();
755 assert_eq!(strict.payload["sample"]["house_key"], REDACTED);
756 }
757
758 #[test]
759 fn the_key_conventions_are_matched_however_they_are_wrapped() {
760 for key in [
761 "password",
762 "Password",
763 "api_key",
764 "apiKey",
765 "X-API-KEY",
766 "client_secret",
767 "refresh_token",
768 "AUTHORIZATION",
769 "sessionId",
770 "Cookie",
771 ] {
772 assert!(is_secret_key(key), "{key} should be secret");
773 }
774 for key in ["username", "id", "email", "created_at", "digest", "count"] {
775 assert!(!is_secret_key(key), "{key} should not be");
776 }
777 }
778
779 #[tokio::test]
782 async fn the_output_never_exceeds_the_cap() {
783 let store = MemoryStore::new();
784 let raw = raw_of(&big_response());
785
786 for cap in [100, 120, 200, 400, 800, 2_000, 8_000] {
787 let condensate = JsonSlicer::new()
788 .condense(&raw, cap, &store)
789 .await
790 .unwrap_or_else(|e| panic!("cap {cap} should be satisfiable: {e}"));
791 assert_eq!(condensate.cap, cap);
792 }
793 }
794
795 #[tokio::test]
796 async fn slicing_is_deterministic() {
797 let raw = raw_of(&big_response());
798 let first = JsonSlicer::new().slice(&raw, 600).await.unwrap();
799 for _ in 0..8 {
800 assert_eq!(JsonSlicer::new().slice(&raw, 600).await.unwrap(), first);
801 }
802 }
803
804 #[tokio::test]
807 async fn input_that_is_not_json_is_unsuitable_rather_than_fatal() {
808 let err = JsonSlicer::new()
809 .slice(&Raw::new("this is not json at all"), 400)
810 .await
811 .unwrap_err();
812
813 assert!(matches!(err, SliceError::Unsuitable { .. }));
814 assert!(err.can_fall_back());
815 }
816
817 #[tokio::test]
818 async fn a_response_that_fits_entirely_is_not_marked_lossy() {
819 let value = json!({ "ok": true, "count": 2 });
820 let slice = slice(&value, 400).await;
821
822 assert!(!slice.lossy);
823 assert_eq!(slice.payload["sample"], value);
824 }
825
826 #[tokio::test]
827 async fn a_top_level_array_is_handled_as_readily_as_an_object() {
828 let value = json!((0..100).map(|i| json!({ "i": i })).collect::<Vec<_>>());
829 let slice = slice(&value, 400).await;
830
831 assert_eq!(slice.payload["schema"]["type"], "array");
832 assert_eq!(slice.payload["counts"]["$"], 100);
833 }
834}