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
//! Routing coverage for `droidsaw decompile <path> [target]`.
//!
//! Covers the dispatch fix shipped by `droidsaw-top-decompile-cli-dispatch`:
//! the previous implementation sniffed the target string's shape
//! (`L...;`) to discriminate DEX vs HBC, and mis-routed Java FQCN
//! targets to the HBC path. The fix dispatches on the parsed input's
//! namespace via `classify_decompile_target`. These tests assert the
//! routing for the three DEX-target shapes (JVM descriptor, Java FQCN,
//! bare class name) all reach `dex_decompile` and successfully resolve
//! to the `Minimal` class in `classes.dex`.

use std::path::PathBuf;

use droidsaw::commands::{
    classify_decompile_target, dex_decompile, normalize_dex_class_search, DecompileRoute,
};
use droidsaw::context::CrossLayerContext;

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

fn parse_classes_dex() -> CrossLayerContext {
    let path = classes_dex_path();
    CrossLayerContext::parse(&path, None).expect("classes.dex parses")
}

#[test]
fn classify_dex_only_jvm_descriptor_routes_to_dex() {
    let ctx = parse_classes_dex();
    let route = classify_decompile_target(&ctx, "LMinimal;").expect("classify");
    assert!(matches!(route, DecompileRoute::DexClass("LMinimal;")));
}

#[test]
fn classify_dex_only_java_fqcn_routes_to_dex() {
    // The user-reported regression: `com.surebrec.IdProvider` must reach
    // dex_decompile, not the HBC bail.
    let ctx = parse_classes_dex();
    let route = classify_decompile_target(&ctx, "com.foo.Bar").expect("classify");
    assert!(matches!(route, DecompileRoute::DexClass("com.foo.Bar")));
}

#[test]
fn classify_dex_only_bare_name_routes_to_dex() {
    let ctx = parse_classes_dex();
    let route = classify_decompile_target(&ctx, "Minimal").expect("classify");
    assert!(matches!(route, DecompileRoute::DexClass("Minimal")));
}

#[test]
fn classify_dex_only_numeric_routes_to_dex() {
    // A DEX-only file with a numeric target is a DEX class (no HBC
    // namespace exists). dex_decompile's resolver will then either find
    // the matching class or report no match — but the dispatch must NOT
    // misroute to the HBC path.
    let ctx = parse_classes_dex();
    let route = classify_decompile_target(&ctx, "0").expect("classify");
    assert!(matches!(route, DecompileRoute::DexClass("0")));
}

#[test]
fn dex_decompile_resolves_jvm_descriptor() {
    let ctx = parse_classes_dex();
    let value = dex_decompile(&ctx, None, Some("LMinimal;"))
        .expect("dex_decompile resolves LMinimal;");
    let classes = value.get("classes").and_then(|v| v.as_array()).expect("classes array");
    assert_eq!(classes.len(), 1, "exactly one match for LMinimal;");
}

#[test]
fn dex_decompile_resolves_java_fqcn() {
    // The user's bug repro shape, in miniature. `Minimal` in classes.dex
    // has no package, so the FQCN is just `Minimal` — but the FQCN
    // normalization path (dot-containing) is exercised by
    // `dex_decompile_resolves_dotted_fqcn` below using a synthetic name.
    let ctx = parse_classes_dex();
    let value = dex_decompile(&ctx, None, Some("Minimal"))
        .expect("dex_decompile resolves bare class name");
    let classes = value.get("classes").and_then(|v| v.as_array()).expect("classes array");
    assert_eq!(classes.len(), 1, "exactly one match for Minimal");
}

#[test]
fn normalize_inner_class_fqcn_matches_descriptor_literally() {
    // Inner-class FQCN — `$` is a legitimate class-name character. The
    // normalizer must escape the `$` and dots so the resulting regex
    // matches the descriptor literally.
    let r = normalize_dex_class_search("com.foo.Outer$Inner");
    let re = regex::Regex::new(&r).expect("normalized regex compiles");
    assert!(
        re.is_match("Lcom/foo/Outer$Inner;"),
        "regex {r:?} must match descriptor 'Lcom/foo/Outer$Inner;'"
    );
    assert!(
        !re.is_match("Lcom/foo/OuterXInner;"),
        "regex {r:?} must NOT coincidentally match 'Lcom/foo/OuterXInner;' (dot matched X)"
    );
    assert!(
        !re.is_match("Lcom-foo-Outer$Inner;"),
        "regex {r:?} must NOT coincidentally match 'Lcom-foo-Outer$Inner;' (dot matched -)"
    );
}

#[test]
fn normalize_bare_inner_class_anchors_correctly() {
    // Bare inner-class name `Outer$Inner` must escape `$` and append
    // a literal `;` tail-anchor so it matches `L<package>/Outer$Inner;`
    // at descriptor end.
    let r = normalize_dex_class_search("Outer$Inner");
    let re = regex::Regex::new(&r).expect("normalized regex compiles");
    assert!(
        re.is_match("Lcom/foo/Outer$Inner;"),
        "regex {r:?} must match 'Lcom/foo/Outer$Inner;'"
    );
    assert!(
        re.is_match("LOuter$Inner;"),
        "regex {r:?} must match 'LOuter$Inner;'"
    );
    assert!(
        !re.is_match("LOuter$InnerX;"),
        "regex {r:?} must NOT match a class whose name doesn't end at $Inner"
    );
}

#[test]
fn normalize_jvm_descriptor_passes_through() {
    // JVM descriptors are user-supplied regexes — pass through unchanged.
    let r = normalize_dex_class_search("Lcom/foo/Bar;");
    assert_eq!(r, "Lcom/foo/Bar;", "JVM descriptors pass through verbatim");
}

#[test]
fn normalize_user_regex_passes_through() {
    // A target with `(`/`[`/`*`/`?`/`^` is intentional regex syntax.
    let r = normalize_dex_class_search("L(com|org)/foo/Bar;");
    assert_eq!(r, "L(com|org)/foo/Bar;", "regex patterns pass through");
}

#[test]
fn dex_decompile_dotted_fqcn_normalizes_to_descriptor() {
    // FQCN normalization tightening: a Java FQCN like `com.foo.Bar` must
    // be normalized to a literal JVM descriptor `Lcom/foo/Bar;` (escaped
    // for regex), not a regex with `.` matching any character. We can't
    // assert success on classes.dex (Minimal has no package) — but we CAN
    // assert that a non-existent dotted FQCN yields a "no matching class"
    // error rather than coincidentally matching a bare-name class.
    let ctx = parse_classes_dex();
    let err = dex_decompile(&ctx, None, Some("com.bogus.Minimal")).expect_err(
        "dotted FQCN com.bogus.Minimal must NOT coincidentally match LMinimal;"
    );
    let msg = format!("{err}");
    assert!(
        msg.contains("no matching class"),
        "expected no-match error, got: {msg}"
    );
}