use axum::extract::FromRequest; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; #[derive(Debug, thiserror::Error)] pub enum Error { #[error("not found")] NotFound, #[error("bad request: {0}")] BadRequest(String), #[error("gate not satisfied: {0}")] GateBlocked(String), #[error(transparent)] Db(#[from] sqlx::Error), #[error(transparent)] Other(#[from] anyhow::Error), } impl IntoResponse for Error { fn into_response(self) -> Response { // Client errors carry their (already user-facing) message; server errors // are logged in full but return a generic body so internal detail (SQL // text, anyhow chains, file paths) never leaks to the client. Every // response is the JSON envelope `{"error": }`, matching Bento // and the 400/404/409/500 shape the endpoints already speak. let (status, message) = match &self { Error::NotFound => (StatusCode::NOT_FOUND, self.to_string()), Error::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), Error::GateBlocked(_) => (StatusCode::CONFLICT, self.to_string()), Error::Db(e) => { tracing::error!(error = %e, "internal db error"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string(), ) } Error::Other(e) => { tracing::error!(error = format!("{e:#}"), "internal error"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string(), ) } }; (status, axum::Json(serde_json::json!({ "error": message }))).into_response() } } pub type Result = std::result::Result; /// A JSON request body that funnels deserialization failures through the daemon /// `Error` envelope instead of axum's raw `422 Unprocessable Entity`. A bare /// `Json` extractor rejects with axum's own plain-text 422, which is outside /// the 400/404/409/500 contract every other endpoint speaks; `TypedBody` maps /// any rejection to `Error::BadRequest` (400) so a non-TUI driver gets one /// consistent error shape. Use this for every handler that takes a required JSON /// body. pub struct TypedBody(pub T); impl FromRequest for TypedBody where T: serde::de::DeserializeOwned, S: Send + Sync, { type Rejection = Error; async fn from_request( req: axum::extract::Request, state: &S, ) -> std::result::Result { let axum::Json(value) = axum::Json::::from_request(req, state) .await .map_err(|rej| { Error::BadRequest(format!("invalid request body: {}", rej.body_text())) })?; Ok(TypedBody(value)) } } #[cfg(test)] mod tests { use super::*; use axum::response::IntoResponse; use http_body_util::BodyExt; async fn parts(err: Error) -> (StatusCode, serde_json::Value) { let resp = err.into_response(); let status = resp.status(); let bytes = resp.into_body().collect().await.unwrap().to_bytes(); (status, serde_json::from_slice(&bytes).unwrap()) } #[tokio::test] async fn client_errors_keep_their_message_in_a_json_envelope() { let (status, body) = parts(Error::BadRequest("no version specified".into())).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body["error"], "bad request: no version specified"); let (status, body) = parts(Error::GateBlocked("boot_smoke red".into())).await; assert_eq!(status, StatusCode::CONFLICT); assert_eq!(body["error"], "gate not satisfied: boot_smoke red"); } /// The leak fix: a server error is a generic body, never the underlying SQL /// text or anyhow chain (which used to reach the client via `to_string()`). #[tokio::test] async fn server_errors_do_not_leak_internal_detail() { let secret = "no such column: super_secret_internal_table.token"; let (status, body) = parts(Error::Other(anyhow::anyhow!(secret))).await; assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); assert_eq!(body["error"], "internal server error"); assert!( !body.to_string().contains("super_secret"), "internal detail must not reach the client: {body}" ); } }