qubit_redact/env/env_redactor.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//! Environment-variable pair and assignment redaction.
9
10use std::{
11 borrow::Cow,
12 ffi::OsStr,
13 fmt::Write as _,
14};
15
16use crate::{
17 DiagnosticInputBudget,
18 LogOutputLimit,
19 LogSafeText,
20 RedactedText,
21 Redactor,
22 Sensitivity,
23 text::internal::BoundedLogEscapeWriter,
24};
25
26use super::RedactedEnvPair;
27
28/// Applies one immutable redaction policy to environment-variable values.
29#[must_use = "use the redactor to produce safe environment diagnostics"]
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct EnvRedactor {
32 /// Core redactor supplying field classification and masking policies.
33 redactor: Redactor,
34}
35
36impl EnvRedactor {
37 /// Creates an environment redactor from a core redactor.
38 ///
39 /// # Parameters
40 ///
41 /// * `redactor` - Core redactor whose immutable policy will be used.
42 ///
43 /// # Returns
44 ///
45 /// An environment redactor owning the supplied policy snapshot.
46 #[inline(always)]
47 pub const fn new(redactor: Redactor) -> Self {
48 Self { redactor }
49 }
50
51 /// Returns the core redactor backing this adapter.
52 ///
53 /// # Returns
54 ///
55 /// A borrowed view of the core redactor.
56 #[inline(always)]
57 pub const fn redactor(&self) -> &Redactor {
58 &self.redactor
59 }
60
61 /// Redacts one UTF-8 environment-variable pair.
62 ///
63 /// Both components are escaped before they can be displayed. The value is
64 /// classified from `name` using the adapter's immutable policy.
65 ///
66 /// # Parameters
67 ///
68 /// * `name` - Environment-variable name used for classification.
69 /// * `value` - Environment-variable value to redact when sensitive.
70 ///
71 /// # Returns
72 ///
73 /// A log-safe pair rendered as `NAME=VALUE`.
74 #[inline]
75 pub fn redact_pair(&self, name: &str, value: &str) -> RedactedEnvPair {
76 let value = self.redactor.redact(name, value).into_owned();
77 let name = log_safe_owned(name.to_owned());
78 RedactedEnvPair::new(name, log_safe_owned(value))
79 }
80
81 /// Redacts one environment pair whose components may not be UTF-8.
82 ///
83 /// If either component is invalid UTF-8, the original value is never
84 /// rendered or supplied to an edge-preserving mask. Instead, the secret
85 /// opaque replacement is used. A non-UTF-8 name is
86 /// rendered lossily and escaped for diagnostics.
87 ///
88 /// # Parameters
89 ///
90 /// * `name` - Operating-system environment-variable name.
91 /// * `value` - Operating-system environment-variable value.
92 ///
93 /// # Returns
94 ///
95 /// A fail-closed, log-safe pair rendered as `NAME=VALUE`.
96 pub fn redact_os_pair(
97 &self,
98 name: &OsStr,
99 value: &OsStr,
100 ) -> RedactedEnvPair {
101 match (name.to_str(), value.to_str()) {
102 (Some(name), Some(value)) => self.redact_pair(name, value),
103 _ => {
104 let name = log_safe_owned(name.to_string_lossy().into_owned());
105 let value = self.mask_opaque_value();
106 RedactedEnvPair::new(name, log_safe_owned(value))
107 }
108 }
109 }
110
111 /// Redacts environment pairs into one bounded log-safe list.
112 ///
113 /// The adapter stops before inspecting a pair that would exceed the
114 /// policy's diagnostic input budget. It also stops once the escaped list
115 /// reaches the diagnostic output budget.
116 ///
117 /// # Type Parameters
118 ///
119 /// * `'a` - Lifetime of environment names and values yielded by the
120 /// iterator.
121 /// * `I` - Iterator source yielding borrowed environment pairs.
122 ///
123 /// # Parameters
124 ///
125 /// * `pairs` - Operating-system environment names and values to redact.
126 ///
127 /// # Returns
128 ///
129 /// A debug-style log-safe list of redacted assignments.
130 pub fn redact_os_pairs<'a, I>(&self, pairs: I) -> LogSafeText<'static>
131 where
132 I: IntoIterator<Item = (&'a OsStr, &'a OsStr)>,
133 {
134 let mut input_budget =
135 self.redactor.policy().diagnostic_budget().input_budget();
136 self.redact_os_pairs_with_input_budget(pairs, &mut input_budget)
137 }
138
139 /// Redacts environment pairs using shared source-byte accounting.
140 ///
141 /// # Type Parameters
142 ///
143 /// * `'a` - Lifetime of environment names and values yielded by the
144 /// iterator.
145 /// * `I` - Iterator source yielding borrowed environment pairs.
146 ///
147 /// # Parameters
148 ///
149 /// * `pairs` - Operating-system environment names and values to redact.
150 /// * `input_budget` - Shared source-byte accounting for this diagnostic.
151 ///
152 /// # Returns
153 ///
154 /// A debug-style log-safe list, ending with `<truncated>` when the next
155 /// pair cannot be inspected within the shared budget.
156 pub fn redact_os_pairs_with_input_budget<'a, I>(
157 &self,
158 pairs: I,
159 input_budget: &mut DiagnosticInputBudget,
160 ) -> LogSafeText<'static>
161 where
162 I: IntoIterator<Item = (&'a OsStr, &'a OsStr)>,
163 {
164 let budget = self.redactor.policy().diagnostic_budget();
165 let limit = LogOutputLimit::from(budget);
166 let mut writer = BoundedLogEscapeWriter::new(limit);
167 let _ = writer.write_str("[");
168 let mut has_item = false;
169
170 for (name, value) in pairs {
171 if writer.is_truncated() {
172 break;
173 }
174 let pair_bytes = name
175 .as_encoded_bytes()
176 .len()
177 .saturating_add(value.as_encoded_bytes().len());
178 if !input_budget.reserve(pair_bytes) {
179 write_debug_item(&mut writer, &mut has_item, "<truncated>");
180 break;
181 }
182 let pair = self.redact_os_pair_bounded(
183 name,
184 value,
185 budget.max_output_bytes(),
186 );
187 write_debug_item(&mut writer, &mut has_item, &pair);
188 }
189 if !writer.is_truncated() {
190 let _ = writer.write_str("]");
191 }
192 LogSafeText::from_escaped(Cow::Owned(writer.finish()))
193 }
194
195 /// Redacts one UTF-8 `NAME=value` assignment.
196 ///
197 /// Input without `=` is treated as a name with an empty value and therefore
198 /// renders as `NAME=`.
199 ///
200 /// # Parameters
201 ///
202 /// * `assignment` - Assignment text to split at its first equals sign.
203 ///
204 /// # Returns
205 ///
206 /// A log-safe pair rendered as `NAME=VALUE`.
207 #[inline]
208 pub fn redact_assignment(&self, assignment: &str) -> RedactedEnvPair {
209 let (name, value) =
210 assignment.split_once('=').unwrap_or((assignment, ""));
211 self.redact_pair(name, value)
212 }
213
214 /// Produces the configured secret replacement without reading opaque bytes.
215 ///
216 /// # Returns
217 ///
218 /// The secret-level opaque replacement.
219 #[inline(always)]
220 fn mask_opaque_value(&self) -> String {
221 self.redactor
222 .policy()
223 .masking()
224 .mask_opaque(Sensitivity::Secret)
225 .to_owned()
226 }
227
228 /// Renders one environment pair while bounding any materialized mask.
229 ///
230 /// # Parameters
231 ///
232 /// * `name` - Environment-variable name used for classification.
233 /// * `value` - Environment-variable value to redact when sensitive.
234 /// * `max_mask_bytes` - Maximum bytes materialized for one mask.
235 ///
236 /// # Returns
237 ///
238 /// A log-safe assignment whose mask allocation fits `max_mask_bytes`.
239 fn redact_os_pair_bounded(
240 &self,
241 name: &OsStr,
242 value: &OsStr,
243 max_mask_bytes: usize,
244 ) -> String {
245 let pair = match (name.to_str(), value.to_str()) {
246 (Some(name), Some(value)) => {
247 let value = match self.redactor.policy().sensitivity_for(name) {
248 Some(level) => self
249 .redactor
250 .policy()
251 .masking()
252 .mask_bounded(level, value, max_mask_bytes)
253 .into_owned(),
254 None => value.to_owned(),
255 };
256 RedactedEnvPair::new(
257 log_safe_owned(name.to_owned()),
258 log_safe_owned(value),
259 )
260 }
261 _ => RedactedEnvPair::new(
262 log_safe_owned(name.to_string_lossy().into_owned()),
263 log_safe_owned(
264 self.redactor.policy().masking().mask_opaque_bounded(
265 Sensitivity::Secret,
266 max_mask_bytes,
267 ),
268 ),
269 ),
270 };
271 pair.to_string()
272 }
273}
274
275impl Default for EnvRedactor {
276 /// Creates an environment redactor from the current default policy
277 /// snapshot.
278 ///
279 /// # Returns
280 ///
281 /// An environment redactor backed by [`Redactor::default`].
282 fn default() -> Self {
283 Self::new(Redactor::default())
284 }
285}
286
287/// Escapes an owned string and labels it safe for text-log display.
288///
289/// # Parameters
290///
291/// * `value` - Owned text to escape.
292///
293/// # Returns
294///
295/// An owned typed log-safe value.
296#[inline(always)]
297fn log_safe_owned(value: String) -> LogSafeText<'static> {
298 RedactedText::new(Cow::Owned(value)).escape_for_log()
299}
300
301/// Appends one redacted assignment to a bounded debug-style list.
302///
303/// # Parameters
304///
305/// * `writer` - Escaped bounded output destination.
306/// * `has_item` - Whether a preceding list item has already been rendered.
307/// * `item` - Redacted assignment safe to format.
308fn write_debug_item(
309 writer: &mut BoundedLogEscapeWriter,
310 has_item: &mut bool,
311 item: &str,
312) {
313 if *has_item {
314 let _ = writer.write_str(", ");
315 }
316 let _ = write!(writer, "{item:?}");
317 *has_item = true;
318}