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}