use std::fmt;
use std::time::Duration;
#[derive(thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("could not start `{program}`: {source}")]
Spawn {
program: String,
#[source]
source: std::io::Error,
},
#[error("{}", display_not_found(program, searched))]
NotFound {
program: String,
searched: Option<String>,
},
#[error(
"`{program}`: no cassette entry matches this invocation (stale or incomplete cassette)"
)]
CassetteMiss {
program: String,
},
#[error("{}", display_exit(program, *code, stdout, stderr))]
Exit {
program: String,
code: i32,
stdout: String,
stderr: String,
},
#[error("{}", display_timeout(program, *timeout, stdout, stderr))]
Timeout {
program: String,
timeout: Duration,
stdout: String,
stderr: String,
},
#[error(
"`{program}` output exceeded its capture ceiling ({total_lines} lines, {total_bytes} bytes total)"
)]
OutputTooLarge {
program: String,
line_limit: Option<usize>,
byte_limit: Option<usize>,
total_lines: usize,
total_bytes: usize,
},
#[error("`{program}` was not ready after {timeout:?}")]
NotReady {
program: String,
timeout: Duration,
},
#[error("{}", display_parse(program, message))]
Parse {
program: String,
message: String,
},
#[cfg(feature = "limits")]
#[error("could not enforce resource limits: {message}")]
ResourceLimit {
message: String,
},
#[error("operation `{operation}` is not supported on this platform")]
Unsupported {
operation: String,
},
#[error("`{program}` was cancelled")]
Cancelled {
program: String,
},
#[error("{}", display_signalled(program, *signal, stdout, stderr))]
Signalled {
program: String,
signal: Option<i32>,
stdout: String,
stderr: String,
},
#[error("failed to write to `{program}` stdin: {source}")]
Stdin {
program: String,
#[source]
source: std::io::Error,
},
#[error(transparent)]
Io(std::io::Error),
}
impl Error {
pub fn diagnostic(&self) -> Option<&str> {
match self {
Error::Exit { stdout, stderr, .. }
| Error::Timeout { stdout, stderr, .. }
| Error::Signalled { stdout, stderr, .. } => exit_diagnostic(stdout, stderr),
Error::Spawn { .. }
| Error::NotFound { .. }
| Error::CassetteMiss { .. }
| Error::OutputTooLarge { .. }
| Error::NotReady { .. }
| Error::Parse { .. }
| Error::Unsupported { .. }
| Error::Cancelled { .. }
| Error::Stdin { .. }
| Error::Io(_) => None,
#[cfg(feature = "limits")]
Error::ResourceLimit { .. } => None,
}
}
pub fn is_not_found(&self) -> bool {
matches!(self, Error::NotFound { .. })
}
pub fn is_permission_denied(&self) -> bool {
self.io_source()
.is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
}
pub fn is_transient(&self) -> bool {
self.io_source().is_some_and(is_transient_io)
}
fn io_source(&self) -> Option<&std::io::Error> {
match self {
Error::Spawn { source, .. } => Some(source),
Error::Io(source) => Some(source),
Error::NotFound { .. }
| Error::CassetteMiss { .. }
| Error::Exit { .. }
| Error::Timeout { .. }
| Error::OutputTooLarge { .. }
| Error::NotReady { .. }
| Error::Parse { .. }
| Error::Unsupported { .. }
| Error::Cancelled { .. }
| Error::Signalled { .. }
| Error::Stdin { .. } => None,
#[cfg(feature = "limits")]
Error::ResourceLimit { .. } => None,
}
}
}
impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Spawn { program, source } => f
.debug_struct("Spawn")
.field("program", program)
.field("source", source)
.finish(),
Error::NotFound { program, searched } => f
.debug_struct("NotFound")
.field("program", program)
.field("searched", &searched.as_deref().map(SearchedRedaction))
.finish(),
Error::CassetteMiss { program } => f
.debug_struct("CassetteMiss")
.field("program", program)
.finish(),
Error::Exit {
program,
code,
stdout,
stderr,
} => f
.debug_struct("Exit")
.field("program", program)
.field("code", code)
.field("stdout", &StreamPreview(stdout))
.field("stderr", &StreamPreview(stderr))
.finish(),
Error::Timeout {
program,
timeout,
stdout,
stderr,
} => f
.debug_struct("Timeout")
.field("program", program)
.field("timeout", timeout)
.field("stdout", &StreamPreview(stdout))
.field("stderr", &StreamPreview(stderr))
.finish(),
Error::OutputTooLarge {
program,
line_limit,
byte_limit,
total_lines,
total_bytes,
} => f
.debug_struct("OutputTooLarge")
.field("program", program)
.field("line_limit", line_limit)
.field("byte_limit", byte_limit)
.field("total_lines", total_lines)
.field("total_bytes", total_bytes)
.finish(),
Error::NotReady { program, timeout } => f
.debug_struct("NotReady")
.field("program", program)
.field("timeout", timeout)
.finish(),
Error::Parse { program, message } => f
.debug_struct("Parse")
.field("program", program)
.field("message", &StreamPreview(message))
.finish(),
#[cfg(feature = "limits")]
Error::ResourceLimit { message } => f
.debug_struct("ResourceLimit")
.field("message", &StreamPreview(message))
.finish(),
Error::Unsupported { operation } => f
.debug_struct("Unsupported")
.field("operation", operation)
.finish(),
Error::Cancelled { program } => f
.debug_struct("Cancelled")
.field("program", program)
.finish(),
Error::Signalled {
program,
signal,
stdout,
stderr,
} => f
.debug_struct("Signalled")
.field("program", program)
.field("signal", signal)
.field("stdout", &StreamPreview(stdout))
.field("stderr", &StreamPreview(stderr))
.finish(),
Error::Stdin { program, source } => f
.debug_struct("Stdin")
.field("program", program)
.field("source", source)
.finish(),
Error::Io(source) => f.debug_tuple("Io").field(source).finish(),
}
}
}
struct StreamPreview<'a>(&'a str);
impl fmt::Debug for StreamPreview<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
const CAP: usize = DIAG_CAP;
let s = self.0;
if s.len() <= CAP {
return fmt::Debug::fmt(s, f);
}
let mut cut = CAP;
while !s.is_char_boundary(cut) {
cut -= 1;
}
write!(f, "{:?}… (+{} bytes)", &s[..cut], s.len() - cut)
}
}
struct SearchedRedaction<'a>(&'a str);
impl fmt::Debug for SearchedRedaction<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
const SEP: char = if cfg!(windows) { ';' } else { ':' };
let n = self.0.split(SEP).filter(|s| !s.is_empty()).count();
write!(f, "<{n} directories>")
}
}
fn display_not_found(program: &str, searched: &Option<String>) -> String {
match searched {
Some(_) => format!("`{program}` not found on PATH"),
None => format!("`{program}` not found"),
}
}
fn display_parse(program: &str, message: &str) -> String {
let mut out = format!("failed to parse `{program}` output: ");
push_sanitized_capped(&mut out, message, DIAG_CAP);
out
}
fn display_signalled(program: &str, signal: Option<i32>, stdout: &str, stderr: &str) -> String {
let mut message = match signal {
Some(n) => format!("`{program}` was terminated by signal {n}"),
None => format!("`{program}` was terminated by a signal"),
};
append_diagnostic_tail(&mut message, stdout, stderr);
message
}
fn display_timeout(program: &str, timeout: Duration, stdout: &str, stderr: &str) -> String {
let mut message = format!("`{program}` timed out after {timeout:?}");
append_diagnostic_tail(&mut message, stdout, stderr);
message
}
fn is_transient_io(e: &std::io::Error) -> bool {
use std::io::ErrorKind;
if matches!(
e.kind(),
ErrorKind::Interrupted
| ErrorKind::WouldBlock
| ErrorKind::ResourceBusy
| ErrorKind::ExecutableFileBusy
) {
return true;
}
#[cfg(windows)]
{
matches!(e.raw_os_error(), Some(32 | 33))
}
#[cfg(not(windows))]
{
false
}
}
fn exit_diagnostic<'a>(stdout: &'a str, stderr: &'a str) -> Option<&'a str> {
[stderr, stdout]
.into_iter()
.map(str::trim)
.find(|text| !text.is_empty())
}
fn display_exit(program: &str, code: i32, stdout: &str, stderr: &str) -> String {
let mut message = format!("`{program}` exited with code {code}");
append_diagnostic_tail(&mut message, stdout, stderr);
message
}
const DIAG_CAP: usize = 200;
fn push_sanitized_capped(out: &mut String, text: &str, cap: usize) {
let mut written = 0usize;
for ch in text.chars() {
let ch = if is_display_unsafe(ch) {
'\u{FFFD}'
} else {
ch
};
if written + ch.len_utf8() > cap {
out.push('…');
return;
}
out.push(ch);
written += ch.len_utf8();
}
}
fn is_display_unsafe(ch: char) -> bool {
ch.is_control()
|| matches!(ch,
'\u{2028}' | '\u{2029}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{200E}' | '\u{200F}' | '\u{061C}' )
}
fn append_diagnostic_tail(message: &mut String, stdout: &str, stderr: &str) {
let tail = exit_diagnostic(stdout, stderr)
.and_then(|text| text.lines().rev().map(str::trim).find(|l| !l.is_empty()));
if let Some(tail) = tail {
message.push_str(": ");
push_sanitized_capped(message, tail, DIAG_CAP);
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_tail_sanitizes_control_chars_against_terminal_injection() {
let err = Error::Exit {
program: "tool".into(),
code: 1,
stdout: String::new(),
stderr: "boom\x1b[31m\x07\x00danger".into(),
};
let msg = err.to_string();
assert!(!msg.contains('\x1b'), "ESC must be sanitized: {msg:?}");
assert!(!msg.contains('\x07'), "BEL must be sanitized: {msg:?}");
assert!(!msg.contains('\x00'), "NUL must be sanitized: {msg:?}");
assert!(msg.contains("boom"), "printable text is kept: {msg:?}");
assert!(msg.contains("danger"), "printable text is kept: {msg:?}");
}
#[test]
fn display_tail_strips_bidi_controls_against_trojan_source() {
let err = Error::Exit {
program: "tool".into(),
code: 1,
stdout: String::new(),
stderr: "safe\u{202E}reversed\u{202C}\u{2066}iso".into(),
};
let msg = err.to_string();
assert!(!msg.contains('\u{202E}'), "RLO must be sanitized: {msg:?}");
assert!(!msg.contains('\u{202C}'), "PDF must be sanitized: {msg:?}");
assert!(!msg.contains('\u{2066}'), "LRI must be sanitized: {msg:?}");
assert!(msg.contains("safe"), "printable text is kept: {msg:?}");
}
#[test]
fn display_tail_strips_unicode_line_separators() {
let err = Error::Exit {
program: "tool".into(),
code: 1,
stdout: String::new(),
stderr: "before\u{2028}after\u{2029}end".into(),
};
let msg = err.to_string();
assert!(!msg.contains('\u{2028}'), "LS must be sanitized: {msg:?}");
assert!(!msg.contains('\u{2029}'), "PS must be sanitized: {msg:?}");
assert!(msg.contains("before"), "printable text is kept: {msg:?}");
assert!(msg.contains("after"), "printable text is kept: {msg:?}");
}
#[test]
fn parse_display_sanitizes_control_and_bidi_injection() {
let err = Error::Parse {
program: "jq".into(),
message: "bad\x1b[31m\x07token\u{202E}flip\u{2069}sep\u{2028}end".into(),
};
let msg = err.to_string();
assert!(!msg.contains('\x1b'), "ESC must be sanitized: {msg:?}");
assert!(!msg.contains('\x07'), "BEL must be sanitized: {msg:?}");
assert!(!msg.contains('\u{202E}'), "RLO must be sanitized: {msg:?}");
assert!(!msg.contains('\u{2069}'), "PDI must be sanitized: {msg:?}");
assert!(!msg.contains('\u{2028}'), "LS must be sanitized: {msg:?}");
assert!(msg.contains("bad"), "printable text is kept: {msg:?}");
assert!(msg.contains("token"), "printable text is kept: {msg:?}");
}
#[test]
fn debug_bounds_exit_streams_so_unwrap_cannot_dump_them() {
let huge = "x".repeat(10_000);
let err = Error::Exit {
program: "tool".into(),
code: 1,
stdout: huge.clone(),
stderr: huge,
};
let dbg = format!("{err:?}");
assert!(
dbg.len() < 700,
"Debug must be bounded (two 200-byte previews + struct), got {} bytes",
dbg.len()
);
assert!(
!dbg.contains(&"x".repeat(300)),
"must not dump the full multi-KiB stream"
);
assert!(dbg.contains("(+9800 bytes)"), "got: {dbg}");
let small = Error::Exit {
program: "tool".into(),
code: 2,
stdout: "hello".into(),
stderr: String::new(),
};
let dbg = format!("{small:?}");
assert!(dbg.contains("\"hello\""), "got: {dbg}");
assert!(
!dbg.contains("bytes)"),
"no truncation note for a short stream: {dbg}"
);
}
#[test]
fn debug_redacts_the_path_value_in_not_found() {
let err = Error::NotFound {
program: "tool".into(),
searched: Some("/secret/bin:/another/private/dir".into()),
};
let dbg = format!("{err:?}");
assert!(
!dbg.contains("/secret/bin") && !dbg.contains("/another/private/dir"),
"PATH value must not appear in Debug: {dbg}"
);
assert!(
dbg.contains("directories"),
"should summarize as a count: {dbg}"
);
}
#[test]
fn exit_display_appends_a_bounded_diagnostic_tail() {
let err = Error::Exit {
program: "git".into(),
code: 2,
stdout: "CONFLICT (content): merge conflict in a.rs".into(),
stderr: "warning: something\nfatal: boom\n".into(),
};
assert_eq!(err.to_string(), "`git` exited with code 2: fatal: boom");
let err = Error::Exit {
program: "git".into(),
code: 2,
stdout: "CONFLICT (content): merge conflict in a.rs".into(),
stderr: " ".into(),
};
assert_eq!(
err.to_string(),
"`git` exited with code 2: CONFLICT (content): merge conflict in a.rs"
);
}
#[test]
fn exit_display_with_blank_streams_has_no_trailing_colon() {
let err = Error::Exit {
program: "git".into(),
code: 2,
stdout: String::new(),
stderr: " \n ".into(),
};
assert_eq!(err.to_string(), "`git` exited with code 2");
}
#[test]
fn exit_display_tail_is_capped_and_never_leaks_the_stream() {
let huge = "é".repeat(3000); let err = Error::Exit {
program: "x".into(),
code: 1,
stdout: String::new(),
stderr: huge,
};
let message = err.to_string();
assert!(message.len() < 250, "capped, got {} bytes", message.len());
assert!(message.ends_with('…'), "got: {message}");
assert!(message.starts_with("`x` exited with code 1: éé"));
}
#[test]
fn diagnostic_is_none_for_non_exit_variants() {
let timeout = Error::Timeout {
program: "git".into(),
timeout: Duration::from_secs(1),
stdout: String::new(),
stderr: String::new(),
};
assert_eq!(timeout.diagnostic(), None);
let unsupported = Error::Unsupported {
operation: "suspend".into(),
};
assert_eq!(unsupported.diagnostic(), None);
let not_ready = Error::NotReady {
program: "server".into(),
timeout: Duration::from_secs(10),
};
assert_eq!(not_ready.diagnostic(), None);
{
let cancelled = Error::Cancelled {
program: "job".into(),
};
assert_eq!(cancelled.diagnostic(), None);
}
#[cfg(feature = "limits")]
{
let limit = Error::ResourceLimit {
message: "cgroup controller delegation unavailable".into(),
};
assert_eq!(limit.diagnostic(), None);
}
}
#[test]
fn cancelled_display_names_the_program() {
let err = Error::Cancelled {
program: "long-job".into(),
};
assert_eq!(err.to_string(), "`long-job` was cancelled");
assert_eq!(err.diagnostic(), None);
}
#[test]
fn timeout_and_signalled_carry_diagnostic_streams() {
let timeout = Error::Timeout {
program: "db-migrate".into(),
timeout: Duration::from_secs(30),
stdout: String::new(),
stderr: "connecting…\nwaiting for lock held by pid 4123\n".into(),
};
assert_eq!(
timeout.diagnostic(),
Some("connecting…\nwaiting for lock held by pid 4123")
);
assert_eq!(
timeout.to_string(),
"`db-migrate` timed out after 30s: waiting for lock held by pid 4123"
);
let signalled = Error::Signalled {
program: "worker".into(),
signal: Some(11),
stdout: "processing batch 7\n".into(),
stderr: String::new(),
};
assert_eq!(signalled.diagnostic(), Some("processing batch 7"));
assert_eq!(
signalled.to_string(),
"`worker` was terminated by signal 11: processing batch 7"
);
}
#[test]
fn timeout_and_signalled_debug_bounds_their_streams() {
let huge = "x".repeat(10_000);
let timeout = Error::Timeout {
program: "t".into(),
timeout: Duration::from_secs(1),
stdout: huge.clone(),
stderr: huge.clone(),
};
let dbg = format!("{timeout:?}");
assert!(dbg.len() < 800, "Debug must be bounded, got {}", dbg.len());
assert!(!dbg.contains(&"x".repeat(300)), "must not dump the stream");
assert!(dbg.contains("(+9800 bytes)"), "got: {dbg}");
let signalled = Error::Signalled {
program: "s".into(),
signal: None,
stdout: huge.clone(),
stderr: huge,
};
let dbg = format!("{signalled:?}");
assert!(dbg.len() < 800, "Debug must be bounded, got {}", dbg.len());
assert!(!dbg.contains(&"x".repeat(300)), "must not dump the stream");
}
#[test]
fn parse_message_is_bounded_in_display_and_debug() {
let huge = "x".repeat(10_000);
let err = Error::Parse {
program: "jq".into(),
message: huge,
};
let display = err.to_string();
assert!(
display.len() < 300,
"Display must be bounded, got {} bytes",
display.len()
);
assert!(display.starts_with("failed to parse `jq` output: "));
assert!(
display.ends_with('…'),
"truncated Display ends with ellipsis"
);
let dbg = format!("{err:?}");
assert!(
dbg.len() < 400,
"Debug must be bounded, got {} bytes",
dbg.len()
);
assert!(
!dbg.contains(&"x".repeat(300)),
"must not dump the full message: {dbg}"
);
assert!(dbg.contains("bytes)"), "truncation note present: {dbg}");
let small = Error::Parse {
program: "jq".into(),
message: "unexpected token at line 3".into(),
};
assert_eq!(
small.to_string(),
"failed to parse `jq` output: unexpected token at line 3"
);
assert!(!format!("{small:?}").contains("bytes)"));
}
#[test]
fn not_ready_display_names_program_and_timeout() {
let err = Error::NotReady {
program: "my-server".into(),
timeout: Duration::from_secs(10),
};
assert_eq!(err.to_string(), "`my-server` was not ready after 10s");
}
#[test]
fn unsupported_display_names_the_operation() {
let err = Error::Unsupported {
operation: "signal(Hup)".into(),
};
assert_eq!(
err.to_string(),
"operation `signal(Hup)` is not supported on this platform"
);
}
#[cfg(feature = "limits")]
#[test]
fn resource_limit_display_carries_reason() {
let err = Error::ResourceLimit {
message: "no cgroup or Job Object available".into(),
};
assert_eq!(
err.to_string(),
"could not enforce resource limits: no cgroup or Job Object available"
);
}
#[test]
fn signalled_display_and_diagnostic() {
let with_signal = Error::Signalled {
program: "git".into(),
signal: Some(9),
stdout: String::new(),
stderr: String::new(),
};
assert_eq!(with_signal.to_string(), "`git` was terminated by signal 9");
assert_eq!(with_signal.diagnostic(), None);
assert!(!with_signal.is_not_found());
assert!(!with_signal.is_permission_denied());
assert!(!with_signal.is_transient());
let no_signal = Error::Signalled {
program: "git".into(),
signal: None,
stdout: String::new(),
stderr: String::new(),
};
assert_eq!(no_signal.to_string(), "`git` was terminated by a signal");
}
#[test]
fn not_found_display_and_classifier() {
let err = Error::NotFound {
program: "my-tool".into(),
searched: Some("/usr/bin:/usr/local/bin".into()),
};
let display = err.to_string();
assert_eq!(display, "`my-tool` not found on PATH");
assert!(
!display.contains("/usr/bin"),
"Display must not expose PATH value: {display}"
);
assert!(err.is_not_found(), "NotFound must satisfy is_not_found()");
assert!(!err.is_permission_denied());
assert!(!err.is_transient());
assert_eq!(err.diagnostic(), None);
}
#[test]
fn not_found_without_path_search_omits_on_path() {
let err = Error::NotFound {
program: "/no/such/tool".into(),
searched: None,
};
assert_eq!(err.to_string(), "`/no/such/tool` not found");
assert!(err.is_not_found());
let bare = Error::NotFound {
program: "tool".into(),
searched: Some("/usr/bin".into()),
};
assert_eq!(bare.to_string(), "`tool` not found on PATH");
}
fn spawn(kind: std::io::ErrorKind) -> Error {
Error::Spawn {
program: "x".into(),
source: std::io::Error::from(kind),
}
}
#[test]
fn not_found_and_permission_denied_are_classified_on_spawn_and_io() {
use std::io::ErrorKind::{NotFound, PermissionDenied};
assert!(
Error::NotFound {
program: "x".into(),
searched: None,
}
.is_not_found()
);
assert!(!spawn(NotFound).is_not_found());
assert!(!Error::Io(std::io::Error::from(NotFound)).is_not_found());
assert!(!spawn(NotFound).is_permission_denied());
assert!(spawn(PermissionDenied).is_permission_denied());
assert!(!spawn(PermissionDenied).is_not_found());
assert!(!spawn(NotFound).is_transient());
assert!(!spawn(PermissionDenied).is_transient());
}
#[test]
fn transient_kinds_are_classified() {
for kind in [
std::io::ErrorKind::Interrupted,
std::io::ErrorKind::WouldBlock,
std::io::ErrorKind::ResourceBusy,
std::io::ErrorKind::ExecutableFileBusy,
] {
assert!(spawn(kind).is_transient(), "{kind:?} should be transient");
assert!(
Error::Io(std::io::Error::from(kind)).is_transient(),
"{kind:?} (Io) should be transient"
);
}
}
#[cfg(unix)]
#[test]
fn etxtbsy_is_transient_on_unix() {
let err = Error::Spawn {
program: "busy".into(),
source: std::io::Error::from_raw_os_error(libc::ETXTBSY),
};
assert!(err.is_transient());
assert!(!err.is_not_found() && !err.is_permission_denied());
}
#[cfg(windows)]
#[test]
fn sharing_and_lock_violations_are_transient_on_windows() {
for code in [32, 33] {
let err = Error::Spawn {
program: "locked".into(),
source: std::io::Error::from_raw_os_error(code),
};
assert!(
err.is_transient(),
"raw os error {code} should be transient"
);
}
}
#[test]
fn classifiers_are_false_for_non_io_variants() {
let exit = Error::Exit {
program: "git".into(),
code: 128,
stdout: String::new(),
stderr: "could not resolve host".into(),
};
assert!(!exit.is_not_found() && !exit.is_permission_denied() && !exit.is_transient());
let timeout = Error::Timeout {
program: "x".into(),
timeout: Duration::from_secs(1),
stdout: String::new(),
stderr: String::new(),
};
assert!(
!timeout.is_transient(),
"Timeout is excluded from is_transient by design"
);
}
}