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
//! Regression tests for `commands::strings` `--layer` filter semantics.
//!
//! The CLI `--help` documents three filter shapes:
//!   `--layer dex`     — all DEX layers (wildcard)
//!   `--layer dexN`    — that specific DEX layer
//!   `--layer hbc` / `arsc` / `native` — that single layer
//!   (omit)            — every layer
//!
//! A prior bug caused `--layer dex` to silently emit zero strings on
//! multi-DEX inputs: the `emit_dex` gate accepted the bare `"dex"`
//! filter, but the per-DEX iteration body required an exact match
//! against `"dex1"` / `"dex2"` / etc, so every layer got skipped.
//! Pin the documented wildcard shape so a future refactor of the
//! filter check can't reintroduce the silent-empty result.

use std::path::PathBuf;

use droidsaw::commands::strings;
use droidsaw::context::CrossLayerContext;
use serde_json::Value;

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

#[test]
fn layer_dex_wildcard_returns_strings_on_single_dex() {
    let ctx = CrossLayerContext::parse(&classes_dex(), None).expect("parse classes.dex");
    assert!(!ctx.dex.is_empty(), "fixture should have ≥1 DEX layer");
    let v = strings(&ctx, None, None, None, Some("dex")).expect("strings call");
    let arr = v.get("strings").and_then(Value::as_array).expect("strings array");
    assert!(
        !arr.is_empty(),
        "`--layer dex` wildcard must emit strings on a DEX context (was 0 prior to filter-shape fix)"
    );
    for entry in arr {
        let layer = entry.get("layer").and_then(Value::as_str).unwrap_or("");
        assert!(
            layer.starts_with("dex"),
            "every entry under `--layer dex` must have a dex* layer; got {layer:?}"
        );
    }
}

#[test]
fn layer_dex_specific_matches_only_that_layer() {
    let ctx = CrossLayerContext::parse(&classes_dex(), None).expect("parse classes.dex");
    let v = strings(&ctx, None, None, None, Some("dex1")).expect("strings call");
    let arr = v.get("strings").and_then(Value::as_array).expect("strings array");
    for entry in arr {
        let layer = entry.get("layer").and_then(Value::as_str).unwrap_or("");
        assert_eq!(
            layer, "dex1",
            "`--layer dex1` must emit only dex1 entries; got {layer:?}"
        );
    }
}

#[test]
fn layer_none_returns_all_layers() {
    let ctx = CrossLayerContext::parse(&classes_dex(), None).expect("parse classes.dex");
    let v = strings(&ctx, None, None, None, None).expect("strings call");
    let arr = v.get("strings").and_then(Value::as_array).expect("strings array");
    assert!(
        !arr.is_empty(),
        "filter=None must emit every available layer (≥1 entry on a DEX-bearing context)"
    );
}