1use crate::error::{Result, SanitizeError};
22use crate::processor::limits::DEFAULT_INPUT_SIZE;
23use crate::processor::{
24 build_path, edit_token, walk_tree, FileTypeProfile, Processor, Replacement, TreeNode,
25};
26use crate::store::MappingStore;
27use jiter::{Jiter, Peek};
28use serde_json::Value;
29
30fn json_err(e: impl std::fmt::Display) -> SanitizeError {
32 SanitizeError::ParseError {
33 format: "JSON".into(),
34 message: format!("JSON parse error: {e}"),
35 }
36}
37
38fn parse_strict_or_lenient(text: &str) -> Result<Value> {
51 match serde_json::from_str(text) {
52 Ok(v) => Ok(v),
53 Err(strict_err) => match json5::from_str(text) {
54 Ok(v) => {
55 tracing::debug!(error = %strict_err, "strict JSON parse failed; parsed leniently as JSON5");
56 Ok(v)
57 }
58 Err(_) => Err(SanitizeError::ParseError {
59 format: "JSON".into(),
60 message: format!("JSON parse error: {strict_err}"),
61 }),
62 },
63 }
64}
65
66pub struct JsonProcessor;
68
69impl Processor for JsonProcessor {
70 fn name(&self) -> &'static str {
71 "json"
72 }
73
74 fn can_handle(&self, content: &[u8], profile: &FileTypeProfile) -> bool {
75 if profile.processor == "json" {
76 return true;
77 }
78 let trimmed = content.iter().copied().find(|b| !b.is_ascii_whitespace());
80 matches!(trimmed, Some(b'{' | b'['))
81 }
82
83 fn process(
84 &self,
85 content: &[u8],
86 profile: &FileTypeProfile,
87 store: &MappingStore,
88 ) -> Result<Vec<u8>> {
89 let text = crate::processor::check_size_and_decode(content, "JSON", DEFAULT_INPUT_SIZE)?;
91
92 let mut value: Value = parse_strict_or_lenient(text)?;
93
94 walk_json(&mut value, "", profile, store, 0)?;
95
96 let compact = profile.options.get("compact").is_some_and(|v| v == "true");
97
98 let output = if compact {
99 serde_json::to_vec(&value)
100 } else {
101 serde_json::to_vec_pretty(&value)
102 }
103 .map_err(|e| {
104 SanitizeError::IoError(std::io::Error::other(format!("JSON serialize error: {e}")))
105 })?;
106
107 Ok(output)
108 }
109
110 fn process_to_edits(
117 &self,
118 content: &[u8],
119 profile: &FileTypeProfile,
120 store: &MappingStore,
121 ) -> Result<Option<Vec<Replacement>>> {
122 crate::processor::check_size_and_decode(content, "JSON", DEFAULT_INPUT_SIZE)?;
124 Ok(Some(json_value_edits(content, profile, store)?))
125 }
126}
127
128pub(crate) fn json_value_edits(
136 content: &[u8],
137 profile: &FileTypeProfile,
138 store: &MappingStore,
139) -> Result<Vec<Replacement>> {
140 let bom = if content.starts_with(&[0xEF, 0xBB, 0xBF]) {
146 3
147 } else {
148 0
149 };
150 let body = &content[bom..];
151 let mut jiter = Jiter::new(body);
152 let mut edits = Vec::new();
153 let peek = jiter.peek().map_err(json_err)?;
154 collect_json_edits(&mut jiter, peek, "", "", body, profile, store, &mut edits)?;
155 if bom != 0 {
156 for e in &mut edits {
157 e.start += bom;
158 e.end += bom;
159 }
160 }
161 Ok(edits)
162}
163
164#[allow(clippy::too_many_arguments)]
168fn collect_json_edits(
169 jiter: &mut Jiter,
170 peek: Peek,
171 key: &str,
172 path: &str,
173 content: &[u8],
174 profile: &FileTypeProfile,
175 store: &MappingStore,
176 edits: &mut Vec<Replacement>,
177) -> Result<()> {
178 if peek == Peek::Object {
179 let mut next = jiter.next_object().map_err(json_err)?.map(str::to_owned);
182 while let Some(k) = next {
183 let child_path = build_path(path, &k);
184 let child_peek = jiter.peek().map_err(json_err)?;
185 collect_json_edits(
186 jiter,
187 child_peek,
188 &k,
189 &child_path,
190 content,
191 profile,
192 store,
193 edits,
194 )?;
195 next = jiter.next_key().map_err(json_err)?.map(str::to_owned);
196 }
197 } else if peek == Peek::Array {
198 let mut elem = jiter.next_array().map_err(json_err)?;
200 while let Some(elem_peek) = elem {
201 collect_json_edits(jiter, elem_peek, key, path, content, profile, store, edits)?;
202 elem = jiter.array_step().map_err(json_err)?;
203 }
204 } else if peek == Peek::String {
205 let start = jiter.current_index();
206 let s = jiter.next_str().map_err(json_err)?.to_owned();
207 let end = jiter.current_index();
208 if let Some(token) = edit_token(key, path, &s, profile, store)? {
209 edits.push(Replacement {
210 start,
211 end,
212 value: format!("\"{token}\""),
213 });
214 }
215 } else {
216 let start = jiter.current_index();
218 jiter.next_skip().map_err(json_err)?;
219 let end = jiter.current_index();
220 let s = String::from_utf8_lossy(&content[start..end]).into_owned();
221 if let Some(token) = edit_token(key, path, &s, profile, store)? {
222 edits.push(Replacement {
223 start,
224 end,
225 value: format!("\"{token}\""),
226 });
227 }
228 }
229 Ok(())
230}
231
232impl TreeNode for Value {
233 fn for_each_map_entry<F>(&mut self, mut f: F) -> Result<()>
234 where
235 F: FnMut(&str, &mut Self) -> Result<()>,
236 {
237 if let Self::Object(map) = self {
238 let keys: Vec<String> = map.keys().cloned().collect();
239 for key in keys {
240 if let Some(v) = map.get_mut(&key) {
241 f(&key, v)?;
242 }
243 }
244 }
245 Ok(())
246 }
247
248 fn for_each_seq_item<F>(&mut self, mut f: F) -> Result<()>
249 where
250 F: FnMut(&mut Self) -> Result<()>,
251 {
252 if let Self::Array(arr) = self {
253 for item in arr.iter_mut() {
254 f(item)?;
255 }
256 }
257 Ok(())
258 }
259
260 fn as_str_mut(&mut self) -> Option<&mut String> {
261 if let Self::String(s) = self {
262 Some(s)
263 } else {
264 None
265 }
266 }
267
268 fn is_scalar(&self) -> bool {
269 matches!(self, Self::Number(_) | Self::Bool(_))
270 }
271
272 fn scalar_to_string(&self) -> String {
273 self.to_string()
274 }
275
276 fn set_string(&mut self, s: String) {
277 *self = Self::String(s);
278 }
279}
280
281pub(crate) fn walk_json(
283 value: &mut Value,
284 prefix: &str,
285 profile: &FileTypeProfile,
286 store: &MappingStore,
287 depth: usize,
288) -> Result<()> {
289 walk_tree(value, prefix, profile, store, depth, "JSON")
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::category::Category;
296 use crate::generator::HmacGenerator;
297 use crate::processor::profile::FieldRule;
298 use std::sync::Arc;
299
300 fn make_store() -> MappingStore {
301 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
302 MappingStore::new(gen, None)
303 }
304
305 #[test]
306 fn basic_json_replacement() {
307 let store = make_store();
308 let proc = JsonProcessor;
309
310 let content =
311 br#"{"database": {"host": "db.corp.com", "password": "s3cret"}, "port": 5432}"#;
312 let profile = FileTypeProfile::new(
313 "json",
314 vec![
315 FieldRule::new("database.password").with_category(Category::Custom("pw".into())),
316 FieldRule::new("database.host").with_category(Category::Hostname),
317 ],
318 )
319 .with_option("compact", "true");
320
321 let result = proc.process(content, &profile, &store).unwrap();
322 let out: Value = serde_json::from_slice(&result).unwrap();
323
324 assert_ne!(out["database"]["password"].as_str().unwrap(), "s3cret");
325 assert_ne!(out["database"]["host"].as_str().unwrap(), "db.corp.com");
326 assert_eq!(out["port"], 5432);
327 }
328
329 #[test]
330 fn json_array_traversal() {
331 let store = make_store();
332 let proc = JsonProcessor;
333
334 let content = br#"{"users": [{"email": "a@b.com"}, {"email": "c@d.com"}]}"#;
335 let profile = FileTypeProfile::new(
336 "json",
337 vec![FieldRule::new("users.email").with_category(Category::Email)],
338 )
339 .with_option("compact", "true");
340
341 let result = proc.process(content, &profile, &store).unwrap();
342 let out: Value = serde_json::from_slice(&result).unwrap();
343
344 let users = out["users"].as_array().unwrap();
345 assert_ne!(users[0]["email"].as_str().unwrap(), "a@b.com");
346 assert_ne!(users[1]["email"].as_str().unwrap(), "c@d.com");
347 assert_eq!(users.len(), 2);
349 let text = String::from_utf8_lossy(&result);
350 assert!(text.contains("\"users\"") && text.contains("\"email\""));
351 }
352
353 #[test]
354 fn json_glob_suffix_pattern() {
355 let store = make_store();
356 let proc = JsonProcessor;
357
358 let content =
359 br#"{"db": {"password": "pw1"}, "cache": {"password": "pw2"}, "name": "app"}"#;
360 let profile = FileTypeProfile::new(
361 "json",
362 vec![FieldRule::new("*.password").with_category(Category::Custom("pw".into()))],
363 )
364 .with_option("compact", "true");
365
366 let result = proc.process(content, &profile, &store).unwrap();
367 let out: Value = serde_json::from_slice(&result).unwrap();
368
369 assert_ne!(out["db"]["password"].as_str().unwrap(), "pw1");
370 assert_ne!(out["cache"]["password"].as_str().unwrap(), "pw2");
371 assert_eq!(out["name"], "app");
372 }
373
374 #[test]
380 fn lenient_json_fallback_applies_field_rules() {
381 let store = make_store();
382 let proc = JsonProcessor;
383
384 let content = br#"{
385 // user-edited project variables
386 db_password: 'hunter2secret',
387 api_token: "tok-abcdef123456",
388 keep: "plain",
389}"#;
390 let profile = FileTypeProfile::new(
391 "json",
392 vec![
393 FieldRule::new("*password*").with_category(Category::Custom("pw".into())),
394 FieldRule::new("*token*").with_category(Category::AuthToken),
395 ],
396 )
397 .with_option("compact", "true");
398
399 let result = proc.process(content, &profile, &store).unwrap();
400 let text = String::from_utf8(result).unwrap();
401 assert!(!text.contains("hunter2secret"), "password leaked: {text}");
402 assert!(!text.contains("tok-abcdef123456"), "token leaked: {text}");
403 let out: Value = serde_json::from_str(&text).unwrap();
405 assert_eq!(out["keep"], "plain");
406 }
407
408 #[test]
411 fn invalid_json_still_errors_after_lenient_fallback() {
412 let store = make_store();
413 let proc = JsonProcessor;
414 let profile = FileTypeProfile::new("json", vec![]);
415 let err = proc
416 .process(b"not json at all {{{", &profile, &store)
417 .expect_err("garbage input must not parse");
418 assert!(err.to_string().contains("JSON parse error"));
419 }
420
421 #[test]
424 fn strict_json_unaffected_by_lenient_fallback() {
425 let store = make_store();
426 let proc = JsonProcessor;
427 let content = br#"{"a": 1, "b": "two"}"#;
428 let profile = FileTypeProfile::new("json", vec![]).with_option("compact", "true");
429 let result = proc.process(content, &profile, &store).unwrap();
430 let out: Value = serde_json::from_slice(&result).unwrap();
431 assert_eq!(out["a"], 1);
432 assert_eq!(out["b"], "two");
433 }
434
435 #[test]
441 fn edits_redact_escaped_and_noncanonical_values() {
442 let store = make_store();
443 let proc = JsonProcessor;
444 let content =
446 br#"{"a":"x\"y-SEC1","u":"http:\/\/SEC2.test","n":"caf\u00e9-SEC3","keep":"ok"}"#;
447 let profile = FileTypeProfile::new(
448 "json",
449 vec![
450 FieldRule::new("a").with_category(Category::Custom("k".into())),
451 FieldRule::new("u").with_category(Category::Custom("k".into())),
452 FieldRule::new("n").with_category(Category::Custom("k".into())),
453 ],
454 );
455 let edits = proc
456 .process_to_edits(content, &profile, &store)
457 .unwrap()
458 .unwrap();
459 let out = crate::processor::apply_edits(content, edits);
460 let text = String::from_utf8(out).unwrap();
461 for leak in ["SEC1", "SEC2", "SEC3"] {
462 assert!(!text.contains(leak), "leaked {leak}: {text}");
463 }
464 let v: serde_json::Value = serde_json::from_str(&text).unwrap();
466 assert_eq!(v["keep"], "ok");
467 }
468
469 #[test]
471 fn edits_preserve_compact_layout() {
472 let store = make_store();
473 let proc = JsonProcessor;
474 let content = br#"{"db":{"password":"SECRETpw","host":"keep.local"},"port":5432}"#;
475 let profile = FileTypeProfile::new(
476 "json",
477 vec![FieldRule::new("db.password").with_category(Category::Custom("pw".into()))],
478 );
479 let edits = proc
480 .process_to_edits(content, &profile, &store)
481 .unwrap()
482 .unwrap();
483 let out = crate::processor::apply_edits(content, edits);
484 let text = String::from_utf8(out).unwrap();
485 assert!(!text.contains("SECRETpw"), "secret leaked: {text}");
486 assert!(!text.contains('\n'), "formatting changed: {text}");
488 assert!(
489 text.contains(r#""host":"keep.local""#),
490 "non-secret changed: {text}"
491 );
492 assert!(
493 text.contains(r#""port":5432"#),
494 "non-secret changed: {text}"
495 );
496 }
497}