Skip to main content

qubit_redact/domain/
redaction_writer.rs

1// =============================================================================
2//    Copyright (c) 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! Restricted structured writer used by domain redaction implementations.
9
10use std::fmt::Debug;
11use std::fmt::Write as _;
12
13use crate::Sensitivity;
14use crate::domain::RedactionEntries;
15use crate::domain::RedactionFields;
16use crate::domain::RedactionItems;
17use crate::domain::internal::bounded_capture::bounded_debug;
18use crate::domain::internal::bounded_debug_writer::BoundedDebugWriter;
19use crate::runtime::runtime_session::RuntimeSession;
20
21/// Restricted writer for one redaction operation.
22///
23/// Implementations use structural scopes to classify every field explicitly.
24/// The writer borrows one transaction and never publishes intermediate text.
25///
26/// # Examples
27///
28/// ```
29/// use qubit_redact::{Redact, RedactionWriter, Redactor, Sensitivity};
30///
31/// struct Credential(&'static str);
32///
33/// impl Redact for Credential {
34///     fn write_redacted(&self, writer: &mut RedactionWriter<'_>) {
35///         writer.record("Credential", |fields| {
36///             fields.sensitive(Sensitivity::Secret, "token", || self.0);
37///         });
38///     }
39/// }
40///
41/// let output = Redactor::standard().redact(&Credential("raw-token"));
42/// assert!(!output.text().as_str().contains("raw-token"));
43/// ```
44///
45/// ```compile_fail
46/// use qubit_redact::{Redact, RedactionWriter};
47///
48/// struct Value;
49/// impl Redact for Value {
50///     fn write_redacted(&self, writer: &mut RedactionWriter<'_>) {
51///         let _ = writer.redact_json_text("{\"token\":\"secret\"}");
52///     }
53/// }
54/// ```
55pub struct RedactionWriter<'session> {
56    /// Transaction session receiving classified output and accounting.
57    pub(super) session: &'session mut dyn RuntimeSession,
58}
59
60impl<'session> RedactionWriter<'session> {
61    /// Creates a writer backed by an existing diagnostic session.
62    #[must_use]
63    pub(crate) fn new(session: &'session mut dyn RuntimeSession) -> Self {
64        Self { session }
65    }
66
67    /// Creates a writer that owns the root output admission for one value.
68    pub(crate) fn new_root(session: &'session mut dyn RuntimeSession) -> Self {
69        Self::new(session)
70    }
71
72    /// Writes a trusted static structural literal.
73    #[inline]
74    pub fn literal(&mut self, text: &'static str) {
75        if self.session.domain_frame_is_truncated() {
76            return;
77        }
78        self.write_fragment(text);
79    }
80
81    /// Writes explicitly trusted dynamic content without redaction.
82    ///
83    /// # Warning
84    ///
85    /// This method is an explicit trust-boundary bypass: it never consults
86    /// field policy, even when the active policy is strict. It is only for
87    /// content that the caller has independently established as safe to expose.
88    /// Never pass credentials, user-controlled diagnostic data, or a value
89    /// whose classification depends on runtime policy; use a redaction-aware
90    /// field method instead.
91    pub fn unredacted<T>(&mut self, value: &T) -> &mut Self
92    where
93        T: Debug + ?Sized,
94    {
95        if self.session.domain_frame_is_truncated() {
96            return self;
97        }
98        if self.session.is_inspection() {
99            return self;
100        }
101        self.write_debug(value);
102        self
103    }
104
105    /// Writes a field without applying redaction policy.
106    ///
107    /// # Warning
108    ///
109    /// This is the semantic alias used for an intentionally unmarked field and
110    /// has the same trust-boundary requirements as [`Self::unredacted`].
111    #[inline]
112    pub fn unmarked<T>(&mut self, value: &T) -> &mut Self
113    where
114        T: Debug + ?Sized,
115    {
116        self.unredacted(value)
117    }
118
119    /// Removes the trailing separator from the active domain frame.
120    pub(crate) fn trim_trailing_separator(&mut self) {
121        self.session.trim_domain_frame_separator();
122    }
123
124    /// Writes JSON text through the active transaction.
125    ///
126    /// This is intentionally private: structured redaction implementations
127    /// must never receive unpublished JSON text before `finish()` publishes
128    /// the surrounding transaction.
129    #[cfg(feature = "json")]
130    pub(super) fn write_json_text(&mut self, value: &str) {
131        if self.session.is_inspection() {
132            crate::formats::json::inspection::inspect_text(self.session, value);
133            return;
134        }
135        if !self.session.admit_input(value.len()) {
136            self.truncate_without_output_limit();
137            return;
138        }
139        // Structural admission happens before JSON redaction parses or walks
140        // the value. A domain writer therefore cannot create a private JSON
141        // traversal budget outside its parent transaction.
142        let admitted = match crate::formats::json::admit_json_text_value(self.session, value) {
143            Ok(value) => value,
144            Err(crate::formats::json::JsonAdmissionError::Invalid) => {
145                let allowance = self.session.remaining_output_bytes().min(self.remaining_output_bytes());
146                let output = crate::formats::json::invalid_json_output(self.session.policy(), allowance);
147                self.session.record_rendered_provenance(&output);
148                self.write_debug(output.text());
149                return;
150            }
151            Err(crate::formats::json::JsonAdmissionError::Limit) => {
152                self.truncate_without_output_limit();
153                return;
154            }
155        };
156        let allowance = self.session.remaining_output_bytes().min(self.remaining_output_bytes());
157        let output = crate::formats::json::redact_json_value_with_limit(self.session.policy(), &admitted, allowance);
158        if output.completion() != crate::RedactionCompletion::Complete {
159            self.truncate_without_output_limit();
160        }
161        self.session.record_rendered_provenance(&output);
162        self.write_debug(output.text());
163    }
164
165    /// Writes a borrowed parsed JSON value as an unquoted JSON fragment.
166    #[cfg(feature = "json")]
167    pub(super) fn write_json_value(&mut self, value: &serde_json::Value) {
168        if self.session.is_inspection() {
169            crate::formats::json::inspection::inspect_borrowed_value(self.session, value);
170            return;
171        }
172        if !self.session.admit_json_value(value) {
173            self.truncate_without_output_limit();
174            return;
175        }
176        let allowance = self.session.remaining_output_bytes().min(self.remaining_output_bytes());
177        let output = crate::formats::json::redact_json_value_with_limit(self.session.policy(), value, allowance);
178        if output.completion() != crate::RedactionCompletion::Complete {
179            self.truncate_without_output_limit();
180        }
181        self.session.record_rendered_provenance(&output);
182        self.write_fragment(output.text());
183    }
184
185    /// Writes a named record through a field scope.
186    pub fn record<F>(&mut self, name: &'static str, configure: F)
187    where
188        F: for<'writer> FnOnce(&mut RedactionFields<'writer, 'session>),
189    {
190        self.write_field_structure(name, " { ", " }", configure);
191    }
192
193    /// Writes a named tuple through a field scope.
194    pub fn tuple<F>(&mut self, name: &'static str, configure: F)
195    where
196        F: for<'writer> FnOnce(&mut RedactionFields<'writer, 'session>),
197    {
198        self.write_field_structure(name, "(", ")", configure);
199    }
200
201    /// Writes exactly one field without a nominal record or tuple wrapper.
202    ///
203    /// This is intended for transparent domain newtypes. The configured field
204    /// still passes through the ordinary classified field operations and the
205    /// same admission limits as a structured value.
206    pub fn transparent<F>(&mut self, configure: F)
207    where
208        F: for<'writer> FnOnce(&mut RedactionFields<'writer, 'session>),
209    {
210        let mut fields = RedactionFields {
211            writer: self,
212            named: false,
213        };
214        configure(&mut fields);
215        self.trim_trailing_separator();
216    }
217
218    /// Writes a bracketed sequence through an item scope.
219    pub fn sequence<F>(&mut self, configure: F)
220    where
221        F: for<'writer> FnOnce(&mut RedactionItems<'writer, 'session>),
222    {
223        self.write_item_structure("", "[", "]", configure);
224    }
225
226    /// Writes a braced map through an entry scope.
227    pub fn map<F>(&mut self, configure: F)
228    where
229        F: for<'writer> FnOnce(&mut RedactionEntries<'writer, 'session>),
230    {
231        self.write_entry_structure("", "{ ", " }", configure);
232    }
233
234    /// Writes a named enum variant through a field scope.
235    pub fn variant<F>(&mut self, enum_name: &'static str, variant_name: &'static str, configure: F)
236    where
237        F: for<'writer> FnOnce(&mut RedactionFields<'writer, 'session>),
238    {
239        self.write_fragment(enum_name);
240        self.write_fragment("::");
241        self.write_field_structure(variant_name, " { ", " }", configure);
242    }
243
244    /// Finishes the writer and reports whether its bounded frame omitted text.
245    #[must_use]
246    pub(crate) fn finish_with_completion(self) -> (String, bool, bool) {
247        self.session.finish_domain_frame()
248    }
249
250    /// Writes one bounded structured frame and accounts for its domain node
251    /// and output bytes.
252    fn write_field_structure<F>(
253        &mut self,
254        name: &'static str,
255        opening: &'static str,
256        closing: &'static str,
257        configure: F,
258    ) where
259        F: for<'writer> FnOnce(&mut RedactionFields<'writer, 'session>),
260    {
261        if !self.session.begin_domain_value() {
262            self.truncate_without_output_limit();
263            return;
264        }
265        self.write_fragment(name);
266        self.write_fragment(opening);
267        if self.can_write() {
268            let mut fields = RedactionFields {
269                writer: self,
270                named: opening == " { ",
271            };
272            configure(&mut fields);
273        }
274        if self.can_write() {
275            self.trim_trailing_separator();
276            self.write_fragment(closing);
277        }
278        self.session.leave_domain_value();
279    }
280
281    /// Writes one named sequence-like domain structure.
282    fn write_item_structure<F>(
283        &mut self,
284        name: &'static str,
285        opening: &'static str,
286        closing: &'static str,
287        configure: F,
288    ) where
289        F: for<'writer> FnOnce(&mut RedactionItems<'writer, 'session>),
290    {
291        if !self.session.begin_domain_value() {
292            self.truncate_without_output_limit();
293            return;
294        }
295        self.write_fragment(name);
296        self.write_fragment(opening);
297        if self.can_write() {
298            configure(&mut RedactionItems { writer: self });
299        }
300        if self.can_write() {
301            self.trim_trailing_separator();
302            self.write_fragment(closing);
303        }
304        self.session.leave_domain_value();
305    }
306
307    /// Writes one named map-like domain structure.
308    fn write_entry_structure<F>(
309        &mut self,
310        name: &'static str,
311        opening: &'static str,
312        closing: &'static str,
313        configure: F,
314    ) where
315        F: for<'writer> FnOnce(&mut RedactionEntries<'writer, 'session>),
316    {
317        if !self.session.begin_domain_value() {
318            self.truncate_without_output_limit();
319            return;
320        }
321        self.write_fragment(name);
322        self.write_fragment(opening);
323        if self.can_write() {
324            configure(&mut RedactionEntries { writer: self });
325        }
326        if self.can_write() {
327            self.trim_trailing_separator();
328            self.write_fragment(closing);
329        }
330        self.session.leave_domain_value();
331    }
332
333    /// Closes this writer after it has actually exceeded its output allowance.
334    pub(super) fn truncate_for_output_limit(&mut self) {
335        self.session.mark_domain_frame_output_limit_reached();
336        self.truncate_without_output_limit();
337    }
338
339    /// Closes this writer without inventing output-limit provenance.
340    ///
341    /// Structural and input admission failures already record their specific
342    /// cause in the shared session. If their fallback marker itself cannot
343    /// fit, [`Self::write_fragment`] records the additional output limit.
344    pub(super) fn truncate_without_output_limit(&mut self) {
345        self.session.truncate_domain_frame_without_output_limit();
346    }
347
348    /// Appends `text` only while its final log-escaped representation fits.
349    ///
350    /// Returning an error from the bounded `fmt::Write` adapter terminates a
351    /// caller's `Debug` implementation before it can format later chunks.
352    pub(super) fn write_fragment(&mut self, text: &str) -> bool {
353        self.session.write_domain_fragment(text)
354    }
355
356    /// Streams a debug representation into the bounded output session.
357    pub(crate) fn write_debug<T>(&mut self, value: &T)
358    where
359        T: Debug + ?Sized,
360    {
361        if self.session.is_inspection() {
362            return;
363        }
364        let mut formatter = BoundedDebugWriter { writer: self };
365        let _ = write!(&mut formatter, "{value:?}");
366    }
367
368    /// Writes an already-accessed dynamic value using the selected policy
369    /// level.
370    pub(super) fn write_masked_debug<T>(&mut self, level: Sensitivity, value: &T)
371    where
372        T: Debug + ?Sized,
373    {
374        if self.session.is_inspection() {
375            self.session.observe_sensitivity(level);
376            return;
377        }
378        if matches!(level, Sensitivity::High | Sensitivity::Secret) {
379            let masked = self
380                .session
381                .policy()
382                .masking()
383                .mask_opaque_bounded(level, self.remaining_output_bytes());
384            self.write_debug(&masked);
385            return;
386        }
387        let raw_limit = self.remaining_output_bytes();
388        let (raw, raw_truncated) = bounded_debug(value, raw_limit);
389        let (masked, mask_truncated) =
390            self.session
391                .policy()
392                .masking()
393                .mask_bounded_with_truncation(level, &raw, self.remaining_output_bytes());
394        self.write_debug(masked.as_ref());
395        if raw_truncated || mask_truncated {
396            self.truncate_for_output_limit();
397        }
398    }
399
400    /// Writes a scalar using the supplied sensitivity level.
401    pub(crate) fn write_level_scalar<T>(&mut self, level: Sensitivity, value: &T)
402    where
403        T: Debug + ?Sized,
404    {
405        if self.session.policy().is_disabled() {
406            self.write_debug(value);
407        } else {
408            self.write_masked_debug(level, value);
409        }
410    }
411
412    /// Writes a tuple whose items carry explicit sensitivities.
413    pub(crate) fn level_tuple<F>(&mut self, configure: F)
414    where
415        F: for<'writer> FnOnce(&mut RedactionItems<'writer, 'session>),
416    {
417        self.write_item_structure("", "(", ")", configure);
418    }
419
420    /// Returns whether the active frame can accept another fragment.
421    #[inline]
422    pub(super) fn can_write(&self) -> bool {
423        !self.session.domain_frame_is_truncated() && self.remaining_output_bytes() > 0
424    }
425
426    /// Returns bytes still available to the active domain frame.
427    #[inline]
428    pub(super) fn remaining_output_bytes(&self) -> usize {
429        self.session.remaining_domain_frame_output_bytes()
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::RedactionWriter;
436    use crate::Redact;
437    use crate::Redactor;
438
439    struct Nested;
440
441    impl Redact for Nested {
442        fn write_redacted(&self, writer: &mut RedactionWriter<'_>) {
443            writer.record("Nested", |fields| {
444                fields.unredacted("id", || 7_u8);
445            });
446        }
447    }
448
449    struct Container;
450
451    impl Redact for Container {
452        fn write_redacted(&self, writer: &mut RedactionWriter<'_>) {
453            writer.record("Container", |fields| {
454                fields.nested("nested", &Nested);
455            });
456        }
457    }
458
459    /// Nested values render through the borrowed writer and the active
460    /// transaction.
461    #[test]
462    fn nested_values_use_the_active_writer_transaction() {
463        let output = Redactor::standard().redact(&Container);
464
465        assert!(output.text().as_str().contains("Nested { id: 7 }"));
466        assert_eq!(output.summary().usage().output_bytes(), output.text().as_str().len());
467    }
468
469    #[cfg(feature = "json")]
470    struct JsonContainer;
471
472    #[cfg(feature = "json")]
473    impl Redact for JsonContainer {
474        fn write_redacted(&self, writer: &mut RedactionWriter<'_>) {
475            writer.record("JsonContainer", |fields| {
476                fields.json("payload", "{invalid json");
477            });
478        }
479    }
480
481    /// JSON emitted from a domain writer must use the active session for input
482    /// accounting and retain parser provenance in that transaction summary.
483    #[cfg(feature = "json")]
484    #[test]
485    fn writer_json_uses_the_active_session_summary() {
486        let output = Redactor::standard().text_composer().value(&JsonContainer).finish();
487
488        assert_eq!(output.summary().usage().presented_input_bytes(), "{invalid json".len());
489        assert!(output.summary().reasons().contains(crate::RedactionReason::InvalidJson));
490    }
491
492    /// A JSON value emitted by a domain writer must spend the same structural
493    /// budget as the enclosing domain transaction. The structural reason must
494    /// remain visible instead of being relabelled as output exhaustion.
495    #[cfg(feature = "json")]
496    #[test]
497    fn writer_json_uses_shared_structure_budget_and_preserves_its_reason() {
498        let policy = crate::RedactionPolicy::builder()
499            .limits(|limits| {
500                limits.max_depth(1);
501            })
502            .expect("the limit draft should build")
503            .build()
504            .expect("the policy should build");
505        let output = Redactor::new(policy)
506            .text_composer()
507            .value(&JsonContainerWithValidNestedValue)
508            .finish();
509
510        assert_eq!(output.summary().completion(), crate::RedactionCompletion::Truncated);
511        assert!(
512            output
513                .summary()
514                .reasons()
515                .contains(crate::RedactionReason::DepthLimitReached)
516        );
517        assert!(
518            !output
519                .summary()
520                .reasons()
521                .contains(crate::RedactionReason::OutputLimitReached)
522        );
523        assert!(output.text().as_str().contains("<truncated>"));
524    }
525
526    /// Individually resolved domain values must retain the structural reason
527    /// too; their per-item summary is derived from the same writer state.
528    #[cfg(feature = "json")]
529    #[test]
530    fn writer_json_handle_preserves_shared_structure_reason() {
531        let policy = crate::RedactionPolicy::builder()
532            .limits(|limits| {
533                limits.max_depth(1);
534            })
535            .expect("the limit draft should build")
536            .build()
537            .expect("the policy should build");
538        let mut batch = Redactor::new(policy).batch();
539        let handle = batch.redact_value(&JsonContainerWithValidNestedValue);
540        let output = batch.finish();
541        let item = output.resolve(handle).expect("the handle should resolve");
542
543        assert!(
544            item.summary()
545                .reasons()
546                .contains(crate::RedactionReason::DepthLimitReached)
547        );
548        assert!(
549            !item
550                .summary()
551                .reasons()
552                .contains(crate::RedactionReason::OutputLimitReached)
553        );
554    }
555
556    #[cfg(feature = "json")]
557    struct JsonContainerWithValidNestedValue;
558
559    #[cfg(feature = "json")]
560    impl Redact for JsonContainerWithValidNestedValue {
561        fn write_redacted(&self, writer: &mut RedactionWriter<'_>) {
562            writer.record("JsonContainer", |fields| {
563                fields.json("payload", r#"{"outer":{"inner":"value"}}"#);
564            });
565        }
566    }
567}