Skip to main content

kobold_json/
redact.rs

1//! `KOBOLD.JSON.REDACTION.1` -- redact named fields in a packet while preserving its structure.
2//!
3//! Redaction is applied to the field's **value** (and, where present, its `raw_hex`) so the redacted packet
4//! is still a well-formed packet that diffs cleanly. Three strategies:
5//!
6//! * [`Redaction::Mask`] -- replace the value with `"****"`.
7//! * [`Redaction::Hash`] -- replace the value with `"sha256:<hex>"` of its UTF-8 bytes (a stable,
8//!   non-reversible token -- equal values redact to equal tokens).
9//! * [`Redaction::Remove`] -- drop the field member entirely.
10//!
11//! Field names are matched against the keys under the packet's `fields` object (recursively into groups).
12//! It is independent of GnuCOBOL/libcob.
13
14use crate::json::JsonValue;
15use crate::sha256;
16
17/// A redaction strategy.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Redaction {
20    /// Replace the value with `"****"`.
21    Mask,
22    /// Replace the value with `"sha256:<hex>"` of its bytes.
23    Hash,
24    /// Remove the field member entirely.
25    Remove,
26}
27
28/// `KOBOLD.JSON.REDACTION.1` -- return a copy of `packet` with the named `fields` redacted per `how`.
29/// Unmatched names are ignored. The packet's overall structure (record/encoding/hashes) is preserved;
30/// note that redacting a field invalidates any whole-record `record_hash` -- that is intentional and
31/// visible.
32pub fn redact(packet: &JsonValue, fields: &[&str], how: Redaction) -> JsonValue {
33    let mut result = packet.clone();
34    if let JsonValue::Object(members) = &mut result {
35        for (k, v) in members.iter_mut() {
36            if k == "fields" {
37                *v = redact_fields(v, fields, how);
38            }
39        }
40    }
41    result
42}
43
44fn redact_fields(node: &JsonValue, names: &[&str], how: Redaction) -> JsonValue {
45    let members = match node {
46        JsonValue::Object(m) => m,
47        _ => return node.clone(),
48    };
49    let mut out: Vec<(String, JsonValue)> = Vec::with_capacity(members.len());
50    for (key, val) in members {
51        // Recurse into a group detail object (has a nested "fields") or a compact nested object.
52        let is_group = matches!(val, JsonValue::Object(_)) && val.get("fields").is_some();
53        let target = names.iter().any(|n| n == key);
54
55        if target {
56            match how {
57                Redaction::Remove => continue, // drop the member
58                Redaction::Mask | Redaction::Hash => {
59                    out.push((key.clone(), redact_member(val, how)));
60                }
61            }
62            continue;
63        }
64
65        if is_group {
66            // Rebuild the group object with its inner fields redacted.
67            if let JsonValue::Object(gm) = val {
68                let mut new_gm = Vec::with_capacity(gm.len());
69                for (gk, gv) in gm {
70                    if gk == "fields" {
71                        new_gm.push((gk.clone(), redact_fields(gv, names, how)));
72                    } else {
73                        new_gm.push((gk.clone(), gv.clone()));
74                    }
75                }
76                out.push((key.clone(), JsonValue::Object(new_gm)));
77                continue;
78            }
79        } else if let JsonValue::Object(inner) = val {
80            // Compact nested group: an object of values, no "fields"/"value" keys -> recurse if it looks
81            // like a nested field map (heuristic: no "value" detail key).
82            if val.get("value").is_none() && val.get("raw_hex").is_none() {
83                let _ = inner; // recurse as a plain field map
84                out.push((key.clone(), redact_fields(val, names, how)));
85                continue;
86            }
87        }
88        out.push((key.clone(), val.clone()));
89    }
90    JsonValue::Object(out)
91}
92
93/// Redact a single field member: if it is an Audit/Evidence detail object, redact its `value` and blank its
94/// `raw_hex`; otherwise redact the scalar value directly.
95fn redact_member(val: &JsonValue, how: Redaction) -> JsonValue {
96    if let JsonValue::Object(members) = val {
97        if val.get("value").is_some() || val.get("raw_hex").is_some() {
98            let mut out = Vec::with_capacity(members.len());
99            for (k, v) in members {
100                match k.as_str() {
101                    "value" => out.push((k.clone(), apply(v, how))),
102                    "raw_hex" => out.push((k.clone(), JsonValue::str("REDACTED"))),
103                    _ => out.push((k.clone(), v.clone())),
104                }
105            }
106            return JsonValue::Object(out);
107        }
108    }
109    apply(val, how)
110}
111
112fn apply(v: &JsonValue, how: Redaction) -> JsonValue {
113    match how {
114        Redaction::Mask => JsonValue::str("****"),
115        Redaction::Hash => {
116            let bytes = match v {
117                JsonValue::String(s) => s.clone().into_bytes(),
118                JsonValue::Number(n) => n.clone().into_bytes(),
119                other => crate::json::to_string(other).into_bytes(),
120            };
121            JsonValue::str(format!("sha256:{}", sha256::hex_digest(&bytes)))
122        }
123        Redaction::Remove => JsonValue::Null, // not reached for value-level
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::json::to_string;
131
132    fn compact() -> JsonValue {
133        JsonValue::Object(vec![
134            ("record".into(), JsonValue::str("CUST")),
135            (
136                "fields".into(),
137                JsonValue::Object(vec![
138                    ("NAME".into(), JsonValue::str("JOHN")),
139                    ("SSN".into(), JsonValue::str("123456789")),
140                ]),
141            ),
142        ])
143    }
144
145    #[test]
146    fn mask_replaces_value() {
147        let r = redact(&compact(), &["SSN"], Redaction::Mask);
148        assert_eq!(r.get("fields").unwrap().get("SSN").unwrap().as_str(), Some("****"));
149        // untouched field preserved
150        assert_eq!(r.get("fields").unwrap().get("NAME").unwrap().as_str(), Some("JOHN"));
151    }
152
153    #[test]
154    fn hash_is_stable_token() {
155        let r = redact(&compact(), &["SSN"], Redaction::Hash);
156        let t = r.get("fields").unwrap().get("SSN").unwrap().as_str().unwrap();
157        assert!(t.starts_with("sha256:"));
158        // equal input -> equal token
159        let r2 = redact(&compact(), &["SSN"], Redaction::Hash);
160        assert_eq!(to_string(&r), to_string(&r2));
161    }
162
163    #[test]
164    fn remove_drops_member() {
165        let r = redact(&compact(), &["SSN"], Redaction::Remove);
166        assert!(r.get("fields").unwrap().get("SSN").is_none());
167        assert!(r.get("fields").unwrap().get("NAME").is_some());
168    }
169
170    #[test]
171    fn redacts_audit_detail_value_and_raw_hex() {
172        let packet = JsonValue::Object(vec![
173            ("record".into(), JsonValue::str("R")),
174            (
175                "fields".into(),
176                JsonValue::Object(vec![(
177                    "SSN".into(),
178                    JsonValue::Object(vec![
179                        ("value".into(), JsonValue::str("123456789")),
180                        ("raw_hex".into(), JsonValue::str("313233")),
181                    ]),
182                )]),
183            ),
184        ]);
185        let r = redact(&packet, &["SSN"], Redaction::Mask);
186        let ssn = r.get("fields").unwrap().get("SSN").unwrap();
187        assert_eq!(ssn.get("value").unwrap().as_str(), Some("****"));
188        assert_eq!(ssn.get("raw_hex").unwrap().as_str(), Some("REDACTED"));
189    }
190}