use axum::{Json, http::StatusCode, response::IntoResponse};
use serde_json::json;
#[must_use]
pub fn sanitize_bulk_row_error(raw: &str) -> &'static str {
let lower = raw.to_ascii_lowercase();
if lower.contains("cannot be empty")
|| lower.contains("exceeds max")
|| lower.contains("invalid characters")
|| lower.contains("invalid control characters")
|| lower.contains("must be")
|| lower.contains("required")
{
return "validation failed";
}
if lower.contains("already exists in namespace") || lower.contains("unique constraint") {
return "conflict: already exists";
}
if lower.contains("not found") {
return "not found";
}
if lower.contains("denied by governance") || lower.contains("permission") {
return "forbidden";
}
if lower.contains("quorum") || lower.contains("fanout") || lower.contains("peer") {
return "replication unavailable";
}
"internal error"
}
#[allow(dead_code)]
pub(crate) fn internal_error_response(
context: &'static str,
err: &dyn std::fmt::Display,
) -> axum::response::Response {
tracing::error!("{context}: {err}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": crate::errors::msg::INTERNAL_SERVER_ERROR})),
)
.into_response()
}
pub(crate) fn handler_error_500(e: &dyn std::fmt::Display) -> axum::response::Response {
tracing::error!("handler error: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": crate::errors::msg::INTERNAL_SERVER_ERROR})),
)
.into_response()
}
pub(crate) fn governance_error_500(e: &dyn std::fmt::Display) -> axum::response::Response {
tracing::error!("governance error: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": crate::errors::msg::GOVERNANCE_CHECK_FAILED})),
)
.into_response()
}
pub(crate) fn bad_request_opaque(
context: &'static str,
err: &dyn std::fmt::Display,
) -> axum::response::Response {
tracing::warn!("{context}: {err}");
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "invalid request"})),
)
.into_response()
}
pub(crate) fn to_value_or_500<T: serde::Serialize + ?Sized>(
context: &'static str,
value: &T,
) -> Result<serde_json::Value, axum::response::Response> {
serde_json::to_value(value).map_err(|e| {
tracing::error!("{context}: serialise to JSON failed: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "internal server error: response serialisation failed"})),
)
.into_response()
})
}
#[cfg(test)]
mod tests {
use super::{
bad_request_opaque, governance_error_500, handler_error_500, internal_error_response,
sanitize_bulk_row_error, to_value_or_500,
};
use axum::http::StatusCode;
async fn body_string(resp: axum::response::Response) -> (StatusCode, String) {
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.expect("collect body");
(status, String::from_utf8_lossy(&bytes).to_string())
}
#[test]
fn sanitize_classifies_each_allowlisted_bucket() {
for raw in [
"title cannot be empty",
"content exceeds max length",
"id has invalid characters",
"tag has invalid control characters",
"priority must be 1-10",
"namespace is required",
] {
assert_eq!(
sanitize_bulk_row_error(raw),
"validation failed",
"validation trigger {raw:?} must classify as validation failed"
);
}
assert_eq!(
sanitize_bulk_row_error("title already exists in namespace alpha"),
"conflict: already exists"
);
assert_eq!(
sanitize_bulk_row_error("UNIQUE constraint failed: memories.id"),
"conflict: already exists"
);
assert_eq!(
sanitize_bulk_row_error("memory abc123 not found"),
"not found"
);
assert_eq!(
sanitize_bulk_row_error("write denied by governance policy"),
"forbidden"
);
assert_eq!(
sanitize_bulk_row_error("permission check failed"),
"forbidden"
);
assert_eq!(
sanitize_bulk_row_error("quorum not met for namespace"),
"replication unavailable"
);
assert_eq!(
sanitize_bulk_row_error("fanout to peer host:bob failed"),
"replication unavailable"
);
assert_eq!(
sanitize_bulk_row_error("peer host:bob unreachable"),
"replication unavailable"
);
let raw = "near \"SELECT\": syntax error in /var/db/ai-memory.db";
let label = sanitize_bulk_row_error(raw);
assert_eq!(label, "internal error");
assert!(
!label.contains("SELECT") && !label.contains("/var/db"),
"default label must not leak the raw inner detail"
);
}
#[tokio::test]
async fn internal_error_response_is_sanitized_500() {
let resp = internal_error_response("ctx", &"raw sql leak DROP TABLE memories");
let (status, body) = body_string(resp).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(!body.contains("DROP TABLE"), "body must not echo raw error");
assert!(body.contains("error"));
}
#[tokio::test]
async fn handler_error_500_is_sanitized() {
let resp = handler_error_500(&"rusqlite: database is locked at /secret/path.db");
let (status, body) = body_string(resp).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(!body.contains("/secret/path"));
}
#[tokio::test]
async fn governance_error_500_is_sanitized() {
let resp = governance_error_500(&"rule provider timeout details");
let (status, body) = body_string(resp).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(!body.contains("timeout details"));
assert!(body.contains("error"));
}
#[tokio::test]
async fn bad_request_opaque_is_400_and_opaque() {
let resp = bad_request_opaque("ctx", &"INSERT INTO memories raw text");
let (status, body) = body_string(resp).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!body.contains("INSERT INTO"));
assert_eq!(body, r#"{"error":"invalid request"}"#);
}
#[test]
fn to_value_or_500_ok_on_serializable() {
let v = to_value_or_500("ctx", &serde_json::json!({"k": 1})).expect("serializes");
assert_eq!(v["k"], 1);
}
#[tokio::test]
async fn to_value_or_500_err_on_non_string_map_key() {
use std::collections::HashMap;
let mut m: HashMap<Vec<u8>, i32> = HashMap::new();
m.insert(vec![1, 2, 3], 9);
let err = to_value_or_500("ctx", &m).expect_err("non-string key must fail");
let (status, body) = body_string(err).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(body.contains("serialisation failed"));
}
}