droidsaw 2.0.0

DROIDSAW — unified Android reverse engineering CLI. Hermes, DEX, APK signing. JSON output, MCP server. Bytecode is not a security layer.
Documentation
//! Classified error envelope for agentic output.
//!
//! All errors that would otherwise bubble out of `main` must be printed
//! as a JSON envelope on stdout and cause a non-zero exit. `emit` is the
//! sole exit point: `main()` catches anyhow errors and forwards them
//! here. Progress/noise goes to stderr; stdout is reserved for the final
//! data payload or this envelope.
//!
//! Envelope shape (stable, breakable only with coordination):
//!
//! ```json
//! {
//!   "error": {
//!     "code": "USER_INPUT" | "PERMISSION" | "TRANSIENT" | "CONFIGURATION" | "INTERNAL",
//!     "operation": "info",
//!     "message": "no such file: /tmp/missing.apk",
//!     "hint": "check the path and try again (optional)"
//!   }
//! }
//! ```

use std::io;
use std::process::ExitCode;

use serde::Serialize;

/// Agent-facing error classes. Mirrors go-linear's
/// `internal/cli/error_handler.go` classification.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorClass {
    /// Bad input from the caller: missing path, unreadable file, bad
    /// regex, unparseable APK. Agent should fix the call and retry.
    UserInput,
    /// EACCES / EPERM on file read/write. Agent should check
    /// permissions before retrying.
    Permission,
    /// Interrupted I/O, EAGAIN. Transient — safe to retry.
    Transient,
    /// Conflicting flags, invalid enum value (e.g. unknown severity).
    Configuration,
    /// Anything we don't recognize. These are bugs in droidsaw.
    Internal,
}

impl ErrorClass {
    fn as_str(self) -> &'static str {
        match self {
            ErrorClass::UserInput => "USER_INPUT",
            ErrorClass::Permission => "PERMISSION",
            ErrorClass::Transient => "TRANSIENT",
            ErrorClass::Configuration => "CONFIGURATION",
            ErrorClass::Internal => "INTERNAL",
        }
    }
}

/// Typed marker for operator-supplied configuration that cannot be
/// honored: an unknown flag value (`--format`, `--mode`, `--fail-on`,
/// `--permissive-recovery`) or an environment conflict (rayon pool
/// already initialized against `--single-thread`). Classifies as
/// [`ErrorClass::Configuration`] without message-substring heuristics.
#[derive(Debug)]
pub struct ConfigError(pub String);

impl std::fmt::Display for ConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for ConfigError {}

/// Typed marker for argv-level usage errors (clap parse failures:
/// unknown flag, missing argument, bad subcommand). Classifies as
/// [`ErrorClass::UserInput`].
#[derive(Debug)]
pub struct UsageError(pub String);

impl std::fmt::Display for UsageError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for UsageError {}

/// Sentinel for the `audit --fail-on` exit gate: the audit ran to
/// completion and `count` emitted findings are at or above `threshold`.
/// `main()` intercepts this **before** [`emit`] and maps it to
/// `droidsaw_cli_contract::EXIT_FINDINGS` — stdout already carries the
/// normal audit output, so no error envelope is written.
#[derive(Debug)]
pub struct FailOnTriggered {
    /// The severity threshold the operator gated on.
    pub threshold: droidsaw_common::Severity,
    /// How many emitted findings are at or above the threshold.
    pub count: usize,
}

impl std::fmt::Display for FailOnTriggered {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{} finding(s) at or above the --fail-on={:?} threshold",
            self.count, self.threshold
        )
    }
}

impl std::error::Error for FailOnTriggered {}

/// Classify an anyhow error by walking its source chain for known
/// concrete types (std::io::Error kind, regex::Error, droidsaw_apk
/// errors) and falling back to message-substring heuristics for
/// anyhow-wrapped strings.
pub fn classify(err: &anyhow::Error) -> ErrorClass {
    // Walk the error chain looking for typed sources first.
    for cause in err.chain() {
        if cause.downcast_ref::<ConfigError>().is_some() {
            return ErrorClass::Configuration;
        }
        if cause.downcast_ref::<UsageError>().is_some() {
            return ErrorClass::UserInput;
        }
        if let Some(io_err) = cause.downcast_ref::<io::Error>() {
            return classify_io(io_err);
        }
        if cause.downcast_ref::<regex::Error>().is_some() {
            return ErrorClass::UserInput;
        }
        if cause.downcast_ref::<serde_json::Error>().is_some() {
            return ErrorClass::Internal;
        }
        if let Some(apk_err) = cause.downcast_ref::<droidsaw_apk::ApkError>() {
            return classify_apk(apk_err);
        }
        if let Some(hermes_err) = cause.downcast_ref::<droidsaw_hermes::error::HermesError>() {
            return classify_hermes(hermes_err);
        }
    }

    // Message heuristics for anyhow-wrapped strings (`anyhow::bail!`,
    // `anyhow!(...)` usages). These cover commands that build their
    // own "no Hermes bytecode found" style errors.
    let msg = format!("{err:#}").to_lowercase();
    if msg.contains("--min-severity must be") || msg.contains("--target must be") {
        return ErrorClass::Configuration;
    }
    if msg.contains("no hermes") || msg.contains("no bytecode") {
        return ErrorClass::UserInput;
    }
    if msg.contains("unrecognized input") || msg.contains("requires an apk") {
        return ErrorClass::UserInput;
    }
    if msg.contains("invalid function id") || msg.contains("specify a function id") {
        return ErrorClass::UserInput;
    }
    if msg.contains("rules path does not exist") || msg.contains("is not a directory") {
        return ErrorClass::UserInput;
    }
    if msg.contains("permission denied") {
        return ErrorClass::Permission;
    }

    ErrorClass::Internal
}

fn classify_io(err: &io::Error) -> ErrorClass {
    use io::ErrorKind::*;
    match err.kind() {
        NotFound | InvalidInput | InvalidData | UnexpectedEof => ErrorClass::UserInput,
        PermissionDenied => ErrorClass::Permission,
        Interrupted | WouldBlock | TimedOut => ErrorClass::Transient,
        _ => ErrorClass::Internal,
    }
}

fn classify_apk(err: &droidsaw_apk::ApkError) -> ErrorClass {
    use droidsaw_apk::ApkError;
    match err {
        ApkError::Truncated { .. }
        | ApkError::BadMagic { .. }
        | ApkError::Structural { .. }
        | ApkError::QuotaExceeded { .. }
        | ApkError::Zip(_) => ErrorClass::UserInput,
        ApkError::Io(io_err) => classify_io(io_err),
        ApkError::Contract { .. } | ApkError::Yara { .. } => ErrorClass::Internal,
        // A restricted-directive rejection is a caller-supplied rule policy
        // violation — the MCP caller submitted a rule with `include` or a
        // banned module. Classify as UserInput so the error envelope tells the
        // agent "fix the call and retry" rather than "droidsaw bug".
        ApkError::YaraRuleSourceRestricted { .. } => ErrorClass::UserInput,
        _ => ErrorClass::Internal,
    }
}

fn classify_hermes(err: &droidsaw_hermes::error::HermesError) -> ErrorClass {
    use droidsaw_hermes::error::HermesError;
    match err {
        // Parse-time shape failures on attacker-supplied bytes — caller
        // supplied a non-HBC or malformed file.
        HermesError::HeaderTooSmall { .. }
        | HermesError::InvalidMagic { .. }
        | HermesError::UnsupportedVersion { .. }
        | HermesError::SectionSizeOverflow { .. }
        | HermesError::SectionExceedsBounds { .. }
        | HermesError::SectionCursorOverflow { .. }
        | HermesError::CountExceedsInput { .. }
        | HermesError::ArithmeticOverflow { .. } => ErrorClass::UserInput,
        // Downstream structural / SSA failures: IR invariants violated on
        // adversarial bytecode. Still user-facing bad input from the
        // caller's perspective.
        HermesError::InvalidExceptionLayout { .. } | HermesError::Ssa(_) => ErrorClass::UserInput,
        // Budget exhaustion: attacker-controlled input (too large or adversarially
        // crafted to exhaust memory / steps / deadline). Always UserInput.
        HermesError::Budget(_) => ErrorClass::UserInput,
        _ => ErrorClass::Internal,
    }
}

/// A hint is optional human-readable guidance shown inside the envelope.
/// Kept deliberately short: agents read this and decide whether to retry
/// with different inputs.
fn hint_for(class: ErrorClass, operation: &str) -> Option<&'static str> {
    match (class, operation) {
        (ErrorClass::UserInput, "info")
        | (ErrorClass::UserInput, "manifest")
        | (ErrorClass::UserInput, "signing")
        | (ErrorClass::UserInput, "apk-info")
        | (ErrorClass::UserInput, "strings")
        | (ErrorClass::UserInput, "xrefs")
        | (ErrorClass::UserInput, "audit")
        | (ErrorClass::UserInput, "decompile")
        | (ErrorClass::UserInput, "frida")
        | (ErrorClass::UserInput, "trufflehog")
        | (ErrorClass::UserInput, "sbom")
        | (ErrorClass::UserInput, "semgrep")
        | (ErrorClass::UserInput, "export") => {
            Some("verify the path points to a readable APK/DEX/HBC file")
        }
        (ErrorClass::UserInput, "yara") => {
            Some("pass --rules <.yar|.yara|dir>; the path must exist and be readable")
        }
        (ErrorClass::UserInput, "scan-corpus") => {
            Some("pass one or more directories containing .apk files, or individual apks")
        }
        (ErrorClass::Permission, _) => Some("check file permissions on the target path"),
        (ErrorClass::Configuration, "scan-corpus") => {
            Some("--min-severity must be one of: critical, high, medium, low, info")
        }
        (ErrorClass::Configuration, "yara") => {
            Some("--target must be one of: manifest, dex, resources, native, assets, all")
        }
        (ErrorClass::Transient, _) => Some("retry after a short delay"),
        (ErrorClass::Internal, _) => Some("this is a bug in droidsaw; please file an issue"),
        _ => None,
    }
}

#[derive(Serialize)]
struct Envelope<'a> {
    error: EnvelopeBody<'a>,
}

#[derive(Serialize)]
struct EnvelopeBody<'a> {
    code: &'a str,
    operation: &'a str,
    message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    hint: Option<&'a str>,
}

/// Emit the envelope on stdout and return a non-zero `ExitCode`.
///
/// We use exit code `2` to distinguish classified failures from clap's
/// own parse failures (which exit with `2` as well, but go through
/// clap's own printer). Agents should treat any non-zero exit as
/// failure and parse stdout as JSON.
pub fn emit(err: &anyhow::Error, operation: &str) -> ExitCode {
    let class = classify(err);
    let message = format!("{err:#}");
    let hint = hint_for(class, operation);
    let envelope = Envelope {
        error: EnvelopeBody {
            code: class.as_str(),
            operation,
            message,
            hint,
        },
    };
    match serde_json::to_string_pretty(&envelope) {
        Ok(s) => println!("{s}"),
        Err(_) => {
            // Last-ditch: emit a minimal fixed envelope so the caller
            // still gets valid JSON.
            println!(
                "{{\"error\":{{\"code\":\"INTERNAL\",\"operation\":\"{operation}\",\"message\":\"failed to serialize error envelope\"}}}}"
            );
        }
    }
    ExitCode::from(2)
}