use std::ffi::OsString;
use std::fmt;
#[derive(Clone)]
pub struct CmdDisplay {
program: OsString,
args: Vec<OsString>,
secret: bool,
}
impl fmt::Debug for CmdDisplay {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut s = f.debug_struct("CmdDisplay");
s.field("program", &self.program);
if self.secret {
s.field("args", &"<secret>");
} else {
s.field("args", &self.args);
}
s.field("secret", &self.secret).finish()
}
}
impl CmdDisplay {
pub(crate) fn new(program: OsString, args: Vec<OsString>, secret: bool) -> Self {
Self {
program,
args,
secret,
}
}
pub fn program(&self) -> &OsString {
&self.program
}
pub fn is_secret(&self) -> bool {
self.secret
}
pub fn raw_args(&self) -> &[OsString] {
&self.args
}
}
impl fmt::Display for CmdDisplay {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", shell_quote_os(&self.program))?;
if self.secret {
write!(f, " <secret>")?;
} else {
for arg in &self.args {
write!(f, " {}", shell_quote_os(arg))?;
}
}
Ok(())
}
}
fn shell_quote_os(s: &std::ffi::OsStr) -> String {
let lossy = s.to_string_lossy();
let needs_quote = lossy.is_empty()
|| lossy.chars().any(|c| {
!(c.is_ascii_alphanumeric()
|| matches!(
c,
'_' | '-' | '.' | '/' | ':' | '@' | '%' | '+' | '=' | ','
))
});
if !needs_quote {
return lossy.into_owned();
}
let mut out = String::with_capacity(lossy.len() + 2);
out.push('\'');
for ch in lossy.chars() {
if ch == '\'' {
out.push_str("'\\''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
#[cfg(test)]
mod tests {
use super::*;
fn cd(program: &str, args: &[&str], secret: bool) -> CmdDisplay {
CmdDisplay::new(
program.into(),
args.iter().map(|s| OsString::from(*s)).collect(),
secret,
)
}
#[test]
fn simple_command() {
let d = cd("git", &["status"], false);
assert_eq!(d.to_string(), "git status");
}
#[test]
fn no_args() {
let d = cd("ls", &[], false);
assert_eq!(d.to_string(), "ls");
}
#[test]
fn args_with_spaces_are_quoted() {
let d = cd("git", &["commit", "-m", "fix bug"], false);
assert_eq!(d.to_string(), "git commit -m 'fix bug'");
}
#[test]
fn empty_arg_quoted() {
let d = cd("echo", &[""], false);
assert_eq!(d.to_string(), "echo ''");
}
#[test]
fn embedded_single_quote_escaped() {
let d = cd("echo", &["it's fine"], false);
assert_eq!(d.to_string(), "echo 'it'\\''s fine'");
}
#[test]
fn safe_chars_unquoted() {
let d = cd("git", &["log", "-r", "trunk()..@", "--no-graph"], false);
assert_eq!(d.to_string(), "git log -r 'trunk()..@' --no-graph");
}
#[test]
fn paths_unquoted() {
let d = cd("cat", &["/tmp/file.txt", "/usr/bin/foo"], false);
assert_eq!(d.to_string(), "cat /tmp/file.txt /usr/bin/foo");
}
#[test]
fn secret_redacts_args() {
let d = cd("docker", &["login", "-p", "hunter2"], true);
assert_eq!(d.to_string(), "docker <secret>");
}
#[test]
fn secret_preserves_program() {
let d = cd("docker", &["login", "-p", "hunter2"], true);
assert_eq!(d.program(), &OsString::from("docker"));
assert!(d.is_secret());
assert_eq!(d.raw_args().len(), 3);
}
#[test]
fn special_chars_quoted() {
let d = cd("sh", &["-c", "echo foo > bar"], false);
assert_eq!(d.to_string(), "sh -c 'echo foo > bar'");
}
#[test]
fn debug_impl_works() {
let d = cd("git", &["status"], false);
let dbg = format!("{d:?}");
assert!(dbg.contains("git"));
assert!(dbg.contains("status"));
}
#[test]
fn debug_respects_secret_flag() {
let d = cd("docker", &["login", "-p", "hunter2"], true);
let dbg = format!("{d:?}");
assert!(!dbg.contains("hunter2"), "secret leaked in Debug: {dbg}");
assert!(dbg.contains("<secret>"));
}
}