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    #[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}