mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-11 03:40:05 +00:00
412 lines
14 KiB
Rust
412 lines
14 KiB
Rust
use std::fmt::Write as _;
|
|
|
|
#[cfg(feature = "docker")]
|
|
use bollard::errors::Error as BollardError;
|
|
use fabro_util::error::{collect_causes, render_with_causes};
|
|
|
|
use crate::ExecResult;
|
|
|
|
#[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,
|
|
},
|
|
|
|
#[cfg(feature = "docker")]
|
|
#[error("Failed to connect to Docker daemon")]
|
|
DockerConnect {
|
|
#[source]
|
|
source: BollardError,
|
|
},
|
|
|
|
#[cfg(feature = "docker")]
|
|
#[error("Failed to inspect Docker image {image}")]
|
|
DockerImageInspect {
|
|
image: String,
|
|
#[source]
|
|
source: BollardError,
|
|
},
|
|
|
|
#[cfg(feature = "docker")]
|
|
#[error("Failed to pull Docker image {image}")]
|
|
DockerImagePull {
|
|
image: String,
|
|
#[source]
|
|
source: BollardError,
|
|
},
|
|
|
|
#[error(
|
|
"{label} failed (exit {exit}, termination={termination}, duration_ms={duration_ms}) - hint: {hint}",
|
|
exit = format_exit_code(result.exit_code),
|
|
termination = result.termination,
|
|
duration_ms = result.duration_ms,
|
|
hint = classify_exec_failure(&result.stderr)
|
|
.or_else(|| classify_exec_failure(&result.stdout))
|
|
.unwrap_or("unclassified")
|
|
)]
|
|
Exec { label: String, result: ExecResult },
|
|
}
|
|
|
|
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 exec(label: impl Into<String>, result: ExecResult) -> Self {
|
|
Self::Exec {
|
|
label: label.into(),
|
|
result,
|
|
}
|
|
}
|
|
|
|
pub fn default_redacted_output_tail(&self) -> Option<fabro_types::ExecOutputTail> {
|
|
default_redacted_output_tail(self)
|
|
}
|
|
|
|
#[cfg(feature = "docker")]
|
|
pub fn docker_connect(source: BollardError) -> Self {
|
|
Self::DockerConnect { source }
|
|
}
|
|
|
|
#[cfg(feature = "docker")]
|
|
pub fn docker_image_inspect(image: impl Into<String>, source: BollardError) -> Self {
|
|
Self::DockerImageInspect {
|
|
image: image.into(),
|
|
source,
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "docker")]
|
|
pub fn docker_image_pull(image: impl Into<String>, source: BollardError) -> Self {
|
|
Self::DockerImagePull {
|
|
image: image.into(),
|
|
source,
|
|
}
|
|
}
|
|
|
|
pub fn causes(&self) -> Vec<String> {
|
|
collect_causes(self)
|
|
}
|
|
|
|
pub fn display_with_causes(&self) -> String {
|
|
render_with_causes(&self.to_string(), &self.causes())
|
|
}
|
|
}
|
|
|
|
impl From<String> for Error {
|
|
fn from(value: String) -> Self {
|
|
Self::Message(value)
|
|
}
|
|
}
|
|
|
|
impl From<&str> for Error {
|
|
fn from(value: &str) -> Self {
|
|
Self::Message(value.to_string())
|
|
}
|
|
}
|
|
|
|
pub(crate) fn classify_exec_failure(stderr: &str) -> Option<&'static str> {
|
|
let lower = stderr.to_ascii_lowercase();
|
|
if lower.contains("could not read username") || lower.contains("terminal prompts disabled") {
|
|
Some(
|
|
"no credentials in origin URL - check that the sandbox forwarded \
|
|
GITHUB_APP_PRIVATE_KEY (or GITHUB_TOKEN) and that refresh_push_credentials succeeded",
|
|
)
|
|
} else if lower.contains("permission to") && lower.contains("denied") {
|
|
Some(
|
|
"github denied the push - installation token lacks contents:write \
|
|
on this repo, or a branch protection / push ruleset is rejecting the ref",
|
|
)
|
|
} else if lower.contains("protected branch")
|
|
|| lower.contains("ruleset")
|
|
|| lower.contains("rejected")
|
|
{
|
|
Some("github rejected the ref - likely a branch protection rule or push ruleset")
|
|
} else if lower.contains("authentication failed") || lower.contains("invalid username") {
|
|
Some("github authentication failed - installation token may be expired or wrong scope")
|
|
} else if lower.contains("could not resolve host") || lower.contains("network is unreachable") {
|
|
Some("network failure inside sandbox - check DNS / egress from the run container")
|
|
} else if lower.contains("repository not found") {
|
|
Some("github 404 - repository is unavailable to the current credentials")
|
|
} else if lower.contains("no such remote") && lower.contains("origin") {
|
|
Some("origin remote missing - push credentials could not be installed")
|
|
} else if lower.contains("not a git repository")
|
|
|| lower.contains("does not appear to be a git repository")
|
|
{
|
|
Some("git repository unavailable in sandbox working directory")
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn format_exit_code(exit_code: Option<i32>) -> String {
|
|
exit_code.map_or_else(|| "none".to_string(), |code| code.to_string())
|
|
}
|
|
|
|
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::Exec { result, .. }) = err.downcast_ref::<Error>() {
|
|
return result.default_redacted_output_tail();
|
|
}
|
|
current = err.source();
|
|
}
|
|
None
|
|
}
|
|
|
|
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 fabro_types::CommandTermination;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn exec_display_is_log_safe() {
|
|
let stderr = "fatal: unable to access \
|
|
'https://x-access-token:ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA@github.com/owner/repo/':\n\
|
|
remote: Permission to owner/repo.git denied\n\
|
|
identity ~/.ssh/id_rsa_work";
|
|
let error = Error::exec("git push origin refs/heads/run", crate::ExecResult {
|
|
stdout: String::new(),
|
|
stderr: stderr.to_string(),
|
|
exit_code: Some(128),
|
|
termination: CommandTermination::Exited,
|
|
duration_ms: 210,
|
|
});
|
|
let rendered = error.to_string();
|
|
|
|
assert_exec_rendering_is_safe(&rendered);
|
|
assert!(rendered.contains("git push origin refs/heads/run"));
|
|
assert!(rendered.contains("exit 128"));
|
|
assert!(rendered.contains("termination=exited"));
|
|
assert!(rendered.contains("duration_ms=210"));
|
|
assert!(rendered.contains("hint:"));
|
|
}
|
|
|
|
#[test]
|
|
fn display_with_causes_does_not_reintroduce_raw_exec_output() {
|
|
let stderr = "fatal: unable to access \
|
|
'https://x-access-token:ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA@github.com/owner/repo/':\n\
|
|
remote: Permission to owner/repo.git denied\n\
|
|
identity ~/.ssh/id_rsa_work";
|
|
let exec_error = Error::exec("git push origin refs/heads/run", crate::ExecResult {
|
|
stdout: "stdout secret ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA".to_string(),
|
|
stderr: stderr.to_string(),
|
|
exit_code: Some(128),
|
|
termination: CommandTermination::Exited,
|
|
duration_ms: 210,
|
|
});
|
|
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"));
|
|
assert!(rendered.contains("hint:"));
|
|
}
|
|
|
|
#[test]
|
|
fn display_for_log_walks_context_chain_and_emits_tail() {
|
|
let exec_error = Error::exec("git push origin refs/heads/run", crate::ExecResult {
|
|
stdout: "last stdout line".to_string(),
|
|
stderr: "last stderr line".to_string(),
|
|
exit_code: Some(128),
|
|
termination: CommandTermination::Exited,
|
|
duration_ms: 210,
|
|
});
|
|
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 = Error::exec("git push origin refs/heads/run", crate::ExecResult {
|
|
stdout: "stdout secret ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA".to_string(),
|
|
stderr: "stderr secret ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA".to_string(),
|
|
exit_code: Some(128),
|
|
termination: CommandTermination::Exited,
|
|
duration_ms: 210,
|
|
});
|
|
|
|
let rendered = display_for_log(&error);
|
|
|
|
assert!(
|
|
!rendered.contains("ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"),
|
|
"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",
|
|
"ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA",
|
|
"~/.ssh",
|
|
"id_rsa_work",
|
|
] {
|
|
assert!(
|
|
!rendered.contains(forbidden),
|
|
"Display leaked {forbidden:?}: {rendered}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn exec_error_exposes_default_redacted_output_tail() {
|
|
let stderr = "stderr secret ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA";
|
|
let error = Error::exec("git push origin refs/heads/run", crate::ExecResult {
|
|
stdout: "last stdout line".to_string(),
|
|
stderr: stderr.to_string(),
|
|
exit_code: Some(128),
|
|
termination: CommandTermination::Exited,
|
|
duration_ms: 210,
|
|
});
|
|
|
|
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 = Error::exec("git push origin refs/heads/run", crate::ExecResult {
|
|
stdout: "last stdout line".to_string(),
|
|
stderr: "last stderr line".to_string(),
|
|
exit_code: Some(128),
|
|
termination: CommandTermination::Exited,
|
|
duration_ms: 210,
|
|
});
|
|
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"));
|
|
}
|
|
|
|
#[test]
|
|
fn classify_exec_failure_documents_known_branches() {
|
|
let cases = [
|
|
(
|
|
"fatal: could not read Username for 'https://github.com'",
|
|
"no credentials in origin URL",
|
|
),
|
|
(
|
|
"remote: Permission to owner/repo.git denied to fabro-app[bot].",
|
|
"github denied the push",
|
|
),
|
|
(
|
|
"remote: error: GH013: Repository rule violations found due to ruleset",
|
|
"github rejected the ref",
|
|
),
|
|
(
|
|
"fatal: Authentication failed for 'https://github.com/owner/repo'",
|
|
"github authentication failed",
|
|
),
|
|
(
|
|
"fatal: could not resolve host: github.com",
|
|
"network failure",
|
|
),
|
|
("remote: Repository not found.", "github 404"),
|
|
("error: No such remote 'origin'", "origin remote missing"),
|
|
("fatal: not a git repository", "git repository unavailable"),
|
|
];
|
|
|
|
for (stderr, expected) in cases {
|
|
let hint = classify_exec_failure(stderr).expect(stderr);
|
|
assert!(
|
|
hint.contains(expected),
|
|
"expected {hint:?} to contain {expected:?}"
|
|
);
|
|
}
|
|
assert_eq!(classify_exec_failure("weird new git error"), None);
|
|
}
|
|
}
|