diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index df4a4d5..1aa6463 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - dev pull_request: workflow_dispatch: diff --git a/Cargo.lock b/Cargo.lock index 40e5350..05bf4b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -90,6 +99,12 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a868f137d0dbaade83da5c4700fd9aef2dfe439fac1763fdb925e5054452a65" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitflags" version = "2.13.2" @@ -105,6 +120,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.5.1" @@ -127,6 +154,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "clap" version = "4.6.7" @@ -160,6 +199,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -189,6 +234,18 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + [[package]] name = "errno" version = "0.3.14" @@ -220,6 +277,94 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -241,6 +386,48 @@ dependencies = [ "r-efi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -253,6 +440,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -328,6 +526,15 @@ dependencies = [ "libmimalloc-sys", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -340,6 +547,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "phf" version = "0.14.0" @@ -392,6 +605,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -433,6 +652,26 @@ dependencies = [ "mimalloc", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "regex" version = "1.13.1" @@ -462,6 +701,27 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rmcp" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" +dependencies = [ + "chrono", + "futures", + "indexmap", + "pastey", + "pin-project-lite", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "rust_ASC" version = "0.1.0" @@ -479,8 +739,13 @@ dependencies = [ "quick-xml", "radguard", "regex", + "rmcp", + "schemars", + "serde", + "serde_json", "sha1", "tempfile", + "tokio", ] [[package]] @@ -496,6 +761,38 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + [[package]] name = "serde" version = "1.0.229" @@ -526,6 +823,17 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -562,6 +870,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "strsim" version = "0.11.1" @@ -623,6 +937,73 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typenum" version = "1.20.1" @@ -641,18 +1022,127 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index a843dfe..e8706dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2024" license = "Apache-2.0" +[features] +default = ["mcp"] +mcp = ["dep:rmcp", "dep:tokio", "dep:serde", "dep:serde_json", "dep:schemars"] + [profile.release] opt-level = 3 # max speed lto = "fat" # full link-time optimization @@ -16,6 +20,11 @@ debug = true opt-level = 0 [dependencies] +rmcp = { version = "3.5.1", optional = true, default-features = false, features = ["server", "transport-io"] } +tokio = { version = "1", optional = true, features = ["rt", "io-std", "time"] } +serde = { version = "1", optional = true, features = ["derive"] } +serde_json = { version = "1", optional = true } +schemars = { version = "1", optional = true } # getclass keeps chunked flate2 decompression for cooperative early cancellation. flate2 = { version = "1", default-features = false, features = ["zlib-rs"] } libdeflater = { version = "1.26.1", features = ["use_rust_alloc"] } diff --git a/README.md b/README.md index aae624e..fb50dd5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # rust_ASC -`rust_ASC` is a one-to-one Rust port of [MG1937/ASC](https://github.com/MG1937/ASC), the Python implementation of ASC. The Rust implementation preserves the corresponding source layout, data structures, control flow, function roles, and runtime behavior while targeting substantially lower command-line latency and memory overhead. +**rust_ASC is a stateless Android APK and DEX decompiler with millisecond cross-reference search, written in Rust.** It queries the compiled artifact directly instead of building a global index, so there is no preprocessing step, no persistent cache, and no multi-gigabyte memory footprint. -On the reported CLI workloads below, `rust_ASC` is approximately **10× faster** than the Python ASC implementation. It remains stateless: no preprocessing is required before a query, and it writes no persistent cache to disk. +It is the Rust implementation of [MG1937/ASC](https://github.com/MG1937/ASC), the Python reference project, and preserves the same source layout, data structures, control flow and runtime behavior. On the CLI workloads below it is approximately **10× faster** than the Python version. + +Presented at Black Hat Europe Arsenal: [Droid ASC: R8 Compiler Optimization as a DeCompiler Primitive](https://blackhat.com/europe/arsenal/schedule/index.html#droid-asc-r8-compiler-optimization-as-a-decompiler-primitive-54834). Repository links: @@ -20,13 +22,39 @@ https://github.com/user-attachments/assets/f1f71a75-1eca-4647-89ce-406ed2c63c6b - Export pseudocode for every class in an APK with the public API of [MG1937/radguard](https://github.com/MG1937/radguard). - Handle multi-DEX APKs and DEX 041 logical containers. - Use memory-mapped APK access and no on-disk cache. +- Expose CLI queries as MCP tools over stdio. + +## Why it is fast + +- Stateless by design: no preprocessing, no global index build, no disk cache. +- The APK is memory-mapped, and only the DEX entries a query needs are inflated. +- Cross-DEX scans run in parallel, so a search over dozens of DEX files stays in the millisecond range. +- R8 output is already highly structured. Searches jump to candidate offsets directly instead of rebuilding a bloated cross-reference database. +- On a hit, rust_ASC rebuilds a minimal self-consistent DEX in memory and decompiles it immediately. ## Build +`radguard` is a Git submodule. Clone it with the main repository: + ```bash +git clone --recurse-submodules https://github.com/MG1937/rust_ASC.git +cd rust_ASC cargo build --release ``` +For an existing checkout: + +```bash +git submodule update --init --recursive +cargo build --release +``` + +MCP is enabled by default. Build only the original CLI with: + +```bash +cargo build --release --no-default-features +``` + The executable is written to: ```text @@ -69,7 +97,60 @@ rust_ASC export --thread 10 --debug app.apk output `export` writes one `.java` pseudocode file per class under package-relative paths in the output directory. Its multi-threaded APK export pipeline is supplied by [MG1937/radguard](https://github.com/MG1937/radguard). With `--debug`, it reports the APK export lifecycle and worker activity. -## CLI Benchmark +## MCP + +`rust_ASC mcp` runs a stdio MCP server that exposes the CLI commands as tools: `getclass`, `listclass`, `getmanifest`, and `findrefs`. Each call spawns a fresh CLI process, so behavior always matches the command line. + +Configure it in your MCP client: + +```json +{ + "mcpServers": { + "asc": { + "command": "/absolute/path/to/rust_ASC", + "args": ["mcp"] + } + } +} +``` + +## Benchmark + +### rust_ASC vs JADX + +Same APKs, same workloads. JADX figures are from the ASC benchmark suite. + +**Global string cross-reference search** + +| Tool | TelegramX (59 MB) | WhatsApp (130 MB) | Grab (228 MB) | +| --- | ---: | ---: | ---: | +| rust_ASC | 55 ms | 67 ms | 118 ms | +| JADX | 20 s | 32 s | 2m14s | + +**Single-class decompile** + +| Tool | TelegramX (59 MB) | WhatsApp (130 MB) | Grab (228 MB) | +| --- | ---: | ---: | ---: | +| rust_ASC | 79 ms | 70 ms | 111 ms | +| JADX | 6 s | 15 s | 37 s | + +**CLI peak memory** + +| Tool | TelegramX (59 MB) | WhatsApp (130 MB) | Grab (228 MB) | +| --- | ---: | ---: | ---: | +| rust_ASC | 32 MB | 116 MB | 358 MB | +| JADX | 1,016 MB | 2.1 GB | 7.1 GB | + +**Disk cache** + +| Tool | TelegramX | WhatsApp | Grab | +| --- | ---: | ---: | ---: | +| rust_ASC | 0 MB | 0 MB | 0 MB | +| JADX | 119 MB | 30 MB | 127 MB | + +JADX spends seconds to minutes per operation and gigabytes of memory. rust_ASC answers in tens to low hundreds of milliseconds and keeps a small resident set. Because every query is cheap and stateless, a user or an AI agent can move across dozens or hundreds of APKs on a weak machine, where JADX would need several GB just to preload one. + +### rust_ASC CLI details The following measurements use CLI commands with no cache, no preprocessing, and direct searches of every code reference to the string `https://`. diff --git a/src/cli.rs b/src/cli.rs index 42b7c9b..31581f4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -391,7 +391,7 @@ pub fn _build_main_parser() -> Command { )), ); - Command::new("rust_ASC") + let command = Command::new("rust_ASC") .about("ASC tooling entry.") .after_help( "examples:\n rust_ASC getclass app.apk Lcom/poc/Main; -o Main.java\n \ @@ -466,7 +466,10 @@ pub fn _build_main_parser() -> Command { ) .arg(Arg::new("apk_path").required(true).help("Path to the input APK file.")), ) - .subcommand(findrefs) + .subcommand(findrefs); + #[cfg(feature = "mcp")] + let command = command.subcommand(Command::new("mcp").about("Serve MCP tools over stdio.")); + command } pub fn run_from(args: I) -> Result<(), String> @@ -478,6 +481,8 @@ where .try_get_matches_from(args) .map_err(|error| error.to_string())?; match matches.subcommand() { + #[cfg(feature = "mcp")] + Some(("mcp", _)) => crate::mcp::run(), Some(("getclass", args)) => _handle_getclass(args), Some(("listclass", args)) => _handle_listclass(args), Some(("export", args)) => _handle_export(args), diff --git a/src/lib.rs b/src/lib.rs index 127b77d..d1dbeda 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,3 +3,5 @@ pub mod asc_client; pub mod asc_core; pub mod cli; +#[cfg(feature = "mcp")] +pub mod mcp; diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 0000000..a2a9c7d --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,20 @@ +//! Stdio MCP adapter for the existing command-line interface. +mod schema; +mod server; + +use rmcp::ServiceExt; + +pub fn run() -> Result<(), String> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .map_err(|error| error.to_string())?; + runtime.block_on(async { + let service = server::Server::new() + .serve((tokio::io::stdin(), tokio::io::stdout())) + .await + .map_err(|error| error.to_string())?; + service.waiting().await.map_err(|error| error.to_string())?; + Ok(()) + }) +} diff --git a/src/mcp/schema.rs b/src/mcp/schema.rs new file mode 100644 index 0000000..d43e516 --- /dev/null +++ b/src/mcp/schema.rs @@ -0,0 +1,250 @@ +use std::ffi::OsString; +use std::process::Command; + +use rmcp::model::{Tool, ToolAnnotations}; +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct GetClass { + /// Path to the APK to analyze. + apk_path: String, + /// Dotted class name or Dalvik descriptor. + class_name: String, + /// Positive worker count. Defaults to available CPU count. + thread: Option, + /// Optional source output file. Existing files are overwritten. Content is also returned. + output: Option, +} + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ListClass { + /// Path to the APK to analyze. + apk_path: String, + /// Optional case sensitive class or package prefix. + prefix: Option, + /// Positive worker count. Defaults to available CPU count. + thread: Option, + /// Optional class list output file. When set, stdout is empty. Existing files are overwritten. + output: Option, +} + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct GetManifest { + /// Path to the APK to analyze. + apk_path: String, + /// Optional XML output file. Existing files are overwritten. Content is also returned. + output: Option, +} + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct FindRefs { + /// Path to the APK to analyze. + apk_path: String, + /// Reference target and match mode. + query: RefQuery, + /// Positive worker count. Defaults to available CPU count. + thread: Option, + /// Optional reference output file. Existing files are overwritten. Content is also returned. + output: Option, +} + +#[derive(Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum RefQuery { + /// Find string constant references. + String { + /// Case sensitive string constant substring. + value: String, + }, + /// Find type references. + Type { + /// Case sensitive Dalvik type descriptor substring. + value: String, + }, + /// Find method call references. + Method { + /// Case sensitive method name substring. Supply name, class_name, or both. + name: Option, + /// Dotted name or Dalvik descriptor of the declaring class. + class_name: Option, + /// Match class_name as a case sensitive descriptor substring. + #[serde(default)] + fuzzy_class: bool, + }, + /// Find field access references. + Field { + /// Case sensitive field name substring. Supply name, class_name, or both. + name: Option, + /// Dotted name or Dalvik descriptor of the declaring class. + class_name: Option, + /// Match class_name as a case sensitive descriptor substring. + #[serde(default)] + fuzzy_class: bool, + }, +} + +fn tool(name: &'static str, description: &'static str) -> Tool { + Tool::new(name, description, serde_json::Map::new()) + .with_input_schema::() + .with_annotations( + ToolAnnotations::new() + .read_only(false) + .destructive(true) + .idempotent(true) + .open_world(false), + ) +} + +pub(super) fn tools() -> Vec { + vec![ + tool::("getclass", "Decompile one APK class to Java like source."), + tool::( + "listclass", + "List class descriptors from every DEX in an APK.", + ), + tool::( + "getmanifest", + "Decode AndroidManifest.xml to formatted XML.", + ), + tool::( + "findrefs", + "Find methods that reference strings, types, methods, or fields.", + ), + ] +} + +pub(super) struct Query { + args: Vec, +} + +impl Query { + pub(super) fn parse(name: &str, args: Value) -> Result { + let args = match name { + "getclass" => getclass(serde_json::from_value(args).map_err(invalid_args)?), + "listclass" => listclass(serde_json::from_value(args).map_err(invalid_args)?), + "getmanifest" => getmanifest(serde_json::from_value(args).map_err(invalid_args)?), + "findrefs" => findrefs(serde_json::from_value(args).map_err(invalid_args)?), + _ => return Err("unknown tool".to_owned()), + }; + Ok(Self { args }) + } +} + +fn invalid_args(error: serde_json::Error) -> String { + format!("invalid arguments: {error}") +} + +fn push_thread(args: &mut Vec, thread: Option) { + if let Some(thread) = thread { + args.push("--thread".into()); + args.push(thread.to_string().into()); + } +} + +fn getclass(input: GetClass) -> Vec { + let mut args = vec!["getclass".into()]; + push_thread(&mut args, input.thread); + args.push(input.apk_path.into()); + args.push(input.class_name.into()); + push_output(&mut args, input.output); + args +} + +fn listclass(input: ListClass) -> Vec { + let mut args = vec!["listclass".into()]; + push_thread(&mut args, input.thread); + if let Some(prefix) = input.prefix { + args.push("--prefix".into()); + args.push(prefix.into()); + } + args.push(input.apk_path.into()); + push_output(&mut args, input.output); + args +} + +fn getmanifest(input: GetManifest) -> Vec { + let mut args = vec!["getmanifest".into(), input.apk_path.into()]; + push_output(&mut args, input.output); + args +} + +fn findrefs(input: FindRefs) -> Vec { + let mut args = vec!["findrefs".into()]; + push_thread(&mut args, input.thread); + args.push(input.apk_path.into()); + match input.query { + RefQuery::String { value } => { + args.push("string".into()); + args.push(value.into()); + } + RefQuery::Type { value } => { + args.push("type".into()); + args.push(value.into()); + } + RefQuery::Method { + name, + class_name, + fuzzy_class, + } => push_member(&mut args, "method", name, class_name, fuzzy_class), + RefQuery::Field { + name, + class_name, + fuzzy_class, + } => push_member(&mut args, "field", name, class_name, fuzzy_class), + } + push_output(&mut args, input.output); + args +} + +fn push_output(args: &mut Vec, output: Option) { + if let Some(output) = output { + args.push("-o".into()); + args.push(output.into()); + } +} + +fn push_member( + args: &mut Vec, + kind: &str, + name: Option, + class_name: Option, + fuzzy_class: bool, +) { + args.push(kind.into()); + if let Some(name) = name { + args.push(name.into()); + } + if let Some(class_name) = class_name { + args.push("--class".into()); + args.push(class_name.into()); + } + if fuzzy_class { + args.push("--fuzzy-class".into()); + } +} + +pub(super) fn execute(query: Query) -> Result { + let executable = std::env::current_exe() + .map_err(|error| format!("cannot resolve rust_ASC executable: {error}"))?; + let output = Command::new(executable) + .args(query.args) + .output() + .map_err(|error| format!("cannot start rust_ASC CLI: {error}"))?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let message = stderr.trim(); + if message.is_empty() { + Err(format!("rust_ASC CLI exited with {}", output.status)) + } else { + Err(message.to_owned()) + } +} diff --git a/src/mcp/server.rs b/src/mcp/server.rs new file mode 100644 index 0000000..ceb08ad --- /dev/null +++ b/src/mcp/server.rs @@ -0,0 +1,79 @@ +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation, + ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, +}; +use rmcp::service::RequestContext; +use rmcp::{ErrorData, RoleServer, ServerHandler}; +use serde_json::Value; + +use super::schema; + +pub(super) struct Server { + tools: Vec, +} + +impl Server { + pub(super) fn new() -> Self { + Self { + tools: schema::tools(), + } + } +} + +impl ServerHandler for Server { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("rust_ASC", env!("CARGO_PKG_VERSION"))) + .with_instructions( + "Inspect local Android APK files. Calls are stateless. Paths use the server \ + filesystem. Output files may be overwritten. Failures return CLI stderr.", + ) + } + + async fn list_tools( + &self, + request: Option, + _context: RequestContext, + ) -> Result { + if request.is_some_and(|request| request.cursor.is_some()) { + return Err(ErrorData::invalid_params( + "tool list has no continuation cursor", + None, + )); + } + Ok(ListToolsResult::with_all_items(self.tools.clone())) + } + + fn get_tool(&self, name: &str) -> Option { + self.tools.iter().find(|tool| tool.name == name).cloned() + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + if self.get_tool(&request.name).is_none() { + return Err(ErrorData::invalid_params( + format!("unknown tool: {}", request.name), + None, + )); + } + let args = Value::Object(request.arguments.unwrap_or_default()); + let query = match schema::Query::parse(&request.name, args) { + Ok(query) => query, + Err(error) => { + return Ok(CallToolResult::error(vec![ContentBlock::text(error)]).into()); + } + }; + let result = tokio::task::spawn_blocking(move || schema::execute(query)).await; + let result = match result { + Ok(Ok(stdout)) => CallToolResult::success(vec![ContentBlock::text(stdout)]), + Ok(Err(error)) => CallToolResult::error(vec![ContentBlock::text(error)]), + Err(error) => CallToolResult::error(vec![ContentBlock::text(format!( + "CLI task failed: {error}" + ))]), + }; + Ok(result.into()) + } +} diff --git a/tests/cli_thread.rs b/tests/cli_thread.rs index 5adedd7..7a23710 100644 --- a/tests/cli_thread.rs +++ b/tests/cli_thread.rs @@ -22,6 +22,17 @@ fn thread_option_is_singular_and_belongs_to_each_command() { } } +#[test] +fn mcp_command_follows_the_compile_time_feature() { + let output = run_cli(&["mcp", "--help"]); + let text = stderr(&output); + if cfg!(feature = "mcp") { + assert!(text.contains("Serve MCP tools over stdio."), "{text}"); + } else { + assert!(text.contains("unrecognized subcommand 'mcp'"), "{text}"); + } +} + #[test] fn plural_threads_option_is_rejected() { let output = run_cli(&[ diff --git a/tests/mcp_stdio.rs b/tests/mcp_stdio.rs new file mode 100644 index 0000000..8dc62eb --- /dev/null +++ b/tests/mcp_stdio.rs @@ -0,0 +1,506 @@ +#![cfg(feature = "mcp")] + +mod dex_fixture; +mod zip_fixture; + +use std::ffi::OsString; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Child, ChildStdin, Command, Output, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use dex_fixture::{make_dex, make_instance_field_dex, make_invoke_dex}; +use serde_json::{Value, json}; +use tempfile::tempdir; +use zip_fixture::{CompressionMethod, write_apk}; + +struct Client { + child: Child, + input: Option, + output: Receiver>, + stdout: Option>, + stderr: Option>, + next_id: u64, +} + +impl Client { + fn new() -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_rust_ASC")) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let input = child.stdin.take(); + let stdout = child.stdout.take().unwrap(); + let mut stderr = child.stderr.take().unwrap(); + let stderr = std::thread::spawn(move || { + let mut text = String::new(); + stderr.read_to_string(&mut text).unwrap(); + text + }); + let (send, output) = mpsc::channel(); + let stdout = std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let result = line.map_err(|error| error.to_string()).and_then(|line| { + serde_json::from_str(&line) + .map_err(|error| format!("non-protocol stdout: {line}: {error}")) + }); + if send.send(result).is_err() { + break; + } + } + }); + let mut client = Self { + child, + input, + output, + stdout: Some(stdout), + stderr: Some(stderr), + next_id: 1, + }; + client.send( + json!({"jsonrpc":"2.0", "id":0, "method":"initialize", "params":{ + "protocolVersion":"2025-11-25", + "capabilities":{}, + "clientInfo":{"name":"test", "version":"1"} + }}), + ); + let response = client.recv(); + assert_eq!(response["id"], 0); + assert_eq!(response["result"]["serverInfo"]["name"], "rust_ASC"); + client.send(json!({"jsonrpc":"2.0", "method":"notifications/initialized"})); + client + } + + fn send(&mut self, value: Value) { + let input = self.input.as_mut().unwrap(); + writeln!(input, "{value}").unwrap(); + input.flush().unwrap(); + } + + fn recv(&self) -> Value { + self.output + .recv_timeout(Duration::from_secs(30)) + .expect("MCP reply timeout/disconnect") + .expect("stdout must contain only JSON-RPC") + } + + fn call(&mut self, name: &str, args: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + self.send( + json!({"jsonrpc":"2.0", "id":id, "method":"tools/call", "params":{ + "name":name, "arguments":args + }}), + ); + let response = self.recv(); + assert_eq!(response["id"], id, "{response}"); + assert!(response.get("error").is_none(), "{response}"); + response["result"].clone() + } + + fn finish(mut self) { + self.input.take(); + let start = Instant::now(); + loop { + if let Some(status) = self.child.try_wait().unwrap() { + let stderr = self.stderr.take().unwrap().join().unwrap(); + assert!(status.success(), "MCP exit: {status}; {stderr}"); + assert!(stderr.is_empty(), "unexpected stderr: {stderr}"); + break; + } + assert!( + start.elapsed() < Duration::from_secs(10), + "MCP did not exit on EOF" + ); + std::thread::sleep(Duration::from_millis(10)); + } + self.stdout.take().unwrap().join().unwrap(); + } +} + +impl Drop for Client { + fn drop(&mut self) { + self.input.take(); + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn text(result: &Value) -> &str { + result["content"][0]["text"].as_str().unwrap() +} + +fn run_cli(args: impl IntoIterator) -> Output { + Command::new(env!("CARGO_BIN_EXE_rust_ASC")) + .args(args) + .output() + .unwrap() +} + +fn cli_stdout(args: impl IntoIterator) -> String { + let output = run_cli(args); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +#[test] +fn exposes_four_documented_cli_wrappers() { + let mut client = Client::new(); + client.send(json!({"jsonrpc":"2.0", "id":99, "method":"tools/list"})); + let response = client.recv(); + let tools = response["result"]["tools"].as_array().unwrap(); + let mut names: Vec<_> = tools + .iter() + .map(|tool| tool["name"].as_str().unwrap()) + .collect(); + names.sort(); + assert_eq!(names, ["findrefs", "getclass", "getmanifest", "listclass"]); + for tool in tools { + assert_eq!(tool["inputSchema"]["type"], "object"); + assert_eq!(tool["inputSchema"]["additionalProperties"], false); + assert_eq!(tool["annotations"]["readOnlyHint"], false); + assert_eq!(tool["annotations"]["destructiveHint"], true); + assert!(tool.get("outputSchema").is_none()); + let description = tool["description"].as_str().unwrap(); + assert!( + (20..=80).contains(&description.len()), + "tool description is not concise: {description}" + ); + for forbidden in ["-", "—", "{", "apk_path", "class_name", "fuzzy_class"] { + assert!( + !description.contains(forbidden), + "tool description contains {forbidden}: {description}" + ); + } + assert_described_properties(&tool["inputSchema"]); + assert_concise_descriptions(&tool["inputSchema"]); + let output = tool["inputSchema"]["properties"]["output"]["description"] + .as_str() + .unwrap(); + if tool["name"] == "listclass" { + assert!(output.contains("stdout is empty.")); + } else { + assert!(output.contains("Content is also returned.")); + } + } + client.send( + json!({"jsonrpc":"2.0", "id":100, "method":"tools/call", "params":{ + "name":"export", "arguments":{} + }}), + ); + assert_eq!(client.recv()["error"]["code"], -32602); + client.finish(); +} + +fn assert_described_properties(schema: &Value) { + if let Some(properties) = schema.get("properties").and_then(Value::as_object) { + for (name, property) in properties { + if name != "kind" { + assert!( + property + .get("description") + .and_then(Value::as_str) + .is_some_and(|description| !description.is_empty()), + "parameter {name} has no description: {property}" + ); + } + } + } + if let Some(object) = schema.as_object() { + for value in object.values() { + assert_described_properties(value); + } + } else if let Some(array) = schema.as_array() { + for value in array { + assert_described_properties(value); + } + } +} + +fn assert_concise_descriptions(schema: &Value) { + if let Some(object) = schema.as_object() { + if let Some(description) = object.get("description").and_then(Value::as_str) { + assert!( + description.len() <= 120, + "description is too long: {description}" + ); + assert!( + !description.contains('-') && !description.contains('—'), + "description contains a dash: {description}" + ); + } + for value in object.values() { + assert_concise_descriptions(value); + } + } else if let Some(array) = schema.as_array() { + for value in array { + assert_concise_descriptions(value); + } + } +} + +#[test] +fn tool_results_are_exact_cli_stdout() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture with space.apk"); + let dex = make_dex(); + let manifest = minimal_manifest(); + write_apk( + &apk, + &[("classes.dex", &dex), ("AndroidManifest.xml", &manifest)], + CompressionMethod::Deflated, + ); + let path = apk.to_string_lossy().into_owned(); + let mut client = Client::new(); + + let result = client.call( + "listclass", + json!({"apk_path":path, "prefix":"example", "thread":1}), + ); + assert_eq!(result["isError"], false); + assert_eq!( + text(&result), + cli_stdout( + ["listclass", "--thread", "1", "--prefix", "example", &path,].map(OsString::from) + ) + ); + + let result = client.call( + "getclass", + json!({"apk_path":path, "class_name":"example.Test", "thread":1}), + ); + assert_eq!(result["isError"], false); + assert_eq!( + text(&result), + cli_stdout(["getclass", "--thread", "1", &path, "example.Test"].map(OsString::from)) + ); + + let result = client.call("getmanifest", json!({"apk_path":path})); + assert_eq!(result["isError"], false); + assert_eq!( + text(&result), + cli_stdout(["getmanifest", &path].map(OsString::from)) + ); + + let result = client.call( + "findrefs", + json!({ + "apk_path":path, + "thread":1, + "query":{"kind":"string", "value":"token"} + }), + ); + assert_eq!(result["isError"], false); + assert_eq!( + text(&result), + cli_stdout(["findrefs", "--thread", "1", &path, "string", "token"].map(OsString::from)) + ); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + client.finish(); +} + +#[test] +fn optional_output_writes_files_with_cli_semantics() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture.apk"); + let dex = make_dex(); + let manifest = minimal_manifest(); + write_apk( + &apk, + &[("classes.dex", &dex), ("AndroidManifest.xml", &manifest)], + CompressionMethod::Deflated, + ); + let path = apk.to_string_lossy().into_owned(); + let mut client = Client::new(); + + let class_output = dir.path().join("Test.java"); + let result = client.call( + "getclass", + json!({ + "apk_path":path, + "class_name":"example.Test", + "thread":1, + "output":class_output + }), + ); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!( + std::fs::read_to_string(&class_output).unwrap().trim_end(), + text(&result).trim_end() + ); + + let classes_output = dir.path().join("classes.txt"); + let result = client.call( + "listclass", + json!({"apk_path":path, "thread":1, "output":classes_output}), + ); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!( + std::fs::read_to_string(&classes_output).unwrap(), + "Lexample/Test;\n" + ); + assert_eq!(text(&result), ""); + + let manifest_output = dir.path().join("AndroidManifest.xml"); + let result = client.call( + "getmanifest", + json!({"apk_path":path, "output":manifest_output}), + ); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!( + std::fs::read_to_string(&manifest_output) + .unwrap() + .trim_end(), + text(&result).trim_end() + ); + + let refs_output = dir.path().join("references.txt"); + let result = client.call( + "findrefs", + json!({ + "apk_path":path, + "thread":1, + "query":{"kind":"string", "value":"token"}, + "output":refs_output + }), + ); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!( + std::fs::read_to_string(&refs_output).unwrap(), + text(&result) + ); + client.finish(); +} + +#[test] +fn findrefs_query_variants_map_to_cli_arguments() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("references.apk"); + let mut type_dex = make_dex(); + let instruction = type_dex + .windows(6) + .position(|bytes| bytes == b"\x1a\0\x05\0\x0e\0") + .unwrap(); + type_dex[instruction..instruction + 4].copy_from_slice(b"\x1c\0\0\0"); + write_apk( + &apk, + &[ + ("classes.dex", &type_dex), + ("classes2.dex", &make_invoke_dex()), + ("classes3.dex", &make_instance_field_dex()), + ], + CompressionMethod::Stored, + ); + let path = apk.to_string_lossy().into_owned(); + let mut client = Client::new(); + let cases = [ + ( + json!({"kind":"type", "value":"example"}), + vec!["type", "example"], + ), + ( + json!({ + "kind":"method", + "name":"callToMethod", + "class_name":"example.Target" + }), + vec!["method", "callToMethod", "--class", "example.Target"], + ), + ( + json!({ + "kind":"field", + "name":"a", + "class_name":"example", + "fuzzy_class":true + }), + vec!["field", "a", "--class", "example", "--fuzzy-class"], + ), + ]; + for (query, query_args) in cases { + let result = client.call("findrefs", json!({"apk_path":path, "query":query})); + assert_eq!(result["isError"], false, "{result}"); + let mut cli_args = vec![OsString::from("findrefs"), OsString::from(&path)]; + cli_args.extend(query_args.into_iter().map(OsString::from)); + assert_eq!(text(&result), cli_stdout(cli_args)); + } + client.finish(); +} + +#[test] +fn cli_failures_are_isolated_and_the_server_remains_usable() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture.apk"); + write_apk( + &apk, + &[("classes.dex", &make_dex())], + CompressionMethod::Stored, + ); + let path = apk.to_string_lossy().into_owned(); + let mut client = Client::new(); + + let result = client.call("getclass", json!({"apk_path":path})); + assert_eq!(result["isError"], true); + assert!(text(&result).contains("invalid arguments"), "{result}"); + + let cli = run_cli( + ["getclass", &path, "example.Missing"] + .map(OsString::from) + .into_iter(), + ); + assert!(!cli.status.success()); + let result = client.call( + "getclass", + json!({"apk_path":path, "class_name":"example.Missing"}), + ); + assert_eq!(result["isError"], true); + assert_eq!(text(&result), String::from_utf8_lossy(&cli.stderr).trim()); + + let result = client.call("listclass", json!({"apk_path":path})); + assert_eq!(result["isError"], false); + assert_eq!(text(&result), "Lexample/Test;\n"); + client.finish(); +} + +fn minimal_manifest() -> Vec { + fn u16_(out: &mut Vec, value: u16) { + out.extend(value.to_le_bytes()); + } + fn u32_(out: &mut Vec, value: u32) { + out.extend(value.to_le_bytes()); + } + fn header(out: &mut Vec, kind: u16, header_size: u16, size: u32) { + u16_(out, kind); + u16_(out, header_size); + u32_(out, size); + } + + let mut out = Vec::new(); + header(&mut out, 3, 8, 120); + header(&mut out, 1, 28, 44); + for value in [1, 0, 0x100, 32, 0, 0] { + u32_(&mut out, value); + } + out.extend(b"\x08\x08manifest\0\0"); + header(&mut out, 0x180, 8, 8); + header(&mut out, 0x102, 16, 36); + for value in [1, u32::MAX, u32::MAX, 0] { + u32_(&mut out, value); + } + for value in [20, 20, 0, 0, 0, 0] { + u16_(&mut out, value); + } + header(&mut out, 0x103, 16, 24); + for value in [1, u32::MAX, u32::MAX, 0] { + u32_(&mut out, value); + } + assert_eq!(out.len(), 120); + out +}