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
//! Frida-codegen DEX-side smoke + golden-shape assertions.
//!
//! Fixture: the shared `droidsaw-dex/tests/fixtures/classes.dex` (built from
//! `tests/fixtures/java/Minimal.java`). It contains class `LMinimal;` with
//! method `hello(I)Ljava/lang/String;` whose body loads the const-string
//! `"hello "` for `"hello " + n` concatenation. The xrefs index therefore
//! maps `"hello "` → `[Minimal.hello(I)Ljava/lang/String;]`, which gives a
//! single deterministic match for the regex `^hello $`.
//!
//! Hermes-side assertions are negative-only (the input is raw DEX, so no
//! `hbc` slot exists on the context): we verify the Hermes branch did NOT
//! emit any `Interceptor.attach` literal. This ensures no patterns are
//! silently inserted that could hook arbitrary bytes inside `libhermes.so`.

use std::path::PathBuf;

use droidsaw::commands;
use droidsaw::context::CrossLayerContext;

fn minimal_dex_path() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("worktree has parent")
        .join("droidsaw-dex/tests/fixtures/classes.dex")
}

fn run_frida(regex: &str) -> serde_json::Value {
    let path = minimal_dex_path();
    let ctx = CrossLayerContext::parse(&path, None).expect("classes.dex parses");
    commands::frida(&ctx, regex).expect("frida command runs")
}

/// The matched-string regex `^hello $` should produce exactly one DEX hook,
/// targeting `Minimal.hello(I)Ljava/lang/String;`. The emitted Frida JS is
/// runnable: a `Java.perform` wrapper around a `Java.use("Minimal")` binding
/// whose `hello` overload is hooked with a logging `implementation` body.
#[test]
fn hello_string_emits_runnable_per_method_hook() {
    let out = run_frida(r"^hello $");
    let hooks = out
        .get("hooks")
        .and_then(|v| v.as_array())
        .expect("hooks array");
    assert_eq!(
        hooks.len(),
        1,
        "expected exactly one DEX hook for `hello ` string; got {hooks:#?}"
    );

    let hook = &hooks[0];
    assert_eq!(hook.get("layer").and_then(|v| v.as_str()), Some("dex1"));
    assert_eq!(hook.get("function").and_then(|v| v.as_str()), Some("Minimal"));
    assert_eq!(
        hook.get("matched_string").and_then(|v| v.as_str()),
        Some("hello ")
    );

    let js = hook
        .get("hook")
        .and_then(|v| v.as_str())
        .expect("hook string");

    assert!(
        js.contains("Java.perform(function() {"),
        "DEX hook must wrap in Java.perform; got:\n{js}"
    );
    assert!(
        js.contains(r#"var cls = Java.use("Minimal");"#),
        "DEX hook must bind cls via Java.use; got:\n{js}"
    );
    assert!(
        js.contains("cls.hello.overload('int').implementation = function(arg0)"),
        "DEX hook must overload-bind hello with the dotted-Java type name 'int'; got:\n{js}"
    );
    assert!(
        js.contains("this.hello.overload('int').apply(this, arguments)"),
        "DEX hook must re-dispatch via apply with the same overload args; got:\n{js}"
    );
    assert!(
        !js.contains(".overload('I')"),
        "DEX hook must NOT use single-letter JNI form (frida-java-bridge expects 'int' not 'I'); got:\n{js}"
    );
    assert!(
        js.contains("console.log('hello(' + arg0 + ')')"),
        "DEX hook must log the call with positional arg interpolation; got:\n{js}"
    );
    assert!(
        js.contains("console.log('-> ' + ret)"),
        "DEX hook must log the return value; got:\n{js}"
    );
    assert!(
        !js.contains("// hook methods here"),
        "old class-granularity placeholder must be gone; got:\n{js}"
    );
}

/// `_meta.hint` is rewritten to describe the DEX-runnable / Hermes-reference
/// shape and must no longer claim the entire output is template-only.
/// (The `meta()` helper in `commands/mod.rs` names the field `hint`.)
#[test]
fn meta_hint_reflects_dex_runnable_hermes_reference_shape() {
    let out = run_frida(r"hello");
    let hint = out
        .pointer("/_meta/hint")
        .and_then(|v| v.as_str())
        .expect("_meta.hint string");
    assert!(
        hint.contains("DEX hook lines are runnable Frida JS"),
        "_meta.hint must call out the DEX-runnable shape; got: {hint}"
    );
    assert!(
        hint.contains("Hermes entries are reference comments"),
        "_meta.hint must call out the Hermes-reference shape; got: {hint}"
    );
    assert!(
        !hint.contains("hook strings are templates"),
        "old template-only hint must be gone; got: {hint}"
    );
}

/// Hermes-layer hooks never emit `Interceptor.attach`. The original GTFO
/// finding: the old Hermes comment block named
/// `Module.findBaseAddress('libhermes.so').add(offset)` patterns that
/// would silently hook arbitrary bytes inside libhermes (because
/// `FunctionData.offset` is HBC-blob-relative, not libhermes.so-relative).
/// The replacement Hermes comment must not name `Interceptor.attach`.
/// DEX-layer hooks ARE permitted to mention `Interceptor.attach` — that's
/// the correct route for native methods (JNI symbols), which is a real
/// codegen path emitted by `emit_class_body` for `access_flags & NATIVE`.
#[test]
fn no_interceptor_attach_in_hermes_layer_hooks() {
    let out = run_frida(r"hello");
    let hooks = out
        .get("hooks")
        .and_then(|v| v.as_array())
        .expect("hooks array");
    for h in hooks {
        let layer = h.get("layer").and_then(|v| v.as_str()).unwrap_or("");
        let body = h.get("hook").and_then(|v| v.as_str()).unwrap_or("");
        if layer == "hbc" {
            assert!(
                !body.contains("Interceptor.attach"),
                "hbc-layer hook must not name Interceptor.attach (silent-hook foot-gun); got:\n{body}"
            );
        }
    }
}

/// Negative: an unmatchable regex emits zero hooks. Catches accidental
/// always-emit regressions in the iteration shape.
#[test]
fn unmatchable_regex_emits_no_hooks() {
    let out = run_frida(r"this_string_does_not_exist_anywhere_in_classes_dex_\d{42}");
    let hooks = out
        .get("hooks")
        .and_then(|v| v.as_array())
        .expect("hooks array");
    assert!(
        hooks.is_empty(),
        "unmatchable regex should yield zero hooks; got {} hooks",
        hooks.len()
    );
}