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/// # Type Parameters
27///
28/// - `'session`: Exclusive borrow of the transaction receiving this value.
29///
30/// # Examples
31///
32/// ```
33/// use qubit_redact::{Redact, RedactionWriter, Redactor, Sensitivity};
34///
35/// struct Credential(&'static str);
36///
37/// impl Redact for Credential {
38///     fn write_redacted(&self, writer: &mut RedactionWriter<'_>) {
39///         writer.record("Credential", |fields| {
40///             fields.sensitive_at_least(Sensitivity::Secret, "token", || self.0);
41///         });
42///     }
43/// }
44///
45/// let output = Redactor::standard().redact_text(&Credential("raw-token"));
46/// assert!(!output.text().as_str().contains("raw-token"));
47/// ```
48///
49/// ```compile_fail
50/// use qubit_redact::{Redact, RedactionWriter};
51///
52/// struct Value;
53/// impl Redact for Value {
54///     fn write_redacted(&self, writer: &mut RedactionWriter<'_>) {
55///         let _ = writer.redact_json_text("{\"token\":\"secret\"}");
56///     }
57/// }
58/// ```
59pub struct RedactionWriter<'session> {
60    /// Transaction session receiving classified output and accounting.
61    pub(super) session: &'session mut dyn RuntimeSession,
62}
63
64impl<'session> RedactionWriter<'session> {
65    /// Creates a writer backed by an existing diagnostic session.
66    ///
67    /// # Parameters
68    ///
69    /// - `session`: Existing transaction that owns the output frame and
70    ///   budgets.
71    ///
72    /// # Returns
73    ///
74    /// A writer borrowing that transaction.
75    #[must_use]
76    #[inline(always)]
77    pub(crate) fn new(session: &'session mut dyn RuntimeSession) -> Self {
78        Self { session }
79    }
80
81    /// Creates a writer that owns the root output admission for one value.
82    ///
83    /// # Parameters
84    ///
85    /// - `session`: Transaction whose root domain operation has been admitted.
86    ///
87    /// # Returns
88    ///
89    /// A writer sharing the transaction's root output allowance.
90    #[must_use]
91    #[inline(always)]
92    pub(crate) fn new_root(session: &'session mut dyn RuntimeSession) -> Self {
93        Self::new(session)
94    }
95
96    /// Writes a trusted static structural literal.
97    ///
98    /// # Parameters
99    ///
100    /// - `text`: Trusted static structure; omitted after the frame closes.
101    #[inline]
102    pub fn literal(&mut self, text: &'static str) {
103        if self.session.domain_frame_is_truncated() {
104            return;
105        }
106        self.write_fragment(text);
107    }
108
109    /// Writes explicitly trusted dynamic content without redaction.
110    ///
111    /// # Warning
112    ///
113    /// This method is an explicit trust-boundary bypass: it never consults
114    /// field policy, even when the active policy is strict. It is only for
115    /// content that the caller has independently established as safe to expose.
116    /// Never pass credentials, user-controlled diagnostic data, or a value
117    /// whose classification depends on runtime policy; use a redaction-aware
118    /// field method instead.
119    ///
120    /// # Type Parameters
121    ///
122    /// - `T`: Possibly unsized value rendered with `Debug`.
123    ///
124    /// # Parameters
125    ///
126    /// - `value`: Caller-verified safe value; formatting is skipped during
127    ///   inspection.
128    ///
129    /// # Returns
130    ///
131    /// This writer for subsequent writes.
132    pub fn unredacted<T>(&mut self, value: &T) -> &mut Self
133    where
134        T: Debug + ?Sized,
135    {
136        if self.session.domain_frame_is_truncated() {
137            return self;
138        }
139        if self.session.is_inspection() {
140            return self;
141        }
142        self.write_debug(value);
143        self
144    }
145
146    /// Writes a field without applying redaction policy.
147    ///
148    /// # Warning
149    ///
150    /// This is the semantic alias used for an intentionally unmarked field and
151    /// has the same trust-boundary requirements as [`Self::unredacted`].
152    ///
153    /// # Type Parameters
154    ///
155    /// - `T`: Possibly unsized value rendered with `Debug`.
156    ///
157    /// # Parameters
158    ///
159    /// - `value`: Caller-verified safe value with no policy classification.
160    ///
161    /// # Returns
162    ///
163    /// This writer for subsequent writes.
164    #[inline(always)]
165    pub fn unmarked<T>(&mut self, value: &T) -> &mut Self
166    where
167        T: Debug + ?Sized,
168    {
169        self.unredacted(value)
170    }
171
172    /// Writes a named record through a field scope.
173    ///
174    /// # Type Parameters
175    ///
176    /// - `F`: Callback accepting the scope for any temporary writer borrow.
177    ///
178    /// # Parameters
179    ///
180    /// - `name`: Trusted static type label; an empty label emits no name.
181    /// - `configure`: One-shot callback that writes through the borrowed scope.
182    #[inline(always)]
183    pub fn record<F>(&mut self, name: &'static str, configure: F)
184    where
185        F: for<'writer> FnOnce(&mut RedactionFields<'writer, 'session>),
186    {
187        self.write_field_structure(name, " { ", " }", configure);
188    }
189
190    /// Writes a named tuple through a field scope.
191    ///
192    /// # Type Parameters
193    ///
194    /// - `F`: Callback accepting the scope for any temporary writer borrow.
195    ///
196    /// # Parameters
197    ///
198    /// - `name`: Trusted static type label; an empty label emits no name.
199    /// - `configure`: One-shot callback that writes through the borrowed scope.
200    #[inline(always)]
201    pub fn tuple<F>(&mut self, name: &'static str, configure: F)
202    where
203        F: for<'writer> FnOnce(&mut RedactionFields<'writer, 'session>),
204    {
205        self.write_field_structure(name, "(", ")", configure);
206    }
207
208    /// Writes exactly one field without a nominal record or tuple wrapper.
209    ///
210    /// This is intended for transparent domain newtypes. The configured field
211    /// still passes through the ordinary classified field operations and the
212    /// same admission limits as a structured value.
213    ///
214    /// # Type Parameters
215    ///
216    /// - `F`: Callback accepting the scope for any temporary writer borrow.
217    ///
218    /// # Parameters
219    ///
220    /// - `configure`: One-shot callback that writes through the borrowed scope.
221    #[inline]
222    pub fn transparent<F>(&mut self, configure: F)
223    where
224        F: for<'writer> FnOnce(&mut RedactionFields<'writer, 'session>),
225    {
226        let mut fields = RedactionFields {
227            writer: self,
228            named: false,
229        };
230        configure(&mut fields);
231        self.trim_trailing_separator();
232    }
233
234    /// Writes a bracketed sequence through an item scope.
235    ///
236    /// # Type Parameters
237    ///
238    /// - `F`: Callback accepting the scope for any temporary writer borrow.
239    ///
240    /// # Parameters
241    ///
242    /// - `configure`: One-shot callback that writes through the borrowed scope.
243    #[inline(always)]
244    pub fn sequence<F>(&mut self, configure: F)
245    where
246        F: for<'writer> FnOnce(&mut RedactionItems<'writer, 'session>),
247    {
248        self.write_item_structure("", "[", "]", configure);
249    }
250
251    /// Writes a braced map through an entry scope.
252    ///
253    /// # Type Parameters
254    ///
255    /// - `F`: Callback accepting the scope for any temporary writer borrow.
256    ///
257    /// # Parameters
258    ///
259    /// - `configure`: One-shot callback that writes through the borrowed scope.
260    #[inline(always)]
261    pub fn map<F>(&mut self, configure: F)
262    where
263        F: for<'writer> FnOnce(&mut RedactionEntries<'writer, 'session>),
264    {
265        self.write_entry_structure("", "{ ", " }", configure);
266    }
267
268    /// Writes a named enum variant through a field scope.
269    ///
270    /// # Type Parameters
271    ///
272    /// - `F`: Callback accepting the scope for any temporary writer borrow.
273    ///
274    /// # Parameters
275    ///
276    /// - `enum_name`: Trusted static enum label.
277    /// - `variant_name`: Trusted static variant label.
278    /// - `configure`: One-shot callback that writes through the borrowed scope.
279    #[inline]
280    pub fn variant<F>(&mut self, enum_name: &'static str, variant_name: &'static str, configure: F)
281    where
282        F: for<'writer> FnOnce(&mut RedactionFields<'writer, 'session>),
283    {
284        self.write_fragment(enum_name);
285        self.write_fragment("::");
286        self.write_field_structure(variant_name, " { ", " }", configure);
287    }
288
289    /// Returns whether the active frame can accept another fragment.
290    ///
291    /// # Returns
292    ///
293    /// Whether the frame remains open and has at least one output byte
294    /// available.
295    #[must_use]
296    #[inline]
297    pub(super) fn can_write(&self) -> bool {
298        !self.session.domain_frame_is_truncated() && self.remaining_output_bytes() > 0
299    }
300
301    /// Returns bytes still available to the active domain frame.
302    ///
303    /// # Returns
304    ///
305    /// The remaining escaped-output byte allowance for the current frame.
306    #[must_use]
307    #[inline(always)]
308    pub(super) fn remaining_output_bytes(&self) -> usize {
309        self.session.remaining_domain_frame_output_bytes()
310    }
311
312    /// Removes the trailing separator from the active domain frame.
313    #[inline(always)]
314    pub(crate) fn trim_trailing_separator(&mut self) {
315        self.session.trim_domain_frame_separator();
316    }
317
318    /// Writes JSON text through the active transaction.
319    ///
320    /// This is intentionally private: structured redaction implementations
321    /// must never receive unpublished JSON text before `finish()` publishes
322    /// the surrounding transaction.
323    ///
324    /// # Parameters
325    ///
326    /// - `value`: Raw JSON text charged to this transaction before parsing.
327    #[cfg(feature = "json")]
328    pub(super) fn write_json_text(&mut self, value: &str) {
329        if self.session.is_inspection() {
330            crate::formats::json::inspection::inspect_text(self.session, value);
331            return;
332        }
333        if !self.session.admit_input(value.len()) {
334            self.truncate_without_output_limit();
335            return;
336        }
337        // Structural admission happens before JSON redaction parses or walks
338        // the value. A domain writer therefore cannot create a private JSON
339        // traversal budget outside its parent transaction.
340        let admitted = match crate::formats::json::admit_json_text_value(self.session, value) {
341            Ok(value) => value,
342            Err(crate::formats::json::JsonAdmissionError::Invalid) => {
343                let allowance = self.session.remaining_output_bytes().min(self.remaining_output_bytes());
344                let output = crate::formats::json::invalid_json_output(self.session.policy(), allowance);
345                self.session.record_rendered_provenance(&output);
346                self.write_debug(output.text());
347                return;
348            }
349            Err(crate::formats::json::JsonAdmissionError::Limit) => {
350                self.truncate_without_output_limit();
351                return;
352            }
353        };
354        let allowance = self.session.remaining_output_bytes().min(self.remaining_output_bytes());
355        let output = crate::formats::json::redact_json_value_with_limit(self.session.policy(), &admitted, allowance);
356        if output.completion() != crate::RedactionCompletion::Complete {
357            self.truncate_without_output_limit();
358        }
359        self.session.record_rendered_provenance(&output);
360        self.write_debug(output.text());
361    }
362
363    /// Writes a borrowed parsed JSON value as an unquoted JSON fragment.
364    ///
365    /// # Parameters
366    ///
367    /// - `value`: Parsed JSON whose payload and structure share the active
368    ///   budget.
369    #[cfg(feature = "json")]
370    pub(super) fn write_json_value(&mut self, value: &serde_json::Value) {
371        if self.session.is_inspection() {
372            crate::formats::json::inspection::inspect_borrowed_value(self.session, value);
373            return;
374        }
375        if !self.session.admit_json_value(value) {
376            self.truncate_without_output_limit();
377            return;
378        }
379        let allowance = self.session.remaining_output_bytes().min(self.remaining_output_bytes());
380        let output = crate::formats::json::redact_json_value_with_limit(self.session.policy(), value, allowance);
381        if output.completion() != crate::RedactionCompletion::Complete {
382            self.truncate_without_output_limit();
383        }
384        self.session.record_rendered_provenance(&output);
385        self.write_fragment(output.text());
386    }
387
388    /// Finishes the writer and reports whether its bounded frame omitted text.
389    ///
390    /// # Returns
391    ///
392    /// The owned frame text, whether any frame content was omitted, and whether
393    /// the frame exceeded its output allowance. Finishing resets the local
394    /// frame.
395    #[must_use]
396    #[inline(always)]
397    pub(crate) fn finish_with_completion(self) -> (String, bool, bool) {
398        self.session.finish_domain_frame()
399    }
400
401    /// Closes this writer after it has actually exceeded its output allowance.
402    #[inline]
403    pub(super) fn truncate_for_output_limit(&mut self) {
404        self.session.mark_domain_frame_output_limit_reached();
405        self.truncate_without_output_limit();
406    }
407
408    /// Closes this writer without inventing output-limit provenance.
409    ///
410    /// Structural and input admission failures already record their specific
411    /// cause in the shared session. If their fallback marker itself cannot
412    /// fit, [`Self::write_fragment`] records the additional output limit.
413    #[inline(always)]
414    pub(super) fn truncate_without_output_limit(&mut self) {
415        self.session.truncate_domain_frame_without_output_limit();
416    }
417
418    /// Appends `text` only while its final log-escaped representation fits.
419    ///
420    /// The bounded `fmt::Write` adapter translates rejection into an error,
421    /// allowing a caller's `Debug` implementation to stop formatting.
422    ///
423    /// # Parameters
424    ///
425    /// - `text`: Fragment to append under the active escaped-output allowance.
426    ///
427    /// # Returns
428    ///
429    /// `true` if the complete fragment was accepted; `false` if the frame is
430    /// closed or the fragment cannot fit. Rejection records truncation.
431    #[inline(always)]
432    pub(super) fn write_fragment(&mut self, text: &str) -> bool {
433        self.session.write_domain_fragment(text)
434    }
435
436    /// Streams a debug representation into the bounded output session.
437    ///
438    /// # Type Parameters
439    ///
440    /// - `T`: Possibly unsized value rendered with `Debug`.
441    ///
442    /// # Parameters
443    ///
444    /// - `value`: Borrowed value; formatting is skipped during inspection.
445    #[inline]
446    pub(crate) fn write_debug<T>(&mut self, value: &T)
447    where
448        T: Debug + ?Sized,
449    {
450        if self.session.is_inspection() {
451            return;
452        }
453        let mut formatter = BoundedDebugWriter { writer: self };
454        let _ = write!(&mut formatter, "{value:?}");
455    }
456
457    /// Writes an already-accessed dynamic value using the selected policy
458    /// level.
459    ///
460    /// # Type Parameters
461    ///
462    /// - `T`: Possibly unsized value rendered with `Debug`.
463    ///
464    /// # Parameters
465    ///
466    /// - `level`: Sensitivity selected by the caller.
467    /// - `value`: Borrowed value; formatting is skipped during inspection.
468    pub(super) fn write_masked_debug<T>(&mut self, level: Sensitivity, value: &T)
469    where
470        T: Debug + ?Sized,
471    {
472        if self.session.is_inspection() {
473            self.session.observe_sensitivity(level);
474            return;
475        }
476        if matches!(level, Sensitivity::High | Sensitivity::Secret) {
477            let masked = self
478                .session
479                .policy()
480                .masking()
481                .mask_opaque_bounded(level, self.remaining_output_bytes());
482            self.write_debug(&masked);
483            return;
484        }
485        let raw_limit = self.remaining_output_bytes();
486        let (raw, raw_truncated) = bounded_debug(value, raw_limit);
487        let (masked, mask_truncated) =
488            self.session
489                .policy()
490                .masking()
491                .mask_bounded_with_truncation(level, &raw, self.remaining_output_bytes());
492        self.write_debug(masked.as_ref());
493        if raw_truncated || mask_truncated {
494            self.truncate_for_output_limit();
495        }
496    }
497
498    /// Writes a scalar using the supplied sensitivity level.
499    ///
500    /// # Type Parameters
501    ///
502    /// - `T`: Possibly unsized value rendered with `Debug`.
503    ///
504    /// # Parameters
505    ///
506    /// - `level`: Sensitivity selected by the caller.
507    /// - `value`: Borrowed value; formatting is skipped during inspection.
508    #[inline]
509    pub(crate) fn write_level_scalar<T>(&mut self, level: Sensitivity, value: &T)
510    where
511        T: Debug + ?Sized,
512    {
513        if self.session.policy().is_disabled() {
514            self.write_debug(value);
515        } else {
516            self.write_masked_debug(level, value);
517        }
518    }
519
520    /// Writes a tuple whose items carry explicit sensitivities.
521    ///
522    /// # Type Parameters
523    ///
524    /// - `F`: Callback accepting the scope for any temporary writer borrow.
525    ///
526    /// # Parameters
527    ///
528    /// - `configure`: One-shot callback that writes through the borrowed scope.
529    #[inline(always)]
530    pub(crate) fn level_tuple<F>(&mut self, configure: F)
531    where
532        F: for<'writer> FnOnce(&mut RedactionItems<'writer, 'session>),
533    {
534        self.write_item_structure("", "(", ")", configure);
535    }
536
537    /// Writes one bounded structured frame and accounts for its domain node
538    /// and output bytes.
539    ///
540    /// # Type Parameters
541    ///
542    /// - `F`: Callback accepting the scope for any temporary writer borrow.
543    ///
544    /// # Parameters
545    ///
546    /// - `name`: Trusted static type label; an empty label emits no name.
547    /// - `opening`: Trusted opening punctuation.
548    /// - `closing`: Trusted closing punctuation, emitted only while the frame
549    ///   is open.
550    /// - `configure`: One-shot callback that writes through the borrowed scope.
551    fn write_field_structure<F>(
552        &mut self,
553        name: &'static str,
554        opening: &'static str,
555        closing: &'static str,
556        configure: F,
557    ) where
558        F: for<'writer> FnOnce(&mut RedactionFields<'writer, 'session>),
559    {
560        if !self.session.begin_domain_value() {
561            self.truncate_without_output_limit();
562            return;
563        }
564        self.write_fragment(name);
565        self.write_fragment(opening);
566        if self.can_write() {
567            let mut fields = RedactionFields {
568                writer: self,
569                named: opening == " { ",
570            };
571            configure(&mut fields);
572        }
573        if self.can_write() {
574            self.trim_trailing_separator();
575            self.write_fragment(closing);
576        }
577        self.session.leave_domain_value();
578    }
579
580    /// Writes one named sequence-like domain structure.
581    ///
582    /// # Type Parameters
583    ///
584    /// - `F`: Callback accepting the scope for any temporary writer borrow.
585    ///
586    /// # Parameters
587    ///
588    /// - `name`: Trusted static type label; an empty label emits no name.
589    /// - `opening`: Trusted opening punctuation.
590    /// - `closing`: Trusted closing punctuation, emitted only while the frame
591    ///   is open.
592    /// - `configure`: One-shot callback that writes through the borrowed scope.
593    fn write_item_structure<F>(
594        &mut self,
595        name: &'static str,
596        opening: &'static str,
597        closing: &'static str,
598        configure: F,
599    ) where
600        F: for<'writer> FnOnce(&mut RedactionItems<'writer, 'session>),
601    {
602        if !self.session.begin_domain_value() {
603            self.truncate_without_output_limit();
604            return;
605        }
606        self.write_fragment(name);
607        self.write_fragment(opening);
608        if self.can_write() {
609            configure(&mut RedactionItems {
610                writer: self,
611                admitted_item: false,
612            });
613        }
614        if self.can_write() {
615            self.trim_trailing_separator();
616            self.write_fragment(closing);
617        }
618        self.session.leave_domain_value();
619    }
620
621    /// Writes one named map-like domain structure.
622    ///
623    /// # Type Parameters
624    ///
625    /// - `F`: Callback accepting the scope for any temporary writer borrow.
626    ///
627    /// # Parameters
628    ///
629    /// - `name`: Trusted static type label; an empty label emits no name.
630    /// - `opening`: Trusted opening punctuation.
631    /// - `closing`: Trusted closing punctuation, emitted only while the frame
632    ///   is open.
633    /// - `configure`: One-shot callback that writes through the borrowed scope.
634    fn write_entry_structure<F>(
635        &mut self,
636        name: &'static str,
637        opening: &'static str,
638        closing: &'static str,
639        configure: F,
640    ) where
641        F: for<'writer> FnOnce(&mut RedactionEntries<'writer, 'session>),
642    {
643        if !self.session.begin_domain_value() {
644            self.truncate_without_output_limit();
645            return;
646        }
647        self.write_fragment(name);
648        self.write_fragment(opening);
649        if self.can_write() {
650            configure(&mut RedactionEntries {
651                writer: self,
652                admitted_entry: false,
653            });
654        }
655        if self.can_write() {
656            self.trim_trailing_separator();
657            self.write_fragment(closing);
658        }
659        self.session.leave_domain_value();
660    }
661}