use rmcp::ErrorData as McpError;
use std::sync::LazyLock;
use regex::Regex;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ErrorCategory {
BadRequest,
NotFound,
BudgetExceeded,
PolicyViolation,
InternalError,
TimeoutExceeded,
}
impl ErrorCategory {
pub fn as_kebab(self) -> &'static str {
match self {
Self::BadRequest => "bad-request",
Self::NotFound => "not-found",
Self::BudgetExceeded => "budget-exceeded",
Self::PolicyViolation => "policy-violation",
Self::InternalError => "internal-error",
Self::TimeoutExceeded => "timeout-exceeded",
}
}
}
static USERS_RE: LazyLock<Result<Regex, regex::Error>> =
LazyLock::new(|| Regex::new(r#"/Users/[^/\s\)\]\}"']+"#));
static HOME_RE: LazyLock<Result<Regex, regex::Error>> =
LazyLock::new(|| Regex::new(r#"/home/[^/\s\)\]\}"']+"#));
static PROC_RE: LazyLock<Result<Regex, regex::Error>> =
LazyLock::new(|| Regex::new(r"/proc/\d+"));
pub fn redact_paths(msg: &str) -> String {
let mut s = msg.to_owned();
s = s.replace("/private/var/folders/", "<tempdir>/");
s = s.replace("/var/folders/", "<tempdir>/");
s = s.replace("/private/tmp/", "<tempdir>/");
s = s.replace("/tmp/", "<tempdir>/");
if let Ok(re) = USERS_RE.as_ref() {
s = re.replace_all(&s, "<home>").into_owned();
}
if let Ok(re) = HOME_RE.as_ref() {
s = re.replace_all(&s, "<home>").into_owned();
}
if let Ok(re) = PROC_RE.as_ref() {
s = re.replace_all(&s, "<proc>").into_owned();
}
s
}
fn request_id() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
#[allow(
clippy::as_conversions,
clippy::cast_possible_truncation,
reason = "PROOF: nanos (u128) -> u64 intentional truncation. Used only as a 16-hex correlation tag against operator tracing logs; the dropped top bits would correspond to time intervals >584 years from UNIX_EPOCH, so any nanos value observed in production fits in u64 exactly."
)]
let lo = nanos as u64;
format!("{lo:016x}")
}
pub fn sanitize_to_mcp_error<E: std::fmt::Display + std::fmt::Debug>(
prefix: &'static str,
e: &E,
category: ErrorCategory,
) -> McpError {
let req_id = request_id();
tracing::error!(
category = category.as_kebab(),
request_id = req_id.as_str(),
prefix = prefix,
error = ?e,
"MCP handler error",
);
let redacted = redact_paths(&format!("{e}"));
let message = format!("{prefix}: {redacted}");
let data = serde_json::json!({
"category": category.as_kebab(),
"request_id": req_id,
});
match category {
ErrorCategory::BadRequest
| ErrorCategory::PolicyViolation
| ErrorCategory::NotFound => McpError::invalid_params(message, Some(data)),
ErrorCategory::InternalError
| ErrorCategory::BudgetExceeded
| ErrorCategory::TimeoutExceeded => McpError::internal_error(message, Some(data)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_macos_user_segment() {
assert_eq!(
redact_paths("could not open /Users/USER/code/x.apk: ENOENT"),
"could not open <home>/code/x.apk: ENOENT",
);
}
#[test]
fn redacts_linux_user_segment() {
assert_eq!(
redact_paths("opening /home/operator/audits/y.db failed"),
"opening <home>/audits/y.db failed",
);
}
#[test]
fn redacts_tmp_prefix() {
assert_eq!(
redact_paths("/tmp/droidsaw-audit-08bea69674d79334.db is gone"),
"<tempdir>/droidsaw-audit-08bea69674d79334.db is gone",
);
}
#[test]
fn redacts_macos_canonicalized_tmp() {
assert_eq!(
redact_paths("session DB at /private/tmp/x is gone"),
"session DB at <tempdir>/x is gone",
);
assert_eq!(
redact_paths("audit at /private/var/folders/dx/xl/T/droidsaw-audit.db"),
"audit at <tempdir>/dx/xl/T/droidsaw-audit.db",
);
}
#[test]
fn redacts_proc_pid() {
assert_eq!(
redact_paths("/proc/12345/maps unreadable"),
"<proc>/maps unreadable",
);
}
#[test]
fn preserves_non_path_text() {
let msg = "syntax error near WHERE: code 1, no such column";
assert_eq!(redact_paths(msg), msg);
}
#[test]
fn proc_does_not_overmatch_process_results() {
assert_eq!(
redact_paths("see /process-results.txt"),
"see /process-results.txt",
);
}
#[test]
fn user_segment_handles_trailing_punctuation() {
assert_eq!(
redact_paths("could not open '/Users/USER/x.db'"),
"could not open '<home>/x.db'",
);
}
#[test]
fn error_category_kebab_is_stable() {
assert_eq!(ErrorCategory::BadRequest.as_kebab(), "bad-request");
assert_eq!(ErrorCategory::NotFound.as_kebab(), "not-found");
assert_eq!(ErrorCategory::BudgetExceeded.as_kebab(), "budget-exceeded");
assert_eq!(ErrorCategory::PolicyViolation.as_kebab(), "policy-violation");
assert_eq!(ErrorCategory::InternalError.as_kebab(), "internal-error");
assert_eq!(ErrorCategory::TimeoutExceeded.as_kebab(), "timeout-exceeded");
}
#[test]
fn request_id_is_16_hex_chars() {
let id = request_id();
assert_eq!(id.len(), 16);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn sanitize_builds_internal_error_with_redacted_message() {
let e = anyhow::anyhow!("open /tmp/droidsaw.db failed");
let mcp_err = sanitize_to_mcp_error("db_query open", &e, ErrorCategory::InternalError);
let dbg = format!("{mcp_err:?}");
assert!(dbg.contains("<tempdir>"), "expected redaction; got {dbg}");
assert!(!dbg.contains("/tmp/"), "tmp path leaked; got {dbg}");
assert!(dbg.contains("db_query open"), "prefix lost; got {dbg}");
assert!(dbg.contains("request_id"), "request_id missing; got {dbg}");
}
}