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 #[inline(always)]
53 pub const fn new(
54 text: &'text str,
55 policy: &'policy RedactionPolicy,
56 ) -> Self {
57 Self { text, policy }
58 }
59
60 /// Reports whether diagnostic formatting must refuse the raw input.
61 ///
62 /// # Returns
63 ///
64 /// True when the text exceeds the policy input limit.
65 #[inline(always)]
66 const fn exceeds_diagnostic_input_budget(&self) -> bool {
67 self.text.len() > self.policy.diagnostic_budget().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.diagnostic_budget(),
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.diagnostic_budget(),
162 ));
163 let _ = writer.write_str(&self.diagnostic_json_text());
164 formatter.write_str(&writer.finish())
165 }
166}
167
168#[cfg(feature = "serde")]
169impl serde::Serialize for RedactedJsonText<'_, '_> {
170 /// Serializes compact redacted JSON while preserving the outer string
171 /// shape.
172 ///
173 /// # Type Parameters
174 ///
175 /// * S - Destination serializer type.
176 ///
177 /// # Parameters
178 ///
179 /// * serializer - Destination serde serializer.
180 ///
181 /// # Returns
182 ///
183 /// The destination serializer result.
184 ///
185 /// # Errors
186 ///
187 /// Returns the destination serializer error unchanged.
188 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
189 where
190 S: serde::Serializer,
191 {
192 serializer.serialize_str(&redacted_json_text(self.text, self.policy))
193 }
194}