fabro/lib/components/fabro-sandbox/src/error.rs
Bryan Helmkamp f69a6779c8
Shrink fabro-sandbox's error module to what callers use
After the driver rounds, fabro-sandbox's Error keeps four variants:
Message, Context, AnyhowContext, and Driver. The module still carried
helpers written for callers that never arrived: incomplete_operation,
is_transport, and is_unsupported classified driver variants nothing in
fabro branches on; From<String> and From<&str> let a bare string become
an error, which no call site did; driver_error duplicated the From impl;
exec_failure and is_not_found had only test callers, and those tests can
match on the driver error directly.

This removes them. Callers build a Driver error through Error::from, and
the two tests that inspected a failure now match on Error::driver(). The
Driver variant's doc names the driver variants fabro does act on: Exec,
Git, and NotFound. The redaction and log-rendering helpers stay; they
are what the error module is for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:56:34 -06:00

300 lines
9.5 KiB
Rust

use std::fmt::Write as _;
use fabro_util::error::{collect_causes, render_with_causes};
use crate::sandbox::{DEFAULT_EXEC_OUTPUT_TAIL_BYTES, redacted_output_tail};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("{0}")]
Message(String),
#[error("{message}")]
Context {
message: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error("{message}")]
AnyhowContext {
message: String,
#[source]
source: anyhow::Error,
},
/// A sandbox-driver failure: provider, transport, or an operation whose
/// outcome is unknown. The driver's own variants stay reachable through
/// [`Error::driver`] so callers can act on `Exec`, `Git`, and `NotFound`
/// without string matching.
#[error(transparent)]
Driver(Box<sandbox_driver::Error>),
}
impl Error {
pub fn message(message: impl Into<String>) -> Self {
Self::Message(message.into())
}
pub fn context(
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::Context {
message: message.into(),
source: Box::new(source),
}
}
pub fn context_anyhow(message: impl Into<String>, source: anyhow::Error) -> Self {
Self::AnyhowContext {
message: message.into(),
source,
}
}
pub fn default_redacted_output_tail(&self) -> Option<fabro_types::ExecOutputTail> {
default_redacted_output_tail(self)
}
pub fn causes(&self) -> Vec<String> {
collect_causes(self)
}
/// The underlying sandbox-driver error, when this error carries one
/// anywhere in its chain.
pub fn driver(&self) -> Option<&sandbox_driver::Error> {
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(self);
while let Some(err) = current {
if let Some(Self::Driver(driver)) = err.downcast_ref::<Self>() {
return Some(driver.as_ref());
}
if let Some(driver) = err.downcast_ref::<sandbox_driver::Error>() {
return Some(driver);
}
current = err.source();
}
None
}
pub fn display_with_causes(&self) -> String {
render_with_causes(&self.to_string(), &self.causes())
}
}
impl From<sandbox_driver::Error> for Error {
fn from(value: sandbox_driver::Error) -> Self {
Self::Driver(Box::new(value))
}
}
pub type Result<T> = std::result::Result<T, Error>;
pub fn default_redacted_output_tail(
err: &(dyn std::error::Error + 'static),
) -> Option<fabro_types::ExecOutputTail> {
let mut current = Some(err);
while let Some(err) = current {
if let Some(Error::Driver(driver)) = err.downcast_ref::<Error>() {
if let Some(tail) = driver_output_tail(driver) {
return Some(tail);
}
}
if let Some(driver) = err.downcast_ref::<sandbox_driver::Error>() {
if let Some(tail) = driver_output_tail(driver) {
return Some(tail);
}
}
current = err.source();
}
None
}
/// The output a driver failure carries: a command that ran and failed, or
/// a git operation whose command output the driver kept as evidence.
fn driver_output_tail(error: &sandbox_driver::Error) -> Option<fabro_types::ExecOutputTail> {
let failure = match error {
sandbox_driver::Error::Exec(failure) => failure,
sandbox_driver::Error::Git(git) => git.output()?,
_ => return None,
};
redacted_output_tail(
&String::from_utf8_lossy(failure.stdout()),
&String::from_utf8_lossy(failure.stderr()),
DEFAULT_EXEC_OUTPUT_TAIL_BYTES,
)
}
pub fn display_for_log(err: &(dyn std::error::Error + 'static)) -> String {
let mut rendered = render_with_causes(&err.to_string(), &collect_causes(err));
if let Some(tail) = default_redacted_output_tail(err) {
append_tail_for_log(
&mut rendered,
"stderr",
tail.stderr.as_deref(),
tail.stderr_truncated,
);
append_tail_for_log(
&mut rendered,
"stdout",
tail.stdout.as_deref(),
tail.stdout_truncated,
);
}
rendered
}
fn append_tail_for_log(rendered: &mut String, stream: &str, tail: Option<&str>, truncated: bool) {
let tail = tail.unwrap_or("");
let _ = write!(
rendered,
"\n--- {stream} (truncated={truncated}, bytes={}) ---\n{tail}",
tail.len()
);
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use sandbox_driver::{ExecResult, Termination};
use super::*;
use crate::exec::ExecResultExt;
const SECRET: &str = "ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA";
fn failed_push(stdout: &str, stderr: &str) -> Error {
let mut result =
ExecResult::new(Termination::Exited, Some(128), Duration::from_millis(210));
result.stdout = stdout.as_bytes().to_vec();
result.stderr = stderr.as_bytes().to_vec();
result.into_exec_error("git push origin refs/heads/run")
}
fn leaky_stderr() -> String {
format!(
"fatal: unable to access 'https://x-access-token:{SECRET}@github.com/owner/repo/':\n\
remote: Permission to owner/repo.git denied\n\
identity ~/.ssh/id_rsa_work"
)
}
#[test]
fn exec_display_is_log_safe() {
let error = failed_push("", &leaky_stderr());
let rendered = error.to_string();
assert_exec_rendering_is_safe(&rendered);
assert!(rendered.contains("git push origin refs/heads/run"));
assert!(rendered.contains("128"));
assert!(rendered.contains("210 ms"));
}
#[test]
fn display_with_causes_does_not_reintroduce_raw_exec_output() {
let exec_error = failed_push(&format!("stdout secret {SECRET}"), &leaky_stderr());
let error = Error::context("metadata push failed", exec_error);
let rendered = error.display_with_causes();
assert_exec_rendering_is_safe(&rendered);
assert!(rendered.contains("metadata push failed"));
assert!(rendered.contains("git push origin refs/heads/run"));
}
#[test]
fn the_driver_error_is_reachable_through_the_context_chain() {
let error = Error::context("metadata push failed", failed_push("", "boom"));
let Some(sandbox_driver::Error::Exec(failure)) = error.driver() else {
panic!("expected an exec failure, got {error:?}");
};
assert_eq!(failure.label(), "git push origin refs/heads/run");
assert_eq!(failure.exit_code(), Some(128));
assert_eq!(failure.termination(), Termination::Exited);
assert!(Error::message("plain").driver().is_none());
}
#[test]
fn display_for_log_walks_context_chain_and_emits_tail() {
let exec_error = failed_push("last stdout line", "last stderr line");
let error = Error::context("metadata push failed", exec_error);
let rendered = display_for_log(&error);
assert!(rendered.contains("metadata push failed"));
assert!(rendered.contains("git push origin refs/heads/run"));
assert!(rendered.contains("--- stderr (truncated=false, bytes=16) ---"));
assert!(rendered.contains("last stderr line"));
assert!(rendered.contains("--- stdout (truncated=false, bytes=16) ---"));
assert!(rendered.contains("last stdout line"));
}
#[test]
fn display_for_log_redacts_secrets() {
let error = failed_push(
&format!("stdout secret {SECRET}"),
&format!("stderr secret {SECRET}"),
);
let rendered = display_for_log(&error);
assert!(
!rendered.contains(SECRET),
"log rendering leaked raw secret: {rendered}"
);
assert!(rendered.contains("REDACTED"));
}
#[test]
fn display_for_log_for_non_exec_error_returns_chain_only() {
let error = Error::context("outer failure", std::io::Error::other("leaf failure"));
let rendered = display_for_log(&error);
assert_eq!(rendered, "outer failure\n caused by: leaf failure");
assert!(!rendered.contains("--- stderr"));
assert!(!rendered.contains("--- stdout"));
}
fn assert_exec_rendering_is_safe(rendered: &str) {
for forbidden in [
"fatal:",
"remote:",
"x-access-token",
SECRET,
"~/.ssh",
"id_rsa_work",
] {
assert!(
!rendered.contains(forbidden),
"Display leaked {forbidden:?}: {rendered}"
);
}
}
#[test]
fn exec_error_exposes_default_redacted_output_tail() {
let error = failed_push("last stdout line", &format!("stderr secret {SECRET}"));
let tail = error.default_redacted_output_tail().expect("tail present");
assert_eq!(tail.stdout.as_deref(), Some("last stdout line"));
assert!(
tail.stderr
.as_deref()
.expect("stderr tail")
.contains("REDACTED")
);
}
#[test]
fn free_tail_helper_walks_context_chain() {
let exec_error = failed_push("last stdout line", "last stderr line");
let error = Error::context("metadata push failed", exec_error);
let tail = default_redacted_output_tail(&error).expect("tail present");
assert_eq!(tail.stdout.as_deref(), Some("last stdout line"));
assert_eq!(tail.stderr.as_deref(), Some("last stderr line"));
}
}