vcs_cli_support/logging.rs
1//! A command-logging [`ProcessRunner`] decorator and its argv redaction.
2//!
3//! [`LoggingRunner`] wraps any real [`ProcessRunner`] (the default [`JobRunner`],
4//! a `ManagedClient`'s inner runner, a test double) and reports every command it
5//! runs — program, argv, working directory, exit code, and duration — to a
6//! [`CommandObserver`]. Because it sits on the single seam every wrapper spawns
7//! through, coverage is complete *by construction*: it observes all of `vcs-git`
8//! / `vcs-jj` / the forge wrappers without any per-call-site instrumentation, and
9//! it can't drift out of date when a new operation is added.
10//!
11//! # Why this is a security boundary
12//!
13//! Logging argv is delicate: the value slots can carry a PR/issue body, a commit
14//! message, a clone URL, or — in principle — a secret. This module never emits a
15//! value verbatim. [`redact_args`] applies a **fail-closed** policy before anything
16//! reaches an observer:
17//!
18//! - The value after a **sensitive flag** (`--token`, `--password`, `--secret`,
19//! `--authorization`, …) is replaced with `<redacted>`, as is the value of a
20//! `--flag=value` form of one.
21//! - A value that **contains a secret shape** (a `ghp_`/`github_pat_`/`glpat-`/…
22//! token prefix, or an `x-access-token:` embed) is replaced with `<redacted>`.
23//! - A **URL with userinfo** (`scheme://user@host/…` or
24//! `scheme://user:pass@host/…`) keeps its host/path but masks the userinfo
25//! (`scheme://<redacted>@host/…`). The conventional non-secret
26//! `ssh://git@host/…` form remains visible.
27//! - Any **long free-text** value (a PR/issue body, a commit message) is truncated
28//! to [`MAX_VALUE_LEN`] characters plus a length marker.
29//!
30//! This is defence in depth on top of the workspace's existing "the token never
31//! rides in argv" contract (forge tokens travel in `GH_TOKEN`/`GITLAB_TOKEN`
32//! *environment*, git's secret via `credential.helper`) — the decorator never logs
33//! the environment at all, so the token-carrying channel is out of scope for the
34//! log by construction, and the argv redaction guards the residual risk.
35//!
36//! The default [`StderrObserver`] writes a one-line summary to **stderr**, never
37//! stdout — so a JSON-RPC transport sharing the process's stdout (the `vcs-mcp`
38//! server) stays a clean transport. Supply your own [`CommandObserver`] to route
39//! the same structured record into `tracing`, a file, or a test buffer instead.
40
41use std::ffi::OsString;
42use std::fmt;
43use std::path::Path;
44use std::sync::Arc;
45use std::time::{Duration, Instant};
46
47use async_trait::async_trait;
48use processkit::{
49 Command, Error, ErrorKind, JobRunner, ProcessResult, ProcessRunner, Result, RunningProcess,
50};
51
52/// The longest a single free-text argv value is rendered before it is truncated
53/// with a `…(<n> chars)` marker. Normal argv (subcommands, flags, refs, paths,
54/// revsets) sits well under this, so it only ever clips genuinely large values —
55/// a PR/issue body, a long commit message — keeping the log both readable and
56/// free of bulk user text. The exact number is not load-bearing.
57pub const MAX_VALUE_LEN: usize = 160;
58
59/// Long-flag names (without the leading dashes, lower-cased) whose *value* is
60/// treated as a secret and masked. Deliberately only unambiguous long names — a
61/// short flag like `-p` means different things per tool (`git log -p` is a patch,
62/// not a password), so masking the token after it would corrupt diagnostics for
63/// no real safety gain. Over-masking a genuine value here is the safe direction
64/// (a redacted diagnostic vs. a leaked secret), so the list errs toward inclusion.
65const SENSITIVE_FLAGS: &[&str] = &[
66 "token",
67 "password",
68 "passwd",
69 "secret",
70 "auth",
71 "authorization",
72 "credential",
73 "credentials",
74 "api-key",
75 "apikey",
76 "access-token",
77 "private-token",
78 "gh-token",
79 "github-token",
80 "gitlab-token",
81 "bearer",
82 "otp",
83 "pat",
84];
85
86/// Case-insensitive token prefixes that mark any containing free-text value as
87/// secret-bearing, so it is masked wholesale even in a positional slot. Covers
88/// the forge PATs the workspace touches plus a few common provider tokens; extend
89/// as needed.
90const SECRET_PREFIXES: &[&str] = &[
91 "ghp_",
92 "gho_",
93 "ghu_",
94 "ghs_",
95 "ghr_",
96 "github_pat_",
97 "glpat-",
98 "glptt-",
99 "xoxb-",
100 "xoxp-",
101 "xoxa-",
102 "xoxr-",
103];
104
105/// How an observed command finished. Carries no captured stdout/stderr — only a
106/// coarse, allocation-free category — so an observer can never leak process
107/// output (which could echo user text) into a log.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum CommandStatus {
110 /// The process exited with this code (`0` for success; any code, since a
111 /// non-zero exit is not an error at the runner seam).
112 Exited(i32),
113 /// Terminated by a signal (Unix); the number when the kernel exposed one.
114 Signalled(Option<i32>),
115 /// Killed for exceeding its timeout.
116 TimedOut,
117 /// A live streaming handle was returned ([`ProcessRunner::start`]); the
118 /// command's completion, exit code, and duration are observed by whoever
119 /// drives the handle, not here.
120 Started,
121 /// The run failed before producing an exit code (spawn/launch/IO error). The
122 /// `&'static str` is a stable category — never the error's captured output.
123 Failed(&'static str),
124}
125
126/// A display-safe, already-redacted record of one command a [`LoggingRunner`] ran,
127/// handed to a [`CommandObserver`]. Every field is safe to print: `args` has been
128/// through [`redact_args`], and `status` carries no captured process output.
129///
130/// [`Display`](fmt::Display) renders the canonical one-line summary the built-in
131/// [`StderrObserver`] uses (minus its tag), so a custom observer can reuse the
132/// exact formatting or read the structured fields directly.
133#[derive(Debug)]
134pub struct CommandRecord<'a> {
135 /// The program launched (its path/name as given — not a secret).
136 pub program: &'a str,
137 /// The arguments, already redacted by [`redact_args`].
138 pub args: &'a [String],
139 /// The working directory the command ran in, if one was bound.
140 pub working_dir: Option<&'a Path>,
141 /// How the run finished.
142 pub status: CommandStatus,
143 /// Wall-clock time the run took. [`Duration::ZERO`] for a
144 /// [`CommandStatus::Started`] record (completion is observed elsewhere).
145 pub duration: Duration,
146}
147
148impl fmt::Display for CommandRecord<'_> {
149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150 write!(f, "{}", self.program)?;
151 for arg in self.args {
152 write!(f, " {arg}")?;
153 }
154 if let Some(dir) = self.working_dir {
155 write!(f, " (cwd: {})", dir.display())?;
156 }
157 match self.status {
158 CommandStatus::Started => write!(f, " -> started (streaming)"),
159 CommandStatus::Exited(code) => write!(f, " -> exit {code} in {:?}", self.duration),
160 CommandStatus::Signalled(Some(sig)) => {
161 write!(f, " -> signal {sig} in {:?}", self.duration)
162 }
163 CommandStatus::Signalled(None) => write!(f, " -> signalled in {:?}", self.duration),
164 CommandStatus::TimedOut => write!(f, " -> timed out in {:?}", self.duration),
165 CommandStatus::Failed(kind) => write!(f, " -> failed: {kind} in {:?}", self.duration),
166 }
167 }
168}
169
170/// A sink for the command records a [`LoggingRunner`] produces. Implement it to
171/// route the (already-redacted) [`CommandRecord`] into `tracing`, a file, a
172/// metrics counter, or a test buffer; the built-in [`StderrObserver`] writes a
173/// one-line summary to stderr.
174pub trait CommandObserver: Send + Sync {
175 /// Called once per observed command, synchronously, after it finishes (or,
176 /// for a streaming [`ProcessRunner::start`], right after the handle is
177 /// returned). Keep it cheap and non-blocking; it runs on the calling task.
178 fn on_command(&self, record: &CommandRecord<'_>);
179}
180
181/// The default [`CommandObserver`]: writes one line per command to **stderr**
182/// (never stdout, so a stdout JSON-RPC transport stays clean), prefixed with a
183/// short tag. Format: `` `<tag>: <program> <args…> (cwd: <dir>) -> <status> in <dur>` ``.
184#[derive(Debug, Clone)]
185pub struct StderrObserver {
186 tag: Arc<str>,
187}
188
189impl StderrObserver {
190 /// A stderr observer tagged `tag` (a short prefix that identifies the source,
191 /// e.g. the server binary name).
192 pub fn new(tag: impl Into<Arc<str>>) -> Self {
193 Self { tag: tag.into() }
194 }
195}
196
197impl Default for StderrObserver {
198 /// Tagged `command`.
199 fn default() -> Self {
200 Self::new("command")
201 }
202}
203
204impl CommandObserver for StderrObserver {
205 fn on_command(&self, record: &CommandRecord<'_>) {
206 eprintln!("{}: {record}", self.tag);
207 }
208}
209
210/// A [`ProcessRunner`] decorator that reports every command it runs to a
211/// [`CommandObserver`], then forwards the real runner's result unchanged.
212///
213/// It adds only observation — the wrapped runner's behaviour, results, and errors
214/// are passed through verbatim. Construct one with [`new`](Self::new) (a
215/// [`StderrObserver`]) or [`with_observer`](Self::with_observer) (a custom sink),
216/// then hand it to any client's `with_runner` builder:
217///
218/// ```no_run
219/// use processkit::JobRunner;
220/// use vcs_cli_support::logging::LoggingRunner;
221/// // A boxed runner erases the concrete type, so the same client type works
222/// // whether or not logging is enabled.
223/// let runner: Box<dyn processkit::ProcessRunner> =
224/// Box::new(LoggingRunner::new(JobRunner::new(), "vcs-mcp"));
225/// ```
226pub struct LoggingRunner<R: ProcessRunner = JobRunner> {
227 inner: R,
228 observer: Arc<dyn CommandObserver>,
229}
230
231impl<R: ProcessRunner> LoggingRunner<R> {
232 /// Wrap `inner`, logging each command to stderr with the tag `tag` (via
233 /// [`StderrObserver`]).
234 pub fn new(inner: R, tag: impl Into<Arc<str>>) -> Self {
235 Self::with_observer(inner, Arc::new(StderrObserver::new(tag)))
236 }
237
238 /// Wrap `inner`, reporting each command to `observer`.
239 pub fn with_observer(inner: R, observer: Arc<dyn CommandObserver>) -> Self {
240 Self { inner, observer }
241 }
242
243 /// The observer this runner reports to.
244 pub fn observer(&self) -> &Arc<dyn CommandObserver> {
245 &self.observer
246 }
247
248 /// A reference to the wrapped runner.
249 pub fn inner(&self) -> &R {
250 &self.inner
251 }
252
253 /// Build a redacted record for `command` and hand it to the observer. The
254 /// program path and working directory are not secrets; the argv is passed
255 /// through [`redact_args`] first, and `status` carries no captured output.
256 fn observe(&self, command: &Command, status: CommandStatus, duration: Duration) {
257 let program = command.program().to_string_lossy();
258 let args = redact_args(command.arguments());
259 let record = CommandRecord {
260 program: program.as_ref(),
261 args: &args,
262 working_dir: command.working_dir(),
263 status,
264 duration,
265 };
266 self.observer.on_command(&record);
267 }
268}
269
270impl<R: ProcessRunner + fmt::Debug> fmt::Debug for LoggingRunner<R> {
271 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272 // The observer is a trait object with no meaningful rendering; report only
273 // that one is attached, and delegate to the inner runner's own Debug.
274 f.debug_struct("LoggingRunner")
275 .field("inner", &self.inner)
276 .field("observer", &"<dyn CommandObserver>")
277 .finish()
278 }
279}
280
281#[async_trait]
282impl<R: ProcessRunner> ProcessRunner for LoggingRunner<R> {
283 async fn output_string(&self, command: &Command) -> Result<ProcessResult<String>> {
284 let started = Instant::now();
285 let result = self.inner.output_string(command).await;
286 self.observe(command, status_of(&result), started.elapsed());
287 result
288 }
289
290 async fn output_bytes(&self, command: &Command) -> Result<ProcessResult<Vec<u8>>> {
291 let started = Instant::now();
292 let result = self.inner.output_bytes(command).await;
293 self.observe(command, status_of(&result), started.elapsed());
294 result
295 }
296
297 async fn start(&self, command: &Command) -> Result<RunningProcess> {
298 // A streaming handle: the command's completion (exit code, duration) is
299 // observed by whoever drives the handle, not here — so log the spawn with
300 // `Started` (or the launch failure) and let the caller own the rest.
301 let result = self.inner.start(command).await;
302 let status = match &result {
303 Ok(_) => CommandStatus::Started,
304 Err(err) => CommandStatus::Failed(error_category(err)),
305 };
306 self.observe(command, status, Duration::ZERO);
307 result
308 }
309}
310
311/// Map a finished-run result to a [`CommandStatus`]. A non-zero exit is an `Ok`
312/// result here (the runner seam does not raise on it), so `Ok` maps to the
313/// process outcome and `Err` to a launch/IO failure category.
314fn status_of<T>(result: &Result<ProcessResult<T>>) -> CommandStatus {
315 match result {
316 Ok(res) => {
317 // Use the accessors rather than matching the `#[non_exhaustive]`
318 // `Outcome`: `code()` is `Some` only for a real exit, `timed_out()`
319 // for a deadline kill, otherwise it was a signal.
320 if let Some(code) = res.code() {
321 CommandStatus::Exited(code)
322 } else if res.timed_out() {
323 CommandStatus::TimedOut
324 } else {
325 CommandStatus::Signalled(res.signal())
326 }
327 }
328 Err(err) => CommandStatus::Failed(error_category(err)),
329 }
330}
331
332/// A stable, output-free category for a runner error — never the error's captured
333/// stdout/stderr (which could echo user text).
334///
335/// Classifies through processkit's flat [`ErrorKind`] rather than matching the
336/// error's [`reason`](Error::reason): what this needs *is* a coarse classification,
337/// not a field, and `kind()` already folds the cases we would otherwise have to
338/// enumerate (a `PermissionDenied` spawn/IO failure now reads as such instead of
339/// hiding behind "spawn failed"/"io error"). The one distinction `kind()` collapses
340/// that is worth keeping is our own output cap firing, which the dedicated
341/// [`Error::output_overflow`] accessor recovers without destructuring a variant. A
342/// conservative wildcard keeps the `#[non_exhaustive]` enum (and the
343/// `limits`-feature-gated `ResourceLimit` kind, which this workspace does not
344/// enable) safe.
345fn error_category(err: &Error) -> &'static str {
346 match err.kind() {
347 ErrorKind::NotFound => "program not found",
348 ErrorKind::Spawn => "spawn failed",
349 ErrorKind::PermissionDenied => "permission denied",
350 ErrorKind::Timeout => "timed out",
351 ErrorKind::Cancelled => "cancelled",
352 ErrorKind::Unsupported => "unsupported",
353 ErrorKind::Exit => "non-zero exit",
354 ErrorKind::Signalled => "signalled",
355 ErrorKind::Predicate => "predicate rejected",
356 _ if err.output_overflow().is_some() => "output too large",
357 _ => "error",
358 }
359}
360
361/// Redact a command's argv for display: mask secret-bearing values, mask the
362/// userinfo of a URL, and truncate long free text — see the
363/// [module docs](self) for the full policy. Returns one display string per input
364/// argument, in order. The policy is **fail-closed**: when in doubt it masks.
365///
366/// This is sequence-aware (the value *after* a sensitive flag is masked), so pass
367/// the whole argv, not one argument at a time.
368pub fn redact_args(args: &[OsString]) -> Vec<String> {
369 let mut out = Vec::with_capacity(args.len());
370 // Set when the previous token was a bare sensitive flag (`--token`), so the
371 // next token (its value) is masked.
372 let mut mask_next = false;
373 for arg in args {
374 let s = arg.to_string_lossy();
375 if mask_next {
376 out.push(REDACTED.to_string());
377 mask_next = false;
378 continue;
379 }
380 if s.starts_with('-') {
381 let name_part = s.trim_start_matches('-');
382 let dashes_len = s.len() - name_part.len();
383 if let Some(eq) = name_part.find('=') {
384 // `--flag=value`: mask the value if the flag is sensitive, else
385 // redact the value as ordinary free text (secret-scan/truncate).
386 let name = &name_part[..eq];
387 let value = &name_part[eq + 1..];
388 if is_sensitive_flag(name) {
389 out.push(format!("{}{name}={REDACTED}", &s[..dashes_len]));
390 } else {
391 out.push(format!(
392 "{}{name}={}",
393 &s[..dashes_len],
394 redact_arg_value(value)
395 ));
396 }
397 } else {
398 // A bare flag is structural and safe to show verbatim; if it is a
399 // sensitive flag, mask whatever value follows it.
400 if is_sensitive_flag(name_part) {
401 mask_next = true;
402 }
403 out.push(s.into_owned());
404 }
405 } else {
406 out.push(redact_arg_value(&s));
407 }
408 }
409 out
410}
411
412/// The placeholder emitted in place of a masked value.
413const REDACTED: &str = "<redacted>";
414
415/// Whether `name` (a flag name without leading dashes) is one whose value must be
416/// masked. Case-insensitive.
417fn is_sensitive_flag(name: &str) -> bool {
418 let name = name.to_ascii_lowercase();
419 SENSITIVE_FLAGS.contains(&name.as_str())
420}
421
422/// Redact one free-text value without sequence-aware flag handling or truncation.
423///
424/// This is the single-value counterpart to [`redact_args`]. It is useful at
425/// boundaries that receive one field at a time, such as a record/replay cassette
426/// scrubber, where truncating a captured JSON document would make the fixture
427/// unusable. Long argv values are still truncated by [`redact_args`].
428pub fn redact_value(value: &str) -> String {
429 if value.is_empty() {
430 return String::new();
431 }
432 let lower = value.to_ascii_lowercase();
433 if let Some(masked) = mask_url_userinfo(value) {
434 // Preserve the useful host/path after removing userinfo, and redact any
435 // second token shape that remains elsewhere (for example in the URL
436 // path/query) without destroying a captured JSON/document shape.
437 return redact_secret_shapes(&masked);
438 }
439 // Tokens are often embedded in a sentence, config fragment, or `--body=...`
440 // value rather than occupying the entire argv slot. Fail closed on the shape
441 // anywhere in free text; a false positive only hides one diagnostic value.
442 if contains_secret_shape(&lower) {
443 return redact_secret_shapes(value);
444 }
445 value.to_owned()
446}
447
448/// Apply the argv-only length cap after the shared single-value redaction policy.
449fn redact_arg_value(value: &str) -> String {
450 if contains_secret_shape(&value.to_ascii_lowercase()) && mask_url_userinfo(value).is_none() {
451 return REDACTED.to_string();
452 }
453 truncate(&redact_value(value))
454}
455
456/// Replace known token-shaped spans in a larger text value while retaining the
457/// surrounding document (notably JSON output captured by a cassette).
458fn redact_secret_shapes(value: &str) -> String {
459 let lower = value.to_ascii_lowercase();
460 let mut out = String::with_capacity(value.len());
461 let mut cursor = 0;
462 while cursor < value.len() {
463 let Some((start, marker_len)) = SECRET_PREFIXES
464 .iter()
465 .map(|prefix| (*prefix, prefix.len()))
466 .chain(std::iter::once((
467 "x-access-token:",
468 "x-access-token:".len(),
469 )))
470 .filter_map(|(marker, marker_len)| {
471 lower[cursor..]
472 .find(marker)
473 .map(|at| (cursor + at, marker_len))
474 })
475 .min_by_key(|(start, _)| *start)
476 else {
477 out.push_str(&value[cursor..]);
478 break;
479 };
480 out.push_str(&value[cursor..start]);
481 let end = secret_shape_end(value.as_bytes(), start + marker_len);
482 out.push_str(REDACTED);
483 cursor = end;
484 }
485 out
486}
487
488fn secret_shape_end(bytes: &[u8], start: usize) -> usize {
489 let mut end = start;
490 while end < bytes.len()
491 && !bytes[end].is_ascii_whitespace()
492 && !matches!(bytes[end], b'"' | b'\'' | b',' | b']' | b'}' | b')' | b';')
493 {
494 end += 1;
495 }
496 end
497}
498
499/// Whether an already-lowercased value contains a token form this crate knows.
500fn contains_secret_shape(lower: &str) -> bool {
501 SECRET_PREFIXES.iter().any(|p| lower.contains(p)) || lower.contains("x-access-token:")
502}
503
504/// If `value` is a URL of the form `scheme://userinfo@host/…`, return it with the
505/// userinfo masked (`scheme://<redacted>@host/…`). The conventional
506/// `ssh://git@host/…` transport identity is the sole allowlisted exception. Keeps
507/// the host/path visible for diagnostics while never printing a token used as a
508/// username (or any other unexpected userinfo).
509fn mask_url_userinfo(value: &str) -> Option<String> {
510 let scheme_end = value.find("://")?;
511 let after = &value[scheme_end + 3..];
512 // Search for `userinfo@` only within the **authority** component — the span
513 // from just past `://` up to the first `/`, `?`, or `#`. An `@` in the path or
514 // query (e.g. `…/dir/file@rev`) is not a credential, so it must not drag the
515 // host/port/path into the mask. This is the same authority boundary
516 // `credentials::https_host` applies; take the **last** `@` in it (as
517 // `https_host`'s `rsplit_once('@')` does) so the userinfo is split off at the
518 // host, not at an earlier `@`. `authority` is a prefix of `after`, so the byte
519 // offset of the `@` is the same in both.
520 let authority = after.split(['/', '?', '#']).next().unwrap_or(after);
521 let at = authority.rfind('@')?;
522 let userinfo = &authority[..at];
523 // `git@` in an SSH URL is the standard, non-secret transport identity. Any
524 // other userinfo is fail-closed: PATs are commonly supplied as the username
525 // without a colon (e.g. `https://ghp_…@github.com/o/r.git`).
526 if value[..scheme_end].eq_ignore_ascii_case("ssh") && userinfo == "git" {
527 return None;
528 }
529 Some(format!(
530 "{}://{REDACTED}@{}",
531 &value[..scheme_end],
532 &after[at + 1..]
533 ))
534}
535
536/// Truncate `value` to [`MAX_VALUE_LEN`] characters plus a `…(<n> chars)` marker,
537/// or `None` if it already fits. Char-boundary safe.
538fn truncate_cow(value: &str) -> Option<String> {
539 // `redact_args` is safe to apply at every logging boundary, including to a
540 // value an upstream decorator already redacted. Preserve only our exact
541 // truncation shape: accepting an arbitrary `…(n chars)` suffix would let a
542 // caller bypass the cap with a much longer forged value.
543 if is_canonical_truncation(value) {
544 return None;
545 }
546 let count = value.chars().count();
547 if count <= MAX_VALUE_LEN {
548 return None;
549 }
550 let head: String = value.chars().take(MAX_VALUE_LEN).collect();
551 Some(format!("{head}…({count} chars)"))
552}
553
554/// Whether `value` is exactly the output shape produced by [`truncate_cow`].
555fn is_canonical_truncation(value: &str) -> bool {
556 let Some((head, marker)) = value.rsplit_once("…(") else {
557 return false;
558 };
559 let Some(original_len) = marker
560 .strip_suffix(" chars)")
561 .and_then(|digits| digits.parse::<usize>().ok())
562 else {
563 return false;
564 };
565 head.chars().count() == MAX_VALUE_LEN && original_len > MAX_VALUE_LEN
566}
567
568/// Truncate an already-owned value the same way, in place of a no-op when it fits.
569fn truncate(value: &str) -> String {
570 truncate_cow(value).unwrap_or_else(|| value.to_string())
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use processkit::testing::{RecordingRunner, Reply};
577 use proptest::prelude::*;
578 use std::sync::Mutex;
579
580 /// Build an `OsString` argv from `&str`s.
581 fn argv(args: &[&str]) -> Vec<OsString> {
582 args.iter().map(OsString::from).collect()
583 }
584
585 /// A `CommandObserver` that captures each record's rendered line, for asserting
586 /// on what would actually be logged.
587 #[derive(Default)]
588 struct Capture(Mutex<Vec<String>>);
589
590 impl CommandObserver for Capture {
591 fn on_command(&self, record: &CommandRecord<'_>) {
592 self.0.lock().unwrap().push(record.to_string());
593 }
594 }
595
596 // The category label a failure is logged under. Pinned per failure kind because
597 // `error_category` classifies through processkit's flat `ErrorKind` (plus the
598 // `output_overflow` accessor for the one case that kind folds into its
599 // catch-all), so a future `ErrorKind` reshuffle upstream must be a visible,
600 // deliberate change here rather than a silent relabelling of every log line.
601 #[tokio::test]
602 async fn every_failure_kind_gets_its_stable_category() {
603 use processkit::{ErrorReason, OutputBufferPolicy, OverflowMode};
604 use std::io;
605
606 let io_err = |kind: io::ErrorKind| io::Error::from(kind);
607 let cases: Vec<(Error, &str)> = vec![
608 (Error::not_found("git", None), "program not found"),
609 (
610 Error::spawn("git", io_err(io::ErrorKind::InvalidInput)),
611 "spawn failed",
612 ),
613 (
614 Error::spawn("git", io_err(io::ErrorKind::PermissionDenied)),
615 "permission denied",
616 ),
617 (
618 Error::timeout("git", Duration::from_secs(1), "", ""),
619 "timed out",
620 ),
621 (
622 ErrorReason::Cancelled {
623 program: "git".into(),
624 }
625 .into(),
626 "cancelled",
627 ),
628 (
629 ErrorReason::Unsupported {
630 operation: "suspend".into(),
631 }
632 .into(),
633 "unsupported",
634 ),
635 (Error::exit("git", 1, "", "boom"), "non-zero exit"),
636 (Error::signalled("git", Some(9), "", ""), "signalled"),
637 // Reasons `ErrorKind` folds into its catch-all: a plain IO failure and a
638 // parse failure both read as the generic label.
639 (
640 ErrorReason::Io(io_err(io::ErrorKind::BrokenPipe)).into(),
641 "error",
642 ),
643 (Error::parse("git", "unrecognisable version"), "error"),
644 ];
645 for (err, expected) in cases {
646 assert_eq!(error_category(&err), expected, "for {err:?}");
647 }
648
649 // `OutputTooLarge` is `#[non_exhaustive]`, so it can only be produced by
650 // actually tripping a byte ceiling — which is also the honest check that the
651 // dedicated `output_overflow` accessor still recovers the category.
652 // T-130: unaffected by processkit 3.0's raw-pipe-byte accounting — this trips
653 // the RAW-stdout ceiling (`output_bytes`), whose unit 3.0 left untouched, and
654 // 4 KiB is 256x the 16-byte cap under either unit.
655 let runner = RecordingRunner::replying(Reply::ok("x".repeat(4096)));
656 let command = Command::new("git").args(["diff"]).output_buffer(
657 OutputBufferPolicy::unbounded()
658 .with_overflow(OverflowMode::Error)
659 .with_max_bytes(16),
660 );
661 let over_budget = runner
662 .output_bytes(&command)
663 .await
664 .expect_err("4 KiB of output must trip a 16-byte ceiling");
665 assert_eq!(error_category(&over_budget), "output too large");
666 }
667
668 #[test]
669 fn ordinary_argv_is_shown_verbatim() {
670 let out = redact_args(&argv(&["status", "--porcelain", "-z"]));
671 assert_eq!(out, vec!["status", "--porcelain", "-z"]);
672 }
673
674 #[test]
675 fn single_value_redaction_masks_secrets_without_truncating_output() {
676 let secret = "github_pat_SINGLE_VALUE_MUST_NOT_LEAK";
677 assert_eq!(redact_value(&format!("token={secret}")), "token=<redacted>");
678 assert_eq!(
679 redact_value("https://user:password@example.test/repo"),
680 "https://<redacted>@example.test/repo"
681 );
682 let output = "ordinary-json-value ".repeat(32);
683 assert_eq!(redact_value(&output), output);
684 assert_eq!(redact_value("status"), "status");
685 }
686
687 #[test]
688 fn value_after_a_sensitive_flag_is_masked() {
689 let out = redact_args(&argv(&["--token", "ghp_supersecretvalue", "pr", "list"]));
690 assert_eq!(out, vec!["--token", "<redacted>", "pr", "list"]);
691 // The `-p` short flag is NOT sensitive (git log -p is a patch), so the
692 // following value is not masked by the flag rule.
693 let out = redact_args(&argv(&["log", "-p", "HEAD~1"]));
694 assert_eq!(out, vec!["log", "-p", "HEAD~1"]);
695 }
696
697 #[test]
698 fn inline_sensitive_flag_value_is_masked() {
699 let out = redact_args(&argv(&["--password=hunter2", "--auth=Bearer xyz"]));
700 assert_eq!(out, vec!["--password=<redacted>", "--auth=<redacted>"]);
701 }
702
703 #[test]
704 fn secret_looking_positional_is_masked_even_without_a_flag() {
705 // A bare token that matched no sensitive flag is still masked by shape.
706 let out = redact_args(&argv(&[
707 "push",
708 "glpat-abcdEFGH1234 ",
709 "github_pat_11ABCDEF",
710 ]));
711 assert_eq!(out[0], "push");
712 assert_eq!(out[1], "<redacted>");
713 assert_eq!(out[2], "<redacted>");
714 }
715
716 #[test]
717 fn url_userinfo_credentials_are_masked_but_host_kept() {
718 let out = redact_args(&argv(&[
719 "clone",
720 "https://user:tokensecret@github.com/o/r.git",
721 ]));
722 assert_eq!(out[0], "clone");
723 assert_eq!(out[1], "https://<redacted>@github.com/o/r.git");
724 assert!(!out[1].contains("tokensecret"));
725 // The conventional SSH transport identity stays visible.
726 let out = redact_args(&argv(&["fetch", "ssh://git@github.com/o/r.git"]));
727 assert_eq!(out[1], "ssh://git@github.com/o/r.git");
728 }
729
730 #[test]
731 fn url_userinfo_token_usernames_are_masked() {
732 for (url, secret) in [
733 (
734 "https://ghp_THIS_MUST_NOT_LEAK@github.com/o/r.git",
735 "ghp_THIS_MUST_NOT_LEAK",
736 ),
737 (
738 "https://glpat-THIS_MUST_NOT_LEAK@gitlab.com/o/r.git",
739 "glpat-THIS_MUST_NOT_LEAK",
740 ),
741 (
742 "https://x-access-token@github.com/o/r.git",
743 "x-access-token",
744 ),
745 ] {
746 let out = redact_args(&argv(&["clone", url]));
747 assert_eq!(
748 out[1],
749 "https://<redacted>@".to_string() + url.split('@').nth(1).unwrap()
750 );
751 assert!(
752 !out[1].contains(secret),
753 "userinfo leaked from {url}: {}",
754 out[1]
755 );
756 }
757 }
758
759 #[test]
760 fn url_at_in_path_is_not_mistaken_for_userinfo() {
761 // A URL with a port and an `@` in the *path* — but no embedded credential.
762 // The `@` lives past the authority boundary (the first `/`), so it is not
763 // userinfo: the host, port, and path must stay fully visible, never masked.
764 // (Regression: searching the whole remainder for `@` treated
765 // `host:8443/dir/file` as userinfo because of the port's `:`, collapsing the
766 // value to `https://<redacted>@rev`.)
767 let out = redact_args(&argv(&["clone", "https://host:8443/dir/file@rev"]));
768 assert_eq!(out[0], "clone");
769 assert_eq!(
770 out[1], "https://host:8443/dir/file@rev",
771 "no credential ⇒ nothing is masked; host/port/path stay intact"
772 );
773 assert!(
774 !out[1].contains(REDACTED),
775 "the value must not be redacted: {}",
776 out[1]
777 );
778
779 // And the credentialed form of the *same* shape — real `user:secret`
780 // userinfo, plus an `@` later in the path — still masks the credential while
781 // keeping host/port/path visible (the trailing path `@` is not userinfo).
782 let out = redact_args(&argv(&[
783 "clone",
784 "https://user:secret@host:8443/dir/file@rev",
785 ]));
786 assert_eq!(out[1], "https://<redacted>@host:8443/dir/file@rev");
787 assert!(!out[1].contains("secret"), "credential masked: {}", out[1]);
788 }
789
790 #[test]
791 fn long_free_text_is_truncated_not_dumped() {
792 let body = "x".repeat(MAX_VALUE_LEN + 50);
793 let out = redact_args(&argv(&["pr", "create", "--body", &body]));
794 assert_eq!(&out[..3], &["pr", "create", "--body"]);
795 let shown = &out[3];
796 assert!(shown.len() < body.len(), "the body was truncated");
797 assert!(shown.contains("chars)"), "carries a length marker: {shown}");
798 // And the inline `--body=<huge>` form is truncated too (flag kept).
799 let out = redact_args(&argv(&["pr", "create", &format!("--body={body}")]));
800 assert!(out[2].starts_with("--body=x"));
801 assert!(out[2].contains("chars)"));
802 }
803
804 fn harmless_args() -> impl Strategy<Value = Vec<String>> {
805 prop::collection::vec("[a-z0-9./_]{0,24}", 0..6)
806 }
807
808 fn known_token() -> impl Strategy<Value = String> {
809 (prop::sample::select(SECRET_PREFIXES), "[A-Za-z0-9_-]{8,48}")
810 .prop_map(|(prefix, suffix)| format!("{prefix}{suffix}"))
811 }
812
813 fn opaque_secret() -> impl Strategy<Value = String> {
814 "LEAK_[A-Za-z0-9_-]{8,48}"
815 }
816
817 /// Generate each supported secret-bearing argv shape with unrelated values
818 /// before and after it, so sequence-aware masking is exercised at arbitrary
819 /// positions rather than only in a two-element fixture.
820 fn secret_argv() -> impl Strategy<Value = (Vec<OsString>, String)> {
821 (
822 harmless_args(),
823 harmless_args(),
824 known_token(),
825 opaque_secret(),
826 prop::sample::select(SENSITIVE_FLAGS),
827 "[a-z0-9 =:;]{0,24}",
828 "[a-z0-9 =:;]{0,24}",
829 0_u8..8,
830 )
831 .prop_map(|(before, after, token, opaque, flag, left, right, shape)| {
832 let mut args = before;
833 let secret = match shape {
834 0 => {
835 args.push(format!("{left}{token}{right}"));
836 token
837 }
838 1 => {
839 args.extend([format!("--{flag}"), opaque.clone()]);
840 opaque
841 }
842 2 => {
843 args.push(format!("--{flag}={opaque}"));
844 opaque
845 }
846 3 => {
847 args.push(format!("https://user:{opaque}@example.test/owner/repo.git"));
848 opaque
849 }
850 4 => {
851 args.push(format!("https://{token}@example.test/owner/repo.git"));
852 token
853 }
854 5 => {
855 args.push(format!(
856 "https://x-access-token:{opaque}@example.test/owner/repo.git"
857 ));
858 opaque
859 }
860 6 => {
861 args.push(format!("--body={left}{token}{right}"));
862 token
863 }
864 _ => {
865 args.push(format!(
866 "https://user:{opaque}@example.test/{left}{token}{right}"
867 ));
868 token
869 }
870 };
871 args.extend(after);
872 (args.into_iter().map(OsString::from).collect(), secret)
873 })
874 }
875
876 fn unicode_string(max_chars: usize) -> impl Strategy<Value = String> {
877 prop::collection::vec(any::<char>(), 0..max_chars)
878 .prop_map(|chars| chars.into_iter().collect())
879 }
880
881 proptest! {
882 #![proptest_config(ProptestConfig::with_cases(128))]
883
884 /// Security invariant: the exact generated secret literal never reaches
885 /// any rendered argv slot, regardless of its position or supported shape.
886 #[test]
887 fn generated_secret_literals_never_survive((args, secret) in secret_argv()) {
888 let redacted = redact_args(&args);
889 prop_assert!(
890 redacted.iter().all(|arg| !arg.contains(&secret)),
891 "secret {secret:?} survived in {redacted:?}"
892 );
893 }
894
895 #[test]
896 fn generated_single_value_secrets_never_survive(token in known_token()) {
897 let value = format!("gh output: {token}");
898 let redacted = redact_value(&value);
899 prop_assert!(!redacted.contains(&token), "secret {token:?} survived");
900 }
901
902 #[test]
903 fn arbitrary_single_values_are_idempotent(value in unicode_string(512)) {
904 let once = redact_value(&value);
905 prop_assert_eq!(redact_value(&once), once);
906 }
907
908 /// Arbitrary Unicode argv — flags included — must be total and stable if
909 /// multiple logging decorators apply the same security boundary.
910 #[test]
911 fn arbitrary_argv_is_panic_free_and_idempotent(
912 args in prop::collection::vec(unicode_string(512), 0..24)
913 ) {
914 let args: Vec<OsString> = args.into_iter().map(OsString::from).collect();
915 let once = redact_args(&args);
916 let twice_input: Vec<OsString> = once.iter().map(OsString::from).collect();
917 prop_assert_eq!(redact_args(&twice_input), once);
918 }
919
920 /// Exercise the truncation boundary with genuinely large, multibyte text;
921 /// char-based clipping must neither panic nor split a code point.
922 #[test]
923 fn huge_multibyte_values_are_panic_free_and_idempotent(
924 chars in prop::collection::vec(
925 prop::sample::select(vec!['é', 'Ж', '漢', '🦀']),
926 (MAX_VALUE_LEN + 1)..4096,
927 )
928 ) {
929 let value: String = chars.into_iter().collect();
930 let once = redact_args(&[OsString::from(value)]);
931 let twice_input: Vec<OsString> = once.iter().map(OsString::from).collect();
932 prop_assert_eq!(redact_args(&twice_input), once);
933 }
934 }
935
936 // Unix argv can contain byte sequences that are not valid UTF-8. Feed such
937 // fragments through `to_string_lossy` and the char-safe truncator as well;
938 // the Windows property above covers every representable Unicode `OsString`.
939 #[cfg(unix)]
940 proptest! {
941 #![proptest_config(ProptestConfig::with_cases(128))]
942
943 #[test]
944 fn arbitrary_os_bytes_are_panic_free_and_idempotent(
945 bytes in prop::collection::vec(any::<u8>(), 0..4096)
946 ) {
947 use std::os::unix::ffi::OsStringExt;
948
949 let once = redact_args(&[OsString::from_vec(bytes)]);
950 let twice_input: Vec<OsString> = once.iter().map(OsString::from).collect();
951 prop_assert_eq!(redact_args(&twice_input), once);
952 }
953 }
954
955 #[tokio::test]
956 async fn runner_observes_a_command_without_leaking_a_secret() {
957 // A hermetic inner runner: replies with canned output, records the calls.
958 let inner = RecordingRunner::replying(Reply::ok("ok"));
959 let capture = Arc::new(Capture::default());
960 let runner = LoggingRunner::with_observer(&inner, capture.clone());
961
962 // A command whose argv carries a value we must never see in the log.
963 let secret = "ghp_THIS_MUST_NOT_APPEAR";
964 let command = Command::new("gh")
965 .args([
966 "pr",
967 "create",
968 "--token",
969 secret,
970 "--body",
971 &"z".repeat(400),
972 ])
973 .current_dir("/tmp/work");
974
975 let result = runner
976 .output_string(&command)
977 .await
978 .expect("the inner runner replied ok");
979 assert_eq!(result.stdout(), "ok");
980 // The decorator forwarded the real call unchanged.
981 assert_eq!(inner.calls().len(), 1);
982
983 let lines = capture.0.lock().unwrap();
984 assert_eq!(lines.len(), 1, "exactly one record per command");
985 let line = &lines[0];
986 // The core safety property: the secret never reaches the observer.
987 assert!(
988 !line.contains(secret),
989 "the secret must not appear in the log line: {line}"
990 );
991 assert!(
992 line.contains("<redacted>"),
993 "the token value is masked: {line}"
994 );
995 // The useful diagnostics ARE present: program, subcommand, cwd, exit code.
996 assert!(line.contains("gh"), "shows the program: {line}");
997 assert!(line.contains("pr create"), "shows the subcommand: {line}");
998 assert!(
999 line.contains("cwd: "),
1000 "shows the working directory: {line}"
1001 );
1002 assert!(line.contains("exit 0"), "shows the exit code: {line}");
1003 // The long body was truncated, not dumped whole.
1004 assert!(
1005 !line.contains(&"z".repeat(400)),
1006 "the body is not dumped: {line}"
1007 );
1008 }
1009
1010 #[tokio::test]
1011 async fn the_streaming_start_path_logs_the_spawn() {
1012 // The streaming seam is instrumented too (so a `first_line`-style verb is
1013 // observed), reported as a `Started` record — completion is owned by the
1014 // handle's driver, not this decorator.
1015 let inner = RecordingRunner::replying(Reply::ok("a line\n"));
1016 let capture = Arc::new(Capture::default());
1017 let runner = LoggingRunner::with_observer(&inner, capture.clone());
1018
1019 let command = Command::new("gh").args(["run", "watch"]);
1020 let _ = runner.start(&command).await;
1021
1022 let lines = capture.0.lock().unwrap();
1023 assert_eq!(lines.len(), 1, "the spawn is logged exactly once");
1024 assert!(
1025 lines[0].contains("gh run watch"),
1026 "logs the spawn: {}",
1027 lines[0]
1028 );
1029 assert!(
1030 lines[0].contains("started"),
1031 "reports the streaming spawn: {}",
1032 lines[0]
1033 );
1034 }
1035}