Skip to main content

rust_sanitize/processor/
json_proc.rs

1//! JSON structured processor.
2//!
3//! The CLI uses [`process_to_edits`](JsonProcessor::process_to_edits): it walks
4//! the document with a byte-position parser (`jiter`) and replaces each matched
5//! value at its exact source span, so whitespace, key order, and the escaping of
6//! unrelated content are preserved byte-for-byte and values escaped in the
7//! source (e.g. `\/`, `\uXXXX`) are redacted without leaking. `process` is the
8//! re-serializing fallback used when span editing is unavailable.
9//!
10//! # Key Paths
11//!
12//! Nested keys are expressed as dot-separated paths:
13//! `database.password`, `smtp.credentials.user`.
14//!
15//! Array elements are traversed transparently — a rule for `users.email`
16//! matches the `email` field inside every object in the `users` array.
17
18use crate::error::{Result, SanitizeError};
19use crate::processor::limits::DEFAULT_INPUT_SIZE;
20use crate::processor::{
21    build_path, edit_token, walk_tree, FileTypeProfile, Processor, Replacement, TreeNode,
22};
23use crate::store::MappingStore;
24use jiter::{Jiter, Peek};
25use serde_json::Value;
26
27/// Map a `jiter` parse error to a `SanitizeError::ParseError`.
28fn json_err(e: impl std::fmt::Display) -> SanitizeError {
29    SanitizeError::ParseError {
30        format: "JSON".into(),
31        message: format!("JSON parse error: {e}"),
32    }
33}
34
35/// Structured processor for JSON files.
36pub struct JsonProcessor;
37
38impl Processor for JsonProcessor {
39    fn name(&self) -> &'static str {
40        "json"
41    }
42
43    fn can_handle(&self, content: &[u8], profile: &FileTypeProfile) -> bool {
44        if profile.processor == "json" {
45            return true;
46        }
47        // Heuristic: starts with `{` or `[` after optional whitespace.
48        let trimmed = content.iter().copied().find(|b| !b.is_ascii_whitespace());
49        matches!(trimmed, Some(b'{' | b'['))
50    }
51
52    fn process(
53        &self,
54        content: &[u8],
55        profile: &FileTypeProfile,
56        store: &MappingStore,
57    ) -> Result<Vec<u8>> {
58        // F-04 fix: enforce input size limit.
59        let text = crate::processor::check_size_and_decode(content, "JSON", DEFAULT_INPUT_SIZE)?;
60
61        let mut value: Value =
62            serde_json::from_str(text).map_err(|e| SanitizeError::ParseError {
63                format: "JSON".into(),
64                message: format!("JSON parse error: {}", e),
65            })?;
66
67        walk_json(&mut value, "", profile, store, 0)?;
68
69        let compact = profile.options.get("compact").is_some_and(|v| v == "true");
70
71        let output = if compact {
72            serde_json::to_vec(&value)
73        } else {
74            serde_json::to_vec_pretty(&value)
75        }
76        .map_err(|e| {
77            SanitizeError::IoError(std::io::Error::other(format!("JSON serialize error: {e}")))
78        })?;
79
80        Ok(output)
81    }
82
83    /// Span-based redaction: walk the document with `jiter` (a byte-position
84    /// JSON parser), recording an edit that replaces each matched value's exact
85    /// source span with a quoted token. Whitespace, key order, and the precise
86    /// escaping of unrelated content are preserved, and the value is hit in the
87    /// source *as written* — so values escaped as `\/`, `\uXXXX`, etc. are
88    /// redacted with no leak.
89    fn process_to_edits(
90        &self,
91        content: &[u8],
92        profile: &FileTypeProfile,
93        store: &MappingStore,
94    ) -> Result<Option<Vec<Replacement>>> {
95        // Enforce the size limit and reject non-UTF-8 (JSON must be UTF-8).
96        crate::processor::check_size_and_decode(content, "JSON", DEFAULT_INPUT_SIZE)?;
97        Ok(Some(json_value_edits(content, profile, store)?))
98    }
99}
100
101/// Compute span edits for a single JSON document in `content` (spans are
102/// relative to `content`). Shared by the JSON processor and, per line, by the
103/// JSONL processor.
104///
105/// # Errors
106///
107/// Returns [`SanitizeError::ParseError`] if `content` is not valid JSON.
108pub(crate) fn json_value_edits(
109    content: &[u8],
110    profile: &FileTypeProfile,
111    store: &MappingStore,
112) -> Result<Vec<Replacement>> {
113    // A leading UTF-8 BOM is not valid JSON to jiter (which would error and
114    // leave the value un-redacted). Skip it for parsing, then shift the spans
115    // back so they stay aligned to the original `content` (the BOM is preserved
116    // in the output, sitting before the first edit). For JSONL this only
117    // affects the file's first line.
118    let bom = if content.starts_with(&[0xEF, 0xBB, 0xBF]) {
119        3
120    } else {
121        0
122    };
123    let body = &content[bom..];
124    let mut jiter = Jiter::new(body);
125    let mut edits = Vec::new();
126    let peek = jiter.peek().map_err(json_err)?;
127    collect_json_edits(&mut jiter, peek, "", "", body, profile, store, &mut edits)?;
128    if bom != 0 {
129        for e in &mut edits {
130            e.start += bom;
131            e.end += bom;
132        }
133    }
134    Ok(edits)
135}
136
137/// Recursively walk a JSON value via `jiter`, emitting span edits for matched
138/// leaf values. `peek` is the already-peeked type of the value about to be read,
139/// and the parser is positioned at its first byte.
140#[allow(clippy::too_many_arguments)]
141fn collect_json_edits(
142    jiter: &mut Jiter,
143    peek: Peek,
144    key: &str,
145    path: &str,
146    content: &[u8],
147    profile: &FileTypeProfile,
148    store: &MappingStore,
149    edits: &mut Vec<Replacement>,
150) -> Result<()> {
151    if peek == Peek::Object {
152        // `next_object`/`next_key` return each key and leave the parser at the
153        // value (the `:` is consumed). Copy the key to release the borrow.
154        let mut next = jiter.next_object().map_err(json_err)?.map(str::to_owned);
155        while let Some(k) = next {
156            let child_path = build_path(path, &k);
157            let child_peek = jiter.peek().map_err(json_err)?;
158            collect_json_edits(
159                jiter,
160                child_peek,
161                &k,
162                &child_path,
163                content,
164                profile,
165                store,
166                edits,
167            )?;
168            next = jiter.next_key().map_err(json_err)?.map(str::to_owned);
169        }
170    } else if peek == Peek::Array {
171        // Array elements are path-transparent (keep the parent key/path).
172        let mut elem = jiter.next_array().map_err(json_err)?;
173        while let Some(elem_peek) = elem {
174            collect_json_edits(jiter, elem_peek, key, path, content, profile, store, edits)?;
175            elem = jiter.array_step().map_err(json_err)?;
176        }
177    } else if peek == Peek::String {
178        let start = jiter.current_index();
179        let s = jiter.next_str().map_err(json_err)?.to_owned();
180        let end = jiter.current_index();
181        if let Some(token) = edit_token(key, path, &s, profile, store)? {
182            edits.push(Replacement {
183                start,
184                end,
185                value: format!("\"{token}\""),
186            });
187        }
188    } else {
189        // null / true / false / number — capture the exact source text.
190        let start = jiter.current_index();
191        jiter.next_skip().map_err(json_err)?;
192        let end = jiter.current_index();
193        let s = String::from_utf8_lossy(&content[start..end]).into_owned();
194        if let Some(token) = edit_token(key, path, &s, profile, store)? {
195            edits.push(Replacement {
196                start,
197                end,
198                value: format!("\"{token}\""),
199            });
200        }
201    }
202    Ok(())
203}
204
205impl TreeNode for Value {
206    fn for_each_map_entry<F>(&mut self, mut f: F) -> Result<()>
207    where
208        F: FnMut(&str, &mut Self) -> Result<()>,
209    {
210        if let Self::Object(map) = self {
211            let keys: Vec<String> = map.keys().cloned().collect();
212            for key in keys {
213                if let Some(v) = map.get_mut(&key) {
214                    f(&key, v)?;
215                }
216            }
217        }
218        Ok(())
219    }
220
221    fn for_each_seq_item<F>(&mut self, mut f: F) -> Result<()>
222    where
223        F: FnMut(&mut Self) -> Result<()>,
224    {
225        if let Self::Array(arr) = self {
226            for item in arr.iter_mut() {
227                f(item)?;
228            }
229        }
230        Ok(())
231    }
232
233    fn as_str_mut(&mut self) -> Option<&mut String> {
234        if let Self::String(s) = self {
235            Some(s)
236        } else {
237            None
238        }
239    }
240
241    fn is_scalar(&self) -> bool {
242        matches!(self, Self::Number(_) | Self::Bool(_))
243    }
244
245    fn scalar_to_string(&self) -> String {
246        self.to_string()
247    }
248
249    fn set_string(&mut self, s: String) {
250        *self = Self::String(s);
251    }
252}
253
254/// Recursively walk a JSON value tree, replacing matched field values.
255pub(crate) fn walk_json(
256    value: &mut Value,
257    prefix: &str,
258    profile: &FileTypeProfile,
259    store: &MappingStore,
260    depth: usize,
261) -> Result<()> {
262    walk_tree(value, prefix, profile, store, depth, "JSON")
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::category::Category;
269    use crate::generator::HmacGenerator;
270    use crate::processor::profile::FieldRule;
271    use std::sync::Arc;
272
273    fn make_store() -> MappingStore {
274        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
275        MappingStore::new(gen, None)
276    }
277
278    #[test]
279    fn basic_json_replacement() {
280        let store = make_store();
281        let proc = JsonProcessor;
282
283        let content =
284            br#"{"database": {"host": "db.corp.com", "password": "s3cret"}, "port": 5432}"#;
285        let profile = FileTypeProfile::new(
286            "json",
287            vec![
288                FieldRule::new("database.password").with_category(Category::Custom("pw".into())),
289                FieldRule::new("database.host").with_category(Category::Hostname),
290            ],
291        )
292        .with_option("compact", "true");
293
294        let result = proc.process(content, &profile, &store).unwrap();
295        let out: Value = serde_json::from_slice(&result).unwrap();
296
297        assert_ne!(out["database"]["password"].as_str().unwrap(), "s3cret");
298        assert_ne!(out["database"]["host"].as_str().unwrap(), "db.corp.com");
299        assert_eq!(out["port"], 5432);
300    }
301
302    #[test]
303    fn json_array_traversal() {
304        let store = make_store();
305        let proc = JsonProcessor;
306
307        let content = br#"{"users": [{"email": "a@b.com"}, {"email": "c@d.com"}]}"#;
308        let profile = FileTypeProfile::new(
309            "json",
310            vec![FieldRule::new("users.email").with_category(Category::Email)],
311        )
312        .with_option("compact", "true");
313
314        let result = proc.process(content, &profile, &store).unwrap();
315        let out: Value = serde_json::from_slice(&result).unwrap();
316
317        let users = out["users"].as_array().unwrap();
318        assert_ne!(users[0]["email"].as_str().unwrap(), "a@b.com");
319        assert_ne!(users[1]["email"].as_str().unwrap(), "c@d.com");
320        // Non-secret structure preserved: both elements and their keys remain.
321        assert_eq!(users.len(), 2);
322        let text = String::from_utf8_lossy(&result);
323        assert!(text.contains("\"users\"") && text.contains("\"email\""));
324    }
325
326    #[test]
327    fn json_glob_suffix_pattern() {
328        let store = make_store();
329        let proc = JsonProcessor;
330
331        let content =
332            br#"{"db": {"password": "pw1"}, "cache": {"password": "pw2"}, "name": "app"}"#;
333        let profile = FileTypeProfile::new(
334            "json",
335            vec![FieldRule::new("*.password").with_category(Category::Custom("pw".into()))],
336        )
337        .with_option("compact", "true");
338
339        let result = proc.process(content, &profile, &store).unwrap();
340        let out: Value = serde_json::from_slice(&result).unwrap();
341
342        assert_ne!(out["db"]["password"].as_str().unwrap(), "pw1");
343        assert_ne!(out["cache"]["password"].as_str().unwrap(), "pw2");
344        assert_eq!(out["name"], "app");
345    }
346
347    // ── process_to_edits (span-based, format-preserving) ─────────────────────
348
349    /// Edit-mode alone (no scanner) must redact values that are **escaped** in
350    /// the source — including non-canonical escapes (`\/`, `\uXXXX`) that the
351    /// literal/alias approach leaks.
352    #[test]
353    fn edits_redact_escaped_and_noncanonical_values() {
354        let store = make_store();
355        let proc = JsonProcessor;
356        // \" escaped quote, \/ PHP-style slash, \uXXXX unicode escape.
357        let content =
358            br#"{"a":"x\"y-SEC1","u":"http:\/\/SEC2.test","n":"caf\u00e9-SEC3","keep":"ok"}"#;
359        let profile = FileTypeProfile::new(
360            "json",
361            vec![
362                FieldRule::new("a").with_category(Category::Custom("k".into())),
363                FieldRule::new("u").with_category(Category::Custom("k".into())),
364                FieldRule::new("n").with_category(Category::Custom("k".into())),
365            ],
366        );
367        let edits = proc
368            .process_to_edits(content, &profile, &store)
369            .unwrap()
370            .unwrap();
371        let out = crate::processor::apply_edits(content, edits);
372        let text = String::from_utf8(out).unwrap();
373        for leak in ["SEC1", "SEC2", "SEC3"] {
374            assert!(!text.contains(leak), "leaked {leak}: {text}");
375        }
376        // Untouched field preserved and output is still valid JSON.
377        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
378        assert_eq!(v["keep"], "ok");
379    }
380
381    /// Edits preserve compact formatting and leave non-matched values byte-exact.
382    #[test]
383    fn edits_preserve_compact_layout() {
384        let store = make_store();
385        let proc = JsonProcessor;
386        let content = br#"{"db":{"password":"SECRETpw","host":"keep.local"},"port":5432}"#;
387        let profile = FileTypeProfile::new(
388            "json",
389            vec![FieldRule::new("db.password").with_category(Category::Custom("pw".into()))],
390        );
391        let edits = proc
392            .process_to_edits(content, &profile, &store)
393            .unwrap()
394            .unwrap();
395        let out = crate::processor::apply_edits(content, edits);
396        let text = String::from_utf8(out).unwrap();
397        assert!(!text.contains("SECRETpw"), "secret leaked: {text}");
398        // Still single-line/compact, untouched bytes intact.
399        assert!(!text.contains('\n'), "formatting changed: {text}");
400        assert!(
401            text.contains(r#""host":"keep.local""#),
402            "non-secret changed: {text}"
403        );
404        assert!(
405            text.contains(r#""port":5432"#),
406            "non-secret changed: {text}"
407        );
408    }
409}