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