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}