diff --git a/src/ore/src/str.rs b/src/ore/src/str.rs index a2121add7438d..ee06d43ce6f55 100644 --- a/src/ore/src/str.rs +++ b/src/ore/src/str.rs @@ -445,18 +445,27 @@ struct Redacting { debug_mode: bool, } +/// The character masking applied by [redact]: digits become `#`, alphabetic +/// characters become `X`, everything else is unchanged. +/// +/// Exposed so that renderings that cannot go through [redact]'s `Debug` +/// wrapper (e.g. JSON values) can apply the identical masking. +pub fn redact_char(c: char) -> char { + if c.is_digit(10) { + '#' + } else if c.is_alphabetic() { + 'X' + } else { + c + } +} + struct RedactingWriter<'a, 'b>(&'a mut Formatter<'b>); impl<'a, 'b> Write for RedactingWriter<'a, 'b> { fn write_str(&mut self, s: &str) -> fmt::Result { for c in s.chars() { - self.0.write_char(if c.is_digit(10) { - '#' - } else if c.is_alphabetic() { - 'X' - } else { - c - })?; + self.0.write_char(redact_char(c))?; } Ok(()) } diff --git a/src/persist-types/src/stats.rs b/src/persist-types/src/stats.rs index 0029bafa9e8a8..88f0226d44d7a 100644 --- a/src/persist-types/src/stats.rs +++ b/src/persist-types/src/stats.rs @@ -801,3 +801,63 @@ pub(crate) fn any_columnar_stats() -> impl Strategy { }) }) } + +/// Redacts scalar values in a [DynStats::debug_json] rendering. +/// +/// Mirrors [mz_ore::str::redact]: when soft assertions are enabled the value +/// is returned unchanged. Otherwise scalar leaves are rendered as strings +/// with each character masked by [mz_ore::str::redact_char] and wrapped in +/// `<>`. Object keys are left as is, they are column names, not data. +pub(crate) fn redact_json(v: serde_json::Value) -> serde_json::Value { + if mz_ore::assert::soft_assertions_enabled() { + return v; + } + redact_json_masked(v) +} + +fn redact_json_masked(v: serde_json::Value) -> serde_json::Value { + use serde_json::Value; + fn mask(s: String) -> Value { + let mut out = String::with_capacity(s.len() + 2); + out.push('<'); + out.extend(s.chars().map(mz_ore::str::redact_char)); + out.push('>'); + Value::String(out) + } + match v { + Value::Null => Value::Null, + Value::Bool(x) => mask(x.to_string()), + Value::Number(x) => mask(x.to_string()), + Value::String(x) => mask(x), + Value::Array(xs) => Value::Array(xs.into_iter().map(redact_json_masked).collect()), + Value::Object(kvs) => Value::Object( + kvs.into_iter() + .map(|(k, v)| (k, redact_json_masked(v))) + .collect(), + ), + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::redact_json_masked; + + // Tests the masking directly: `redact_json` itself is a passthrough in + // test builds because soft assertions are enabled. + #[mz_ore::test] + fn redact_json_masks_scalars() { + assert_eq!( + redact_json_masked(json!("TEST_STRING")), + json!("") + ); + assert_eq!(redact_json_masked(json!(1.234)), json!("<#.###>")); + assert_eq!(redact_json_masked(json!(true)), json!("")); + assert_eq!(redact_json_masked(json!(null)), json!(null)); + assert_eq!( + redact_json_masked(json!({"lower": "abc1", "upper": [2, "d"]})), + json!({"lower": "", "upper": ["<#>", ""]}) + ); + } +} diff --git a/src/persist-types/src/stats/bytes.rs b/src/persist-types/src/stats/bytes.rs index 8c3d17cd26a2f..544c6576c53e8 100644 --- a/src/persist-types/src/stats/bytes.rs +++ b/src/persist-types/src/stats/bytes.rs @@ -19,7 +19,7 @@ use crate::stats::primitive::{PrimitiveStats, any_primitive_vec_u8_stats}; use crate::stats::{ ColumnStatKinds, ColumnStats, ColumnarStats, DynStats, OptionStats, ProtoAtomicBytesStats, ProtoBytesStats, ProtoFixedSizeBytesStats, TrimStats, proto_bytes_stats, - proto_fixed_size_bytes_stats, + proto_fixed_size_bytes_stats, redact_json, }; /// `PrimitiveStats>` that cannot safely be trimmed. @@ -43,8 +43,8 @@ impl Debug for AtomicBytesStats { impl AtomicBytesStats { fn debug_json(&self) -> serde_json::Value { serde_json::json!({ - "lower": hex::encode(&self.lower), - "upper": hex::encode(&self.upper), + "lower": redact_json(hex::encode(&self.lower).into()), + "upper": redact_json(hex::encode(&self.upper).into()), }) } } @@ -92,8 +92,8 @@ impl Debug for FixedSizeBytesStats { impl FixedSizeBytesStats { fn debug_json(&self) -> serde_json::Value { serde_json::json!({ - "lower": hex::encode(&self.lower), - "upper": hex::encode(&self.upper), + "lower": redact_json(hex::encode(&self.lower).into()), + "upper": redact_json(hex::encode(&self.upper).into()), "kind": self.kind, }) } diff --git a/src/persist-types/src/stats/primitive.rs b/src/persist-types/src/stats/primitive.rs index 082715817eaed..a32f04d2abae7 100644 --- a/src/persist-types/src/stats/primitive.rs +++ b/src/persist-types/src/stats/primitive.rs @@ -17,7 +17,7 @@ use serde::Serialize; use crate::stats::{ BytesStats, ColumnStatKinds, ColumnStats, ColumnarStats, DynStats, OptionStats, - ProtoPrimitiveBytesStats, ProtoPrimitiveStats, TrimStats, proto_primitive_stats, + ProtoPrimitiveBytesStats, ProtoPrimitiveStats, TrimStats, proto_primitive_stats, redact_json, }; use crate::timestamp::try_parse_monotonic_iso8601_timestamp; @@ -160,7 +160,7 @@ where fn debug_json(&self) -> serde_json::Value { let l = serde_json::to_value(&self.lower).expect("valid json"); let u = serde_json::to_value(&self.upper).expect("valid json"); - serde_json::json!({"lower": l, "upper": u}) + serde_json::json!({"lower": redact_json(l), "upper": redact_json(u)}) } fn into_columnar_stats(self) -> ColumnarStats { @@ -174,8 +174,8 @@ where impl DynStats for PrimitiveStats> { fn debug_json(&self) -> serde_json::Value { serde_json::json!({ - "lower": hex::encode(&self.lower), - "upper": hex::encode(&self.upper), + "lower": redact_json(hex::encode(&self.lower).into()), + "upper": redact_json(hex::encode(&self.upper).into()), }) }