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::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}