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}