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