Skip to main content

boundgraph_slice/
json.rs

1//! Condensing JSON: keep the shape, sample the values.
2//!
3//! # Why the schema is the part worth keeping
4//!
5//! A condensate is typed, not a blob of text (US-0104), and for an API response
6//! the type *is* the useful part. A model that knows the response is
7//! `{ users: [{ id: integer, email: string }], next_cursor: string|null }` can
8//! plan its next step without reading five thousand users. One shown the first
9//! two hundred users and nothing else has to guess whether `next_cursor` exists
10//! at all.
11//!
12//! So the schema is retained unconditionally and the values compete for what is
13//! left. Under a cap that fits nothing else, a JSON condensate is still a
14//! complete and accurate description of the response's shape.
15//!
16//! # Secrets are removed, not condensed
17//!
18//! Redaction happens before anything else and is not subject to the cap. A
19//! token that fits is *worse* than one that does not: it reaches the model, the
20//! ledger, and any sink the ledger feeds (US-1004 exports spans to a collector).
21//! `ARCHITECTURE.md` §11 requires secrets never to be journalled and US-1005
22//! asks for a redaction test to prove it; this is the layer where API responses
23//! get that treatment.
24//!
25//! Matching is by field name against the conventions credentials actually use,
26//! plus a check on the value's own shape for the formats that are unmistakable.
27//! It is deliberately eager: a false positive costs a field the model probably
28//! did not need, and a false negative costs a credential.
29//!
30//! # What gives way under pressure
31//!
32//! 1. **Array samples**, shortest first — the element count is kept regardless,
33//!    so "five thousand users" survives even when no user does.
34//! 2. **Depth**, outermost last — a nested object becomes a named type, and the
35//!    path that was elided is listed.
36//! 3. **The sample entirely**, leaving the schema.
37//!
38//! The counts and the elided paths are what stop this from being lossy in the
39//! dangerous sense: the model can always see *that* there was more.
40
41use 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
47/// The schema a JSON condensate declares.
48pub const JSON_SCHEMA: &str = "boundgraph.json.v1";
49
50/// What a redacted value is replaced with.
51pub const REDACTED: &str = "<redacted>";
52
53/// Field names that carry credentials, by convention.
54///
55/// Matched as substrings of the lowercased key, because the wrapping varies
56/// endlessly — `api_key`, `apiKey`, `X-Api-Key`, `client_secret` — while the
57/// stem does not.
58const 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
83/// Whether a field name suggests a credential.
84pub 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
89/// Whether a *value* is unmistakably a credential whatever it is called.
90///
91/// Only the formats that cannot plausibly be anything else. A long hex string
92/// is a digest as often as it is a key, so length alone is not enough — that
93/// would redact every content address in the system.
94pub fn looks_like_secret(value: &str) -> bool {
95    // Vendor-prefixed keys: `sk-…`, `ghp_…`, `AKIA…`, `xoxb-…`.
96    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    // A JWT: three base64url segments separated by dots, with a `eyJ` header.
117    if value.starts_with("eyJ") && value.matches('.').count() == 2 {
118        return true;
119    }
120    false
121}
122
123/// A schema inferred from a value.
124///
125/// Deliberately a small subset of JSON Schema — types, properties, and item
126/// shapes. Enough for a model to plan against and for a consumer to compare
127/// two responses; not an attempt to reconstruct a contract the API never
128/// published. Validating against a *declared* contract is US-0803.
129pub 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                // The key survives even when its value will not: knowing a
152                // response carries a `token` field is shape, not secret.
153                properties.insert(key.clone(), infer(child));
154            }
155            json!({ "type": "object", "properties": Value::Object(properties) })
156        }
157    }
158}
159
160/// Combine two inferred schemas.
161///
162/// Arrays in real responses are homogeneous but not perfectly so — a nullable
163/// field is present on some elements and null on others. Merging across every
164/// element rather than trusting the first is what stops the schema from
165/// claiming a field is required when it is not.
166fn 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    // A nullable field: report the type it has when it has one.
173    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    // Genuinely mixed. Saying so beats picking one and being wrong.
206    json!({ "type": [ta.unwrap_or("unknown"), tb.unwrap_or("unknown")] })
207}
208
209/// Condenses JSON responses.
210///
211/// # Example
212///
213/// ```
214/// use boundgraph_slice::{JsonSlicer, MemoryStore, Raw, Slicer};
215///
216/// let body = serde_json::json!({
217///     "users": (0..5_000).map(|i| serde_json::json!({ "id": i })).collect::<Vec<_>>(),
218///     "api_key": "sk-live-do-not-leak-this",
219/// });
220///
221/// let condensate = tokio::runtime::Builder::new_current_thread()
222///     .build()?
223///     .block_on(JsonSlicer::new().condense(
224///         &Raw::new(body.to_string()),
225///         300,
226///         &MemoryStore::new(),
227///     ))?;
228///
229/// let payload = format!("{:?}", condensate.payload);
230/// assert!(!payload.contains("do-not-leak-this"), "the key never reaches the model");
231/// # Ok::<(), Box<dyn std::error::Error>>(())
232/// ```
233#[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    /// A slicer sampling three array elements and four levels deep.
248    pub fn new() -> Self {
249        Self {
250            max_depth: 4,
251            array_sample: 3,
252            extra_secret_keys: Vec::new(),
253        }
254    }
255
256    /// Sample this many array elements, and elide below this depth.
257    #[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    /// Treat these field names as secret too, on top of the built-in list.
265    ///
266    /// For a response whose credential field is called something nobody would
267    /// guess. The built-in list is not replaced — narrowing it is not offered,
268    /// because the cost of the two mistakes is not symmetric.
269    #[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    /// Reduce a value to a sample, redacting and recording what was elided.
285    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                    // Kept whatever else is dropped: "five thousand users"
298                    // survives even when no user does.
299                    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                    // Redaction is not subject to depth, sampling, or the cap.
333                    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    /// The payload at a given fidelity. `None` for `elements` means no sample.
363    fn payload(&self, value: &Value, schema: &Value, fidelity: Option<(usize, usize)>) -> Value {
364        let mut payload = json!({
365            "schema": schema,
366            // A stable fingerprint of the shape, so a consumer can tell that
367            // two responses are the same kind of thing without diffing them.
368            "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        // The schema is retained unconditionally, so if it alone does not fit
411        // there is nothing to produce.
412        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        // Fidelity ladder: shorten array samples first, then reduce depth.
421        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    // ── the schema always survives ──────────────────────────────────────────
477
478    /// The criterion, and the reason the slicer exists.
479    #[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    /// Trusting the first element would claim a field is always present when a
504    /// later element omits it.
505    #[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    /// The criterion: a schema id so consumers can compare and validate.
531    #[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    // ── arrays ──────────────────────────────────────────────────────────────
562
563    /// The criterion: a retained element count and a sample.
564    #[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    /// The count is the part that must not be lost: "five thousand users"
580    /// survives even when no user does.
581    #[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    // ── depth ───────────────────────────────────────────────────────────────
610
611    /// The criterion: elided by depth, with the paths named.
612    #[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    // ── secrets ─────────────────────────────────────────────────────────────
643
644    /// The criterion, and the one where a false negative is expensive.
645    #[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    /// A token that fits is worse than one that does not — it reaches the
688    /// model, the ledger, and every sink the ledger feeds.
689    #[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    /// The shape is not the secret: knowing a response carries a `token` field
702    /// is exactly what a model needs to plan, and it is not a disclosure.
703    #[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    /// Some values are unmistakable whatever the field is called.
715    #[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    /// A digest is a long hex string and so is an API key. Redacting on length
732    /// alone would redact every content address in the system.
733    #[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    // ── the contract ────────────────────────────────────────────────────────
780
781    #[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    /// Not JSON is a fallback-able condition, not a failure of the step — the
805    /// caller can try the log slicer, exactly as the AST slicer does.
806    #[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}