Skip to main content

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}