use crate::error::{Result, SanitizeError};
use crate::processor::limits::DEFAULT_INPUT_SIZE;
use crate::processor::{
build_path, edit_token, walk_tree, FileTypeProfile, Processor, Replacement, TreeNode,
};
use crate::store::MappingStore;
use jiter::{Jiter, Peek};
use serde_json::Value;
fn json_err(e: impl std::fmt::Display) -> SanitizeError {
SanitizeError::ParseError {
format: "JSON".into(),
message: format!("JSON parse error: {e}"),
}
}
fn parse_strict_or_lenient(text: &str) -> Result<Value> {
match serde_json::from_str(text) {
Ok(v) => Ok(v),
Err(strict_err) => match json5::from_str(text) {
Ok(v) => {
tracing::debug!(error = %strict_err, "strict JSON parse failed; parsed leniently as JSON5");
Ok(v)
}
Err(_) => Err(SanitizeError::ParseError {
format: "JSON".into(),
message: format!("JSON parse error: {strict_err}"),
}),
},
}
}
pub struct JsonProcessor;
impl Processor for JsonProcessor {
fn name(&self) -> &'static str {
"json"
}
fn can_handle(&self, content: &[u8], profile: &FileTypeProfile) -> bool {
if profile.processor == "json" {
return true;
}
let trimmed = content.iter().copied().find(|b| !b.is_ascii_whitespace());
matches!(trimmed, Some(b'{' | b'['))
}
fn process(
&self,
content: &[u8],
profile: &FileTypeProfile,
store: &MappingStore,
) -> Result<Vec<u8>> {
let text = crate::processor::check_size_and_decode(content, "JSON", DEFAULT_INPUT_SIZE)?;
let mut value: Value = parse_strict_or_lenient(text)?;
walk_json(&mut value, "", profile, store, 0)?;
let compact = profile.options.get("compact").is_some_and(|v| v == "true");
let output = if compact {
serde_json::to_vec(&value)
} else {
serde_json::to_vec_pretty(&value)
}
.map_err(|e| {
SanitizeError::IoError(std::io::Error::other(format!("JSON serialize error: {e}")))
})?;
Ok(output)
}
fn process_to_edits(
&self,
content: &[u8],
profile: &FileTypeProfile,
store: &MappingStore,
) -> Result<Option<Vec<Replacement>>> {
crate::processor::check_size_and_decode(content, "JSON", DEFAULT_INPUT_SIZE)?;
Ok(Some(json_value_edits(content, profile, store)?))
}
}
pub(crate) fn json_value_edits(
content: &[u8],
profile: &FileTypeProfile,
store: &MappingStore,
) -> Result<Vec<Replacement>> {
let bom = if content.starts_with(&[0xEF, 0xBB, 0xBF]) {
3
} else {
0
};
let body = &content[bom..];
let mut jiter = Jiter::new(body);
let mut edits = Vec::new();
let peek = jiter.peek().map_err(json_err)?;
collect_json_edits(&mut jiter, peek, "", "", body, profile, store, &mut edits)?;
if bom != 0 {
for e in &mut edits {
e.start += bom;
e.end += bom;
}
}
Ok(edits)
}
#[allow(clippy::too_many_arguments)]
fn collect_json_edits(
jiter: &mut Jiter,
peek: Peek,
key: &str,
path: &str,
content: &[u8],
profile: &FileTypeProfile,
store: &MappingStore,
edits: &mut Vec<Replacement>,
) -> Result<()> {
if peek == Peek::Object {
let mut next = jiter.next_object().map_err(json_err)?.map(str::to_owned);
while let Some(k) = next {
let child_path = build_path(path, &k);
let child_peek = jiter.peek().map_err(json_err)?;
collect_json_edits(
jiter,
child_peek,
&k,
&child_path,
content,
profile,
store,
edits,
)?;
next = jiter.next_key().map_err(json_err)?.map(str::to_owned);
}
} else if peek == Peek::Array {
let mut elem = jiter.next_array().map_err(json_err)?;
while let Some(elem_peek) = elem {
collect_json_edits(jiter, elem_peek, key, path, content, profile, store, edits)?;
elem = jiter.array_step().map_err(json_err)?;
}
} else if peek == Peek::String {
let start = jiter.current_index();
let s = jiter.next_str().map_err(json_err)?.to_owned();
let end = jiter.current_index();
if let Some(token) = edit_token(key, path, &s, profile, store)? {
edits.push(Replacement {
start,
end,
value: format!("\"{token}\""),
});
}
} else {
let start = jiter.current_index();
jiter.next_skip().map_err(json_err)?;
let end = jiter.current_index();
let s = String::from_utf8_lossy(&content[start..end]).into_owned();
if let Some(token) = edit_token(key, path, &s, profile, store)? {
edits.push(Replacement {
start,
end,
value: format!("\"{token}\""),
});
}
}
Ok(())
}
impl TreeNode for Value {
fn for_each_map_entry<F>(&mut self, mut f: F) -> Result<()>
where
F: FnMut(&str, &mut Self) -> Result<()>,
{
if let Self::Object(map) = self {
let keys: Vec<String> = map.keys().cloned().collect();
for key in keys {
if let Some(v) = map.get_mut(&key) {
f(&key, v)?;
}
}
}
Ok(())
}
fn for_each_seq_item<F>(&mut self, mut f: F) -> Result<()>
where
F: FnMut(&mut Self) -> Result<()>,
{
if let Self::Array(arr) = self {
for item in arr.iter_mut() {
f(item)?;
}
}
Ok(())
}
fn as_str_mut(&mut self) -> Option<&mut String> {
if let Self::String(s) = self {
Some(s)
} else {
None
}
}
fn is_scalar(&self) -> bool {
matches!(self, Self::Number(_) | Self::Bool(_))
}
fn scalar_to_string(&self) -> String {
self.to_string()
}
fn set_string(&mut self, s: String) {
*self = Self::String(s);
}
}
pub(crate) fn walk_json(
value: &mut Value,
prefix: &str,
profile: &FileTypeProfile,
store: &MappingStore,
depth: usize,
) -> Result<()> {
walk_tree(value, prefix, profile, store, depth, "JSON")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::category::Category;
use crate::generator::HmacGenerator;
use crate::processor::profile::FieldRule;
use std::sync::Arc;
fn make_store() -> MappingStore {
let gen = Arc::new(HmacGenerator::new([42u8; 32]));
MappingStore::new(gen, None)
}
#[test]
fn basic_json_replacement() {
let store = make_store();
let proc = JsonProcessor;
let content =
br#"{"database": {"host": "db.corp.com", "password": "s3cret"}, "port": 5432}"#;
let profile = FileTypeProfile::new(
"json",
vec![
FieldRule::new("database.password").with_category(Category::Custom("pw".into())),
FieldRule::new("database.host").with_category(Category::Hostname),
],
)
.with_option("compact", "true");
let result = proc.process(content, &profile, &store).unwrap();
let out: Value = serde_json::from_slice(&result).unwrap();
assert_ne!(out["database"]["password"].as_str().unwrap(), "s3cret");
assert_ne!(out["database"]["host"].as_str().unwrap(), "db.corp.com");
assert_eq!(out["port"], 5432);
}
#[test]
fn json_array_traversal() {
let store = make_store();
let proc = JsonProcessor;
let content = br#"{"users": [{"email": "a@b.com"}, {"email": "c@d.com"}]}"#;
let profile = FileTypeProfile::new(
"json",
vec![FieldRule::new("users.email").with_category(Category::Email)],
)
.with_option("compact", "true");
let result = proc.process(content, &profile, &store).unwrap();
let out: Value = serde_json::from_slice(&result).unwrap();
let users = out["users"].as_array().unwrap();
assert_ne!(users[0]["email"].as_str().unwrap(), "a@b.com");
assert_ne!(users[1]["email"].as_str().unwrap(), "c@d.com");
assert_eq!(users.len(), 2);
let text = String::from_utf8_lossy(&result);
assert!(text.contains("\"users\"") && text.contains("\"email\""));
}
#[test]
fn json_glob_suffix_pattern() {
let store = make_store();
let proc = JsonProcessor;
let content =
br#"{"db": {"password": "pw1"}, "cache": {"password": "pw2"}, "name": "app"}"#;
let profile = FileTypeProfile::new(
"json",
vec![FieldRule::new("*.password").with_category(Category::Custom("pw".into()))],
)
.with_option("compact", "true");
let result = proc.process(content, &profile, &store).unwrap();
let out: Value = serde_json::from_slice(&result).unwrap();
assert_ne!(out["db"]["password"].as_str().unwrap(), "pw1");
assert_ne!(out["cache"]["password"].as_str().unwrap(), "pw2");
assert_eq!(out["name"], "app");
}
#[test]
fn lenient_json_fallback_applies_field_rules() {
let store = make_store();
let proc = JsonProcessor;
let content = br#"{
// user-edited project variables
db_password: 'hunter2secret',
api_token: "tok-abcdef123456",
keep: "plain",
}"#;
let profile = FileTypeProfile::new(
"json",
vec![
FieldRule::new("*password*").with_category(Category::Custom("pw".into())),
FieldRule::new("*token*").with_category(Category::AuthToken),
],
)
.with_option("compact", "true");
let result = proc.process(content, &profile, &store).unwrap();
let text = String::from_utf8(result).unwrap();
assert!(!text.contains("hunter2secret"), "password leaked: {text}");
assert!(!text.contains("tok-abcdef123456"), "token leaked: {text}");
let out: Value = serde_json::from_str(&text).unwrap();
assert_eq!(out["keep"], "plain");
}
#[test]
fn invalid_json_still_errors_after_lenient_fallback() {
let store = make_store();
let proc = JsonProcessor;
let profile = FileTypeProfile::new("json", vec![]);
let err = proc
.process(b"not json at all {{{", &profile, &store)
.expect_err("garbage input must not parse");
assert!(err.to_string().contains("JSON parse error"));
}
#[test]
fn strict_json_unaffected_by_lenient_fallback() {
let store = make_store();
let proc = JsonProcessor;
let content = br#"{"a": 1, "b": "two"}"#;
let profile = FileTypeProfile::new("json", vec![]).with_option("compact", "true");
let result = proc.process(content, &profile, &store).unwrap();
let out: Value = serde_json::from_slice(&result).unwrap();
assert_eq!(out["a"], 1);
assert_eq!(out["b"], "two");
}
#[test]
fn edits_redact_escaped_and_noncanonical_values() {
let store = make_store();
let proc = JsonProcessor;
let content =
br#"{"a":"x\"y-SEC1","u":"http:\/\/SEC2.test","n":"caf\u00e9-SEC3","keep":"ok"}"#;
let profile = FileTypeProfile::new(
"json",
vec![
FieldRule::new("a").with_category(Category::Custom("k".into())),
FieldRule::new("u").with_category(Category::Custom("k".into())),
FieldRule::new("n").with_category(Category::Custom("k".into())),
],
);
let edits = proc
.process_to_edits(content, &profile, &store)
.unwrap()
.unwrap();
let out = crate::processor::apply_edits(content, edits);
let text = String::from_utf8(out).unwrap();
for leak in ["SEC1", "SEC2", "SEC3"] {
assert!(!text.contains(leak), "leaked {leak}: {text}");
}
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(v["keep"], "ok");
}
#[test]
fn edits_preserve_compact_layout() {
let store = make_store();
let proc = JsonProcessor;
let content = br#"{"db":{"password":"SECRETpw","host":"keep.local"},"port":5432}"#;
let profile = FileTypeProfile::new(
"json",
vec![FieldRule::new("db.password").with_category(Category::Custom("pw".into()))],
);
let edits = proc
.process_to_edits(content, &profile, &store)
.unwrap()
.unwrap();
let out = crate::processor::apply_edits(content, edits);
let text = String::from_utf8(out).unwrap();
assert!(!text.contains("SECRETpw"), "secret leaked: {text}");
assert!(!text.contains('\n'), "formatting changed: {text}");
assert!(
text.contains(r#""host":"keep.local""#),
"non-secret changed: {text}"
);
assert!(
text.contains(r#""port":5432"#),
"non-secret changed: {text}"
);
}
}