droidsaw 1.0.0

DROIDSAW — unified Android reverse engineering CLI. Hermes, DEX, APK signing. JSON output, MCP server. Bytecode is not a security layer.
Documentation
//! MCP error-path sanitization.
//!
//! The MCP response surface faces a remote caller. Interpolating
//! `format!("{e:#}", e)` (Debug-with-cause-chain) on an anyhow /
//! io::Error / sqlite error directly into the response body leaks
//! server-internal filesystem paths, environment fragments, and
//! adjacent file metadata — an info-disclosure surface on the MCP
//! boundary.
//!
//! This module is the single sanitizer every MCP handler runs an
//! error through before constructing an `McpError` response.
//! Sanitization preserves the human-readable message text and
//! redacts only the host-leaking parts: filesystem paths get
//! their variable identifier segment replaced with a generic
//! placeholder (`/Users/USER/code/x.apk` → `<home>/code/x.apk`),
//! tempfile paths collapse to `<tempdir>/...`, and `/proc/<pid>`
//! collapses to `<proc>/...`.
//!
//! The unsanitized error chain stays visible to the operator via
//! `tracing::error!` at the catch site — the sanitizer hides the
//! chain from the remote caller, not from the local server logs.

use rmcp::ErrorData as McpError;
use std::sync::LazyLock;

use regex::Regex;

/// Typed category for an MCP error response. Surfaced to the caller
/// via the `data` field on `McpError` so they can branch on category
/// without parsing the message text. Maps loosely to JSON-RPC's
/// numeric `code` plus extra granularity — e.g., `BudgetExceeded`
/// and `TimeoutExceeded` are both `internal_error` at the RPC level
/// but want different retry semantics.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ErrorCategory {
    /// Caller-supplied input failed validation.
    BadRequest,
    /// Caller asked for a resource that does not exist.
    NotFound,
    /// A budgeted operation hit a cap (parse budget, row cap, etc.).
    BudgetExceeded,
    /// Operator policy refused the operation (tool class, path allow).
    PolicyViolation,
    /// Server-internal failure; the operator should consult logs.
    InternalError,
    /// Timeout exceeded.
    TimeoutExceeded,
}

impl ErrorCategory {
    /// Stable kebab-case string for the `data.category` field.
    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",
        }
    }
}

// ── path redactors ──────────────────────────────────────────────────

/// `/Users/<user>` → `<home>` (strip the user segment).
static USERS_RE: LazyLock<Result<Regex, regex::Error>> =
    LazyLock::new(|| Regex::new(r#"/Users/[^/\s\)\]\}"']+"#));

/// `/home/<user>` → `<home>`.
static HOME_RE: LazyLock<Result<Regex, regex::Error>> =
    LazyLock::new(|| Regex::new(r#"/home/[^/\s\)\]\}"']+"#));

/// `/proc/<pid>` → `<proc>`. `<pid>` is one-or-more decimal digits;
/// avoids over-matching paths like `/process-results`.
static PROC_RE: LazyLock<Result<Regex, regex::Error>> =
    LazyLock::new(|| Regex::new(r"/proc/\d+"));

/// Redact filesystem-path tokens from an arbitrary message string.
///
/// Substitutions, applied in order:
/// - `/private/var/folders/...` → `<tempdir>/...` (macOS canonicalized)
/// - `/var/folders/...`         → `<tempdir>/...` (macOS)
/// - `/private/tmp/...`         → `<tempdir>/...` (macOS canonicalized)
/// - `/tmp/...`                 → `<tempdir>/...` (Linux)
/// - `/Users/<seg>/...`         → `<home>/...`    (macOS user)
/// - `/home/<seg>/...`          → `<home>/...`    (Linux user)
/// - `/proc/<pid>/...`          → `<proc>/...`
///
/// Preserves the path suffix after the redacted segment — the goal
/// is to hide the host's identifier, not the entire path shape.
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
}

// ── error builders ──────────────────────────────────────────────────

/// Build a request ID for an internal-error response. Encoded as
/// 16-hex-char of the system clock's nanosecond timestamp — the
/// operator can correlate the response to a `tracing::error!`
/// entry on the server by approximate timestamp. Not unique under
/// the nanosecond grain on hot paths, but good enough for human
/// correlation against a low-rate error log.
fn request_id() -> String {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    // Truncate to a u64-sized window; the top bits are noise we
    // can drop without losing correlation utility.
    #[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}")
}

/// Build a sanitized `McpError` from an `anyhow::Error` or
/// `Box<dyn Error>` shape (anything `Display`able).
///
/// The remote caller sees:
///   - the typed `code` field of the McpError set by the rmcp helper
///     (`internal_error` / `invalid_params`),
///   - a message of the form `"<prefix>: <redacted Display>"`,
///   - a `data` object containing `{category, request_id}` keyed
///     for category-based branching and log correlation.
///
/// The operator's tracing log gets the unsanitized full `{:#}`
/// chain via `tracing::error!` so cause information survives where
/// only the server can read it.
///
/// `prefix` is a static handler-name string (e.g., `"db_query open"`),
/// not user input — interpolating it is safe.
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,
        // Full Debug for operator-side correlation; never returned
        // to the remote caller.
        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() {
        // Quoted strings, identifiers, error codes — all preserved.
        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() {
        // `/proc/` requires a digit after the slash, so `/process` is
        // unaffected.
        assert_eq!(
            redact_paths("see /process-results.txt"),
            "see /process-results.txt",
        );
    }

    #[test]
    fn user_segment_handles_trailing_punctuation() {
        // Path terminated by quote/paren — common in error wrappers
        // like `("/Users/USER/x.db")`.
        assert_eq!(
            redact_paths("could not open '/Users/USER/x.db'"),
            "could not open '<home>/x.db'",
        );
    }

    #[test]
    fn error_category_kebab_is_stable() {
        // Locked so existing callers parsing `data.category` keep
        // working as new categories are added.
        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}");
    }
}