kobold-json 0.1.0

Forensic JSON evidence packets for COBOL record migration: raw-byte custody, copybook/record hashes, field findings, round-trip proof. Clean-room; independent of GnuCOBOL/libcob.
Documentation
//! `KOBOLD.JSON.REDACTION.1` -- redact named fields in a packet while preserving its structure.
//!
//! Redaction is applied to the field's **value** (and, where present, its `raw_hex`) so the redacted packet
//! is still a well-formed packet that diffs cleanly. Three strategies:
//!
//! * [`Redaction::Mask`] -- replace the value with `"****"`.
//! * [`Redaction::Hash`] -- replace the value with `"sha256:<hex>"` of its UTF-8 bytes (a stable,
//!   non-reversible token -- equal values redact to equal tokens).
//! * [`Redaction::Remove`] -- drop the field member entirely.
//!
//! Field names are matched against the keys under the packet's `fields` object (recursively into groups).
//! It is independent of GnuCOBOL/libcob.

use crate::json::JsonValue;
use crate::sha256;

/// A redaction strategy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Redaction {
    /// Replace the value with `"****"`.
    Mask,
    /// Replace the value with `"sha256:<hex>"` of its bytes.
    Hash,
    /// Remove the field member entirely.
    Remove,
}

/// `KOBOLD.JSON.REDACTION.1` -- return a copy of `packet` with the named `fields` redacted per `how`.
/// Unmatched names are ignored. The packet's overall structure (record/encoding/hashes) is preserved;
/// note that redacting a field invalidates any whole-record `record_hash` -- that is intentional and
/// visible.
pub fn redact(packet: &JsonValue, fields: &[&str], how: Redaction) -> JsonValue {
    let mut result = packet.clone();
    if let JsonValue::Object(members) = &mut result {
        for (k, v) in members.iter_mut() {
            if k == "fields" {
                *v = redact_fields(v, fields, how);
            }
        }
    }
    result
}

fn redact_fields(node: &JsonValue, names: &[&str], how: Redaction) -> JsonValue {
    let members = match node {
        JsonValue::Object(m) => m,
        _ => return node.clone(),
    };
    let mut out: Vec<(String, JsonValue)> = Vec::with_capacity(members.len());
    for (key, val) in members {
        // Recurse into a group detail object (has a nested "fields") or a compact nested object.
        let is_group = matches!(val, JsonValue::Object(_)) && val.get("fields").is_some();
        let target = names.iter().any(|n| n == key);

        if target {
            match how {
                Redaction::Remove => continue, // drop the member
                Redaction::Mask | Redaction::Hash => {
                    out.push((key.clone(), redact_member(val, how)));
                }
            }
            continue;
        }

        if is_group {
            // Rebuild the group object with its inner fields redacted.
            if let JsonValue::Object(gm) = val {
                let mut new_gm = Vec::with_capacity(gm.len());
                for (gk, gv) in gm {
                    if gk == "fields" {
                        new_gm.push((gk.clone(), redact_fields(gv, names, how)));
                    } else {
                        new_gm.push((gk.clone(), gv.clone()));
                    }
                }
                out.push((key.clone(), JsonValue::Object(new_gm)));
                continue;
            }
        } else if let JsonValue::Object(inner) = val {
            // Compact nested group: an object of values, no "fields"/"value" keys -> recurse if it looks
            // like a nested field map (heuristic: no "value" detail key).
            if val.get("value").is_none() && val.get("raw_hex").is_none() {
                let _ = inner; // recurse as a plain field map
                out.push((key.clone(), redact_fields(val, names, how)));
                continue;
            }
        }
        out.push((key.clone(), val.clone()));
    }
    JsonValue::Object(out)
}

/// Redact a single field member: if it is an Audit/Evidence detail object, redact its `value` and blank its
/// `raw_hex`; otherwise redact the scalar value directly.
fn redact_member(val: &JsonValue, how: Redaction) -> JsonValue {
    if let JsonValue::Object(members) = val {
        if val.get("value").is_some() || val.get("raw_hex").is_some() {
            let mut out = Vec::with_capacity(members.len());
            for (k, v) in members {
                match k.as_str() {
                    "value" => out.push((k.clone(), apply(v, how))),
                    "raw_hex" => out.push((k.clone(), JsonValue::str("REDACTED"))),
                    _ => out.push((k.clone(), v.clone())),
                }
            }
            return JsonValue::Object(out);
        }
    }
    apply(val, how)
}

fn apply(v: &JsonValue, how: Redaction) -> JsonValue {
    match how {
        Redaction::Mask => JsonValue::str("****"),
        Redaction::Hash => {
            let bytes = match v {
                JsonValue::String(s) => s.clone().into_bytes(),
                JsonValue::Number(n) => n.clone().into_bytes(),
                other => crate::json::to_string(other).into_bytes(),
            };
            JsonValue::str(format!("sha256:{}", sha256::hex_digest(&bytes)))
        }
        Redaction::Remove => JsonValue::Null, // not reached for value-level
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::json::to_string;

    fn compact() -> JsonValue {
        JsonValue::Object(vec![
            ("record".into(), JsonValue::str("CUST")),
            (
                "fields".into(),
                JsonValue::Object(vec![
                    ("NAME".into(), JsonValue::str("JOHN")),
                    ("SSN".into(), JsonValue::str("123456789")),
                ]),
            ),
        ])
    }

    #[test]
    fn mask_replaces_value() {
        let r = redact(&compact(), &["SSN"], Redaction::Mask);
        assert_eq!(r.get("fields").unwrap().get("SSN").unwrap().as_str(), Some("****"));
        // untouched field preserved
        assert_eq!(r.get("fields").unwrap().get("NAME").unwrap().as_str(), Some("JOHN"));
    }

    #[test]
    fn hash_is_stable_token() {
        let r = redact(&compact(), &["SSN"], Redaction::Hash);
        let t = r.get("fields").unwrap().get("SSN").unwrap().as_str().unwrap();
        assert!(t.starts_with("sha256:"));
        // equal input -> equal token
        let r2 = redact(&compact(), &["SSN"], Redaction::Hash);
        assert_eq!(to_string(&r), to_string(&r2));
    }

    #[test]
    fn remove_drops_member() {
        let r = redact(&compact(), &["SSN"], Redaction::Remove);
        assert!(r.get("fields").unwrap().get("SSN").is_none());
        assert!(r.get("fields").unwrap().get("NAME").is_some());
    }

    #[test]
    fn redacts_audit_detail_value_and_raw_hex() {
        let packet = JsonValue::Object(vec![
            ("record".into(), JsonValue::str("R")),
            (
                "fields".into(),
                JsonValue::Object(vec![(
                    "SSN".into(),
                    JsonValue::Object(vec![
                        ("value".into(), JsonValue::str("123456789")),
                        ("raw_hex".into(), JsonValue::str("313233")),
                    ]),
                )]),
            ),
        ]);
        let r = redact(&packet, &["SSN"], Redaction::Mask);
        let ssn = r.get("fields").unwrap().get("SSN").unwrap();
        assert_eq!(ssn.get("value").unwrap().as_str(), Some("****"));
        assert_eq!(ssn.get("raw_hex").unwrap().as_str(), Some("REDACTED"));
    }
}