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}