use std::fmt; use std::path::PathBuf; #[derive(Debug)] pub enum CmdError { Spawn { prog: String, source: std::io::Error, }, Io { path: PathBuf, source: std::io::Error, }, NonZeroExit { prog: String, args: Vec, code: i32, stderr: String, }, Timeout { prog: String, timeout_ms: u64, }, InvalidArgs(String), Cancelled, Pipe(String), PayloadTooLarge { cap_bytes: u64, actual_bytes: u64, }, Custom(String), } impl fmt::Display for CmdError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Spawn { prog, source } => { write!(f, "failed to spawn `{}`: {}", prog, source) } Self::Io { path, source } => { write!(f, "io error at `{}`: {}", path.display(), source) } Self::NonZeroExit { prog, args, code, stderr, } => { let argv_summary = redact_argv(prog, args); write!( f, "`{} {}` exited with code {}: {}", prog, argv_summary, code, stderr.trim() ) } Self::Timeout { prog, timeout_ms } => { write!(f, "`{}` timed out after {}ms", prog, timeout_ms) } Self::InvalidArgs(msg) => write!(f, "invalid args: {}", msg), Self::Cancelled => write!(f, "command was cancelled"), Self::Pipe(msg) => write!(f, "pipe error: {}", msg), Self::PayloadTooLarge { cap_bytes, actual_bytes, } => write!( f, "output exceeds cap: {} bytes > {} bytes cap", actual_bytes, cap_bytes ), Self::Custom(msg) => write!(f, "{}", msg), } } } impl std::error::Error for CmdError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Spawn { source, .. } | Self::Io { source, .. } => Some(source), _ => None, } } } impl From for CmdError { fn from(source: std::io::Error) -> Self { Self::Io { path: PathBuf::new(), source, } } } pub type CmdResult = std::result::Result; const REDACTED: &str = "***REDACTED***"; fn redact_argv(prog: &str, args: &[String]) -> String { let mut out = String::new(); out.push_str(prog); let mut redact_next = false; for (idx, arg) in args.iter().enumerate() { out.push(' '); if redact_next { out.push_str(REDACTED); redact_next = false; continue; } if SENSITIVE_FLAGS .iter() .any(|flag| arg == *flag || arg.starts_with(flag)) { let label = if arg.contains('=') { arg.split_once('=').map(|(k, _)| k).unwrap_or(arg) } else { arg.as_str() }; out.push_str(label); out.push('='); out.push_str(REDACTED); continue; } if URL_VALUE_FLAGS .iter() .any(|flag| arg == *flag || arg.starts_with(flag)) { let label = if arg.contains('=') { arg.split_once('=').map(|(k, _)| k).unwrap_or(arg) } else { arg.as_str() }; out.push_str(label); if idx + 1 < args.len() && looks_like_url(&args[idx + 1]) { out.push(' '); out.push_str(REDACTED); let _ = arg; continue; } out.push_str(" "); continue; } if looks_like_url(arg) { out.push_str(REDACTED); continue; } if idx == args.len() - 1 && redact_next_marker(prog, arg) { redact_next = true; } out.push_str(arg); } out } fn looks_like_url(s: &str) -> bool { if s.starts_with("http://") || s.starts_with("https://") || s.starts_with("ssh://") || s.starts_with("git://") || s.starts_with("file://") { true } else { s.contains("://") && s.contains('@') } } fn redact_next_marker(prog: &str, arg: &str) -> bool { if prog != "git" { return false; } matches!( arg, "credential.helper" | "credential.username" | "credential.password" | "http.extraheader" | "core.sshcommand" | "core.gitproxy" | "url..insteadof" | "include.path" ) } static SENSITIVE_FLAGS: &[&str] = &[ "--upload-pack=", "--receive-pack=", "-c", "--config=", "--config-env=", "-S", "--gpg-sign=", "--key-id=", "--ssh-command=", "--askpass=", ]; static URL_VALUE_FLAGS: &[&str] = &[ "remote.add", "remote set-url", "remote prune", "--exec=", "--uploadarchive=", ];