Skip to main content

qubit_redact/json/
redacted_json.rs

1// =============================================================================
2//    Copyright (c) 2025 - 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! Lazy recursive formatting for an already parsed JSON value.
9
10use std::fmt;
11
12use serde_json::Value;
13
14#[cfg(feature = "serde")]
15use serde::ser::{
16    SerializeMap as _,
17    SerializeSeq as _,
18};
19
20use crate::{
21    RedactValue as _,
22    RedactedValue,
23    RedactionPolicy,
24    policy::ResolvedField,
25};
26
27/// A borrowed JSON value rendered with policy-aware object-key redaction.
28#[must_use = "format or serialize the redacted JSON view"]
29pub struct RedactedJson<'value, 'policy> {
30    /// Original parsed JSON borrowed without cloning for formatting.
31    value: &'value Value,
32    /// Policy used to classify every encountered object key.
33    policy: &'policy RedactionPolicy,
34    /// Current recursive container depth measured from the root.
35    depth: usize,
36    /// Whether a scalar at this node lacks an object-field key.
37    unkeyed: bool,
38}
39
40impl<'value, 'policy> RedactedJson<'value, 'policy> {
41    /// Creates a lazy redacted view over one parsed JSON value.
42    ///
43    /// # Parameters
44    ///
45    /// * value - Parsed JSON borrowed without cloning.
46    /// * policy - Immutable policy used to classify object keys.
47    ///
48    /// # Returns
49    ///
50    /// A borrowed JSON redaction view.
51    #[inline(always)]
52    pub const fn new(
53        value: &'value Value,
54        policy: &'policy RedactionPolicy,
55    ) -> Self {
56        Self {
57            value,
58            policy,
59            depth: 0,
60            unkeyed: true,
61        }
62    }
63
64    /// Creates a nested view sharing the same policy and depth budget.
65    ///
66    /// # Parameters
67    ///
68    /// * `value` - Nested JSON value borrowed from the current node.
69    ///
70    /// # Returns
71    ///
72    /// A borrowed view at the next recursive depth.
73    #[inline(always)]
74    fn nested<'nested>(
75        &self,
76        value: &'nested Value,
77        unkeyed: bool,
78    ) -> RedactedJson<'nested, 'policy> {
79        RedactedJson {
80            value,
81            policy: self.policy,
82            depth: self.depth.saturating_add(1),
83            unkeyed,
84        }
85    }
86
87    /// Reports whether the current container must fail closed at the depth
88    /// budget.
89    ///
90    /// # Returns
91    ///
92    /// True for an object or array at or beyond the configured maximum depth.
93    #[inline(always)]
94    fn depth_limit_reached(&self) -> bool {
95        self.depth >= self.policy.json_depth_budget().max_depth()
96            && matches!(self.value, Value::Object(_) | Value::Array(_))
97    }
98
99    /// Returns the policy's opaque Secret replacement for an over-depth tree.
100    ///
101    /// # Returns
102    ///
103    /// A borrowed redacted scalar safe to use in debug and Serde output.
104    #[inline(always)]
105    fn depth_limit_mask(&self) -> RedactedValue<'_> {
106        RedactedValue::opaque(crate::Sensitivity::Secret, self.policy.masking())
107    }
108
109    /// Reports whether the current scalar must use the opaque Secret mask.
110    #[inline(always)]
111    fn redact_unkeyed_scalar(&self) -> bool {
112        self.unkeyed
113            && self.policy.unkeyed_json_value_policy()
114                == crate::UnkeyedJsonValuePolicy::Redact
115            && !matches!(self.value, Value::Array(_) | Value::Object(_))
116    }
117}
118
119impl fmt::Debug for RedactedJson<'_, '_> {
120    /// Formats nested objects and arrays while masking policy-selected values.
121    ///
122    /// # Parameters
123    ///
124    /// * formatter - Destination formatting context.
125    ///
126    /// # Returns
127    ///
128    /// The formatter result for the redacted JSON representation.
129    ///
130    /// # Errors
131    ///
132    /// Returns a formatting error when the destination rejects output.
133    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
134        fmt_json(self, formatter)
135    }
136}
137
138#[cfg(feature = "serde")]
139impl serde::Serialize for RedactedJson<'_, '_> {
140    /// Serializes a bounded redacted view while retaining safe JSON shapes.
141    ///
142    /// # Type Parameters
143    ///
144    /// * S - Destination serializer type.
145    ///
146    /// # Parameters
147    ///
148    /// * serializer - Destination serde serializer.
149    ///
150    /// # Returns
151    ///
152    /// The destination serializer result.
153    ///
154    /// # Errors
155    ///
156    /// Returns the destination serializer error unchanged.
157    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
158    where
159        S: serde::Serializer,
160    {
161        if self.depth_limit_reached() {
162            return serde::Serialize::serialize(
163                &self.depth_limit_mask(),
164                serializer,
165            );
166        }
167        match self.value {
168            Value::Array(values) => {
169                let mut output =
170                    serializer.serialize_seq(Some(values.len()))?;
171                for value in values {
172                    output.serialize_element(&self.nested(value, true))?;
173                }
174                output.end()
175            }
176            Value::Object(values) => {
177                let mut output =
178                    serializer.serialize_map(Some(values.len()))?;
179                for (key, value) in values {
180                    let resolved = self.policy.resolve_field(key);
181                    match resolved {
182                        ResolvedField::Sensitive { sensitivity } => match value
183                        {
184                            Value::String(text) => {
185                                let redacted = text.redact_value(
186                                    sensitivity,
187                                    self.policy.masking(),
188                                );
189                                output.serialize_entry(key, &redacted)?;
190                            }
191                            _ => {
192                                let redacted = RedactedValue::opaque(
193                                    sensitivity,
194                                    self.policy.masking(),
195                                );
196                                output.serialize_entry(key, &redacted)?;
197                            }
198                        },
199                        ResolvedField::PassThrough => {
200                            output.serialize_entry(
201                                key,
202                                &self.nested(value, false),
203                            )?;
204                        }
205                    }
206                }
207                output.end()
208            }
209            _ if self.redact_unkeyed_scalar() => serde::Serialize::serialize(
210                &self.depth_limit_mask(),
211                serializer,
212            ),
213            value => serde::Serialize::serialize(value, serializer),
214        }
215    }
216}
217
218mod session_view {
219    use std::fmt::{
220        self,
221        Write as _,
222    };
223
224    use serde_json::Value;
225
226    use crate::{
227        LogOutputLimit,
228        RedactedJson,
229        RedactionSession,
230        policy::OutputCharge,
231        text::internal::{
232            BoundedLogEscapeWriter,
233            LogEscapeWriter,
234        },
235    };
236
237    /// A nested parsed JSON view that reuses one diagnostic session.
238    #[must_use = "format the nested redacted JSON view"]
239    pub struct RedactedJsonSession<'value, 'session, 'policy> {
240        /// Parsed JSON borrowed without cloning.
241        value: &'value Value,
242        /// Shared diagnostic session for the enclosing representation.
243        session: &'session RedactionSession<'policy>,
244    }
245
246    impl<'value, 'session, 'policy> RedactedJsonSession<'value, 'session, 'policy> {
247        /// Creates a parsed JSON view using an existing diagnostic session.
248        #[inline(always)]
249        pub fn new(
250            value: &'value Value,
251            session: &'session RedactionSession<'policy>,
252        ) -> Self {
253            Self { value, session }
254        }
255
256        /// Returns an opaque fallback when the shared output budget cannot
257        /// accept another JSON fragment.
258        #[inline]
259        fn opaque(&self) -> &str {
260            self.session
261                .policy()
262                .masking()
263                .mask_opaque(crate::Sensitivity::Secret)
264        }
265
266        /// Formats the parsed JSON through a bounded writer and charges the
267        /// resulting fragment to the enclosing diagnostic session.
268        fn render(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
269            let remaining = self.session.remaining_output_bytes();
270            let Some(limit) = LogOutputLimit::new(remaining).ok() else {
271                return self.write_fallback(formatter);
272            };
273            let mut writer = BoundedLogEscapeWriter::new(limit);
274            let view = RedactedJson::new(self.value, self.session.policy());
275            let _ = if formatter.alternate() {
276                write!(&mut writer, "{view:#?}")
277            } else {
278                write!(&mut writer, "{view:?}")
279            };
280            let rendered = writer.finish();
281            match self
282                .session
283                .charge_output_or_fallback(rendered.len(), self.opaque().len())
284            {
285                OutputCharge::Complete => formatter.write_str(&rendered),
286                OutputCharge::Fallback => formatter.write_str(self.opaque()),
287                OutputCharge::Exhausted => Ok(()),
288            }
289        }
290
291        /// Writes one charged opaque fallback, or nothing after output
292        /// exhaustion.
293        fn write_fallback(
294            &self,
295            formatter: &mut fmt::Formatter<'_>,
296        ) -> fmt::Result {
297            let fallback = self.opaque();
298            match self
299                .session
300                .charge_output_or_fallback(fallback.len(), fallback.len())
301            {
302                OutputCharge::Complete => formatter.write_str(fallback),
303                OutputCharge::Fallback | OutputCharge::Exhausted => Ok(()),
304            }
305        }
306    }
307
308    impl fmt::Debug for RedactedJsonSession<'_, '_, '_> {
309        /// Formats parsed JSON through the shared diagnostic session.
310        #[inline]
311        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
312            self.render(formatter)
313        }
314    }
315
316    impl fmt::Display for RedactedJsonSession<'_, '_, '_> {
317        /// Escapes the shared-session JSON representation for plain-text logs.
318        #[inline]
319        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
320            let mut writer = LogEscapeWriter::new(formatter);
321            write!(&mut writer, "{self:?}")
322        }
323    }
324}
325
326pub use session_view::RedactedJsonSession;
327
328/// Recursively formats one JSON node with policy-aware object keys.
329///
330/// # Parameters
331///
332/// * view - Current value, policy, and recursive depth.
333/// * formatter - Destination formatting context.
334///
335/// # Returns
336///
337/// The formatter result for the complete node.
338///
339/// # Errors
340///
341/// Returns a formatting error when the destination rejects output.
342fn fmt_json(
343    view: &RedactedJson<'_, '_>,
344    formatter: &mut fmt::Formatter<'_>,
345) -> fmt::Result {
346    if view.depth_limit_reached() {
347        return fmt::Debug::fmt(&view.depth_limit_mask(), formatter);
348    }
349    match view.value {
350        Value::Array(values) => {
351            let mut output = formatter.debug_list();
352            for value in values {
353                output.entry(&view.nested(value, true));
354            }
355            output.finish()
356        }
357        Value::Object(values) => {
358            let mut output = formatter.debug_map();
359            for (key, value) in values {
360                let resolved = view.policy.resolve_field(key);
361                match resolved {
362                    ResolvedField::Sensitive { sensitivity } => {
363                        fmt_masked_entry(
364                            &mut output,
365                            key,
366                            value,
367                            sensitivity,
368                            view.policy.masking(),
369                        );
370                    }
371                    ResolvedField::PassThrough => {
372                        output.entry(key, &view.nested(value, false));
373                    }
374                }
375            }
376            output.finish()
377        }
378        _ if view.redact_unkeyed_scalar() => {
379            fmt::Debug::fmt(&view.depth_limit_mask(), formatter)
380        }
381        value => fmt::Debug::fmt(value, formatter),
382    }
383}
384
385/// Writes one object entry whose key selected a sensitivity level.
386///
387/// # Parameters
388///
389/// * output - In-progress debug map.
390/// * key - Original object key preserved in output.
391/// * value - Sensitive value to replace.
392/// * sensitivity - Level selecting the configured mask.
393/// * masking - Shared masking configuration selected by sensitivity.
394fn fmt_masked_entry(
395    output: &mut fmt::DebugMap<'_, '_>,
396    key: &str,
397    value: &Value,
398    sensitivity: crate::Sensitivity,
399    masking: &crate::MaskingPolicy,
400) {
401    match value {
402        Value::String(text) => {
403            let redacted = text.redact_value(sensitivity, masking);
404            output.entry(&key, &redacted);
405        }
406        _ => {
407            let redacted = RedactedValue::opaque(sensitivity, masking);
408            output.entry(&key, &redacted);
409        }
410    };
411}