Skip to main content

qubit_redact/json/
redacted_json_text.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 fail-closed formatting for JSON stored as text.
9
10use std::fmt::{
11    self,
12    Write as _,
13};
14
15use crate::{
16    LogOutputLimit,
17    RedactionPolicy,
18    Sensitivity,
19    text::internal::BoundedLogEscapeWriter,
20};
21
22use super::{
23    RedactedJson,
24    redact_json_text_in_place::redacted_json_text,
25};
26
27/// JSON text rendered with recursive object-key redaction.
28///
29/// [`fmt::Debug`] and [`fmt::Display`] are diagnostic boundaries: they reject
30/// text larger than the policy diagnostic input budget before parsing it and
31/// apply the policy output budget. Explicit mutation and Serde serialization
32/// preserve complete JSON instead.
33#[must_use = "format or serialize the redacted JSON text view"]
34pub struct RedactedJsonText<'text, 'policy> {
35    /// Original JSON text borrowed without cloning.
36    text: &'text str,
37    /// Policy used to classify every parsed object key.
38    policy: &'policy RedactionPolicy,
39}
40
41impl<'text, 'policy> RedactedJsonText<'text, 'policy> {
42    /// Creates a lazy redacted view over text expected to contain JSON.
43    ///
44    /// # Parameters
45    ///
46    /// * text - JSON text borrowed without parsing.
47    /// * policy - Immutable policy used to classify parsed object keys.
48    ///
49    /// # Returns
50    ///
51    /// A borrowed fail-closed JSON text view.
52    pub const fn new(
53        text: &'text str,
54        policy: &'policy RedactionPolicy,
55    ) -> Self {
56        Self { text, policy }
57    }
58
59    /// Reports whether diagnostic formatting must refuse the raw input.
60    ///
61    /// # Returns
62    ///
63    /// True when the text exceeds the policy input limit.
64    #[inline(always)]
65    const fn exceeds_diagnostic_input_budget(&self) -> bool {
66        self.text.len()
67            > self.policy.limits().diagnostic_event().max_input_bytes()
68    }
69
70    /// Returns the configured opaque replacement for unsafe JSON text.
71    ///
72    /// # Returns
73    ///
74    /// An opaque Secret-sensitivity marker.
75    #[inline(always)]
76    fn opaque_secret(&self) -> &str {
77        self.policy.masking().mask_opaque(Sensitivity::Secret)
78    }
79
80    /// Produces compact redacted JSON for a diagnostic rendering.
81    ///
82    /// # Returns
83    ///
84    /// Compact redacted JSON, or an opaque marker when the input is unsafe.
85    fn diagnostic_json_text(&self) -> String {
86        if self.exceeds_diagnostic_input_budget() {
87            return self.opaque_secret().to_owned();
88        }
89        redacted_json_text(self.text, self.policy)
90    }
91}
92
93impl fmt::Debug for RedactedJsonText<'_, '_> {
94    /// Formats parsed redacted JSON or an opaque replacement for unsafe text.
95    ///
96    /// Output that exceeds the diagnostic budget ends in the log truncation
97    /// marker.
98    ///
99    /// # Parameters
100    ///
101    /// * formatter - Destination formatting context.
102    ///
103    /// # Returns
104    ///
105    /// The formatter result for a safe representation.
106    ///
107    /// # Errors
108    ///
109    /// Returns a formatting error when the destination rejects output.
110    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111        let mut writer = BoundedLogEscapeWriter::new(LogOutputLimit::from(
112            self.policy.limits().diagnostic_event(),
113        ));
114        if self.exceeds_diagnostic_input_budget() {
115            let _ = write!(&mut writer, "{:?}", self.opaque_secret());
116        } else {
117            match serde_json::from_str(self.text) {
118                Ok(value) if formatter.alternate() => {
119                    let _ = write!(
120                        &mut writer,
121                        "{:#?}",
122                        RedactedJson::new(&value, self.policy),
123                    );
124                }
125                Ok(value) => {
126                    let _ = write!(
127                        &mut writer,
128                        "{:?}",
129                        RedactedJson::new(&value, self.policy),
130                    );
131                }
132                Err(_) => {
133                    let _ = write!(&mut writer, "{:?}", self.opaque_secret());
134                }
135            }
136        }
137        formatter.write_str(&writer.finish())
138    }
139}
140
141impl fmt::Display for RedactedJsonText<'_, '_> {
142    /// Writes compact redacted JSON for a bounded plain-text log boundary.
143    ///
144    /// Complete valid input that fits both diagnostic budgets produces compact
145    /// valid JSON. Rejected input produces the opaque Secret mask; output that
146    /// exceeds its budget ends in the log truncation marker and is not JSON.
147    ///
148    /// # Parameters
149    ///
150    /// * formatter - Destination formatting context.
151    ///
152    /// # Returns
153    ///
154    /// The formatter result for safe log output.
155    ///
156    /// # Errors
157    ///
158    /// Returns a formatting error when the destination rejects output.
159    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
160        let mut writer = BoundedLogEscapeWriter::new(LogOutputLimit::from(
161            self.policy.limits().diagnostic_event(),
162        ));
163        let _ = writer.write_str(&self.diagnostic_json_text());
164        formatter.write_str(&writer.finish())
165    }
166}
167
168mod session_view {
169    use std::fmt;
170
171    use crate::{
172        RedactedJsonText,
173        RedactionSession,
174        Sensitivity,
175        policy::OutputCharge,
176    };
177
178    /// A nested JSON text view that accounts against an existing diagnostic
179    /// session.
180    #[must_use = "format the nested redacted JSON text view"]
181    pub struct RedactedJsonTextSession<'text, 'session, 'policy> {
182        text: &'text str,
183        session: &'session RedactionSession<'policy>,
184    }
185
186    impl<'text, 'session, 'policy>
187        RedactedJsonTextSession<'text, 'session, 'policy>
188    {
189        /// Creates a JSON text view borrowing an existing diagnostic session.
190        #[inline(always)]
191        pub fn new(
192            text: &'text str,
193            session: &'session RedactionSession<'policy>,
194        ) -> Self {
195            Self { text, session }
196        }
197
198        /// Renders the nested JSON text while consuming session input and
199        /// output.
200        fn render(&self) -> String {
201            let policy = self.session.policy();
202            if !self.session.consume_input(self.text.len()) {
203                return self.fallback();
204            }
205            let mut rendered = String::new();
206            if fmt::write(
207                &mut rendered,
208                format_args!("{:?}", RedactedJsonText::new(self.text, policy),),
209            )
210            .is_err()
211            {
212                return self.fallback();
213            }
214            let fallback = policy.masking().mask_opaque(Sensitivity::Secret);
215            match self
216                .session
217                .charge_output_or_fallback(rendered.len(), fallback.len())
218            {
219                OutputCharge::Complete => rendered,
220                OutputCharge::Fallback => fallback.to_owned(),
221                OutputCharge::Exhausted => String::new(),
222            }
223        }
224
225        /// Charges one opaque fallback or returns no bytes after exhaustion.
226        fn fallback(&self) -> String {
227            let fallback = self
228                .session
229                .policy()
230                .masking()
231                .mask_opaque(Sensitivity::Secret);
232            match self
233                .session
234                .charge_output_or_fallback(fallback.len(), fallback.len())
235            {
236                OutputCharge::Complete => fallback.to_owned(),
237                OutputCharge::Fallback | OutputCharge::Exhausted => {
238                    String::new()
239                }
240            }
241        }
242    }
243
244    impl fmt::Debug for RedactedJsonTextSession<'_, '_, '_> {
245        /// Formats nested JSON text through the shared session.
246        #[inline]
247        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
248            formatter.write_str(&self.render())
249        }
250    }
251
252    impl fmt::Display for RedactedJsonTextSession<'_, '_, '_> {
253        /// Escapes nested JSON text through the shared session.
254        #[inline]
255        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
256            formatter.write_str(&self.render())
257        }
258    }
259}
260
261pub use session_view::RedactedJsonTextSession;
262
263#[cfg(feature = "serde")]
264impl serde::Serialize for RedactedJsonText<'_, '_> {
265    /// Serializes compact redacted JSON while preserving the outer string
266    /// shape.
267    ///
268    /// # Type Parameters
269    ///
270    /// * S - Destination serializer type.
271    ///
272    /// # Parameters
273    ///
274    /// * serializer - Destination serde serializer.
275    ///
276    /// # Returns
277    ///
278    /// The destination serializer result.
279    ///
280    /// # Errors
281    ///
282    /// Returns the destination serializer error unchanged.
283    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
284    where
285        S: serde::Serializer,
286    {
287        serializer.serialize_str(&redacted_json_text(self.text, self.policy))
288    }
289}