Skip to main content

qubit_redact/formats/json/
json_redaction_writer.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//! Mutable JSON façade over one diagnostic redaction session.
9
10use qubit_budget::json::JsonDecodeSession;
11use qubit_json::decode::JsonDecodeErrorKind;
12use qubit_json::decode::JsonDecoder;
13use serde_json::Value;
14
15use super::JsonAdmissionError;
16use super::bounded_json_redaction::BoundedJsonRedaction;
17use super::bounded_json_redaction::redacted_json_value_bounded;
18use super::internal::JsonStructureSeed;
19use crate::output::log_escape::escape_log_control_characters;
20use crate::runtime::OperationSink;
21use crate::runtime::RenderedOperation;
22use crate::runtime::TextSession;
23use crate::runtime::runtime_session::RuntimeSession;
24
25/// Parses `text` at root depth, charging the shared `session` ledger.
26///
27/// Returns the admitted JSON tree. Returns `JsonAdmissionError::Limit` for a
28/// rejected structural or JSON allowance, or `JsonAdmissionError::Invalid` for
29/// invalid syntax or unsupported numeric values. The caller admits input bytes.
30pub(crate) fn admit_json_text_value(session: &mut dyn RuntimeSession, text: &str) -> Result<Value, JsonAdmissionError> {
31    admit_json_text_value_at_depth(session, text, 1)
32}
33
34/// Parses `text` with its root at `root_depth`, charging the shared `session`.
35///
36/// # Parameters
37///
38/// * `session`: Transaction owning the structural and JSON-value budgets.
39/// * `text`: Complete JSON text whose input bytes the caller has already
40///   admitted.
41/// * `root_depth`: Root-inclusive depth used when admitting this nested
42///   document.
43///
44/// # Returns
45///
46/// The JSON tree constructed in one admitted decoding pass.
47///
48/// # Errors
49///
50/// Returns `JsonAdmissionError::Limit` after structural or JSON-budget
51/// rejection, recording JSON-value exhaustion in the session where applicable.
52/// Returns `JsonAdmissionError::Invalid` for malformed input or unsupported
53/// numbers; error values do not retain source text.
54pub(crate) fn admit_json_text_value_at_depth(
55    session: &mut dyn RuntimeSession,
56    text: &str,
57    root_depth: usize,
58) -> Result<Value, JsonAdmissionError> {
59    #[cfg(test)]
60    super::parse_counter::record_json_parse();
61    let mut rejected = false;
62    let (mut admission, json_budget) = session.split_json_admission();
63    let mut decoder = JsonDecoder::new(JsonDecodeSession::borrowing_value(json_budget));
64    let admitted = decoder.decode_seed_str(
65        JsonStructureSeed {
66            admission: &mut admission,
67            depth: root_depth,
68            collection_item: false,
69            rejected: &mut rejected,
70        },
71        text,
72    );
73    match admitted {
74        Ok(value) => Ok(value),
75        Err(_) if rejected => Err(JsonAdmissionError::Limit),
76        Err(error) if error.kind() == JsonDecodeErrorKind::Budget => {
77            session.record_json_value_limit_reached();
78            Err(JsonAdmissionError::Limit)
79        }
80        Err(_) => Err(JsonAdmissionError::Invalid),
81    }
82}
83
84/// Copies trusted JSON text under the output allowance supplied by its caller.
85///
86/// Disabled policies intentionally do not parse or redact their input. This
87/// helper performs only log-control escaping and output-bound enforcement.
88#[must_use]
89pub(crate) fn passthrough_json_text_with_limit(text: &str, max_output_bytes: usize) -> RenderedOperation {
90    json_output_from_bounded(BoundedJsonRedaction::Complete(text.to_owned()), max_output_bytes)
91}
92
93/// Converts bounded JSON rendering into unpublished adapter state.
94#[must_use]
95pub(crate) fn json_output_from_bounded(
96    bounded: super::bounded_json_redaction::BoundedJsonRedaction,
97    max_output_bytes: usize,
98) -> RenderedOperation {
99    let (rendered, raw_truncated, invalid_json) = bounded.into_parts();
100    let output_text = escape_log_control_characters(std::borrow::Cow::Owned(rendered)).into_owned();
101    if output_text.len() > max_output_bytes {
102        let fallback = "<truncated>";
103        let mut output = if fallback.len() <= max_output_bytes {
104            OperationSink::truncated(fallback, crate::RedactionReason::OutputLimitReached)
105        } else {
106            OperationSink::exhausted(String::new())
107        };
108        if invalid_json {
109            output = output.with_reason(crate::RedactionReason::InvalidJson);
110        }
111        return output.finish();
112    }
113    if raw_truncated {
114        OperationSink::truncated(output_text, crate::RedactionReason::OutputLimitReached).finish()
115    } else if invalid_json {
116        OperationSink::complete_with_reason(output_text, crate::RedactionReason::InvalidJson).finish()
117    } else {
118        OperationSink::complete(output_text).finish()
119    }
120}
121
122/// Feature-gated JSON operations sharing one mutable diagnostic session.
123///
124/// # Type Parameters
125///
126/// * `'session` - Borrow of the parent composer's unpublished transaction.
127///
128/// # Examples
129///
130/// ```
131/// use qubit_redact::Redactor;
132///
133/// let output = Redactor::standard().text_composer().json(|json| {
134///     json.text(r#"{"password":"raw-secret","visible":7}"#);
135/// }).finish();
136/// assert!(!output.text().as_str().contains("raw-secret"));
137/// assert!(output.text().as_str().contains("visible"));
138/// ```
139pub struct JsonRedactionWriter<'session> {
140    /// Text transaction that owns structural accounting and aggregate output.
141    pub(super) session: &'session mut TextSession,
142}
143
144impl<'session> JsonRedactionWriter<'session> {
145    /// Creates a JSON facade borrowing a parent session.
146    ///
147    /// # Parameters
148    ///
149    /// * `session` - Parent transaction receiving admitted JSON output.
150    ///
151    /// # Returns
152    ///
153    /// A writer borrowing the existing policy and resource ledger.
154    #[must_use]
155    #[inline(always)]
156    pub(crate) const fn new(session: &'session mut TextSession) -> Self {
157        Self { session }
158    }
159
160    /// Redacts JSON text into the parent session's aggregate output.
161    ///
162    /// # Parameters
163    ///
164    /// * `text` - One complete JSON document. Enabled redaction parses it once
165    ///   under the shared input and traversal limits.
166    ///
167    /// # Returns
168    ///
169    /// This writer for further operations; malformed or rejected input uses
170    /// safe output and records its cause in the parent summary.
171    pub fn text(&mut self, text: &str) -> &mut Self {
172        if self.session.skip_aggregate_for_exhausted_output() {
173            return self;
174        }
175        if !self.session.admit_input(text.len()) {
176            self.session.append_rendered_operation(
177                crate::runtime::OperationSink::truncated("<truncated>", crate::RedactionReason::InputLimitReached)
178                    .finish(),
179            );
180            return self;
181        }
182        let result = if self.session.policy().is_disabled() {
183            self.redact_text_direct(text)
184        } else {
185            match admit_json_text_value(self.session, text) {
186                Ok(value) => self.redact_value_direct(&value),
187                Err(JsonAdmissionError::Invalid) => {
188                    invalid_json_output(self.session.policy(), self.session.remaining_output_bytes())
189                }
190                Err(JsonAdmissionError::Limit) => {
191                    OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish()
192                }
193            }
194        };
195        self.session.append_rendered_operation(result);
196        self
197    }
198
199    /// Redacts a borrowed parsed JSON value into the aggregate transaction.
200    ///
201    /// # Parameters
202    ///
203    /// * `value` - Parsed JSON borrowed without cloning or modifying its tree.
204    ///
205    /// # Returns
206    ///
207    /// This writer after shared admission and rendering, or after recording a
208    /// safe replacement when traversal cannot complete.
209    pub fn value(&mut self, value: &Value) -> &mut Self {
210        if self.session.skip_aggregate_for_exhausted_output() {
211            return self;
212        }
213        if !self.session.admit_json_value(value) {
214            self.session.append_rendered_operation(
215                OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish(),
216            );
217            return self;
218        }
219        let result = self.redact_value_direct(value);
220        self.session.append_rendered_operation(result);
221        self
222    }
223}
224
225impl JsonRedactionWriter<'_> {
226    /// Escapes previously admitted JSON text for disabled-mode publication.
227    ///
228    /// The caller performs input admission and checks output closure before
229    /// entering this helper. This operation only escapes and bounds the text;
230    /// it does not parse or classify JSON.
231    #[must_use]
232    pub(crate) fn redact_text_direct(&mut self, text: &str) -> RenderedOperation {
233        passthrough_json_text_with_limit(text, self.session.remaining_output_bytes())
234    }
235
236    /// Redacts a parsed value under the session's remaining output allowance.
237    #[must_use]
238    pub(crate) fn redact_value_direct(&mut self, value: &Value) -> RenderedOperation {
239        redact_json_value_with_limit(self.session.policy(), value, self.session.remaining_output_bytes())
240    }
241}
242
243/// Redacts a parsed JSON value under a caller-supplied output allowance.
244#[must_use]
245pub(crate) fn redact_json_value_with_limit(
246    policy: &crate::RedactionPolicy,
247    value: &Value,
248    max_output_bytes: usize,
249) -> RenderedOperation {
250    json_output_from_bounded(
251        redacted_json_value_bounded(value, policy, max_output_bytes),
252        max_output_bytes,
253    )
254}
255
256/// Creates fail-closed output for JSON text that could not be parsed.
257pub(crate) fn invalid_json_output(policy: &crate::RedactionPolicy, max_output_bytes: usize) -> RenderedOperation {
258    json_output_from_bounded(
259        BoundedJsonRedaction::Invalid(policy.masking().mask_opaque(crate::Sensitivity::Secret).to_owned()),
260        max_output_bytes,
261    )
262}
263
264#[cfg(test)]
265mod tests {
266    use super::passthrough_json_text_with_limit;
267    use crate::RedactionCompletion;
268
269    /// Verifies the JSON execution helper receives and honors its caller's
270    /// final output allowance rather than selecting an independent budget.
271    #[test]
272    fn test_bounded_json_helper_never_exceeds_the_caller_allowance() {
273        let output = passthrough_json_text_with_limit(
274            r#"{"description":"this value is deliberately longer than the allowance"}"#,
275            16,
276        );
277
278        assert_eq!(output.completion(), RedactionCompletion::Truncated);
279        assert!(output.reasons().contains(crate::RedactionReason::OutputLimitReached));
280        assert!(output.text().len() <= 16);
281    }
282}