use actix_web::HttpResponse;
use bamboo_plugin::PluginError;
const GENERIC_INTERNAL_ERROR_MESSAGE: &str = "internal error during plugin operation";
pub fn plugin_error_response(error: &PluginError) -> HttpResponse {
let actionable_status = match error {
PluginError::Conflict { .. } => Some(actix_web::http::StatusCode::CONFLICT),
PluginError::AlreadyInstalled(_) => Some(actix_web::http::StatusCode::CONFLICT),
PluginError::UnsupportedPlatform { .. } => {
Some(actix_web::http::StatusCode::UNPROCESSABLE_ENTITY)
}
PluginError::NotFound(_) => Some(actix_web::http::StatusCode::NOT_FOUND),
PluginError::InvalidManifest(_) => Some(actix_web::http::StatusCode::BAD_REQUEST),
PluginError::ArtifactVerificationFailed(_) => {
Some(actix_web::http::StatusCode::BAD_REQUEST)
}
PluginError::BundleVerificationFailed(_) => Some(actix_web::http::StatusCode::BAD_REQUEST),
PluginError::ChecksumRequired(_) => Some(actix_web::http::StatusCode::BAD_REQUEST),
PluginError::UntrustedHost(_) => Some(actix_web::http::StatusCode::FORBIDDEN),
PluginError::UnsignedOrUntrustedSignature(_) => {
Some(actix_web::http::StatusCode::FORBIDDEN)
}
PluginError::RedirectRefused(_) => Some(actix_web::http::StatusCode::FORBIDDEN),
PluginError::Registration(_)
| PluginError::NotImplemented(_)
| PluginError::Io(_)
| PluginError::Json(_) => None,
};
match actionable_status {
Some(status) => {
let body = serde_json::json!({ "error": error.to_string() });
HttpResponse::build(status).json(body)
}
None => {
tracing::error!(%error, "plugin operation failed with an internal error");
let body = serde_json::json!({ "error": GENERIC_INTERNAL_ERROR_MESSAGE });
HttpResponse::InternalServerError().json(body)
}
}
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use bamboo_plugin::PluginError;
use super::plugin_error_response;
#[test]
fn maps_every_variant_to_the_documented_status() {
let cases: Vec<(PluginError, StatusCode)> = vec![
(
PluginError::Conflict {
kind: "mcp server",
name: "shared-tool".to_string(),
plugin_id: "demo".to_string(),
},
StatusCode::CONFLICT,
),
(
PluginError::AlreadyInstalled("demo".to_string()),
StatusCode::CONFLICT,
),
(
PluginError::UnsupportedPlatform {
plugin_id: "demo".to_string(),
platform: "linux".to_string(),
},
StatusCode::UNPROCESSABLE_ENTITY,
),
(
PluginError::NotFound("demo".to_string()),
StatusCode::NOT_FOUND,
),
(
PluginError::InvalidManifest("bad id".to_string()),
StatusCode::BAD_REQUEST,
),
(
PluginError::ArtifactVerificationFailed("sha256 mismatch".to_string()),
StatusCode::BAD_REQUEST,
),
(
PluginError::BundleVerificationFailed("sha256 mismatch".to_string()),
StatusCode::BAD_REQUEST,
),
(
PluginError::ChecksumRequired("refusing to install without a checksum".to_string()),
StatusCode::BAD_REQUEST,
),
(
PluginError::UntrustedHost("host not in trusted_hosts".to_string()),
StatusCode::FORBIDDEN,
),
(
PluginError::UnsignedOrUntrustedSignature("bundle is unsigned".to_string()),
StatusCode::FORBIDDEN,
),
(
PluginError::RedirectRefused("refused to follow a redirect".to_string()),
StatusCode::FORBIDDEN,
),
(
PluginError::Registration("config write failed".to_string()),
StatusCode::INTERNAL_SERVER_ERROR,
),
(
PluginError::NotImplemented("todo".to_string()),
StatusCode::INTERNAL_SERVER_ERROR,
),
(
PluginError::Io(std::io::Error::other("boom")),
StatusCode::INTERNAL_SERVER_ERROR,
),
];
for (error, expected) in cases {
let response = plugin_error_response(&error);
assert_eq!(response.status(), expected, "{error}");
}
}
#[actix_web::test]
async fn error_body_is_a_flat_error_string() {
let error = PluginError::NotFound("demo".to_string());
let response = plugin_error_response(&error);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let bytes = actix_web::body::to_bytes(response.into_body())
.await
.expect("read body");
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
assert_eq!(
json,
serde_json::json!({ "error": "plugin not found: demo" })
);
}
#[actix_web::test]
async fn internal_error_variants_get_a_generic_sanitized_body() {
let sensitive_path = "/home/alice/.bamboo/plugins/some-plugin/secret-detail";
let cases: Vec<PluginError> = vec![
PluginError::Registration(format!("failed touching {sensitive_path}")),
PluginError::NotImplemented(format!("not done yet: {sensitive_path}")),
PluginError::Io(std::io::Error::other(format!(
"permission denied: {sensitive_path}"
))),
PluginError::Json(
serde_json::from_str::<serde_json::Value>("{ not valid json")
.expect_err("deliberately malformed"),
),
];
for error in cases {
let response = plugin_error_response(&error);
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let bytes = actix_web::body::to_bytes(response.into_body())
.await
.expect("read body");
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
assert_eq!(
json,
serde_json::json!({ "error": "internal error during plugin operation" }),
"500 body must be the fixed generic message, not {error}'s own Display text"
);
let body_text = String::from_utf8(bytes.to_vec()).unwrap();
assert!(
!body_text.contains("/home/alice"),
"500 body must never contain a local filesystem path fragment"
);
}
}
}