Skip to main content

qubit_redact/argv/
argv_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//! Explicit and heuristic argument-vector redaction.
9
10use std::ffi::OsStr;
11
12use crate::{
13    DiagnosticInputBudget,
14    Redactor,
15    Sensitivity,
16};
17
18use super::{
19    ArgvItem,
20    RedactedArgv,
21    redacted_argv_builder::TRUNCATED_ITEM,
22};
23
24/// Applies one immutable redaction policy to argument vectors.
25#[must_use = "use the redactor to produce a safe argv rendering"]
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ArgvRedactor {
28    /// Core redactor supplying field classification and masking policies.
29    redactor: Redactor,
30}
31
32impl ArgvRedactor {
33    /// Creates an argv redactor from a core redactor.
34    ///
35    /// # Parameters
36    ///
37    /// * `redactor` - Core redactor whose immutable policy will be used.
38    ///
39    /// # Returns
40    ///
41    /// An argv redactor owning the supplied policy snapshot.
42    #[inline(always)]
43    pub const fn new(redactor: Redactor) -> Self {
44        Self { redactor }
45    }
46
47    /// Returns the core redactor backing this adapter.
48    ///
49    /// # Returns
50    ///
51    /// A borrowed view of the core redactor.
52    #[inline(always)]
53    pub const fn redactor(&self) -> &Redactor {
54        &self.redactor
55    }
56
57    /// Redacts only values explicitly marked sensitive by their caller.
58    ///
59    /// Plain items are rendered as ordinary argv values without guessing
60    /// whether they are options, assignments, or option values. Non-UTF-8
61    /// sensitive items are masked from an opaque sentinel so their original
62    /// bytes can never reach output.
63    ///
64    /// # Type Parameters
65    ///
66    /// * `'a` - Lifetime of argument values borrowed by the iterator.
67    /// * `I` - Iterator source yielding borrowed [`ArgvItem`] values.
68    ///
69    /// # Parameters
70    ///
71    /// * `items` - Borrowed argv items with optional authoritative levels.
72    ///
73    /// # Returns
74    ///
75    /// A log-safe rendering in input order.
76    pub fn redact_items<'a, I>(&self, items: I) -> RedactedArgv
77    where
78        I: IntoIterator<Item = ArgvItem<'a>>,
79    {
80        let mut input_budget =
81            self.redactor.policy().diagnostic_budget().input_budget();
82        self.redact_items_with_input_budget(items, &mut input_budget)
83    }
84
85    /// Redacts explicitly classified values using shared input accounting.
86    ///
87    /// The caller owns `input_budget` and may pass it to later diagnostic
88    /// segments, ensuring the combined rendering never inspects more source
89    /// bytes than the configured policy permits.
90    ///
91    /// # Type Parameters
92    ///
93    /// * `'a` - Lifetime of argument values borrowed by the iterator.
94    /// * `I` - Iterator source yielding borrowed [`ArgvItem`] values.
95    ///
96    /// # Parameters
97    ///
98    /// * `items` - Borrowed argv items with optional authoritative levels.
99    /// * `input_budget` - Shared source-byte accounting for this diagnostic.
100    ///
101    /// # Returns
102    ///
103    /// A log-safe rendering in input order, ending with `<truncated>` when the
104    /// next item cannot be inspected within the shared budget.
105    pub fn redact_items_with_input_budget<'a, I>(
106        &self,
107        items: I,
108        input_budget: &mut DiagnosticInputBudget,
109    ) -> RedactedArgv
110    where
111        I: IntoIterator<Item = ArgvItem<'a>>,
112    {
113        let mut rendered =
114            RedactedArgv::builder(self.redactor.policy().diagnostic_budget());
115        for item in items {
116            if !input_budget.reserve(item.value().as_encoded_bytes().len()) {
117                let _ = rendered.push(TRUNCATED_ITEM);
118                break;
119            }
120            if !rendered.push(&self.render_explicit_or_plain(item)) {
121                break;
122            }
123        }
124        rendered.finish()
125    }
126
127    /// Redacts explicit sensitive values and heuristically classified plain
128    /// values.
129    ///
130    /// Explicit sensitivity always wins. Plain items recognize
131    /// `--name value`, `--name=value`, `-name value`, `NAME=value`, and
132    /// JVM-style `-Dname=value` properties. Compact options such as
133    /// `-pSECRET` and shell payload syntax are not inferred. Callers must mark
134    /// those values explicitly when they are sensitive. Because this is a
135    /// safety heuristic rather than a command-specific parser, an option
136    /// delimiter does not disable recognition in later wrapper or child-command
137    /// segments. A non-UTF-8 plain item is masked at [`Sensitivity::Secret`]
138    /// because it cannot be classified safely.
139    ///
140    /// # Type Parameters
141    ///
142    /// * `'a` - Lifetime of argument values borrowed by the iterator.
143    /// * `I` - Iterator source yielding borrowed [`ArgvItem`] values.
144    ///
145    /// # Parameters
146    ///
147    /// * `items` - Borrowed argv items with optional authoritative levels.
148    ///
149    /// # Returns
150    ///
151    /// A log-safe rendering in input order.
152    pub fn redact_heuristically<'a, I>(&self, items: I) -> RedactedArgv
153    where
154        I: IntoIterator<Item = ArgvItem<'a>>,
155    {
156        let mut input_budget =
157            self.redactor.policy().diagnostic_budget().input_budget();
158        self.redact_heuristically_with_input_budget(items, &mut input_budget)
159    }
160
161    /// Redacts explicit and heuristic values using shared input accounting.
162    ///
163    /// The caller owns `input_budget` and may pass it to later diagnostic
164    /// segments, ensuring the combined rendering never inspects more source
165    /// bytes than the configured policy permits.
166    ///
167    /// # Type Parameters
168    ///
169    /// * `'a` - Lifetime of argument values borrowed by the iterator.
170    /// * `I` - Iterator source yielding borrowed [`ArgvItem`] values.
171    ///
172    /// # Parameters
173    ///
174    /// * `items` - Borrowed argv items with optional authoritative levels.
175    /// * `input_budget` - Shared source-byte accounting for this diagnostic.
176    ///
177    /// # Returns
178    ///
179    /// A log-safe rendering in input order, ending with `<truncated>` when the
180    /// next item cannot be inspected within the shared budget.
181    pub fn redact_heuristically_with_input_budget<'a, I>(
182        &self,
183        items: I,
184        input_budget: &mut DiagnosticInputBudget,
185    ) -> RedactedArgv
186    where
187        I: IntoIterator<Item = ArgvItem<'a>>,
188    {
189        let mut rendered =
190            RedactedArgv::builder(self.redactor.policy().diagnostic_budget());
191        let mut pending_sensitivity = None;
192
193        for item in items {
194            if !input_budget.reserve(item.value().as_encoded_bytes().len()) {
195                let _ = rendered.push(TRUNCATED_ITEM);
196                break;
197            }
198            if let Some(level) = item.sensitivity() {
199                pending_sensitivity = None;
200                if !rendered.push(&self.mask_os_value(item.value(), level)) {
201                    break;
202                }
203                continue;
204            }
205            if !rendered.push(
206                &self.redact_plain_item(item.value(), &mut pending_sensitivity),
207            ) {
208                break;
209            }
210        }
211        rendered.finish()
212    }
213
214    /// Renders an item according to explicit sensitivity without heuristics.
215    ///
216    /// # Parameters
217    ///
218    /// * `item` - Item whose explicit metadata is authoritative.
219    ///
220    /// # Returns
221    ///
222    /// The masked or plain owned rendering.
223    #[inline]
224    fn render_explicit_or_plain(&self, item: ArgvItem<'_>) -> String {
225        match item.sensitivity() {
226            Some(level) => self.mask_os_value(item.value(), level),
227            None => item.value().to_string_lossy().into_owned(),
228        }
229    }
230
231    /// Masks an operating-system value without exposing invalid UTF-8 bytes.
232    ///
233    /// # Parameters
234    ///
235    /// * `value` - Operating-system value to mask.
236    /// * `level` - Explicit masking level for valid UTF-8 input.
237    ///
238    /// # Returns
239    ///
240    /// The configured mask, using the secret opaque replacement when `value`
241    /// is not valid UTF-8.
242    #[inline]
243    fn mask_os_value(&self, value: &OsStr, level: Sensitivity) -> String {
244        match value.to_str() {
245            Some(value) => self
246                .redactor
247                .policy()
248                .masking()
249                .mask_bounded(level, value, self.mask_output_limit())
250                .into_owned(),
251            None => self.mask_opaque_value(),
252        }
253    }
254
255    /// Redacts one plain item while updating pending-value state.
256    ///
257    /// # Parameters
258    ///
259    /// * `value` - Plain operating-system argument to inspect.
260    /// * `pending_sensitivity` - Level expected for the next separate value.
261    ///
262    /// # Returns
263    ///
264    /// The redacted owned rendering of `value`.
265    fn redact_plain_item(
266        &self,
267        value: &OsStr,
268        pending_sensitivity: &mut Option<Sensitivity>,
269    ) -> String {
270        let Some(value) = value.to_str() else {
271            let encoded = value.as_encoded_bytes();
272            let may_take_separate_value =
273                encoded.starts_with(b"-") && !encoded.contains(&b'=');
274            *pending_sensitivity =
275                may_take_separate_value.then_some(Sensitivity::Secret);
276            return self.mask_opaque_value();
277        };
278
279        let option_sensitivity = self.option_sensitivity(value);
280        if let Some(pending) = pending_sensitivity.take() {
281            if let Some(level) = option_sensitivity {
282                *pending_sensitivity = Some(level);
283            }
284            return self.mask_utf8_value(value, pending);
285        }
286        if let Some(value) = self.redact_assignment(value) {
287            return value;
288        }
289        if let Some(value) = self.redact_inline_option(value) {
290            return value;
291        }
292        if let Some(value) = self.redact_jvm_property(value) {
293            return value;
294        }
295        if let Some(level) = option_sensitivity {
296            *pending_sensitivity = Some(level);
297        }
298        value.to_owned()
299    }
300
301    /// Resolves sensitivity for one bare option token.
302    ///
303    /// # Parameters
304    ///
305    /// * `value` - Plain argument that may name an option.
306    ///
307    /// # Returns
308    ///
309    /// `Some(level)` for a configured option name, or `None` otherwise.
310    #[inline]
311    fn option_sensitivity(&self, value: &str) -> Option<Sensitivity> {
312        let name = option_name(value)?;
313        if value.starts_with("--") {
314            self.redactor.policy().sensitivity_for(name)
315        } else {
316            self.redactor.policy().sensitivity_for_exact(name)
317        }
318    }
319
320    /// Redacts a plain `NAME=value` token when its name is sensitive.
321    ///
322    /// # Parameters
323    ///
324    /// * `value` - Plain argument that may be an assignment.
325    ///
326    /// # Returns
327    ///
328    /// `Some(rendering)` for an assignment-like argument, or `None` otherwise.
329    fn redact_assignment(&self, value: &str) -> Option<String> {
330        if value.starts_with('-') {
331            return None;
332        }
333        let (name, raw_value) = value.split_once('=')?;
334        if name.is_empty() {
335            return None;
336        }
337        let level = self.redactor.policy().sensitivity_for(name)?;
338        let redacted = self.mask_utf8_value(raw_value, level);
339        Some(format!("{name}={redacted}"))
340    }
341
342    /// Redacts a plain `--name=value` token when its name is sensitive.
343    ///
344    /// # Parameters
345    ///
346    /// * `value` - Plain argument that may be an inline option.
347    ///
348    /// # Returns
349    ///
350    /// `Some(rendering)` for a sensitive long inline option, or `None`
351    /// otherwise. Single-dash attached forms remain uninterpreted.
352    #[inline]
353    fn redact_inline_option(&self, value: &str) -> Option<String> {
354        if !value.starts_with("--") {
355            return None;
356        }
357        let (left, raw_value) = value.split_once('=')?;
358        let name = option_name(left)?;
359        let level = self.redactor.policy().sensitivity_for(name)?;
360        let redacted = self.mask_utf8_value(raw_value, level);
361        Some(format!("{left}={redacted}"))
362    }
363
364    /// Redacts a JVM `-Dname=value` property when its name is sensitive.
365    ///
366    /// # Parameters
367    ///
368    /// * `value` - Plain argument that may be a JVM system property.
369    ///
370    /// # Returns
371    ///
372    /// `Some(rendering)` for a sensitive JVM property, or `None` otherwise.
373    fn redact_jvm_property(&self, value: &str) -> Option<String> {
374        let property = value.strip_prefix("-D")?;
375        let (name, raw_value) = property.split_once('=')?;
376        if name.is_empty() {
377            return None;
378        }
379        let level = self.redactor.policy().sensitivity_for(name)?;
380        let redacted = self.mask_utf8_value(raw_value, level);
381        Some(format!("-D{name}={redacted}"))
382    }
383
384    /// Masks one valid UTF-8 value at an explicit sensitivity level.
385    ///
386    /// # Parameters
387    ///
388    /// * `value` - Valid UTF-8 value to mask.
389    /// * `level` - Masking level to apply.
390    ///
391    /// # Returns
392    ///
393    /// The configured mask as an owned string.
394    #[inline(always)]
395    fn mask_utf8_value(&self, value: &str, level: Sensitivity) -> String {
396        self.redactor
397            .policy()
398            .masking()
399            .mask_bounded(level, value, self.mask_output_limit())
400            .into_owned()
401    }
402
403    /// Produces the configured secret replacement without reading opaque bytes.
404    ///
405    /// # Returns
406    ///
407    /// The secret-level opaque replacement.
408    #[inline(always)]
409    fn mask_opaque_value(&self) -> String {
410        self.redactor
411            .policy()
412            .masking()
413            .mask_opaque_bounded(Sensitivity::Secret, self.mask_output_limit())
414    }
415
416    /// Returns the largest mask that can contribute to one argv diagnostic.
417    ///
418    /// # Returns
419    ///
420    /// The configured final diagnostic output limit in bytes.
421    #[inline(always)]
422    fn mask_output_limit(&self) -> usize {
423        self.redactor
424            .policy()
425            .diagnostic_budget()
426            .max_output_bytes()
427    }
428}
429
430impl Default for ArgvRedactor {
431    /// Creates an argv redactor from the current default policy snapshot.
432    ///
433    /// # Returns
434    ///
435    /// An argv redactor backed by [`Redactor::default`].
436    #[inline(always)]
437    fn default() -> Self {
438        Self::new(Redactor::default())
439    }
440}
441
442/// Returns an option name without its leading dashes.
443///
444/// # Parameters
445///
446/// * `value` - Argument token that may name an option.
447///
448/// # Returns
449///
450/// `Some(name)` for an option-looking token with a non-empty name, or `None`
451/// otherwise.
452#[inline]
453fn option_name(value: &str) -> Option<&str> {
454    if !value.starts_with('-') || value == "-" || value.contains('=') {
455        return None;
456    }
457    let name = value.trim_start_matches('-');
458    if name.is_empty() { None } else { Some(name) }
459}