Skip to main content

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}