Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
492 changes: 492 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ version = "0.1.0"
edition = "2024"
license = "Apache-2.0"

[features]
default = ["mcp"]
mcp = ["dep:rmcp", "dep:tokio", "dep:tokio-util", "dep:serde", "dep:serde_json", "dep:schemars"]

[profile.release]
opt-level = 3 # max speed
lto = "fat" # full link-time optimization
Expand All @@ -16,6 +20,12 @@ 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", "sync", "macros", "time"] }
tokio-util = { version = "0.7", optional = true, features = ["rt"] }
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"] }
Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,21 @@ 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 four read-only, structured MCP tools over stdio, suitable for Pi CodeMode.

## Build

```bash
git submodule update --init --recursive
cargo build --release

# Build without MCP and its dedicated dependencies.
cargo build --release --no-default-features
```

MCP is enabled by default, including CI/releases. `--no-default-features`
removes only MCP and its dedicated dependencies; existing CLI commands remain.

The executable is written to:

```text
Expand Down Expand Up @@ -69,6 +77,43 @@ 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.

## MCP

Read-only MCP tools over stdio, designed for CodeMode:
`getclass`, `listclass`, `getmanifest`, and `findrefs`.

Configure:

Ask your agent to install and configure ASC, or follow the manual setup below.

<details>
<summary>Agent setup instructions</summary>

1. Confirm the target MCP client and user- or project-level configuration scope.
2. Locate an MCP-enabled `rust_ASC` executable. If needed, run
`git submodule update --init --recursive` followed by `cargo install --path . --locked`
from this repository. MCP is enabled by default; the executable is normally installed
to `~/.cargo/bin/rust_ASC`.
3. Register `asc` as a stdio server using the executable's absolute path and
`args: ["mcp"]`. Use the client's native configuration format and preserve existing settings.
4. Reload or restart the client and verify that the four tools listed above are available.

</details>

For manual setup, replace `/absolute/path/to/rust_ASC` with your executable path
and follow your client's MCP configuration instructions:

```json
{
"mcpServers": {
"asc": {
"command": "/absolute/path/to/rust_ASC",
"args": ["mcp"]
}
}
}
```

## CLI Benchmark

The following measurements use CLI commands with no cache, no preprocessing, and direct searches of every code reference to the string `https://`.
Expand Down
69 changes: 67 additions & 2 deletions src/asc_client/apk/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,63 @@ pub(crate) fn _parse_cd_dex_entries(buf: &[u8]) -> Vec<DexEntry> {
entries
}

/// Strict validation for long-lived query callers. The CLI's fast scanner is
/// retained, but malformed archives must not become successful empty results.
pub(crate) fn checked_dex_entries(buf: &[u8]) -> Result<Vec<DexEntry>, String> {
let eocd = _find_eocd(buf).ok_or("EOCD not found")?;
if _STRUCT_H::unpack_from(buf, eocd + 4) != 0
|| _STRUCT_H::unpack_from(buf, eocd + 6) != 0
|| _STRUCT_H::unpack_from(buf, eocd + 8) != _STRUCT_H::unpack_from(buf, eocd + 10)
{
return Err("multi-disk ZIP is unsupported".to_owned());
}
let count = _STRUCT_H::unpack_from(buf, eocd + 10) as usize;
if count == u16::MAX as usize {
return Err("ZIP64 is unsupported".to_owned());
}
let comment = _STRUCT_H::unpack_from(buf, eocd + 20) as usize;
if eocd + 22 + comment != buf.len() {
return Err("bad ZIP comment length".to_owned());
}
let start = _STRUCT_I::unpack_from(buf, eocd + 16) as usize;
let end = start
.checked_add(_STRUCT_I::unpack_from(buf, eocd + 12) as usize)
.filter(|&end| end <= eocd)
.ok_or("bad central directory range")?;
let mut pos = start;
let mut entries = Vec::new();
for _ in 0..count {
if pos.checked_add(46).is_none_or(|next| next > end) || &buf[pos..pos + 4] != _CD_SIG {
return Err("bad central directory entry".to_owned());
}
let name_len = _STRUCT_H::unpack_from(buf, pos + 28) as usize;
let extra_len = _STRUCT_H::unpack_from(buf, pos + 30) as usize;
let comment_len = _STRUCT_H::unpack_from(buf, pos + 32) as usize;
let next = pos
.checked_add(46 + name_len + extra_len + comment_len)
.filter(|&next| next <= end)
.ok_or("bad central directory entry range")?;
if _STRUCT_H::unpack_from(buf, pos + 8) & 1 != 0 {
return Err("encrypted ZIP entries are unsupported".to_owned());
}
let name = &buf[pos + 46..pos + 46 + name_len];
if name.ends_with(_DEX_SUFFIX) && !name.contains(&b'/') {
entries.push(DexEntry {
name: String::from_utf8_lossy(name).into_owned(),
uncomp_size: _STRUCT_I::unpack_from(buf, pos + 24),
comp_size: _STRUCT_I::unpack_from(buf, pos + 20),
local_header_off: _STRUCT_I::unpack_from(buf, pos + 42),
comp_method: _STRUCT_H::unpack_from(buf, pos + 10),
});
}
pos = next;
}
if pos != end {
return Err("central directory size/count mismatch".to_owned());
}
Ok(entries)
}

fn _inflate_deflate_chunks(compressed: &[u8], stop_event: &AtomicBool) -> Option<Vec<u8>> {
let mut decoder = DeflateDecoder::new(compressed);
let mut out = Vec::with_capacity(compressed.len() * 3);
Expand Down Expand Up @@ -231,6 +288,14 @@ pub(crate) fn _inflate_dex(
}

pub(crate) fn _extract_zip_entry(buf: &[u8], target_name: &str) -> Result<Vec<u8>, String> {
try_extract_zip_entry(buf, target_name)?
.ok_or_else(|| format!("{target_name} not found in APK"))
}

pub(crate) fn try_extract_zip_entry(
buf: &[u8],
target_name: &str,
) -> Result<Option<Vec<u8>>, String> {
let eocd_off = _find_eocd(buf).ok_or_else(|| "EOCD not found".to_owned())?;
let cd_size = _STRUCT_I::unpack_from(buf, eocd_off + 12) as usize;
let cd_off = _STRUCT_I::unpack_from(buf, eocd_off + 16) as usize;
Expand Down Expand Up @@ -262,7 +327,7 @@ pub(crate) fn _extract_zip_entry(buf: &[u8], target_name: &str) -> Result<Vec<u8
local_header_off: _STRUCT_I::unpack_from(buf, ptr + 42),
comp_method: _STRUCT_H::unpack_from(buf, ptr + 10),
};
return _inflate_dex(buf, &entry, None)?.ok_or_else(|| "inflate stopped".to_owned());
return _inflate_dex(buf, &entry, None);
}

ptr = name_end
Expand All @@ -271,5 +336,5 @@ pub(crate) fn _extract_zip_entry(buf: &[u8], target_name: &str) -> Result<Vec<u8
.ok_or_else(|| "bad central directory entry range".to_owned())?;
}

Err(format!("{target_name} not found in APK"))
Ok(None)
}
109 changes: 80 additions & 29 deletions src/asc_client/asc_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,57 @@ use std::vec;

use crate::asc_core::core::dex::dex_manager::DexManager;
use crate::asc_core::findrefs::findrefs_manager::{FindRefInput, FindRefManager};
use crate::asc_core::findrefs::locator::field_locator::FieldFind;
use crate::asc_core::findrefs::locator::method_locator::MethodFind;
use crate::asc_core::utils::decompiler::{
decompile_dex_bytes, decompile_dex_bytes_with_metadata, MemberReference,
MemberReference, decompile_dex_bytes, decompile_dex_bytes_with_metadata,
};
use crate::asc_core::utils::tinydex::DEX;

// -----------------------------------------------------------------
/// One caller and its matched targets. Built from DEX records, never CLI text.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "mcp", derive(serde::Serialize, schemars::JsonSchema))]
pub struct Reference {
pub dex_name: String,
pub class_name: String,
pub method_name: String,
pub matched: Vec<String>,
}

// Static dispatch keeps structured allocations off the CLI path.
trait ReferenceOutput: Sized {
fn single(dex_name: &str, dex: &DEX, mid: u32, matched: &str) -> Self;
fn aggregate(dex_name: &str, dex: &DEX, mid: u32, matched: Vec<String>) -> Self;
}

impl ReferenceOutput for String {
fn single(dex_name: &str, dex: &DEX, mid: u32, matched: &str) -> Self {
let method = dex.get_method(mid);
let caller = format!("{}->{}", method.cls().fullname, method.name());
format!("{} | {} | matched=({})", dex_name, caller, matched)
}

fn aggregate(dex_name: &str, dex: &DEX, mid: u32, matched: Vec<String>) -> Self {
let joined = matched.join("; ");
// Release target buffers before formatting, as in the legacy CLI path.
drop(matched);
Self::single(dex_name, dex, mid, &joined)
}
}

impl ReferenceOutput for Reference {
fn single(dex_name: &str, dex: &DEX, mid: u32, matched: &str) -> Self {
Self::aggregate(dex_name, dex, mid, vec![matched.to_owned()])
}

fn aggregate(dex_name: &str, dex: &DEX, mid: u32, matched: Vec<String>) -> Self {
let method = dex.get_method(mid);
Self {
dex_name: dex_name.to_owned(),
class_name: method.cls().fullname,
method_name: method.name().to_owned(),
matched,
}
}
}

/// Core orchestrator integrating DexManager, FindRefManager and decompiler
pub struct AscHandler {
Expand Down Expand Up @@ -42,9 +85,13 @@ impl AscHandler {
decompile_dex_bytes_with_metadata(&new_dex_bytes, dalvik_class)
}

fn _format_method(&self, dex: &DEX, midx: usize) -> String {
let method = dex.get_method(midx as u32);
format!("{}->{}", method.cls().fullname, method.name())
/// Unlike the legacy text helper, decompiler failures remain errors.
pub fn try_getclass(&self, dex_buf: &[u8], dalvik_class: &str) -> Result<String, String> {
let mut manager = DexManager::from_bytes(dex_buf.to_vec(), self.debug);
let new_dex_bytes = manager.extract_and_rebuild(dalvik_class);
radguard::decompile_dex_bytes(&new_dex_bytes, dalvik_class)
.map(|output| output.source)
.map_err(|error| error.to_string())
}

fn _format_matched_name(&self, dex: &DEX, find_type: &str, idx: usize) -> String {
Expand Down Expand Up @@ -73,7 +120,7 @@ impl AscHandler {
aggregate: bool,
) -> Vec<String> {
let dex = DEX::parse(dex_buf, dex_name);
self._findrefs(dex_name, &dex, find_type, find, aggregate)
self.findrefs_output::<String>(dex_name, &dex, find_type, find, aggregate)
}

pub fn findrefs_vec(
Expand All @@ -85,17 +132,28 @@ impl AscHandler {
aggregate: bool,
) -> Vec<String> {
let dex = DEX::from_vec(dex_buf, dex_name);
self._findrefs(dex_name, &dex, find_type, find, aggregate)
self.findrefs_output::<String>(dex_name, &dex, find_type, find, aggregate)
}

pub fn findrefs_records(
&self,
dex_name: &str,
dex_buf: Vec<u8>,
find_type: &str,
find: &FindRefInput,
) -> Vec<Reference> {
let dex = DEX::from_vec(dex_buf, dex_name);
self.findrefs_output::<Reference>(dex_name, &dex, find_type, find, true)
}

fn _findrefs(
fn findrefs_output<R: ReferenceOutput>(
&self,
dex_name: &str,
dex: &DEX,
find_type: &str,
find: &FindRefInput,
aggregate: bool,
) -> Vec<String> {
) -> Vec<R> {
let mut ref_manager = FindRefManager::new(dex, self.debug);
let output = ref_manager.find_ref(find, true);

Expand All @@ -121,15 +179,12 @@ impl AscHandler {
let mut ret = Vec::new();
for (i, mid_entry) in mids.iter().enumerate() {
let idx = matched_idxs.get(i).copied().unwrap_or(0);
let matched = self._format_matched_name(&dex, find_type, idx);
let Some(mid_list) = mid_entry else { continue; };
let matched = self._format_matched_name(dex, find_type, idx);
let Some(mid_list) = mid_entry else {
continue;
};
for &mid in mid_list {
ret.push(format!(
"{} | {} | matched=({})",
dex_name,
self._format_method(&dex, mid as usize),
matched
));
ret.push(R::single(dex_name, dex, mid, &matched));
}
}
return ret;
Expand All @@ -139,7 +194,9 @@ impl AscHandler {
let mut grouped: HashMap<usize, HashSet<usize>> = HashMap::new();
for (i, mid_entry) in mids.iter().enumerate() {
let idx = matched_idxs.get(i).copied().unwrap_or(0);
let Some(mid_list) = mid_entry else { continue; };
let Some(mid_list) = mid_entry else {
continue;
};
for &mid in mid_list {
grouped.entry(mid as usize).or_default().insert(idx);
}
Expand All @@ -154,15 +211,9 @@ impl AscHandler {
sorted_idxs.sort();
let matched = sorted_idxs
.iter()
.map(|&&idx| self._format_matched_name(&dex, find_type, idx))
.collect::<Vec<_>>()
.join("; ");
ret.push(format!(
"{} | {} | matched=({})",
dex_name,
self._format_method(&dex, *mid),
matched
));
.map(|&&idx| self._format_matched_name(dex, find_type, idx))
.collect::<Vec<_>>();
ret.push(R::aggregate(dex_name, dex, *mid as u32, matched));
}
ret
}
Expand Down
7 changes: 6 additions & 1 deletion src/asc_client/manifest_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,13 @@ pub fn get_manifest_xml(apk_path: &str, pretty: bool) -> Result<String, String>
let apk = fs::read(apk_path).map_err(|e| format!("cannot open APK: {}", e))?;
let manifest_data = _extract_zip_entry(&apk, "AndroidManifest.xml")?;

decode_manifest_xml(&manifest_data, pretty)
}

/// Decode already-extracted data so query callers can retain typed file errors.
pub(crate) fn decode_manifest_xml(manifest_data: &[u8], pretty: bool) -> Result<String, String> {
_quiet_loguru();
let mut input = manifest_data.as_slice();
let mut input = manifest_data;
let axml = AXML::new(&mut input, None)
.map_err(|e| format!("AndroidManifest.xml is not a valid binary XML: {}", e))?;
_format_xml(&axml.get_xml_string(), pretty)
Expand Down
2 changes: 2 additions & 0 deletions src/asc_client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ pub mod apk_handler;
pub mod asc_handler;
pub mod dex_container;
pub mod manifest_handler;
pub mod query;
pub(crate) mod query_input;
Loading
Loading