From 24d3770d69e5b4e9240b5fdceb3a5fdbc07f71fe Mon Sep 17 00:00:00 2001 From: Tsingshui Date: Tue, 20 Oct 2026 16:59:00 +0800 Subject: [PATCH] feat(listmethod): add indexed method listing for CLI and MCP Enumerate defined methods with complete signatures and class/name filters. Reuse StringLocator for name-index selection and keep scanning in method_id_scan. Render ordered DEX text chunks, transfer DEX buffer ownership, and borrow UTF-8 names to reduce allocations and peak memory. Simplify shared listing traversal and preserve CLI/MCP output semantics. Verified with cargo test (69 passed) and cargo test --no-default-features (64 passed). --- README.md | 15 +- src/asc_client/apk_handler.rs | 133 ++++-- .../findrefs/locator/string_locator.rs | 76 +++- src/asc_core/findrefs/scan/method_id_scan.rs | 320 ++++++++++++++ src/asc_core/findrefs/scan/mod.rs | 3 +- src/cli.rs | 53 +++ src/mcp/schema.rs | 35 ++ tests/cli_thread.rs | 2 +- tests/listmethod.rs | 414 ++++++++++++++++++ tests/mcp_stdio.rs | 71 ++- 10 files changed, 1068 insertions(+), 54 deletions(-) create mode 100644 src/asc_core/findrefs/scan/method_id_scan.rs create mode 100644 tests/listmethod.rs diff --git a/README.md b/README.md index 7a109df..e7d4260 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ https://github.com/user-attachments/assets/f1f71a75-1eca-4647-89ce-406ed2c63c6b - Search code references for strings, types, methods, and fields across every DEX in an APK. - Locate and decompile one class directly from an APK. -- List classes and decode `AndroidManifest.xml`. +- List classes and defined methods, and decode `AndroidManifest.xml`. - 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. @@ -65,7 +65,7 @@ On Windows, the executable name is `rust_ASC.exe`. ## Command Line Usage -Thread control is consistent across `getclass`, `listclass`, `findrefs`, and `export`: +Thread control is consistent across `getclass`, `listclass`, `listmethod`, `findrefs`, and `export`: - Use only `--thread `. - Put `--thread` immediately after the subcommand. @@ -88,6 +88,11 @@ rust_ASC getclass --thread 10 app.apk com.example.Main -o Main.java rust_ASC listclass --thread 10 app.apk rust_ASC listclass --thread 10 --prefix com.example app.apk +# List method definitions without decompiling, optionally filtered by class/name. +rust_ASC listmethod --thread 10 app.apk +rust_ASC listmethod --class com.example.Main -f onCreate app.apk +rust_ASC listmethod app.apk -o methods.txt + # Decode AndroidManifest.xml. rust_ASC getmanifest app.apk -o AndroidManifest.xml @@ -95,11 +100,15 @@ rust_ASC getmanifest app.apk -o AndroidManifest.xml rust_ASC export --thread 10 --debug app.apk output ``` +`listmethod` prints one full Dalvik method descriptor per line, for example `Lcom/example/Main;->onCreate(Landroid/os/Bundle;)V`. It lists only methods defined in class data, including native and abstract methods, not external methods that are merely referenced. `--class` matches an exact dotted name or Dalvik descriptor; `--filter` / `-f` matches a case insensitive method name substring. Results retain DEX/class/declaration order and duplicate definitions across DEX files. Like `listclass`, `-o` writes the list instead of printing it; no matches produce an empty list. + `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 -`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. +`rust_ASC mcp` runs a stdio MCP server that exposes the CLI commands as tools: `getclass`, `listclass`, `listmethod`, `getmanifest`, and `findrefs`. Each call spawns a fresh CLI process, so behavior always matches the command line. + +The `listmethod` tool accepts `apk_path`, optional `class_name`, `filter`, `thread`, and `output`. It returns exactly the CLI stdout; with `output`, it writes the method list to that file and returns empty text. Configure it in your MCP client: diff --git a/src/asc_client/apk_handler.rs b/src/asc_client/apk_handler.rs index 4adb25b..7c95fe7 100644 --- a/src/asc_client/apk_handler.rs +++ b/src/asc_client/apk_handler.rs @@ -2,7 +2,7 @@ // Strict 1:1 translation from Python apk_handler.py use std::fs::File; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Instant; @@ -16,8 +16,9 @@ use crate::asc_client::apk::dex_reader::{ use crate::asc_client::apk::loader::{ _inflate_dex as _load_dex, _parse_cd_dex_entries as _parse_entries, DexEntry, }; -use crate::asc_client::dex_container::iter_logical_dex_buffers; +use crate::asc_client::dex_container::{into_logical_dex_buffers, iter_logical_dex_buffers}; use crate::asc_core::findrefs::findrefs_manager::FindRefInput; +use crate::asc_core::findrefs::scan::method_id_scan::render_dex_methods; fn _inflate_and_hit( mm: &[u8], @@ -242,6 +243,58 @@ impl ApkHandler { .filter(|prefix| prefix.is_ascii()) .map(str::as_bytes); + self._list_dex_items("class", |buf| { + let names = _list_classes(buf, prefix.as_deref(), prefix_bytes)?; + let count = names.len(); + Ok((names, count)) + }) + } + + /// List defined method signatures in DEX declaration order. + pub fn list_methods( + &self, + class: Option<&str>, + filter: Option<&str>, + ) -> Result, String> { + Ok(self + .list_method_chunks(class, filter)? + .iter() + .flat_map(|text| text.lines().map(str::to_owned)) + .collect()) + } + + pub(crate) fn list_method_chunks( + &self, + class: Option<&str>, + filter: Option<&str>, + ) -> Result, String> { + let class = class + .map(str::trim) + .map(|name| { + if name.is_empty() { + return Err("Class name cannot be empty".to_owned()); + } + Ok(if name.starts_with('L') && name.ends_with(';') { + name.to_owned() + } else { + format!("L{};", name.replace('.', "/")) + }) + }) + .transpose()?; + let filter = filter.map(str::to_lowercase); + self._list_dex_items("method", |buf| { + let (text, count) = render_dex_methods(buf, class.as_deref(), filter.as_deref())?; + Ok((vec![text], count)) + }) + } + + fn _list_dex_items(&self, kind: &str, list: F) -> Result, String> + where + F: Fn(&[u8]) -> Result<(Vec, usize), String> + Sync, + { + if self.max_workers == 0 { + return Err("Worker count must be greater than zero".to_owned()); + } let t_start = Instant::now(); let mm = self._open_apk()?; let entries = _parse_entries(&mm); @@ -250,61 +303,51 @@ impl ApkHandler { } let workers = entries.len().min(self.max_workers); - let entries = Arc::new(entries); - let work_queue = Arc::new(Mutex::new(0usize)); - let results = Arc::new(Mutex::new(vec![None; entries.len()])); + let next = AtomicUsize::new(0); + let results = Mutex::new(vec![None; entries.len()]); thread::scope(|scope| { for _ in 0..workers { - let entries = Arc::clone(&entries); - let work_queue = Arc::clone(&work_queue); - let results = Arc::clone(&results); - let prefix = prefix.as_deref(); - let prefix_bytes = prefix_bytes; - let mm = &mm; - scope.spawn(move || loop { - let index = { - let mut next = work_queue.lock().unwrap(); - if *next >= entries.len() { - return; - } - let index = *next; - *next += 1; - index - }; - let entry = &entries[index]; - let result = _load_dex(mm, entry, None).and_then(|data| { - let data = data.ok_or_else(|| "inflate stopped".to_owned())?; - let mut names = Vec::new(); - for (_dex_name, dex_buf) in iter_logical_dex_buffers(&entry.name, &data) { - names.extend(_list_classes(&dex_buf, prefix, prefix_bytes)?); - } - Ok((entry.name.clone(), names)) - }); - results.lock().unwrap()[index] = Some(result); + scope.spawn(|| { + loop { + let index = next.fetch_add(1, Ordering::Relaxed); + let Some(entry) = entries.get(index) else { + break; + }; + let result = _load_dex(&mm, entry, None).and_then(|data| { + let data = data.ok_or_else(|| "inflate stopped".to_owned())?; + let mut items = Vec::new(); + let mut count = 0; + for (_, dex_buf) in into_logical_dex_buffers(&entry.name, data) { + let (dex_items, dex_count) = list(&dex_buf)?; + items.extend(dex_items); + count += dex_count; + } + Ok((items, count)) + }); + results.lock().unwrap()[index] = Some(result); + } }); } }); - let mut names = Vec::new(); - let mut results = results.lock().unwrap(); - for result in results.iter_mut() { - let (dex_name, dex_names) = result - .take() - .ok_or_else(|| "listclass worker did not return a result".to_owned())??; - names.extend(dex_names.iter().cloned()); - self._log(&format!( - "[APK] '{dex_name}' class_count={}", - dex_names.len() - )); + let mut items = Vec::new(); + let mut total_count = 0; + for (entry, result) in entries.iter().zip(results.into_inner().unwrap()) { + let (dex_items, count) = + result.ok_or_else(|| format!("list{kind} worker did not return a result"))??; + if self.debug { + self._log(&format!("[APK] '{}' {kind}_count={count}", entry.name)); + } + items.extend(dex_items); + total_count += count; } if self.debug { self._log(&format!( - "[APK] listclass total={:.2} us count={} workers={workers}", + "[APK] list{kind} total={:.2} us count={total_count} workers={workers}", t_start.elapsed().as_secs_f64() * 1_000_000.0, - names.len(), )); } - Ok(names) + Ok(items) } pub fn for_each_findrefs( diff --git a/src/asc_core/findrefs/locator/string_locator.rs b/src/asc_core/findrefs/locator/string_locator.rs index 0b77959..313ec58 100644 --- a/src/asc_core/findrefs/locator/string_locator.rs +++ b/src/asc_core/findrefs/locator/string_locator.rs @@ -5,11 +5,30 @@ use crate::asc_core::findrefs::locator::base_locator::{_debug_log, BaseLocator}; use crate::asc_core::utils::structs::_STRUCT_I; -use crate::asc_core::utils::tinydex::DEX; +use crate::asc_core::utils::tinydex::{DEX, _decode_mutf8_fallback}; use memchr::memmem::Finder; use std::collections::HashSet; use std::time::Instant; +fn matches_name(raw: &[u8], query: &str) -> bool { + if query.is_empty() { + return true; + } + if !query.is_ascii() || !raw.is_ascii() { + return _decode_mutf8_fallback(raw).to_lowercase().contains(query); + } + let pattern = query.as_bytes(); + memchr::memchr2_iter( + pattern[0].to_ascii_lowercase(), + pattern[0].to_ascii_uppercase(), + raw, + ) + .any(|start| { + raw.get(start..start + pattern.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(pattern)) + }) +} + pub struct StringLocator<'a> { pub base: BaseLocator<'a>, pub stridx_map: Vec, // [(string_data_off << 32) | string_idx, ...] @@ -150,6 +169,61 @@ impl<'a> StringLocator<'a> { located_idx } + /// Match each distinct method-name index once, regardless of physical string order. + pub(crate) fn locate_name_indices( + buf: &[u8], + name_indices: impl Iterator, + query: &str, + ) -> Result, String> { + if buf.len() < 0x70 { + return Err("invalid DEX header".to_owned()); + } + let count = _STRUCT_I::unpack_from(buf, 0x38) as usize; + let offset = _STRUCT_I::unpack_from(buf, 0x3c) as usize; + let end = count + .checked_mul(4) + .and_then(|size| offset.checked_add(size)) + .ok_or("DEX string table overflow")?; + if end > buf.len() { + return Err("DEX string table outside input".to_owned()); + } + let query = query.to_lowercase(); + let mut seen = vec![false; count]; + let mut matches = HashSet::new(); + for index in name_indices { + let index_usize = index as usize; + let visited = seen + .get_mut(index_usize) + .ok_or("DEX string index out of range")?; + if *visited { + continue; + } + *visited = true; + let data = _STRUCT_I::unpack_from(buf, offset + index_usize * 4) as usize; + let raw = Self::string_data_bytes(buf, data)?; + if matches_name(raw, &query) { + matches.insert(index); + } + } + Ok(matches) + } + + pub(crate) fn string_data_bytes(buf: &[u8], mut offset: usize) -> Result<&[u8], String> { + for shift in (0..35).step_by(7) { + let byte = *buf.get(offset).ok_or("truncated DEX string length")?; + offset += 1; + if shift == 28 && byte > 0x0f { + return Err("DEX string length overflow".to_owned()); + } + if byte & 0x80 == 0 { + let tail = &buf[offset..]; + let end = memchr::memchr(0, tail).ok_or("unterminated DEX string")?; + return Ok(&tail[..end]); + } + } + Err("DEX string length overflow".to_owned()) + } + pub fn locate(&self, string: &str) -> HashSet { let t_start = if self.base.debug { Some(Instant::now()) } else { None }; // Note: _build_map needs &mut self but locate takes &self diff --git a/src/asc_core/findrefs/scan/method_id_scan.rs b/src/asc_core/findrefs/scan/method_id_scan.rs new file mode 100644 index 0000000..c1ab853 --- /dev/null +++ b/src/asc_core/findrefs/scan/method_id_scan.rs @@ -0,0 +1,320 @@ +//! Read method definitions without parsing bytecode or decompiling classes. +use crate::asc_core::findrefs::locator::string_locator::StringLocator; +use crate::asc_core::utils::tinydex::_decode_mutf8_fallback; +use std::borrow::Cow; + +const METHOD_ID_SIZE: usize = 8; +const SPARSE_NAME_LIMIT: usize = 8; + +struct MethodId { + class: u16, + prototype: u16, + name: u32, +} + +fn decode_name(raw: &[u8]) -> Cow<'_, str> { + match std::str::from_utf8(raw) { + Ok(name) => Cow::Borrowed(name), + Err(_) => Cow::Owned(_decode_mutf8_fallback(raw)), + } +} + +struct Table { + offset: usize, + count: usize, + width: usize, +} + +impl Table { + fn read(buf: &[u8], header_offset: usize, width: usize) -> Result { + let count = u32_at(buf, header_offset)? as usize; + let offset = u32_at(buf, header_offset + 4)? as usize; + let size = count.checked_mul(width).ok_or("DEX table size overflow")?; + let end = offset.checked_add(size).ok_or("DEX table range overflow")?; + if end > buf.len() { + return Err("DEX table outside input".to_owned()); + } + Ok(Self { + offset, + count, + width, + }) + } + + fn entry(&self, index: usize) -> Result { + if index >= self.count { + return Err("DEX table index out of range".to_owned()); + } + Ok(self.offset + index * self.width) + } + + fn bytes<'a>(&self, buf: &'a [u8]) -> &'a [u8] { + &buf[self.offset..self.offset + self.count * self.width] + } +} + +fn u16_at(buf: &[u8], offset: usize) -> Result { + let bytes = buf + .get(offset..) + .and_then(|tail| tail.get(..2)) + .ok_or("truncated DEX u16")?; + Ok(u16::from_le_bytes(bytes.try_into().unwrap())) +} + +fn u32_at(buf: &[u8], offset: usize) -> Result { + let bytes = buf + .get(offset..) + .and_then(|tail| tail.get(..4)) + .ok_or("truncated DEX u32")?; + Ok(u32::from_le_bytes(bytes.try_into().unwrap())) +} + +fn uleb(buf: &[u8], cursor: &mut usize) -> Result { + let mut value = 0; + for shift in (0..35).step_by(7) { + let byte = *buf.get(*cursor).ok_or("truncated DEX uleb128")?; + *cursor += 1; + if shift == 28 && byte > 0x0f { + return Err("DEX uleb128 overflow".to_owned()); + } + value |= u32::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + } + Err("DEX uleb128 overflow".to_owned()) +} + +struct Reader<'a> { + buf: &'a [u8], + strings: Table, + types: Table, + protos: Table, + methods: Table, + classes: Table, + names: Vec>>, + prototypes: Vec>>, +} + +impl<'a> Reader<'a> { + fn new(buf: &'a [u8]) -> Result { + if buf.len() < 0x70 || &buf[..4] != b"dex\n" || buf[7] != 0 { + return Err("invalid DEX header".to_owned()); + } + let strings = Table::read(buf, 0x38, 4)?; + let types = Table::read(buf, 0x40, 4)?; + let protos = Table::read(buf, 0x48, 12)?; + let methods = Table::read(buf, 0x58, METHOD_ID_SIZE)?; + let classes = Table::read(buf, 0x60, 32)?; + let names = vec![None; strings.count]; + let prototypes = vec![None; protos.count]; + Ok(Self { + buf, + strings, + types, + protos, + methods, + classes, + names, + prototypes, + }) + } + + fn string(&mut self, index: usize) -> Result<&str, String> { + let entry = self.strings.entry(index)?; + if self.names[index].is_none() { + let offset = u32_at(self.buf, entry)? as usize; + self.names[index] = Some(decode_name(StringLocator::string_data_bytes( + self.buf, offset, + )?)); + } + Ok(self.names[index].as_deref().unwrap()) + } + + fn type_name(&mut self, index: usize) -> Result<&str, String> { + let entry = self.types.entry(index)?; + self.string(u32_at(self.buf, entry)? as usize) + } + + fn type_bytes(&self, index: usize) -> Result<&[u8], String> { + let entry = self.types.entry(index)?; + let string = self.strings.entry(u32_at(self.buf, entry)? as usize)?; + StringLocator::string_data_bytes(self.buf, u32_at(self.buf, string)? as usize) + } + + fn method(&self, index: u32) -> Result { + let offset = self.methods.entry(index as usize)?; + Ok(MethodId { + class: u16_at(self.buf, offset)?, + prototype: u16_at(self.buf, offset + 2)?, + name: u32_at(self.buf, offset + 4)?, + }) + } + + fn render_method( + &mut self, + method: MethodId, + class: &str, + filter: Option<&str>, + text: &mut String, + ) -> Result { + let name_index = method.name as usize; + let proto_index = usize::from(method.prototype); + let name = self.string(name_index)?; + if filter.is_some_and(|filter| !name.to_lowercase().contains(filter)) { + return Ok(false); + } + self.ensure_prototype(proto_index)?; + let name = self.names[name_index].as_deref().unwrap(); + let prototype = self.prototypes[proto_index].as_deref().unwrap(); + text.reserve(class.len() + name.len() + prototype.len() + 3); + text.push_str(class); + text.push_str("->"); + text.push_str(name); + text.push_str(prototype); + text.push('\n'); + Ok(true) + } + + fn ensure_prototype(&mut self, index: usize) -> Result<(), String> { + let entry = self.protos.entry(index)?; + if self.prototypes[index].is_none() { + let return_type = u32_at(self.buf, entry + 4)? as usize; + let parameters = u32_at(self.buf, entry + 8)? as usize; + let mut descriptor = String::from("("); + if parameters != 0 { + let count = u32_at(self.buf, parameters)? as usize; + let start = parameters + .checked_add(4) + .ok_or("DEX parameter range overflow")?; + let size = count.checked_mul(2).ok_or("DEX parameter size overflow")?; + let end = start + .checked_add(size) + .ok_or("DEX parameter range overflow")?; + if end > self.buf.len() { + return Err("DEX parameters outside input".to_owned()); + } + for offset in (start..end).step_by(2) { + let type_index = u16_at(self.buf, offset)? as usize; + descriptor.push_str(self.type_name(type_index)?); + } + } + descriptor.push(')'); + descriptor.push_str(self.type_name(return_type)?); + self.prototypes[index] = Some(descriptor.into_boxed_str()); + } + Ok(()) + } +} + +fn matching_methods(reader: &Reader<'_>, query: &str) -> Result, String> { + let table = reader.methods.bytes(reader.buf); + let names = StringLocator::locate_name_indices( + reader.buf, + table + .chunks_exact(METHOD_ID_SIZE) + .map(|row| u32::from_le_bytes(row[4..8].try_into().unwrap())), + query, + )?; + let mut methods = vec![false; reader.methods.count]; + if names.len() <= SPARSE_NAME_LIMIT { + for name in names { + let needle = name.to_le_bytes(); + let finder = memchr::memmem::Finder::new(&needle); + let mut offset = 0; + while let Some(relative) = finder.find(&table[offset..]) { + let hit = offset + relative; + if hit % METHOD_ID_SIZE == 4 { + methods[hit / METHOD_ID_SIZE] = true; + offset = hit + 4; + } else { + // False hits can overlap a real field; advance by one, not four. + offset = hit + 1; + } + } + } + } else { + let mut names_mask = vec![false; reader.strings.count]; + for name in names { + names_mask[name as usize] = true; + } + for (index, row) in table.chunks_exact(METHOD_ID_SIZE).enumerate() { + let name = u32::from_le_bytes(row[4..8].try_into().unwrap()) as usize; + methods[index] = names_mask[name]; + } + } + Ok(methods) +} + +/// Render defined methods in DEX declaration order. +pub(crate) fn render_dex_methods( + buf: &[u8], + class: Option<&str>, + filter: Option<&str>, +) -> Result<(String, usize), String> { + let mut reader = Reader::new(buf)?; + let method_matches = if class.is_none() { + filter + .filter(|query| !query.is_empty()) + .map(|query| matching_methods(&reader, query)) + .transpose()? + } else { + None + }; + let name_filter = if method_matches.is_none() { + filter + } else { + None + }; + let class_bytes = class.map(cesu8::to_java_cesu8); + let mut text = String::new(); + let mut rendered_count = 0; + for class_index in 0..reader.classes.count { + let entry = reader.classes.entry(class_index)?; + let type_index = u32_at(buf, entry)? as usize; + if let Some(wanted) = &class_bytes + && reader.type_bytes(type_index)? != wanted.as_ref() + { + continue; + } + let descriptor = reader.type_name(type_index)?.to_owned(); + let mut cursor = u32_at(buf, entry + 24)? as usize; + if cursor == 0 { + continue; + } + let static_fields = uleb(buf, &mut cursor)?; + let instance_fields = uleb(buf, &mut cursor)?; + let direct_methods = uleb(buf, &mut cursor)?; + let virtual_methods = uleb(buf, &mut cursor)?; + for count in [static_fields, instance_fields] { + for _ in 0..count { + uleb(buf, &mut cursor)?; // field index delta + uleb(buf, &mut cursor)?; // access flags + } + } + for count in [direct_methods, virtual_methods] { + let mut index = 0u32; + for _ in 0..count { + index = index + .checked_add(uleb(buf, &mut cursor)?) + .ok_or("DEX method index overflow")?; + uleb(buf, &mut cursor)?; // access flags + uleb(buf, &mut cursor)?; // code offset; do not parse bytecode + let method = reader.method(index)?; + if usize::from(method.class) != type_index { + return Err("DEX method owner differs from class definition".to_owned()); + } + if method_matches + .as_ref() + .is_some_and(|matches| !matches[index as usize]) + { + continue; + } + if reader.render_method(method, &descriptor, name_filter, &mut text)? { + rendered_count += 1; + } + } + } + } + Ok((text, rendered_count)) +} diff --git a/src/asc_core/findrefs/scan/mod.rs b/src/asc_core/findrefs/scan/mod.rs index 8e50aa7..88baafb 100644 --- a/src/asc_core/findrefs/scan/mod.rs +++ b/src/asc_core/findrefs/scan/mod.rs @@ -1 +1,2 @@ -pub mod code_item_scan; \ No newline at end of file +pub mod code_item_scan; +pub(crate) mod method_id_scan; diff --git a/src/cli.rs b/src/cli.rs index 31581f4..5d42285 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -177,6 +177,36 @@ fn _handle_listclass(args: &ArgMatches) -> Result<(), String> { Ok(()) } +fn _handle_listmethod(args: &ArgMatches) -> Result<(), String> { + let t_start = Instant::now(); + let apk_path = args.get_one::("apk_path").unwrap(); + let debug = args.get_flag("debug"); + let thread = _get_worker_count(args)?; + let class = args.get_one::("class_name").map(String::as_str); + let filter = args.get_one::("filter").map(String::as_str); + let chunks = ApkHandler::new(apk_path, debug, thread).list_method_chunks(class, filter)?; + let write_chunks = |writer: &mut dyn Write| { + chunks + .iter() + .try_for_each(|chunk| writer.write_all(chunk.as_bytes())) + }; + if let Some(output) = args.get_one::("output") { + File::create(output) + .and_then(|mut file| write_chunks(&mut file)) + .map_err(|error| format!("cannot write {output}: {error}"))?; + } else { + write_chunks(&mut io::stdout().lock()) + .map_err(|error| format!("cannot write stdout: {error}"))?; + } + if debug { + println!( + "[DEBUG] Total Execution Time: {:.2} us", + t_start.elapsed().as_secs_f64() * 1_000_000.0 + ); + } + Ok(()) +} + fn _export_progress(progress: ExportProgress) { match progress { ExportProgress::Activity { .. } | ExportProgress::DexInflated { .. } => {} @@ -439,6 +469,28 @@ pub fn _build_main_parser() -> Command { ) .arg(Arg::new("apk_path").required(true).help("Path to the input APK file.")), )) + .subcommand(common_args( + Command::new("listmethod") + .about("List method definitions across all DEX entries without decompilation.") + .arg(Arg::new("apk_path").required(true).help("Path to the input APK file.")) + .arg( + Arg::new("class_name") + .long("class") + .help("Only list methods defined by this class (dotted name or Dalvik descriptor)."), + ) + .arg( + Arg::new("filter") + .short('f') + .long("filter") + .help("Case insensitive substring filter over method names."), + ) + .arg( + Arg::new("output") + .short('o') + .long("output") + .help("Write method descriptors to this file instead of stdout."), + ), + )) .subcommand(common_args( Command::new("export") .about("Export pseudocode for every class in an APK into an output directory.") @@ -485,6 +537,7 @@ where Some(("mcp", _)) => crate::mcp::run(), Some(("getclass", args)) => _handle_getclass(args), Some(("listclass", args)) => _handle_listclass(args), + Some(("listmethod", args)) => _handle_listmethod(args), Some(("export", args)) => _handle_export(args), Some(("getmanifest", args)) => _handle_getmanifest(args), Some(("findrefs", args)) => _handle_findrefs(args), diff --git a/src/mcp/schema.rs b/src/mcp/schema.rs index d43e516..5041949 100644 --- a/src/mcp/schema.rs +++ b/src/mcp/schema.rs @@ -32,6 +32,21 @@ struct ListClass { output: Option, } +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ListMethod { + /// Path to the APK to analyze. + apk_path: String, + /// Only list methods defined by this class. Accepts a dotted name or Dalvik descriptor. + class_name: Option, + /// Optional case insensitive substring filter over method names. + filter: Option, + /// Positive worker count. Defaults to available CPU count. + thread: Option, + /// Optional method list output file. When set, stdout is empty. Existing files are overwritten. + output: Option, +} + #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] struct GetManifest { @@ -108,6 +123,10 @@ pub(super) fn tools() -> Vec { "listclass", "List class descriptors from every DEX in an APK.", ), + tool::( + "listmethod", + "List defined method descriptors from every DEX in an APK.", + ), tool::( "getmanifest", "Decode AndroidManifest.xml to formatted XML.", @@ -128,6 +147,7 @@ impl Query { 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)?), + "listmethod" => listmethod(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()), @@ -168,6 +188,21 @@ fn listclass(input: ListClass) -> Vec { args } +fn listmethod(input: ListMethod) -> Vec { + let mut args = vec!["listmethod".into()]; + push_thread(&mut args, input.thread); + if let Some(class) = input.class_name { + args.push(format!("--class={class}").into()); + } + if let Some(filter) = input.filter { + args.push(format!("--filter={filter}").into()); + } + push_output(&mut args, input.output); + args.push("--".into()); + args.push(input.apk_path.into()); + args +} + fn getmanifest(input: GetManifest) -> Vec { let mut args = vec!["getmanifest".into(), input.apk_path.into()]; push_output(&mut args, input.output); diff --git a/tests/cli_thread.rs b/tests/cli_thread.rs index 7a23710..a3fb65a 100644 --- a/tests/cli_thread.rs +++ b/tests/cli_thread.rs @@ -13,7 +13,7 @@ fn stderr(output: &Output) -> String { #[test] fn thread_option_is_singular_and_belongs_to_each_command() { - for command in ["getclass", "listclass", "export", "findrefs"] { + for command in ["getclass", "listclass", "listmethod", "export", "findrefs"] { let output = run_cli(&[command, "--help"]); assert_eq!(output.status.code(), Some(1)); let stderr = stderr(&output); diff --git a/tests/listmethod.rs b/tests/listmethod.rs new file mode 100644 index 0000000..06efbd5 --- /dev/null +++ b/tests/listmethod.rs @@ -0,0 +1,414 @@ +mod dex_fixture; +mod zip_fixture; + +use std::path::Path; +use std::process::{Command, Output}; + +use dex_fixture::{ + get_u32, make_class_only_dex, make_dex, make_invoke_dex, make_static_field_dex, put_u32, +}; +use tempfile::tempdir; +use zip_fixture::{CompressionMethod, write_apk}; + +fn run_cli(apk: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_rust_ASC")) + .arg("listmethod") + .args(args) + .arg(apk) + .output() + .unwrap() +} + +fn success(output: Output) -> String { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stderr.is_empty()); + String::from_utf8(output.stdout).unwrap() +} + +#[test] +fn lists_definitions_not_external_references_in_deterministic_multidex_order() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture.apk"); + let first = make_dex(); + let second = make_invoke_dex(); + let third = make_static_field_dex(); + for compression in [CompressionMethod::Stored, CompressionMethod::Deflated] { + write_apk( + &apk, + &[ + ("classes.dex", &first), + ("classes2.dex", &second), + ("classes3.dex", &third), + ("classes4.dex", &first), + ], + compression, + ); + for threads in ["1", "3"] { + assert_eq!( + success(run_cli(&apk, &["--thread", threads])), + "Lexample/Test;->first()V\nLexample/Test;->second()V\n\ + Lexample/Test;->run()V\nLexample/Statics;->getSecond()I\n\ + Lexample/Test;->first()V\nLexample/Test;->second()V\n" + ); + } + } +} + +#[test] +fn class_and_name_filters_and_output_file_follow_listclass_semantics() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture with space.apk"); + write_apk( + &apk, + &[ + ("classes.dex", &make_dex()), + ("classes2.dex", &make_static_field_dex()), + ], + CompressionMethod::Deflated, + ); + let expected = "Lexample/Test;->second()V\n"; + for class in ["example.Test", "Lexample/Test;"] { + assert_eq!( + success(run_cli(&apk, &["--class", class, "--filter", "ECON"])), + expected + ); + } + assert_eq!(success(run_cli(&apk, &["-f", "missing"])), ""); + assert_eq!(success(run_cli(&apk, &["--class", "example.Missing"])), ""); + let output = dir.path().join("methods.txt"); + assert_eq!( + success(run_cli( + &apk, + &[ + "--class", + "example.Test", + "-f", + "second", + "-o", + output.to_str().unwrap() + ] + )), + "" + ); + assert_eq!(std::fs::read_to_string(&output).unwrap(), expected); + // An empty match overwrites rather than leaving stale output. + assert_eq!( + success(run_cli( + &apk, + &["-f", "missing", "-o", output.to_str().unwrap()] + )), + "" + ); + assert_eq!(std::fs::read_to_string(output).unwrap(), ""); +} + +#[test] +fn native_and_abstract_methods_are_included_and_virtual_indices_restart() { + let mut dex = make_dex(); + let classes = get_u32(&dex, 0x64) as usize; + let offset = dex.len() as u32; + // Direct method index 1, native; virtual method index 0, abstract. + dex.extend_from_slice(&[0, 0, 1, 1, 1, 0x89, 0x02, 0, 0, 0x81, 0x08, 0]); + put_u32(&mut dex, classes + 24, offset); + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture.apk"); + write_apk(&apk, &[("classes.dex", &dex)], CompressionMethod::Stored); + assert_eq!( + success(run_cli(&apk, &[])), + "Lexample/Test;->second()V\nLexample/Test;->first()V\n" + ); +} + +#[test] +fn full_prototypes_distinguish_overloads() { + let mut dex = make_dex(); + let old_proto = get_u32(&dex, 0x4c) as usize; + let proto = dex[old_proto..old_proto + 12].to_vec(); + let parameters = dex.len() as u32; + dex.extend_from_slice(&[2, 0, 0, 0, 0, 0, 1, 0]); + let prototypes = dex.len() as u32; + dex.extend_from_slice(&proto); + dex.extend_from_slice(&proto); + put_u32(&mut dex, prototypes as usize + 20, parameters); + put_u32(&mut dex, 0x48, 2); + put_u32(&mut dex, 0x4c, prototypes); + let methods = get_u32(&dex, 0x5c) as usize; + dex[methods + 10..methods + 12].copy_from_slice(&1u16.to_le_bytes()); + put_u32(&mut dex, methods + 12, 3); // same name as the first method + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture.apk"); + write_apk(&apk, &[("classes.dex", &dex)], CompressionMethod::Stored); + assert_eq!( + success(run_cli(&apk, &[])), + "Lexample/Test;->first()V\nLexample/Test;->first(Lexample/Test;Ljava/lang/Object;)V\n" + ); +} + +#[test] +fn mutf8_method_names_are_decoded_and_filtered() { + let mut dex = make_dex(); + let strings = get_u32(&dex, 0x3c) as usize; + let offset = dex.len() as u32; + let name = "méthod😀"; + dex.push(name.encode_utf16().count() as u8); + dex.extend_from_slice(&cesu8::to_java_cesu8(name)); + dex.push(0); + put_u32(&mut dex, strings + 12, offset); + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture.apk"); + write_apk(&apk, &[("classes.dex", &dex)], CompressionMethod::Deflated); + assert_eq!( + success(run_cli(&apk, &["-f", "MÉTHOD😀"])), + "Lexample/Test;->méthod😀()V\n" + ); +} + +#[test] +fn index_scan_checks_name_field_alignment_and_overlapping_zero_bytes() { + let mut dex = make_dex(); + let methods = get_u32(&dex, 0x5c) as usize; + // name_idx == 0 has overlapping zero-byte matches in class/proto fields. + put_u32(&mut dex, methods + 4, 0); + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture.apk"); + write_apk(&apk, &[("classes.dex", &dex)], CompressionMethod::Stored); + let all = success(run_cli(&apk, &[])); + for query in ["EXAMPLE", "second", "missing", ""] { + let expected = all + .lines() + .filter(|line| { + line.split_once("->") + .unwrap() + .1 + .split('(') + .next() + .unwrap() + .to_lowercase() + .contains(&query.to_lowercase()) + }) + .collect::>(); + let actual = success(run_cli(&apk, &["--filter", query])); + assert_eq!(actual.lines().collect::>(), expected); + } +} + +#[test] +fn name_index_matches_do_not_turn_external_references_into_definitions() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture.apk"); + write_apk( + &apk, + &[("classes.dex", &make_invoke_dex())], + CompressionMethod::Stored, + ); + assert_eq!(success(run_cli(&apk, &["--filter", "callToMethod"])), ""); + assert_eq!( + success(run_cli(&apk, &["--filter", "RUN"])), + "Lexample/Test;->run()V\n" + ); +} + +#[test] +fn row_api_remains_compatible_with_chunked_cli_output() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture.apk"); + write_apk( + &apk, + &[("classes.dex", &make_dex()), ("classes2.dex", &make_dex())], + CompressionMethod::Deflated, + ); + let handler = + rust_ASC::asc_client::apk_handler::ApkHandler::new(apk.to_str().unwrap(), false, 2); + let rows = handler + .list_methods(Some("example.Test"), Some("FIRST")) + .unwrap(); + assert_eq!( + rows, + vec!["Lexample/Test;->first()V", "Lexample/Test;->first()V"] + ); + assert_eq!( + rows.join("\n") + "\n", + success(run_cli(&apk, &["--class", "example.Test", "-f", "FIRST"])) + ); +} + +#[test] +fn sparse_and_dense_name_queries_match_filtering_the_full_listing() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("sample.apk"); + write_apk( + &apk, + &[( + "classes.dex", + include_bytes!("../radguard/tests/fixtures/sample.dex"), + )], + CompressionMethod::Deflated, + ); + let all = success(run_cli(&apk, &[])); + let names: std::collections::HashSet<_> = all + .lines() + .map(|line| line.split_once("->").unwrap().1.split('(').next().unwrap()) + .filter(|name| name.to_lowercase().contains('a')) + .collect(); + assert!(names.len() > 8, "exercise the dense name-index scan"); + for query in ["a", "OnCrEaTe", "", "not_a_method_xyz", ""] { + let expected = all + .lines() + .filter(|line| { + line.split_once("->") + .unwrap() + .1 + .split('(') + .next() + .unwrap() + .to_lowercase() + .contains(&query.to_lowercase()) + }) + .collect::>(); + let actual = success(run_cli(&apk, &["--filter", query])); + assert_eq!(actual.lines().collect::>(), expected); + } +} + +// A DEX 041 logical member with absolute physical offsets and one native method. +fn method_dex041(base: usize, class: &str) -> Vec { + let mut dex = make_class_only_dex(&[class], base, b"041"); + let old_strings = get_u32(&dex, 0x3c) as usize - base; + let class_string = get_u32(&dex, old_strings); + let classes = get_u32(&dex, 0x64) as usize - base; + let name = (base + dex.len()) as u32; + dex.extend_from_slice(b"\x03run\0"); + let void = (base + dex.len()) as u32; + dex.extend_from_slice(b"\x01V\0"); + while dex.len() % 4 != 0 { + dex.push(0); + } + let strings = (base + dex.len()) as u32; + for value in [class_string, name, void] { + dex.extend_from_slice(&value.to_le_bytes()); + } + let types = (base + dex.len()) as u32; + for value in [0u32, 2] { + dex.extend_from_slice(&value.to_le_bytes()); + } + let protos = (base + dex.len()) as u32; + for value in [2u32, 1, 0] { + dex.extend_from_slice(&value.to_le_bytes()); + } + let methods = (base + dex.len()) as u32; + dex.extend_from_slice(&[0, 0, 0, 0, 1, 0, 0, 0]); + let data = (base + dex.len()) as u32; + dex.extend_from_slice(&[0, 0, 1, 0, 0, 0x89, 0x02, 0]); + put_u32(&mut dex, classes + 24, data); + for (header, count, offset) in [ + (0x38, 3, strings), + (0x40, 2, types), + (0x48, 1, protos), + (0x58, 1, methods), + ] { + put_u32(&mut dex, header, count); + put_u32(&mut dex, header + 4, offset); + } + let size = dex.len() as u32; + put_u32(&mut dex, 0x20, size); + dex +} + +#[test] +fn dex041_lists_methods_in_every_logical_member() { + let mut dex = method_dex041(0, "Lalpha/First;"); + let second_offset = dex.len(); + dex.extend(method_dex041(second_offset, "Lbeta/Second;")); + let size = dex.len() as u32; + put_u32(&mut dex, 0x70, size); + put_u32(&mut dex, second_offset + 0x70, size); + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture.apk"); + for compression in [CompressionMethod::Stored, CompressionMethod::Deflated] { + write_apk(&apk, &[("classes.dex", &dex)], compression); + assert_eq!( + success(run_cli(&apk, &[])), + "Lalpha/First;->run()V\nLbeta/Second;->run()V\n" + ); + assert_eq!( + success(run_cli(&apk, &["--class", "beta.Second"])), + "Lbeta/Second;->run()V\n" + ); + } +} + +#[test] +fn no_definitions_is_an_empty_success() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture.apk"); + write_apk( + &apk, + &[( + "classes.dex", + &make_class_only_dex(&["Lempty/Class;"], 0, b"035"), + )], + CompressionMethod::Stored, + ); + assert_eq!(success(run_cli(&apk, &[])), ""); +} + +#[test] +fn invalid_inputs_return_clean_errors() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("fixture.apk"); + let dex = make_dex(); + write_apk(&apk, &[("classes.dex", &dex)], CompressionMethod::Stored); + for args in [&["--thread", "0"][..], &["--class", " "][..]] { + let output = run_cli(&apk, args); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + } + let methods = get_u32(&dex, 0x5c) as usize; + let classes = get_u32(&dex, 0x64) as usize; + let class_data = get_u32(&dex, classes + 24) as usize; + let protos = get_u32(&dex, 0x4c) as usize; + let strings = get_u32(&dex, 0x3c) as usize; + let mut cases = vec![vec![0; 10]]; + for offset in [ + 0x3c, + 0x4c, + 0x5c, + classes, + classes + 24, + methods + 4, + protos + 4, + protos + 8, + strings, + ] { + let mut bad = dex.clone(); + put_u32(&mut bad, offset, u32::MAX); + cases.push(bad); + } + let mut bad = dex.clone(); + bad[methods + 2..methods + 4].copy_from_slice(&u16::MAX.to_le_bytes()); + cases.push(bad); + let mut bad = dex.clone(); + bad[class_data + 4..class_data + 9].copy_from_slice(&[0xff; 5]); + cases.push(bad); + let mut bad = dex; + bad.truncate(class_data + 5); + cases.push(bad); + for bad in cases { + write_apk(&apk, &[("classes.dex", &bad)], CompressionMethod::Stored); + let output = run_cli(&apk, &[]); + assert_eq!( + output.status.code(), + Some(1), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stdout.is_empty()); + assert!(!String::from_utf8_lossy(&output.stderr).contains("panicked")); + } + let output = run_cli(&dir.path().join("missing.apk"), &[]); + assert_eq!(output.status.code(), Some(1)); +} diff --git a/tests/mcp_stdio.rs b/tests/mcp_stdio.rs index 8dc62eb..989bb3c 100644 --- a/tests/mcp_stdio.rs +++ b/tests/mcp_stdio.rs @@ -152,7 +152,7 @@ fn cli_stdout(args: impl IntoIterator) -> String { } #[test] -fn exposes_four_documented_cli_wrappers() { +fn exposes_five_documented_cli_wrappers() { let mut client = Client::new(); client.send(json!({"jsonrpc":"2.0", "id":99, "method":"tools/list"})); let response = client.recv(); @@ -162,7 +162,16 @@ fn exposes_four_documented_cli_wrappers() { .map(|tool| tool["name"].as_str().unwrap()) .collect(); names.sort(); - assert_eq!(names, ["findrefs", "getclass", "getmanifest", "listclass"]); + assert_eq!( + names, + [ + "findrefs", + "getclass", + "getmanifest", + "listclass", + "listmethod" + ] + ); for tool in tools { assert_eq!(tool["inputSchema"]["type"], "object"); assert_eq!(tool["inputSchema"]["additionalProperties"], false); @@ -185,7 +194,7 @@ fn exposes_four_documented_cli_wrappers() { let output = tool["inputSchema"]["properties"]["output"]["description"] .as_str() .unwrap(); - if tool["name"] == "listclass" { + if tool["name"] == "listclass" || tool["name"] == "listmethod" { assert!(output.contains("stdout is empty.")); } else { assert!(output.contains("Content is also returned.")); @@ -273,6 +282,35 @@ fn tool_results_are_exact_cli_stdout() { ) ); + let result = client.call( + "listmethod", + json!({"apk_path":path, "class_name":"example.Test", "filter":"ECON", "thread":1}), + ); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(text(&result), "Lexample/Test;->second()V\n"); + assert_eq!( + text(&result), + cli_stdout( + [ + "listmethod", + "--thread", + "1", + "--class", + "example.Test", + "--filter", + "ECON", + &path + ] + .map(OsString::from) + ) + ); + let result = client.call( + "listmethod", + json!({"apk_path":path, "filter":"--not-a-flag"}), + ); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(text(&result), ""); + let result = client.call( "getclass", json!({"apk_path":path, "class_name":"example.Test", "thread":1}), @@ -349,6 +387,18 @@ fn optional_output_writes_files_with_cli_semantics() { ); assert_eq!(text(&result), ""); + let methods_output = dir.path().join("methods.txt"); + let result = client.call( + "listmethod", + json!({"apk_path":path, "class_name":"example.Test", "filter":"first", "thread":1, "output":methods_output}), + ); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(text(&result), ""); + assert_eq!( + std::fs::read_to_string(&methods_output).unwrap(), + "Lexample/Test;->first()V\n" + ); + let manifest_output = dir.path().join("AndroidManifest.xml"); let result = client.call( "getmanifest", @@ -463,6 +513,21 @@ fn cli_failures_are_isolated_and_the_server_remains_usable() { assert_eq!(result["isError"], true); assert_eq!(text(&result), String::from_utf8_lossy(&cli.stderr).trim()); + for args in [ + json!({"apk_path":path, "thread":0}), + json!({"apk_path":path, "class_name":" "}), + json!({"apk_path":path, "filter":true}), + json!({"apk_path":path, "unexpected":1}), + ] { + let result = client.call("listmethod", args); + assert_eq!(result["isError"], true, "{result}"); + } + let result = client.call("listmethod", json!({"apk_path":path})); + assert_eq!(result["isError"], false); + assert_eq!( + text(&result), + "Lexample/Test;->first()V\nLexample/Test;->second()V\n" + ); let result = client.call("listclass", json!({"apk_path":path})); assert_eq!(result["isError"], false); assert_eq!(text(&result), "Lexample/Test;\n");