diff --git a/README.md b/README.md index e7d4260..9f5ad2a 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ On Windows, the executable name is `rust_ASC.exe`. ## Command Line Usage -Thread control is consistent across `getclass`, `listclass`, `listmethod`, `findrefs`, and `export`: +Thread control is consistent across `getclass`, `listclass`, `listmethod`, `liststring`, `findrefs`, and `export`: - Use only `--thread `. - Put `--thread` immediately after the subcommand. @@ -93,6 +93,11 @@ 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 +# List string pool values, optionally filtered by substring. +rust_ASC liststring --thread 10 app.apk +rust_ASC liststring -f https app.apk +rust_ASC liststring app.apk -o strings.txt + # Decode AndroidManifest.xml. rust_ASC getmanifest app.apk -o AndroidManifest.xml @@ -102,14 +107,18 @@ 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. +`liststring` prints one string pool value per line, read straight from the DEX string table rather than from class or method data: `findrefs string` reports who references a literal, `liststring` reports which literals exist, including the ones nothing references, so an empty `findrefs` result means no reference rather than not present. `--filter` / `-f` matches a case insensitive substring anywhere in the value and uses the same predicate as `findrefs string`, so a listed string and a located string agree on what matches. Order is DEX central-directory order followed by string index, which is the index a `findrefs` row resolves through; duplicates across DEX files are preserved. Like `listclass`, `-o` writes the list instead of printing it, and no matches produce an empty list. A pool holds arbitrary payloads, so a value that contains a newline spans several lines here, exactly as a `findrefs` matched payload does; `-o` uses the same framing. + `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`, `listmethod`, `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`, `liststring`, `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. +The `liststring` tool accepts `apk_path`, optional `filter`, `thread`, and `output`, with the same stdout and `output` semantics. + Configure it in your MCP client: ```json diff --git a/src/asc_client/apk/dex_reader.rs b/src/asc_client/apk/dex_reader.rs index 016dad5..8afca7a 100644 --- a/src/asc_client/apk/dex_reader.rs +++ b/src/asc_client/apk/dex_reader.rs @@ -1,6 +1,7 @@ // Auth: MG1937 // Strict 1:1 translation from Python apk/dex_reader.py +use crate::asc_core::findrefs::locator::string_locator::matches_name; use crate::asc_core::utils::structs::_STRUCT_I; use crate::asc_core::utils::tinydex::_decode_mutf8_fallback; @@ -398,3 +399,66 @@ pub(crate) fn list_dex_classes( } Ok(names) } + +/// Walk the string pool in table order, handing every value that passes +/// `filter` to `sink`. String index is what a `findrefs` row resolves through, +/// so table order is the only order this reports. +fn for_each_dex_string(buf: &[u8], filter: Option<&str>, mut sink: S) -> Result +where + S: FnMut(&str) -> Result<(), String>, +{ + if buf.len() < 0x70 || buf[..3] != *b"dex" { + return Err("invalid DEX header".to_owned()); + } + + let string_ids_size = _STRUCT_I::unpack_from(buf, 0x38) as usize; + let string_ids_off = _STRUCT_I::unpack_from(buf, 0x3C) as usize; + if string_ids_size == 0 { + return Ok(0); + } + let ids_end = string_ids_off + .checked_add(string_ids_size.saturating_mul(4)) + .filter(|&end| end <= buf.len()) + .ok_or_else(|| "bad string_ids range".to_owned())?; + + let mut count = 0; + let mut off = string_ids_off; + while off < ids_end { + let string_off = _STRUCT_I::unpack_from(buf, off) as usize; + off += 4; + if string_off >= buf.len() { + return Err("bad string_data_off".to_owned()); + } + let raw = _read_string_data_bytes(buf, string_off)?; + if !filter.is_none_or(|filter| matches_name(raw, filter)) { + continue; + } + sink(&_decode_name(raw))?; + count += 1; + } + Ok(count) +} + +/// Write string values one per line, skipping the ones `filter` rejects. +/// `list_dex_strings` is the record level API that keeps embedded newlines. +pub(crate) fn write_dex_strings( + buf: &[u8], + filter: Option<&str>, + out: &mut Vec, +) -> Result { + for_each_dex_string(buf, filter, |value| { + out.reserve(value.len() + 1); + out.extend_from_slice(value.as_bytes()); + out.push(b'\n'); + Ok(()) + }) +} + +pub(crate) fn list_dex_strings(buf: &[u8], filter: Option<&str>) -> Result, String> { + let mut values = Vec::new(); + for_each_dex_string(buf, filter, |value| { + values.push(value.to_owned()); + Ok(()) + })?; + Ok(values) +} diff --git a/src/asc_client/apk_handler.rs b/src/asc_client/apk_handler.rs index 40b086a..40d5565 100644 --- a/src/asc_client/apk_handler.rs +++ b/src/asc_client/apk_handler.rs @@ -12,7 +12,8 @@ use memmap2::{Mmap, MmapOptions}; use crate::asc_client::apk::dex_reader::{ _dex_defines_class as _defines_class, class_defines_prefix_len, class_names_prefix_len, - list_dex_classes, write_dex_class_names as _write_classes, + list_dex_classes, list_dex_strings, write_dex_class_names as _write_classes, + write_dex_strings as _write_strings, }; use crate::asc_client::apk::loader::{ _inflate_dex as _load_dex, _inflate_dex_into, _inflate_dex_into_with, @@ -630,6 +631,50 @@ impl ApkHandler { ) } + /// List string pool values as individual records, preserving the row API. + /// CLI consumers can use `for_each_string_chunk` to avoid collecting rows. + pub fn list_strings(&self, filter: Option<&str>) -> Result, String> { + let filter = filter.map(str::to_lowercase); + let mut rows = Vec::new(); + self._stream_dex_items( + "string", + |buf| { + // One record per value: a pool can hold strings with newlines, + // and the row API must not reframe them as CLI lines. + let values = list_dex_strings(buf, filter.as_deref())?; + let count = values.len(); + Ok((values.into_iter().map(String::into_bytes).collect(), count)) + }, + // Every string is potentially needed, so entries inflate in full. + None, + |bytes| { + let value = String::from_utf8(bytes) + .map_err(|error| format!("invalid UTF-8 string value: {error}"))?; + rows.push(value); + Ok(()) + }, + )?; + Ok(rows) + } + + /// Stream string values (one per line) in DEX table order. + pub fn for_each_string_chunk(&self, filter: Option<&str>, mut emit: E) -> Result + where + E: FnMut(&[u8]) -> Result<(), String>, + { + let filter = filter.map(str::to_lowercase); + self._stream_dex_items( + "string", + |buf| { + let mut out = Vec::new(); + let count = _write_strings(buf, filter.as_deref(), &mut out)?; + Ok((vec![out], count)) + }, + None, + |chunk| emit(&chunk), + ) + } + pub fn for_each_findrefs( &self, find_type: &str, diff --git a/src/asc_core/findrefs/locator/string_locator.rs b/src/asc_core/findrefs/locator/string_locator.rs index 313ec58..94283ac 100644 --- a/src/asc_core/findrefs/locator/string_locator.rs +++ b/src/asc_core/findrefs/locator/string_locator.rs @@ -10,7 +10,10 @@ use memchr::memmem::Finder; use std::collections::HashSet; use std::time::Instant; -fn matches_name(raw: &[u8], query: &str) -> bool { +/// Case insensitive substring match over a raw string_data_item payload. +/// Shared with `liststring` so both agree on what the filter matches; `query` +/// is expected to be lowercased by the caller. +pub(crate) fn matches_name(raw: &[u8], query: &str) -> bool { if query.is_empty() { return true; } diff --git a/src/cli.rs b/src/cli.rs index c4f3e3d..d95697c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -213,6 +213,37 @@ fn _handle_listmethod(args: &ArgMatches) -> Result<(), String> { Ok(()) } +fn _handle_liststring(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 filter = args.get_one::("filter").map(String::as_str); + let handler = ApkHandler::new(apk_path, debug, thread); + if let Some(output) = args.get_one::("output") { + let mut file = File::create(output) + .map_err(|error| format!("cannot write {output}: {error}"))?; + handler.for_each_string_chunk(filter, |chunk| { + file.write_all(chunk) + .map_err(|error| format!("cannot write {output}: {error}")) + })?; + } else { + let stdout = io::stdout(); + let mut out = stdout.lock(); + handler.for_each_string_chunk(filter, |chunk| { + out.write_all(chunk) + .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 { .. } => {} @@ -434,6 +465,7 @@ pub fn _build_main_parser() -> Command { rust_ASC getclass --thread 16 app.apk com.poc.Main\n \ rust_ASC listclass app.apk -o classes.txt\n \ rust_ASC listclass --prefix com.poc app.apk\n \ + rust_ASC liststring app.apk -f https -o strings.txt\n \ rust_ASC export --thread 16 app.apk output\n \ rust_ASC getmanifest app.apk -o AndroidManifest.xml\n \ rust_ASC findrefs app.apk string token -o string_refs.txt\n \ @@ -497,6 +529,23 @@ pub fn _build_main_parser() -> Command { .help("Write method descriptors to this file instead of stdout."), ), )) + .subcommand(common_args( + Command::new("liststring") + .about("List string pool values across all DEX entries; use --filter to match a substring.") + .arg(Arg::new("apk_path").required(true).help("Path to the input APK file.")) + .arg( + Arg::new("filter") + .short('f') + .long("filter") + .help("Case insensitive substring filter over string values."), + ) + .arg( + Arg::new("output") + .short('o') + .long("output") + .help("Write string values 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.") @@ -544,6 +593,7 @@ where Some(("getclass", args)) => _handle_getclass(args), Some(("listclass", args)) => _handle_listclass(args), Some(("listmethod", args)) => _handle_listmethod(args), + Some(("liststring", args)) => _handle_liststring(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 5041949..795d54f 100644 --- a/src/mcp/schema.rs +++ b/src/mcp/schema.rs @@ -47,6 +47,19 @@ struct ListMethod { output: Option, } +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ListString { + /// Path to the APK to analyze. + apk_path: String, + /// Optional case insensitive substring filter over string values. + filter: Option, + /// Positive worker count. Defaults to available CPU count. + thread: Option, + /// Optional string list output file. When set, stdout is empty. Existing files are overwritten. + output: Option, +} + #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] struct GetManifest { @@ -127,6 +140,10 @@ pub(super) fn tools() -> Vec { "listmethod", "List defined method descriptors from every DEX in an APK.", ), + tool::( + "liststring", + "List string pool values from every DEX in an APK.", + ), tool::( "getmanifest", "Decode AndroidManifest.xml to formatted XML.", @@ -148,6 +165,7 @@ impl Query { "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)?), + "liststring" => liststring(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()), @@ -203,6 +221,18 @@ fn listmethod(input: ListMethod) -> Vec { args } +fn liststring(input: ListString) -> Vec { + let mut args = vec!["liststring".into()]; + push_thread(&mut args, input.thread); + 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/dex_fixture/mod.rs b/tests/dex_fixture/mod.rs index e06013b..e8faaf9 100644 --- a/tests/dex_fixture/mod.rs +++ b/tests/dex_fixture/mod.rs @@ -525,3 +525,36 @@ pub fn make_dex041_container() -> Vec { put_u32(&mut data, second_off + 0x70, total); data } + +/// A DEX whose string pool holds arbitrary values and defines no classes. +/// +/// `liststring` walks the string table rather than `class_defs`, so its fixture +/// needs values that are not class descriptors: resource URLs, SQL, UTF-8 text. +pub fn make_strings_dex(values: &[&str]) -> Vec { + let header_size = 0x70; + let mut buf = vec![0; header_size]; + let string_ids = buf.len(); + buf.resize(buf.len() + 4 * values.len(), 0); + let data_off = buf.len(); + + for (index, value) in values.iter().enumerate() { + let string_off = buf.len(); + put_u32(&mut buf, string_ids + index * 4, string_off as u32); + // The leading uleb128 stores UTF-16 code units, not byte length. + buf.extend_from_slice(&uleb(value.encode_utf16().count())); + buf.extend_from_slice(value.as_bytes()); + buf.push(0); + } + + let file_size = buf.len(); + buf[..4].copy_from_slice(b"dex\n"); + buf[4..7].copy_from_slice(b"035"); + put_u32(&mut buf, 0x20, file_size as u32); + put_u32(&mut buf, 0x24, header_size as u32); + // Only the string section is populated; the other id tables stay zero. + put_u32(&mut buf, 0x38, values.len() as u32); + put_u32(&mut buf, 0x3c, string_ids as u32); + put_u32(&mut buf, 0x68, (file_size - data_off) as u32); + put_u32(&mut buf, 0x6c, data_off as u32); + buf +} diff --git a/tests/liststring.rs b/tests/liststring.rs new file mode 100644 index 0000000..ef638ef --- /dev/null +++ b/tests/liststring.rs @@ -0,0 +1,93 @@ +mod dex_fixture; +mod zip_fixture; + +use std::path::Path; +use std::process::{Command, Output}; + +use dex_fixture::make_strings_dex; +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("liststring") + .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() +} + +const VALUES: [&str; 4] = [ + "https://alpha.example/v1", + "mtop.demo.order.create", + "https://beta.example/v2", + "unrelated", +]; + +#[test] +fn prints_one_string_per_line_in_dex_order_across_compressions_and_threads() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("strings.apk"); + let first = make_strings_dex(&VALUES[..2]); + let second = make_strings_dex(&VALUES[2..]); + for compression in [CompressionMethod::Stored, CompressionMethod::Deflated] { + write_apk( + &apk, + &[("classes.dex", &first), ("classes2.dex", &second)], + compression, + ); + for args in [vec![], vec!["--thread", "1"], vec!["--thread", "4"]] { + let stdout = success(run_cli(&apk, &args)); + assert_eq!(stdout, format!("{}\n", VALUES.join("\n"))); + } + } +} + +#[test] +fn filter_and_output_file_follow_listmethod_semantics() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("filter.apk"); + write_apk( + &apk, + &[("classes.dex", &make_strings_dex(&VALUES))], + CompressionMethod::Deflated, + ); + + // Case insensitive substring, anywhere in the value. + let stdout = success(run_cli(&apk, &["-f", "HTTPS"])); + assert_eq!( + stdout, + "https://alpha.example/v1\nhttps://beta.example/v2\n" + ); + assert_eq!( + success(run_cli(&apk, &["--filter", "order"])), + "mtop.demo.order.create\n" + ); + assert_eq!(success(run_cli(&apk, &["-f", "missing"])), ""); + // An empty needle matches every value, as it does for `findrefs string`. + assert_eq!( + success(run_cli(&apk, &["--filter", ""])), + format!("{}\n", VALUES.join("\n")) + ); + + // -o writes the list and leaves stdout empty. + let output = dir.path().join("strings.txt"); + let output_arg = output.to_str().unwrap().to_owned(); + let stdout = success(run_cli(&apk, &["-o", &output_arg])); + assert_eq!(stdout, ""); + assert_eq!( + std::fs::read_to_string(&output).unwrap(), + format!("{}\n", VALUES.join("\n")) + ); +} + diff --git a/tests/liststring_api.rs b/tests/liststring_api.rs new file mode 100644 index 0000000..c7810d1 --- /dev/null +++ b/tests/liststring_api.rs @@ -0,0 +1,161 @@ +mod dex_fixture; +mod zip_fixture; + +use dex_fixture::{make_dex041_container, make_strings_dex}; +use rust_ASC::asc_client::apk_handler::ApkHandler; +use tempfile::tempdir; +use zip_fixture::{CompressionMethod, write_apk}; + +#[test] +fn row_api_returns_strings_in_dex_and_table_order_with_duplicates() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("strings.apk"); + let first = make_strings_dex(&["https://alpha.example/v1", "alpha", "alpha"]); + let second = make_strings_dex(&["beta", "https://alpha.example/v1"]); + for compression in [CompressionMethod::Stored, CompressionMethod::Deflated] { + write_apk( + &apk, + &[("classes.dex", &first), ("classes2.dex", &second)], + compression, + ); + for threads in [1, 2] { + let handler = ApkHandler::new(apk.to_str().unwrap(), false, threads); + // The explicit type catches public API changes at compile time. + let rows: Vec = handler.list_strings(None).unwrap(); + assert_eq!( + rows, + [ + "https://alpha.example/v1", + "alpha", + "alpha", + "beta", + "https://alpha.example/v1", + ] + ); + } + } +} + +#[test] +fn filter_is_a_case_insensitive_substring_over_the_whole_pool() { + let values = [ + "https://alpha.example/v1", + "ALPHA", + "alpha", + "Lalpha/Unrelated;", + "no match here", + ]; + let dir = tempdir().unwrap(); + let apk = dir.path().join("filter.apk"); + for compression in [CompressionMethod::Stored, CompressionMethod::Deflated] { + write_apk( + &apk, + &[("classes.dex", &make_strings_dex(&values))], + compression, + ); + for threads in [1, 2] { + let handler = ApkHandler::new(apk.to_str().unwrap(), false, threads); + let rows: Vec = handler.list_strings(Some("alpha")).unwrap(); + assert_eq!( + rows, + [ + "https://alpha.example/v1", + "ALPHA", + "alpha", + "Lalpha/Unrelated;", + ] + ); + // A prefix is not implied: the needle matches anywhere in the value. + let rows: Vec = handler.list_strings(Some("example")).unwrap(); + assert_eq!(rows, ["https://alpha.example/v1"]); + assert!(handler.list_strings(Some("missing")).unwrap().is_empty()); + } + } +} + +#[test] +fn non_ascii_filters_fold_case_through_the_mutf8_path() { + let values = ["ÄBC-Payload", "名字", "plain"]; + let dir = tempdir().unwrap(); + let apk = dir.path().join("utf8.apk"); + write_apk( + &apk, + &[("classes.dex", &make_strings_dex(&values))], + CompressionMethod::Deflated, + ); + let handler = ApkHandler::new(apk.to_str().unwrap(), false, 1); + assert_eq!(handler.list_strings(None).unwrap(), values); + assert_eq!(handler.list_strings(Some("äbc")).unwrap(), ["ÄBC-Payload"]); + assert_eq!(handler.list_strings(Some("名字")).unwrap(), ["名字"]); +} + +#[test] +fn values_with_embedded_newlines_stay_single_records() { + // The CLI frames one value per line; the row API must not split a value + // that itself contains a newline. + let values = ["multi\nline", "tab\tvalue"]; + let dir = tempdir().unwrap(); + let apk = dir.path().join("records.apk"); + write_apk( + &apk, + &[("classes.dex", &make_strings_dex(&values))], + CompressionMethod::Stored, + ); + let handler = ApkHandler::new(apk.to_str().unwrap(), false, 1); + assert_eq!(handler.list_strings(None).unwrap(), values); +} + +#[test] +fn empty_pool_lists_nothing() { + let dir = tempdir().unwrap(); + let apk = dir.path().join("empty.apk"); + write_apk( + &apk, + &[("classes.dex", &make_strings_dex(&[]))], + CompressionMethod::Stored, + ); + let handler = ApkHandler::new(apk.to_str().unwrap(), false, 1); + assert!(handler.list_strings(None).unwrap().is_empty()); + assert!(handler.list_strings(Some("anything")).unwrap().is_empty()); +} + +#[test] +fn non_dex_payload_in_a_classes_entry_is_reported() { + // A `classes*.dex` entry that is not a DEX is a broken archive, and the + // listing says so instead of returning a silently short list. + let dir = tempdir().unwrap(); + let apk = dir.path().join("broken.apk"); + write_apk( + &apk, + &[("classes.dex", b"not a dex file")], + CompressionMethod::Stored, + ); + let handler = ApkHandler::new(apk.to_str().unwrap(), false, 1); + assert_eq!( + handler.list_strings(None).unwrap_err(), + "invalid DEX header" + ); +} + +#[test] +fn dex_041_container_lists_every_logical_dex_pool_in_order() { + // A 041 container carries several logical DEX files in one zip entry; the + // walk has to visit each of them exactly once, in file order. + let dir = tempdir().unwrap(); + let apk = dir.path().join("container.apk"); + write_apk( + &apk, + &[("classes.dex", &make_dex041_container())], + CompressionMethod::Deflated, + ); + let handler = ApkHandler::new(apk.to_str().unwrap(), false, 1); + assert_eq!( + handler.list_strings(None).unwrap(), + [ + "Lalpha/First;", + "Lshared/Duplicate;", + "Lbeta/Second;", + "Lshared/Duplicate;", + ] + ); +} diff --git a/tests/mcp_stdio.rs b/tests/mcp_stdio.rs index 989bb3c..e214fd8 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_five_documented_cli_wrappers() { +fn exposes_six_documented_cli_wrappers() { let mut client = Client::new(); client.send(json!({"jsonrpc":"2.0", "id":99, "method":"tools/list"})); let response = client.recv(); @@ -169,7 +169,8 @@ fn exposes_five_documented_cli_wrappers() { "getclass", "getmanifest", "listclass", - "listmethod" + "listmethod", + "liststring" ] ); for tool in tools { @@ -194,7 +195,7 @@ fn exposes_five_documented_cli_wrappers() { let output = tool["inputSchema"]["properties"]["output"]["description"] .as_str() .unwrap(); - if tool["name"] == "listclass" || tool["name"] == "listmethod" { + if ["listclass", "listmethod", "liststring"].contains(&tool["name"].as_str().unwrap()) { assert!(output.contains("stdout is empty.")); } else { assert!(output.contains("Content is also returned.")); @@ -282,6 +283,16 @@ fn tool_results_are_exact_cli_stdout() { ) ); + let result = client.call( + "liststring", + json!({"apk_path":path, "filter":"first", "thread":1}), + ); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!( + text(&result), + cli_stdout(["liststring", "--thread", "1", "--filter", "first", "--", &path].map(OsString::from)) + ); + let result = client.call( "listmethod", json!({"apk_path":path, "class_name":"example.Test", "filter":"ECON", "thread":1}),