///|
/// Escape a string for JSON output. Control characters become `\u00xx`
/// escapes; printable non-ASCII characters are emitted as-is because JSON is
/// defined over Unicode text.
fn json_escape(text : String) -> String {
let out = StringBuilder()
for i = 0; i < text.length(); i = i + 1 {
let c = text[i].to_int()
match c {
34 => out.write_string("\\\"")
92 => out.write_string("\\\\")
8 => out.write_string("\\b")
12 => out.write_string("\\f")
10 => out.write_string("\\n")
13 => out.write_string("\\r")
9 => out.write_string("\\t")
_ =>
if c < 32 {
let hex = "0123456789abcdef"
out.write_string("\\u00")
out.write_char(Int::unsafe_to_char(hex[c / 16 % 16].to_int()))
out.write_char(Int::unsafe_to_char(hex[c % 16].to_int()))
} else {
out.write_char(Int::unsafe_to_char(c))
}
}
}
out.to_string()
}
///|
/// Code-unit-wise lexicographic order, matching RFC 8785 (JCS) member
/// ordering. The default `Compare` on `String` is shortlex (length first),
/// which is not what canonical JSON uses.
fn lexicographic_before(a : String, b : String) -> Bool {
let shared = if a.length() < b.length() { a.length() } else { b.length() }
for i = 0; i < shared; i = i + 1 {
let x = a[i].to_int()
let y = b[i].to_int()
if x != y {
return x < y
}
}
a.length() < b.length()
}
///|
fn sort_strings(items : Array[String]) -> Unit {
for i = 1; i < items.length(); i = i + 1 {
let value = items[i]
let mut j = i
while j > 0 && lexicographic_before(value, items[j - 1]) {
items[j] = items[j - 1]
j -= 1
}
items[j] = value
}
}
///|
/// Serialize with deterministic member order: object keys are emitted in
/// ascending lexicographic order and number spellings are preserved through
/// the parser's `repr` so identical inputs always produce identical output.
fn write_json_value(value : Json, buf : StringBuilder) -> Unit {
match value {
Null => buf.write_string("null")
True => buf.write_string("true")
False => buf.write_string("false")
Number(d, repr~) =>
match repr {
Some(spelling) => buf.write_string(spelling)
None => buf.write_string(d.to_string())
}
String(s) => {
buf.write_char(Int::unsafe_to_char(34))
buf.write_string(json_escape(s))
buf.write_char(Int::unsafe_to_char(34))
}
Array(items) => {
buf.write_char(Int::unsafe_to_char(91))
for i = 0; i < items.length(); i = i + 1 {
if i > 0 {
buf.write_char(Int::unsafe_to_char(44))
}
write_json_value(items[i], buf)
}
buf.write_char(Int::unsafe_to_char(93))
}
Object(map) => {
let keys : Array[String] = []
map.each(fn(k, _v) { keys.push(k) })
sort_strings(keys)
buf.write_char(Int::unsafe_to_char(123))
for i = 0; i < keys.length(); i = i + 1 {
if i > 0 {
buf.write_char(Int::unsafe_to_char(44))
}
buf.write_char(Int::unsafe_to_char(34))
buf.write_string(json_escape(keys[i]))
buf.write_char(Int::unsafe_to_char(34))
buf.write_char(Int::unsafe_to_char(58))
if map.get(keys[i]) is Some(entry) {
write_json_value(entry, buf)
} else {
buf.write_string("null")
}
}
buf.write_char(Int::unsafe_to_char(125))
}
}
}
///|
/// Walk a parsed document, redacting every string leaf. Sensitive paths are
/// fail-closed like `redact_fields`. Returns the rebuilt value and how many
/// fields changed.
fn walk_json(
value : Json,
path : String,
config : ScanConfig,
style : RedactionStyle,
findings : Array[JsonFinding],
rules : Array[CustomRule],
) -> (Json, Int) {
match value {
String(text) => {
let leaf : Array[Finding] = if path_is_sensitive(path) &&
text.length() > 0 {
[
{
kind: CredentialAssignment,
start: 0,
end: text.length(),
confidence: High,
},
]
} else {
scan_full(text, rules, config)
}
if leaf.length() == 0 {
return (value, 0)
}
for finding in leaf {
findings.push({ path, finding, })
}
(Json::string(redact_with_findings(text, leaf, style)), 1)
}
Array(items) => {
let rebuilt : Array[Json] = []
let mut changed = 0
for i = 0; i < items.length(); i = i + 1 {
let (item, count) = walk_json(
items[i],
path + "/" + i.to_string(),
config,
style,
findings,
rules,
)
rebuilt.push(item)
changed += count
}
(Json::array(rebuilt), changed)
}
Object(map) => {
let keys : Array[String] = []
map.each(fn(k, _v) { keys.push(k) })
sort_strings(keys)
let rebuilt : Map[String, Json] = Map([])
let mut changed = 0
for key in keys {
let child = map.get(key)
let (item, count) = if child is Some(child_json) {
walk_json(
child_json,
path + "/" + key,
config,
style,
findings,
rules,
)
} else {
(Json::null(), 0)
}
rebuilt[key] = item
changed += count
}
(Json::object(rebuilt), changed)
}
_ => (value, 0)
}
}
///|
/// Redact a JSON document by parsing it and treating every string leaf as a
/// structured field. Malformed input fails closed: it is redacted as plain
/// text with `parsed` set to false. Output is compact and deterministic;
/// object keys are re-emitted in sorted order.
///|
/// redact_json with caller-supplied exact-value rules applied to every
/// string leaf in addition to the configured detectors.
///|
/// Distinct JSON-pointer-like paths that produced findings, sorted, so
/// callers can compare document coverage across runs without values.
pub fn JsonRedactionResult::paths(self : JsonRedactionResult) -> Array[String] {
let out : Array[String] = []
for item in self.findings {
if !out.contains(item.path) {
out.push(item.path)
}
}
sort_strings(out)
out
}
///|
pub fn redact_json_with_rules(
text : String,
rules : Array[CustomRule],
config? : ScanConfig = ScanConfig::standard(),
style? : RedactionStyle = Typed,
) -> JsonRedactionResult {
let outcome = try @json.parse(text) catch {
_ => None
} noraise {
doc => Some(doc)
}
match outcome {
Some(doc) => {
let findings : Array[JsonFinding] = []
let (rebuilt, changed) = walk_json(
doc, "", config, style, findings, rules,
)
let buf = StringBuilder()
write_json_value(rebuilt, buf)
{
text: buf.to_string(),
changed_fields: changed,
findings,
parsed: true,
}
}
None => {
let plain = redact_full(text, rules, config, style~)
let findings : Array[JsonFinding] = []
for finding in plain.findings {
findings.push({ path: "", finding, })
}
{
text: plain.text,
changed_fields: if plain.changed {
1
} else {
0
},
findings,
parsed: false,
}
}
}
}
///|
pub fn redact_json(
text : String,
config? : ScanConfig = ScanConfig::standard(),
style? : RedactionStyle = Typed,
) -> JsonRedactionResult {
let outcome = try @json.parse(text) catch {
_ => None
} noraise {
doc => Some(doc)
}
match outcome {
Some(doc) => {
let findings : Array[JsonFinding] = []
let (rebuilt, changed) = walk_json(doc, "", config, style, findings, [])
let buf = StringBuilder()
write_json_value(rebuilt, buf)
{
text: buf.to_string(),
changed_fields: changed,
findings,
parsed: true,
}
}
None => {
let plain = redact_with_config(text, config, style~)
let findings : Array[JsonFinding] = []
for finding in plain.findings {
findings.push({ path: "", finding, })
}
{
text: plain.text,
changed_fields: if plain.changed {
1
} else {
0
},
findings,
parsed: false,
}
}
}
}