use asc_fixture::{make_apk, make_class_only_dex, make_dex, make_dex041_container, make_field_access_dex, make_static_field_dex};
use std::io::Write;
use std::path::Path;
use std::process::{Command, Output};
fn temp_apk_from(bytes: &[u8]) -> tempfile::NamedTempFile {
let mut f = tempfile::Builder::new().suffix(".apk").tempfile().unwrap();
f.write_all(bytes).unwrap();
f.flush().unwrap();
f
}
fn temp_apk() -> tempfile::NamedTempFile {
let dex = make_dex();
temp_apk_from(&make_apk(&[("classes.dex", &dex), ("classes2.dex", &dex)], true))
}
fn bin() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_asc"));
cmd.env("ASC_CONFIG", "");
cmd
}
fn asc(args: &[&str]) -> Output {
bin().arg("--no-cache").args(args).output().expect("run asc")
}
fn stdout(o: &Output) -> String {
String::from_utf8_lossy(&o.stdout).into_owned()
}
fn stderr(o: &Output) -> String {
String::from_utf8_lossy(&o.stdout).into_owned() + &String::from_utf8_lossy(&o.stderr)
}
fn path(p: &Path) -> &str {
p.to_str().unwrap()
}
#[test]
fn getclass_prints_smali() {
let apk = temp_apk();
for name in ["Lexample/Test;", "example.Test", "example/Test"] {
let out = asc(&["getclass", path(apk.path()), name]);
assert!(out.status.success(), "{}", stderr(&out));
let text = stdout(&out);
assert!(text.contains(".class public Lexample/Test;"), "{text}");
assert!(text.contains("const-string v0, \"token\""), "{text}");
}
let out = asc(&["getclass", path(apk.path()), "Lnope/Missing;"]);
assert_eq!(out.status.code(), Some(1));
assert!(stderr(&out).starts_with("Error:"), "{}", stderr(&out));
}
fn nested_class_apk() -> tempfile::NamedTempFile {
use asc_fixture::{ClassSpec, CodeSpec, DexBuilder, MethodSpec};
const OBJ: &str = "Ljava/lang/Object;";
const OUTER: &str = "Lcom/app/Outer;";
const INNER: &str = "Lcom/app/Outer$Inner;";
let mut d = DexBuilder::new();
d.type_(OBJ).type_(OUTER).type_(INNER).method(INNER, "do", "V", &[]).method(OUTER, "use", "V", &[]);
let mut ids = d.resolve();
let ret = CodeSpec { registers: 1, insns: vec![0x0e, 0x00], ..Default::default() };
let call_do = idx_le(ids.method(INNER, "do", "V", &[]));
let calls = CodeSpec { registers: 1, insns: vec![0x71, 0x00, call_do[0], call_do[1], 0x00, 0x00, 0x0e, 0x00], ..Default::default() };
ids.add_class(ClassSpec {
descriptor: INNER.into(),
access: 0x1,
superclass: Some(OBJ.into()),
direct_methods: vec![MethodSpec { idx: ids.method(INNER, "do", "V", &[]), access: 0x9, code: Some(ret) }],
..Default::default()
});
ids.add_class(ClassSpec {
descriptor: OUTER.into(),
access: 0x1,
superclass: Some(OBJ.into()),
direct_methods: vec![MethodSpec { idx: ids.method(OUTER, "use", "V", &[]), access: 0x9, code: Some(calls) }],
..Default::default()
});
temp_apk_from(&make_apk(&[("classes.dex", &ids.finish())], true))
}
fn idx_le(idx: u32) -> [u8; 2] {
(idx as u16).to_le_bytes()
}
#[test]
fn names_written_as_decompiled_source_spells_them_are_found() {
let apk = nested_class_apk();
let out = asc(&["getclass", path(apk.path()), "com.app.Outer.Inner", "--method", "Cdo"]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(stdout(&out).contains(".method public static do()V"), "{}", stdout(&out));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("# Lcom/app/Outer/Inner; read as the Java/Kotlin spelling of Lcom/app/Outer$Inner;"), "{err}");
let out = asc(&["findrefs", path(apk.path()), "method", "com.app.Outer.Inner.Cdo"]);
assert!(out.status.success(), "{}", stderr(&out));
let text = stdout(&out);
assert!(text.contains("Lcom/app/Outer;->use()V | Lcom/app/Outer$Inner;->do()V"), "{text}");
assert!(stderr(&out).contains("read as Java/Kotlin source"), "{}", stderr(&out));
}
#[test]
fn getclass_debug_keeps_stdout_clean() {
let apk = temp_apk();
let dir = tempfile::tempdir().unwrap();
let dex = dir.path().join("Test.dex");
let out = asc(&["getclass", path(apk.path()), "example.Test", "--debug", "--threads", "1", "--dump-dex", path(&dex)]);
assert!(out.status.success(), "{}", stderr(&out));
let text = String::from_utf8_lossy(&out.stdout);
assert!(!text.contains("[DEBUG]") && !text.contains("[APK]"), "{text}");
assert!(String::from_utf8_lossy(&out.stderr).contains("[DEBUG] Hit DEX: classes.dex"));
assert!(std::fs::read(&dex).unwrap().starts_with(b"dex\n"));
}
#[test]
fn findrefs_prints_one_line_per_method() {
let apk = temp_apk();
let out = asc(&["findrefs", path(apk.path()), "string", "token"]);
assert!(out.status.success(), "{}", stderr(&out));
let text = stdout(&out);
let mut lines: Vec<&str> = text.lines().collect();
lines.sort_unstable();
assert_eq!(
lines,
[
"classes.dex | Lexample/Test;->first()V | \"token\"",
"classes.dex | Lexample/Test;->second()V | \"token\"",
"classes2.dex | Lexample/Test;->first()V | \"token\"",
"classes2.dex | Lexample/Test;->second()V | \"token\"",
]
);
let out = asc(&["findrefs", path(apk.path()), "type", "example.Test"]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(stdout(&out).lines().all(|l| l.contains(" | Lexample/Test;->") && l.split(" | ").count() == 3));
}
#[test]
fn findrefs_all_rules_and_hierarchy() {
let apk = temp_apk();
let a = path(apk.path());
let sorted = |o: &Output| {
let mut l: Vec<String> = stdout(o).lines().map(String::from).collect();
l.sort();
l
};
let out = asc(&["findrefs", a, "all", "string:tok", "literal:token", "--threads", "1"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(
sorted(&out),
[
"classes.dex | Lexample/Test;->first()V | \"token\", \"token\"",
"classes.dex | Lexample/Test;->second()V | \"token\", \"token\"",
"classes2.dex | Lexample/Test;->first()V | \"token\", \"token\"",
"classes2.dex | Lexample/Test;->second()V | \"token\", \"token\"",
]
);
let out = asc(&["findrefs", a, "all", "string:token", "--none", "string:oke"]);
assert!(out.status.success() && stdout(&out).is_empty(), "{}", stderr(&out));
let out = asc(&["findrefs", a, "all", "nonsense"]);
assert_eq!(out.status.code(), Some(1));
assert!(stderr(&out).contains("KIND:VALUE"), "{}", stderr(&out));
let rules = tempfile::NamedTempFile::new().unwrap();
std::fs::write(rules.path(), "# comment\nextends:java.lang.Object\n").unwrap();
let out = asc(&["findrefs", a, "rules", "string:token", "string:nothing & literal:token", "--file", path(rules.path())]);
assert!(out.status.success(), "{}", stderr(&out));
let text = stdout(&out);
assert!(text.starts_with("# r1 string:token\n# r2 string:nothing & literal:token\n# r3 extends:java.lang.Object\n"), "{text}");
assert!(text.ends_with("# r1 4 results\n# r2 0 results\n# r3 2 results\n"), "{text}");
assert_eq!(text.lines().filter(|l| l.starts_with("r1 | ")).count(), 4);
assert!(text.contains("r3 | classes2.dex | Lexample/Test; | extends Ljava/lang/Object;\n"), "{text}");
let out = asc(&["hierarchy", a, "java.lang.Object", "--method", "second"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(
stdout(&out),
"0 Ljava/lang/Object; [not in APK]\n 1 subclass Lexample/Test; classes.dex [defines second()V]\n\
override Lexample/Test;->second()V classes.dex\n# 2 rows, 2 distinct classes (subclasses, depth 16); 1 define second\n"
);
let out = asc(&["callgraph", a, "example.Test", "first", "--virtual"]);
assert!(out.status.success(), "{}", stderr(&out));
}
#[test]
fn global_flags_are_accepted_anywhere() {
let apk = temp_apk();
let a = asc(&["--threads", "2", "--debug", "findrefs", path(apk.path()), "string", "token"]);
let b = asc(&["findrefs", path(apk.path()), "string", "token", "--threads", "2", "--debug"]);
let c = asc(&["findrefs", "--thread", "1", path(apk.path()), "string", "token"]);
for o in [&a, &b, &c] {
assert!(o.status.success(), "{}", stderr(o));
assert_eq!(stdout(o).lines().count(), 4);
assert!(!stdout(o).contains("[DEBUG]"));
}
assert!(String::from_utf8_lossy(&a.stderr).contains("[DEBUG]"));
assert!(String::from_utf8_lossy(&b.stderr).contains("[DEBUG]"));
}
#[test]
fn member_query_needs_a_class_or_a_name() {
let apk = temp_apk();
let out = asc(&["findrefs", path(apk.path()), "method"]);
assert_eq!(out.status.code(), Some(1));
assert!(stderr(&out).contains("method query needs a class or a method name"), "{}", stderr(&out));
let out = asc(&["findrefs", path(apk.path()), "method", "first", "--class", "example.Test"]);
assert!(out.status.success(), "{}", stderr(&out));
}
#[test]
fn exact_flag_matches_whole_member_names() {
let dex = asc_fixture::make_static_field_dex();
let apk = temp_apk_from(&make_apk(&[("classes.dex", &dex)], false));
let run = |args: &[&str]| {
let mut all = vec!["findrefs", path(apk.path()), "field"];
all.extend_from_slice(args);
let out = asc(&all);
assert!(out.status.success(), "{}", stderr(&out));
stdout(&out)
};
let hit = "classes.dex | Lexample/Statics;->getSecond()I | Lexample/Statics;->SECOND:I [read]\n";
assert_eq!(run(&["SECON"]), hit);
assert_eq!(run(&["SECOND", "--exact"]), hit);
assert_eq!(run(&["SECOND", "--exact", "--class", "example.Statics"]), hit);
assert_eq!(run(&["SECOND", "--exact", "--class", "Statics", "--fuzzy-class"]), hit);
for args in [&["SECON", "--exact"][..], &["SECON", "--exact", "--class", "example.Statics"], &["SEC.ND", "--exact"]] {
assert_eq!(run(args), "", "{args:?}");
}
}
#[test]
fn field_access_is_printed_and_filterable() {
let dex = make_field_access_dex();
let apk = temp_apk_from(&make_apk(&[("classes.dex", &dex)], false));
let p = path(apk.path());
let run = |access: Option<&str>| {
let mut args = vec!["findrefs", p, "field", "Lexample/Fields;->value:I"];
if let Some(access) = access {
args.extend_from_slice(&["--access", access]);
}
let out = asc(&args);
assert!(out.status.success(), "{}", stderr(&out));
stdout(&out)
};
let prefix = "classes.dex | Lexample/Fields;->touch()V | Lexample/Fields;->value:I";
assert_eq!(run(None), format!("{prefix} [read], Lexample/Fields;->value:I [write]\n"));
assert_eq!(run(Some("read")), format!("{prefix} [read]\n"));
assert_eq!(run(Some("write")), format!("{prefix} [write]\n"));
let before = asc(&["findrefs", p, "--access", "read", "field", "Lexample/Fields;->value:I"]);
assert!(before.status.success(), "{}", stderr(&before));
assert_eq!(stdout(&before), format!("{prefix} [read]\n"));
let context = asc(&["findrefs", p, "field", "Lexample/Fields;->value:I", "--context", "0"]);
let text = stdout(&context);
assert!(text.contains("pc 0x0 read") && text.contains("pc 0x2 write"), "{text}");
let bad = asc(&["findrefs", p, "field", "value", "--access", "execute"]);
assert!(!bad.status.success());
assert!(stderr(&bad).contains("invalid value 'execute'"), "{}", stderr(&bad));
let wrong_kind = asc(&["findrefs", p, "--access", "read", "string", "value"]);
assert_eq!(wrong_kind.status.code(), Some(1));
assert!(stderr(&wrong_kind).contains("only valid for `findrefs field`"), "{}", stderr(&wrong_kind));
let help = asc(&["findrefs", "--help"]);
assert!(help.status.success());
assert!(stdout(&help).contains("--access <read|write>"), "{}", stdout(&help));
}
#[test]
fn output_flag_writes_the_file() {
let apk = temp_apk();
let dir = tempfile::tempdir().unwrap();
let refs = dir.path().join("refs.txt");
let out = asc(&["findrefs", path(apk.path()), "string", "token", "-o", path(&refs)]);
assert!(out.status.success(), "{}", stderr(&out));
let file = std::fs::read_to_string(&refs).unwrap();
assert_eq!(file.lines().count(), 4);
assert_eq!(file, stdout(&out));
let smali = dir.path().join("Test.smali");
let out = asc(&["getclass", path(apk.path()), "example.Test", "-o", path(&smali)]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(std::fs::read_to_string(&smali).unwrap(), stdout(&out));
}
#[test]
fn foreign_cache_dir_is_reported_and_left_alone() {
let f = temp_apk();
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("notes.txt"), b"keep").unwrap();
let out = bin()
.env("ASC_DAEMON", "off")
.env("ASC_CACHE_MAX_MB", "1024")
.args(["--cache-dir", path(dir.path()), "findrefs", path(f.path()), "string", "token"])
.output()
.expect("run asc");
assert!(out.status.success(), "{}", stderr(&out));
assert!(String::from_utf8_lossy(&out.stderr).contains("disk cache disabled"), "{}", stderr(&out));
let names: Vec<_> = std::fs::read_dir(dir.path()).unwrap().map(|d| d.unwrap().file_name()).collect();
assert_eq!(names, ["notes.txt"]);
}
#[test]
fn broken_dex_entry_gives_partial_exit_status() {
let dex = make_dex();
let bytes = make_apk(&[("classes.dex", &dex), ("classes2.dex", b"this is not a dex file")], true);
let f = temp_apk_from(&bytes);
let out = asc(&["findrefs", path(f.path()), "string", "token"]);
assert_eq!(out.status.code(), Some(2), "{}", stderr(&out));
assert_eq!(stdout(&out).lines().count(), 2, "{}", stdout(&out));
assert!(String::from_utf8_lossy(&out.stderr).contains("Warning: classes2.dex"), "{}", stderr(&out));
}
#[test]
fn short_help_is_a_digest_of_the_long_one() {
let short = stdout(&asc(&["-h"]));
let long = stdout(&asc(&["--help"]));
for text in [&short, &long] {
assert!(text.contains("examples (Android):") && text.contains("examples (HarmonyOS):"), "{text}");
assert!(text.contains("exit status"), "{text}");
}
assert!(short.contains("`asc --help` adds"), "{short}");
assert!(long.contains("settings:") && !short.contains("settings:"), "{short}");
assert!(long.contains("--set KEY=VALUE") && long.contains("`asc config` lists them"), "{long}");
assert!(short.lines().count() * 2 < long.lines().count(), "-h {} lines, --help {}", short.lines().count(), long.lines().count());
}
#[test]
fn usage_errors_exit_1_not_2() {
for args in [&[][..], &["findrefs"][..], &["findrefs", "x.apk", "string"][..], &["--bogus"][..]] {
let out = asc(args);
assert_eq!(out.status.code(), Some(1), "{args:?}: {}", stderr(&out));
}
assert!(asc(&["--version"]).status.success());
}
#[test]
fn deeply_nested_rule_is_a_usage_error_not_a_crash() {
let n = 2000;
let rule = format!("{}La;->a()V{}", "method:{".repeat(n), "}".repeat(n));
let out = bin().env("ASC_DAEMON", "off").args(["--no-cache", "findrefs", "/nonexistent.apk", "all", &rule]).output().expect("run asc");
assert_eq!(out.status.code(), Some(1), "{}", stderr(&out));
assert!(stderr(&out).contains("nested deeper than 8 levels"), "{}", stderr(&out));
}
#[test]
fn class_that_cannot_be_rebuilt_is_still_disassembled() {
let dex = asc_fixture::make_rich_dex();
let bytes = make_apk(&[("classes.dex", &dex)], true);
let f = temp_apk_from(&bytes);
let out = asc(&["getclass", path(f.path()), "Lexample/Broken;"]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(stdout(&out).contains(".class"), "{}", stdout(&out));
assert!(String::from_utf8_lossy(&out.stderr).contains("Warning: class not rebuilt"), "{}", stderr(&out));
let dir = tempfile::tempdir().unwrap();
let out = asc(&["getclass", path(f.path()), "Lexample/Broken;", "--dump-dex", path(&dir.path().join("b.dex"))]);
assert_eq!(out.status.code(), Some(1), "{}", stderr(&out));
assert!(stderr(&out).contains("not rebuilt"), "{}", stderr(&out));
}
fn rich_apk() -> tempfile::NamedTempFile {
let dex = asc_fixture::make_rich_dex();
temp_apk_from(&make_apk(&[("classes.dex", &dex), ("classes2.dex", &make_dex())], true))
}
fn sorted_lines(o: &Output) -> Vec<String> {
let mut v: Vec<String> = stdout(o).lines().map(str::to_string).collect();
v.sort_unstable();
v
}
#[test]
fn findrefs_context_and_callgraph() {
let apk = temp_apk();
let out = asc(&["findrefs", path(apk.path()), "string", "token", "--context", "0", "--threads", "1"]);
assert!(out.status.success(), "{}", stderr(&out));
let text = stdout(&out);
assert!(
text.contains("classes.dex | Lexample/Test;->first()V | \"token\"\n @ pc 0x0\n > const-string v0, \"token\"\n"),
"{text}"
);
assert_eq!(text.matches(" > const-string").count(), 4, "{text}");
let out = asc(&["findrefs", path(apk.path()), "--context-method", "string", "token"]);
assert!(stdout(&out).contains(" .method public static second()V\n"), "{}", stdout(&out));
let out = asc(&["findrefs", path(apk.path()), "string", "token", "--context", "1", "--context-method"]);
assert_eq!(out.status.code(), Some(1));
let apk = rich_apk();
let out = asc(&["callgraph", path(apk.path()), "example.Rich", "<init>", "--callees"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(
stdout(&out),
"0 Lexample/Rich;-><init>()V\n 1 -> Ljava/lang/Object;-><init>()V [no body]\n# 2 rows, 2 distinct methods (callees, depth 2)\n"
);
let out = asc(&["callgraph", path(apk.path()), "example.Rich", "nope"]);
assert_eq!(out.status.code(), Some(1));
}
#[test]
fn nested_conditions_method_files_and_callgraph_rules() {
let apk = rich_apk();
let a = path(apk.path());
let nested = asc(&["findrefs", a, "method", "{string:llo}"]);
assert!(nested.status.success(), "{}", stderr(&nested));
assert!(stdout(&nested).contains("classes.dex | Lexample/Rich;->compute(II)I | Lexample/Rich;->compute(II)I\n"), "{}", stdout(&nested));
let all = asc(&["findrefs", a, "all", "method:{string:llo}"]);
assert_eq!(stdout(&all), stdout(&nested));
assert_eq!(stdout(&asc(&["findrefs", a, "all", "method:{string:llo}", "!string:llo"])), "");
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("set.txt"), stdout(&nested)).unwrap();
let out = bin().current_dir(dir.path()).args(["--no-cache", "findrefs", a, "method", "@set.txt"]).output().unwrap();
assert_eq!(stdout(&out), stdout(&nested), "{}", stderr(&out));
let out = asc(&["findrefs", a, "rules", "method:{string:llo", "string:x"]);
assert_eq!(out.status.code(), Some(1));
assert!(stderr(&out).contains("expected `}`"), "{}", stderr(&out));
let out = asc(&["callgraph", a, "--to", "string:llo", "--depth", "1"]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(stdout(&out).starts_with("0 Lexample/Rich;->compute(II)I\n"), "{}", stdout(&out));
let out = asc(&["callgraph", a, "--to", "string:llo", "--unless", "string:llo"]);
assert!(stdout(&out).contains("2 methods left out by unless"), "{}", stdout(&out));
for args in [&["callgraph", a][..], &["callgraph", a, "example.Rich"], &["callgraph", a, "example.Rich", "compute", "--to", "string:x"]]
{
assert_eq!(asc(args).status.code(), Some(1), "{args:?}");
}
}
#[test]
fn findrefs_extends_and_impl_print_class_rows() {
let apk = rich_apk();
for target in ["java.lang.Object", "Ljava/lang/Object;", "java/lang/Object"] {
let out = asc(&["findrefs", path(apk.path()), "extends", target]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(
sorted_lines(&out),
[
"classes.dex | Lexample/Broken; | extends Ljava/lang/Object;",
"classes.dex | Lexample/Custom; | extends Ljava/lang/Object;",
"classes.dex | Lexample/Iface; | extends Ljava/lang/Object;",
"classes.dex | Lexample/Rich; | extends Ljava/lang/Object;",
"classes2.dex | Lexample/Test; | extends Ljava/lang/Object;",
]
);
}
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("impls.txt");
let out = asc(&["findrefs", path(apk.path()), "impl", "example.Iface", "-o", path(&file)]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "classes.dex | Lexample/Rich; | implements Lexample/Iface;\n");
assert_eq!(std::fs::read_to_string(&file).unwrap(), stdout(&out));
for args in [["extends", "example.Iface"], ["impl", "java.lang.Object"], ["extends", "no.such.Clazz"]] {
let out = asc(&["findrefs", path(apk.path()), args[0], args[1]]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(out.stdout.is_empty(), "{args:?}: {}", stdout(&out));
}
let out = asc(&["findrefs", path(apk.path()), "extends", ""]);
assert_eq!(out.status.code(), Some(1));
assert!(stderr(&out).contains("Class name cannot be empty"), "{}", stderr(&out));
}
#[test]
fn programs_of_several_archives_name_the_archive() {
let dir = tempfile::tempdir().unwrap();
let dex = make_dex();
std::fs::write(dir.path().join("framework.jar"), make_apk(&[("classes.dex", &dex)], true)).unwrap();
std::fs::write(dir.path().join("services.jar"), make_apk(&[("classes.dex", &dex), ("classes2.dex", &dex)], false)).unwrap();
std::fs::write(dir.path().join("odexed.jar"), make_apk(&[("META-INF/MANIFEST.MF", b"m")], false)).unwrap();
let out = asc(&["findrefs", path(dir.path()), "string", "token", "--threads", "1"]);
assert!(out.status.success(), "{}", stderr(&out));
let lines = sorted_lines(&out);
assert_eq!(lines.len(), 6, "{lines:?}");
assert_eq!(lines[0], "framework.jar/classes.dex | Lexample/Test;->first()V | \"token\"");
assert_eq!(lines[5], "services.jar/classes2.dex | Lexample/Test;->second()V | \"token\"");
assert!(String::from_utf8_lossy(&out.stderr).starts_with("# warning: odexed.jar: no classes*.dex"), "{}", stderr(&out));
let out = asc(&["listclass", path(dir.path())]);
assert_eq!(sorted_lines(&out), ["Lexample/Test; | framework.jar", "Lexample/Test; | services.jar", "Lexample/Test; | services.jar"]);
let out = asc(&["listclass", path(&dir.path().join("framework.jar"))]);
assert_eq!(stdout(&out), "Lexample/Test;\n");
assert!(out.stderr.is_empty());
let none = format!("{0}/odexed.jar,{0}/missing.jar", path(dir.path()));
let out = asc(&["listclass", &none]);
assert_eq!(out.status.code(), Some(1));
assert!(stderr(&out).contains("missing.jar"), "{}", stderr(&out));
}
#[test]
fn findrefs_string_literal_ignores_regex_syntax() {
let apk = temp_apk();
let regex = asc(&["findrefs", path(apk.path()), "string", "t.k"]);
assert_eq!(sorted_lines(®ex).len(), 4, "{}", stderr(®ex));
let literal = asc(&["findrefs", path(apk.path()), "string", "--literal", "t.k"]);
assert!(literal.status.success() && literal.stdout.is_empty(), "{}", stderr(&literal));
let out = asc(&["findrefs", path(apk.path()), "string", "oke(", "--literal"]);
assert!(out.status.success() && out.stdout.is_empty(), "{}", stderr(&out));
assert_eq!(asc(&["findrefs", path(apk.path()), "string", "oke("]).status.code(), Some(1));
let out = asc(&["findrefs", path(apk.path()), "string", "--literal", "oke"]);
assert_eq!(sorted_lines(&out), sorted_lines(&asc(&["findrefs", path(apk.path()), "string", "oke"])));
assert_eq!(sorted_lines(&out).len(), 4);
}
#[test]
fn disassemble_defaults_to_no_debug_directives_and_writes_only_the_file() {
let apk = rich_apk();
let a = asc(&["disassemble", path(apk.path()), "example.Rich"]);
assert!(a.status.success(), "{}", stderr(&a));
let text = stdout(&a);
assert!(text.contains(".method public static compute(II)I") && !text.contains(".line"), "{text}");
let b = asc(&["disassemble", path(apk.path()), "example.Rich", "--debuginfo"]);
assert!(stdout(&b).contains(".line"), "{}", stdout(&b));
assert_eq!(stdout(&b), stdout(&asc(&["getclass", path(apk.path()), "example.Rich"])));
assert_eq!(text, stdout(&asc(&["getclass", path(apk.path()), "example.Rich", "--no-debug-info"])));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("Rich.smali");
let out = asc(&["disassemble", path(apk.path()), "Lexample/Rich;", "-o", path(&file)]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(out.stdout.is_empty(), "{}", stdout(&out));
assert_eq!(std::fs::read_to_string(&file).unwrap(), text);
}
#[test]
fn method_flag_takes_a_name_or_a_signature() {
let apk = rich_apk();
for cmd in ["getclass", "disassemble"] {
let out = asc(&[cmd, path(apk.path()), "example.Rich", "--method", "run()V"]);
assert!(out.status.success(), "{}", stderr(&out));
let text = stdout(&out);
assert_eq!(text.matches(".method ").count(), 1, "{text}");
assert!(text.contains(".method public run()V") && !text.contains(".field"), "{text}");
for m in ["run", "run()", "Lexample/Rich;->run()V"] {
let out = asc(&[cmd, path(apk.path()), "example.Rich", "--method", m]);
assert!(out.status.success(), "{m}: {}", stderr(&out));
let picked = stdout(&out);
assert_eq!(picked.matches(".method ").count(), 1, "{m}: {picked}");
assert!(picked.contains(".method public run()V") && !picked.contains(".field"), "{m}: {picked}");
}
let out = asc(&[cmd, path(apk.path()), "example.Rich", "--method", "run(I)V"]);
assert_eq!(out.status.code(), Some(1));
let err = stderr(&out);
assert!(err.contains("Lexample/Rich; has no method \"run(I)V\"; its methods: ") && err.contains("run()V"), "{err}");
}
let dir = tempfile::tempdir().unwrap();
let (a, b) = (dir.path().join("a.dex"), dir.path().join("b.dex"));
assert!(asc(&["getclass", path(apk.path()), "example.Rich", "--dump-dex", path(&a)]).status.success());
assert!(asc(&["getclass", path(apk.path()), "example.Rich", "--method", "run()V", "--dump-dex", path(&b)]).status.success());
assert_eq!(std::fs::read(&a).unwrap(), std::fs::read(&b).unwrap());
}
#[test]
fn rasc_aliases_and_version_flags() {
let apk = temp_apk();
let a = asc(&["classes", path(apk.path()), "--thread", "1"]);
assert!(a.status.success(), "{}", stderr(&a));
assert_eq!(stdout(&a), stdout(&asc(&["listclass", path(apk.path())])));
let m = asc(&["manifest", path(apk.path())]);
let g = asc(&["getmanifest", path(apk.path())]);
assert_eq!(m.status.code(), Some(1));
assert_eq!(stderr(&m), stderr(&g));
assert!(stderr(&m).starts_with("Error:"), "{}", stderr(&m));
for flag in ["-v", "-V", "--version"] {
let out = asc(&[flag]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(stdout(&out).starts_with("asc "), "{}", stdout(&out));
}
}
#[test]
fn listclass_filters_a_normalized_prefix() {
let dex = make_class_only_dex(&["Lcom/xxx/First;", "Lcom/xxx/internal/Second;", "Lorg/example/Other;"], 0, false);
for deflate in [false, true] {
let apk = temp_apk_from(&make_apk(&[("classes.dex", &dex)], deflate));
for prefix in ["com.xxx", "com/xxx", "Lcom/xxx"] {
let out = asc(&["listclass", path(apk.path()), "--prefix", prefix]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "Lcom/xxx/First;\nLcom/xxx/internal/Second;\n", "{prefix} deflate={deflate}");
}
let out = asc(&["listclass", path(apk.path()), "--prefix", "net"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "");
}
}
#[test]
fn listclass_keeps_dex_order_and_writes_only_the_file_with_output() {
let (a, b) = (make_dex(), make_static_field_dex());
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("classes.txt");
for deflate in [false, true] {
let apk = temp_apk_from(&make_apk(&[("classes.dex", &a), ("classes2.dex", &b), ("classes3.dex", &a)], deflate));
let out = asc(&["listclass", path(apk.path()), "--threads", "2", "-o", path(&file)]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "");
assert_eq!(std::fs::read_to_string(&file).unwrap(), "Lexample/Test;\nLexample/Statics;\nLexample/Test;\n");
}
}
#[test]
fn listclass_lists_a_large_deflated_dex() {
let mut dex = make_dex();
let mut x = 0x2545_f491_4f6c_dd1du64;
dex.extend((0..1 << 20).map(|_| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
x as u8
}));
let len = dex.len() as u32;
dex[0x20..0x24].copy_from_slice(&len.to_le_bytes());
let apk = temp_apk_from(&make_apk(&[("classes.dex", &dex)], true));
let out = asc(&["listclass", path(apk.path())]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "Lexample/Test;\n");
}
#[test]
fn listclass_lists_every_logical_dex_of_a_041_container() {
let container = make_dex041_container();
for deflate in [false, true] {
let apk = temp_apk_from(&make_apk(&[("classes.dex", &container)], deflate));
let out = asc(&["listclass", path(apk.path()), "--threads", "1"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "Lalpha/First;\nLshared/Duplicate;\nLbeta/Second;\nLshared/Duplicate;\n");
}
}
#[test]
fn listclass_bad_class_type_index_is_a_clean_error() {
let mut dex = make_dex();
let off = u32::from_le_bytes(dex[0x64..0x68].try_into().unwrap()) as usize;
dex[off..off + 4].copy_from_slice(&u32::MAX.to_le_bytes());
let apk = temp_apk_from(&make_apk(&[("classes.dex", &dex)], false));
let out = asc(&["listclass", path(apk.path())]);
assert_eq!(out.status.code(), Some(1));
assert!(out.stdout.is_empty());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.starts_with("Error: classes.dex: class_def 0: type index 4294967295 out of range"), "{err}");
}
#[test]
fn listclass_rejects_an_empty_prefix_and_accepts_zero_threads() {
let apk = temp_apk_from(&make_apk(&[("classes.dex", &make_dex())], false));
let out = asc(&["listclass", path(apk.path()), "--prefix", " "]);
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("Error: Class prefix cannot be empty"), "{}", stderr(&out));
let out = asc(&["listclass", path(apk.path()), "--threads", "0"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "Lexample/Test;\n");
}
#[test]
fn mcp_serves_line_delimited_json_rpc_until_stdin_closes() {
use std::process::Stdio;
let apk = temp_apk();
let mut child = bin()
.args(["--no-cache", "--threads", "2", "mcp", "--memory-mb", "64"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let requests = [
serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}),
serde_json::json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
serde_json::json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "findrefs", "arguments": {"apk": path(apk.path()), "kind": "string", "value": "token"}}}),
];
let mut stdin = child.stdin.take().unwrap();
for r in &requests {
writeln!(stdin, "{r}").unwrap();
}
drop(stdin);
let out = child.wait_with_output().unwrap();
assert!(out.status.success(), "{}", stderr(&out));
let replies: Vec<serde_json::Value> = stdout(&out).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(replies.len(), 2, "{}", stdout(&out));
assert_eq!(replies[0]["result"]["protocolVersion"], "2025-06-18");
assert_eq!(replies[1]["result"]["structuredContent"]["total"], 4);
let text = replies[1]["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("classes.dex | Lexample/Test;->first()V | \"token\"\n"), "{text}");
}
#[test]
fn cache_stats_reports_the_disk_budget() {
let f = temp_apk();
let dir = tempfile::tempdir().unwrap();
let run = |env: Option<&str>, args: &[&str]| {
let mut cmd = bin();
cmd.env("ASC_DAEMON", "off");
match env {
Some(v) => cmd.env("ASC_CACHE_MAX_MB", v),
None => cmd.env_remove("ASC_CACHE_MAX_MB"),
};
cmd.args(["--cache-dir", path(dir.path())]).args(args).output().expect("run asc")
};
let out = run(Some("1024"), &["findrefs", path(f.path()), "string", "token"]);
assert!(out.status.success(), "{}", stderr(&out));
let out = run(None, &["cache", "stats"]);
assert!(out.status.success(), "{}", stderr(&out));
let text = stdout(&out);
assert!(text.contains("\n1 APKs, "), "{text}");
assert!(text.contains(" (auto), used "), "{text}");
assert!(text.contains(" L1 dex ") && text.contains(" L2 derived ") && text.contains(" L3 rendered "), "{text}");
if cfg!(unix) {
assert!(text.contains("file system: ") && text.contains(" free of "), "{text}");
}
assert!(stdout(&run(None, &["--cache-max-mb", "3", "cache", "stats"])).contains("budget: 3.0 MiB (explicit)"));
assert!(stdout(&run(Some("5"), &["cache", "stats"])).contains("budget: 5.0 MiB (explicit)"));
assert!(stdout(&run(Some("5"), &["--cache-max-mb", "7", "cache", "stats"])).contains("budget: 7.0 MiB (explicit)"));
}
#[test]
fn zero_disk_budget_evicts_after_the_query() {
let f = temp_apk();
let dir = tempfile::tempdir().unwrap();
let run = |args: &[&str]| bin().env("ASC_DAEMON", "off").args(["--cache-dir", path(dir.path())]).args(args).output().expect("run asc");
let out = run(&["--cache-max-mb", "0", "findrefs", path(f.path()), "string", "token"]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(stdout(&run(&["cache", "stats"])).contains("0 APKs, 0 files"));
}
fn audit_apk() -> tempfile::NamedTempFile {
let (manifest, arsc) = asc_fixture::res::audit_manifest(30);
let files = asc_fixture::res::audit_res_files();
let mut entries: Vec<(&str, &[u8])> = vec![("AndroidManifest.xml", &manifest), ("resources.arsc", &arsc)];
entries.extend(files.iter().map(|(p, b)| (*p, b.as_slice())));
temp_apk_from(&make_apk(&entries, true))
}
#[test]
fn manifest_summary_resources_and_xml() {
let apk = audit_apk();
let p = path(apk.path());
let out = asc(&["manifest-summary", p]);
assert!(out.status.success(), "{}", stderr(&out));
let text = stdout(&out);
assert!(text.lines().any(|l| l == "receiver com.demo.Boot exported=true (explicit)"), "{text}");
let out = asc(&["manifest-summary", p, "--json"]);
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(json["package"], "com.demo");
let out = asc(&["resources", p, "bool/exported_flag"]);
assert_eq!(stdout(&out), "0x7f020000 | bool/exported_flag | false\n0x7f020000 | bool/exported_flag [v31] | true\n");
let out = asc(&["resources", p, "0x7f010000", "--resolve"]);
assert_eq!(stdout(&out), "chain @string/app_name\n0x7f010000 | string/app_name | \"Demo\"\n");
let out = asc(&["resources", p, "--search", "XML/", "--in", "name", "--limit", "1"]);
assert_eq!(stdout(&out), "", "the regex is case-sensitive");
let out = asc(&["resources", p, "--search", "xml/", "--in", "name", "--limit", "1", "--spill-mb", "0"]);
let text = stdout(&out);
assert_eq!(text.lines().count(), 2, "{text}");
assert_eq!(text.lines().last(), Some("# records 1-1 of 2 shown; rerun with --offset 1"), "{text}");
let out = asc(&["resources", p]);
assert_eq!(stdout(&out).lines().count(), 5);
let out = asc(&["resources", p, "--in", "bogus", "--search", "x"]);
assert_eq!(out.status.code(), Some(1));
let out = asc(&["xml", p, "res/xml/network_security_config.xml"]);
assert!(stdout(&out).contains("<base-config cleartextTrafficPermitted=\"false\"/>"), "{}", stdout(&out));
let out = asc(&["getmanifest", p, "--resolve"]);
assert!(stdout(&out).contains("android:networkSecurityConfig=\"@xml/network_security_config\""), "{}", stdout(&out));
let out = asc(&["getmanifest", p]);
assert!(stdout(&out).contains("android:networkSecurityConfig=\"@7F030000\""), "{}", stdout(&out));
}
#[test]
fn member_targets_and_context_spellings_agree() {
let apk = rich_apk();
let p = path(apk.path());
let want = asc(&["findrefs", p, "field", "value", "--class", "example.Rich", "--exact"]);
assert!(want.status.success() && !stdout(&want).is_empty(), "{}", stderr(&want));
for target in ["Lexample/Rich;->value:I", "example.Rich.value", "example.Rich#value", "Rich.value"] {
let out = asc(&["findrefs", p, "field", target]);
assert_eq!(stdout(&out), stdout(&want), "{target}: {}", stderr(&out));
}
let a = asc(&["findrefs", p, "--context", "method", "field", "Lexample/Rich;->value:I"]);
let b = asc(&["findrefs", p, "--context-method", "field", "Lexample/Rich;->value:I"]);
assert!(a.status.success() && stdout(&a).contains(".method"), "{}", stderr(&a));
assert_eq!(stdout(&a), stdout(&b));
let bad = asc(&["findrefs", p, "--context", "lots", "string", "x"]);
assert!(stderr(&bad).contains("neither a number of lines nor `method`"), "{}", stderr(&bad));
}
#[test]
fn misses_print_hints_on_stderr() {
let apk = rich_apk();
let p = path(apk.path());
let out = asc(&["findrefs", p, "string", "HÉLLO"]);
assert!(out.status.success());
assert!(stdout(&out).is_empty(), "{}", stdout(&out));
assert!(stderr(&out).contains("hint: searched 2 DEX entries") && stderr(&out).contains("(?i)"), "{}", stderr(&out));
let out = asc(&["getclass", p, "android.app.Activity"]);
assert_eq!(out.status.code(), Some(1));
assert!(stderr(&out).contains("framework class"), "{}", stderr(&out));
let out = asc(&["getclass", p, "example.Rch"]);
assert!(stderr(&out).contains("did you mean Lexample/Rich;"), "{}", stderr(&out));
let out = asc(&["manifest_summary", p]);
assert!(!stderr(&out).contains("unrecognized subcommand"), "{}", stderr(&out));
let out = asc(&["findrefs", p, "rules", "literal:nope", "string:."]);
assert!(stderr(&out).contains("hint: r1: literal matches are case-sensitive") && !stderr(&out).contains("r2:"), "{}", stderr(&out));
}
#[test]
fn signatures_pick_the_overload() {
let apk = rich_apk();
let p = path(apk.path());
for target in ["Lexample/Rich;->compute(II)I", "example.Rich.compute(int, int)", "Lexample/Rich;->compute(II)"] {
let out = asc(&["findrefs", p, "method", target]);
assert!(out.status.success() && stdout(&out).contains("Lexample/Rich;->compute(II)I"), "{target}: {}", stderr(&out));
}
let out = asc(&["findrefs", p, "method", "Lexample/Rich;->compute(I)I"]);
assert!(stdout(&out).is_empty(), "{}", stdout(&out));
assert!(stderr(&out).contains("has no overload (I)I; it has compute(II)I"), "{}", stderr(&out));
let out = asc(&["findrefs", p, "field", "Lexample/Rich;->value:J"]);
assert!(stdout(&out).is_empty() && stderr(&out).contains("Lexample/Rich;->value is value:I, not :J"), "{}", stderr(&out));
let out = asc(&["findrefs", p, "method", "Lexample/Rich;->compute(%)"]);
assert!(stderr(&out).contains("bad signature"), "{}", stderr(&out));
}
fn many_dex_apk(n: usize) -> tempfile::NamedTempFile {
let dex = make_dex();
let names: Vec<String> = (1..=n).map(|i| if i == 1 { "classes.dex".to_string() } else { format!("classes{i}.dex") }).collect();
let entries: Vec<(&str, &[u8])> = names.iter().map(|n| (n.as_str(), dex.as_slice())).collect();
temp_apk_from(&make_apk(&entries, true))
}
fn asc_in(runtime: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
bin()
.env("XDG_RUNTIME_DIR", runtime)
.env_remove("ASC_LIMIT")
.env_remove("ASC_MAX_BYTES")
.envs(envs.iter().copied())
.arg("--no-cache")
.args(args)
.output()
.expect("run asc")
}
fn spilled(hint: &str) -> String {
let path = hint.split("full output in ").nth(1).and_then(|r| r.split(" (").next()).unwrap_or_else(|| panic!("no file in {hint:?}"));
std::fs::read_to_string(path).unwrap()
}
#[test]
fn findrefs_rows_follow_dex_order() {
let apk = many_dex_apk(12);
let order: Vec<String> = (1..=12).map(|i| if i == 1 { "classes.dex".into() } else { format!("classes{i}.dex") }).collect();
for _ in 0..3 {
let out = asc(&["findrefs", path(apk.path()), "string", "token", "--threads", "8"]);
assert!(out.status.success(), "{}", stderr(&out));
let mut dex: Vec<String> = stdout(&out).lines().map(|l| l.split(" | ").next().unwrap().to_string()).collect();
assert_eq!(dex.len(), 24, "two hits per DEX");
dex.dedup();
assert_eq!(dex, order);
let out = asc(&["findrefs", path(apk.path()), "rules", "string:token", "literal:token", "--threads", "8"]);
let rows: Vec<String> =
stdout(&out).lines().filter(|l| !l.starts_with('#')).map(|l| l.split(" | ").take(2).collect::<Vec<_>>().join(" ")).collect();
let expected: Vec<String> = order.iter().flat_map(|d| [1, 1, 2, 2].map(|r| format!("r{r} {d}"))).collect();
assert_eq!(rows, expected, "grouped by DEX, then by rule");
}
}
#[test]
fn limit_cuts_stdout_and_spills_the_whole_output() {
let apk = many_dex_apk(12);
let runtime = tempfile::tempdir().unwrap();
let full = stdout(&asc(&["findrefs", path(apk.path()), "string", "token"]));
let rows: Vec<&str> = full.lines().collect();
assert_eq!(rows.len(), 24);
let out = asc_in(runtime.path(), &["findrefs", path(apk.path()), "string", "token", "--limit", "3"], &[]);
assert!(out.status.success(), "{}", stderr(&out));
let text = stdout(&out);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[..3], rows[..3]);
let hint = lines[3];
assert!(hint.starts_with("# records 1-3 of 24 shown; full output in "), "{hint}");
assert!(hint.ends_with(" (grep it, or rerun with --offset 3)"), "{hint}");
assert_eq!(spilled(hint), full);
assert!(spilled(hint).starts_with(&rows[0].to_string()));
let out = asc_in(runtime.path(), &["findrefs", path(apk.path()), "string", "token", "--offset", "3", "--limit", "3"], &[]);
let text = stdout(&out);
assert_eq!(text.lines().take(3).collect::<Vec<_>>(), rows[3..6]);
assert!(text.lines().nth(3).unwrap().starts_with("# records 4-6 of 24 shown"), "{text}");
let out = asc_in(runtime.path(), &["findrefs", path(apk.path()), "string", "token", "--offset", "22"], &[]);
assert_eq!(stdout(&out), format!("{}\n{}\n", rows[22], rows[23]), "the rest fits: no hint");
let out = asc_in(runtime.path(), &["findrefs", path(apk.path()), "string", "token", "--offset", "30"], &[]);
assert_eq!(stdout(&out), "# no records after --offset 30 (24 in all)\n");
let budget = rows[0].len() + 1 + rows[1].len() + 1 + 2;
let out = asc_in(runtime.path(), &["findrefs", path(apk.path()), "string", "token", "--max-bytes", &budget.to_string()], &[]);
let text = stdout(&out);
assert_eq!(text.lines().take(2).collect::<Vec<_>>(), rows[..2]);
assert!(text.lines().nth(2).unwrap().starts_with("# records 1-2 of 24 shown"), "{text}");
let out = asc_in(runtime.path(), &["findrefs", path(apk.path()), "string", "token"], &[("ASC_LIMIT", "2")]);
assert!(stdout(&out).lines().nth(2).unwrap().starts_with("# records 1-2 of 24 shown"), "{}", stdout(&out));
let out = asc_in(runtime.path(), &["findrefs", path(apk.path()), "string", "token", "--limit", "5"], &[("ASC_LIMIT", "2")]);
assert!(stdout(&out).contains("# records 1-5 of 24 shown"), "the flag wins: {}", stdout(&out));
let out = asc_in(runtime.path(), &["findrefs", path(apk.path()), "string", "token", "--limit", "3", "--spill-mb", "0"], &[]);
assert_eq!(stdout(&out).lines().last(), Some("# records 1-3 of 24 shown; rerun with --offset 3"));
let dir = tempfile::tempdir().unwrap();
let o = dir.path().join("refs.txt");
let out = asc_in(runtime.path(), &["findrefs", path(apk.path()), "string", "token", "--limit", "1", "-o", path(&o)], &[]);
assert_eq!(std::fs::read_to_string(&o).unwrap(), full);
assert!(stdout(&out).lines().last().unwrap().contains(&format!("full output in {}", o.display())), "{}", stdout(&out));
}
#[test]
fn every_text_command_takes_the_window() {
let apk = many_dex_apk(2);
let runtime = tempfile::tempdir().unwrap();
let p = path(apk.path());
let whole = stdout(&asc(&["getclass", p, "example.Test"]));
let out = asc_in(runtime.path(), &["getclass", p, "example.Test", "--limit", "4"], &[]);
let text = stdout(&out);
assert_eq!(text.lines().take(4).collect::<Vec<_>>(), whole.lines().take(4).collect::<Vec<_>>());
let hint = text.lines().nth(4).unwrap();
assert!(hint.starts_with(&format!("# records 1-4 of {} shown", whole.lines().count())), "{hint}");
assert_eq!(spilled(hint), whole);
let tree = stdout(&asc(&["callgraph", p, "example.Test", "first", "--callees"]));
let out = asc_in(runtime.path(), &["callgraph", p, "example.Test", "first", "--callees", "--limit", "1", "--spill-mb", "0"], &[]);
let text = stdout(&out);
assert_eq!(text.lines().next(), tree.lines().next());
assert_eq!(text.lines().nth(1), tree.lines().last(), "{text}");
for args in [&["listclass", p][..], &["getclass", p, "example.Test"], &["callgraph", p, "example.Test", "first"]] {
let plain = asc(args);
let huge = asc_in(runtime.path(), &[args, &["--limit", "100000"]].concat(), &[]);
assert_eq!(stdout(&huge), stdout(&plain), "{args:?}");
}
}
#[test]
fn subcommand_help_leaves_out_the_global_options() {
let top = stdout(&asc(&["--help"]));
for opt in ["--threads", "--cache-dir", "--cache-max-mb", "--memory-mb", "--no-cache", "--limit", "--max-bytes", "--offset"] {
assert!(top.contains(opt), "{opt} in asc --help:\n{top}");
}
for sub in [&["getclass", "--help"][..], &["findrefs", "x.apk", "string", "--help"], &["callgraph", "--help"]] {
let help = stdout(&asc(sub));
for opt in ["--threads", "--cache-dir", "--cache-max-mb", "--memory-mb", "--no-cache", "--debug"] {
assert!(!help.contains(opt), "{opt} in {sub:?}:\n{help}");
}
assert!(help.contains("--limit"), "{sub:?}:\n{help}");
}
let apk = temp_apk();
let out = asc(&["findrefs", path(apk.path()), "string", "token", "--threads", "1", "--cache-dir", "/nonexistent-x"]);
assert!(out.status.success(), "{}", stderr(&out));
}
#[test]
fn config_lists_settings_with_their_sources() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("config.toml");
std::fs::write(&file, "threads = 3\n[memory]\nmax = \"30%\"\n[cache]\nmax = \"8G\"\n").unwrap();
let run = |args: &[&str], vars: &[(&str, &str)]| {
let mut cmd = bin();
cmd.env("ASC_CONFIG", &file).env_remove("ASC_THREADS").env_remove("ASC_CACHE_MAX_MB").env_remove("ASC_MEMORY");
cmd.envs(vars.iter().copied()).args(args).output().expect("run asc")
};
let out = run(&["config", "--json", "--threads", "5"], &[("ASC_CACHE_MAX_MB", "1024")]);
assert!(out.status.success(), "{}", stderr(&out));
let doc: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(doc["file_read"], true);
let setting = |key: &str| doc["settings"].as_array().unwrap().iter().find(|s| s["key"] == key).unwrap().clone();
assert_eq!((setting("threads")["value"].clone(), setting("threads")["source"].clone()), ("5".into(), "flag".into()));
assert_eq!((setting("cache.max")["value"].clone(), setting("cache.max")["source"].clone()), ("1G".into(), "env".into()));
assert_eq!((setting("memory.max")["value"].clone(), setting("memory.max")["source"].clone()), ("30%".into(), "file".into()));
assert_eq!(setting("cache.writers")["source"], "auto");
assert!(setting("memory.max")["now"].as_str().unwrap().contains("30% of"), "{doc}");
assert_eq!(setting("memory.split")["env"], "ASC_MEMORY_SPLIT");
assert!(doc["device"]["cpus"].as_u64().unwrap() >= 1);
let out = run(&["config", "--set", "cache.tiers=dex"], &[]);
let text = stdout(&out);
assert!(text.contains("\ncache.tiers | dex | flag | -\n"), "{text}");
assert!(text.contains("\nthreads | 3 | file | -\n"), "{text}");
assert_eq!(stdout(&run(&["config", "path"], &[])).trim(), file.to_str().unwrap());
}
#[test]
fn bad_settings_fail_every_command_and_say_what_is_expected() {
let out = bin().env("ASC_MEMORY", "lots").args(["config"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(stderr(&out).contains("$ASC_MEMORY: memory.max = \"lots\": expected auto, a size"), "{}", stderr(&out));
let out = bin().args(["--set", "cache.maxx=1G", "listclass", "x.apk"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(stderr(&out).contains("unknown setting \"cache.maxx\"; known: threads, memory.max"), "{}", stderr(&out));
let out = bin().args(["--set", "threads", "config"]).output().unwrap();
assert!(stderr(&out).contains("expected KEY=VALUE"), "{}", stderr(&out));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("c.toml");
std::fs::write(&file, "[cache\n").unwrap();
let out = bin().env("ASC_CONFIG", &file).args(["config"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(stderr(&out).contains(file.to_str().unwrap()), "{}", stderr(&out));
}
#[test]
fn settings_from_the_environment_reach_the_cache() {
let dir = tempfile::tempdir().unwrap();
let apk = temp_apk_from(&make_apk(&[("classes.dex", &make_dex())], true));
let out = bin()
.env("ASC_DAEMON", "off")
.env("ASC_CACHE_DIR", dir.path())
.env("ASC_CACHE_MAX", "64M")
.env("ASC_CACHE_MIN_DEX", "0")
.args(["findrefs", path(apk.path()), "string", "token"])
.output()
.unwrap();
assert!(out.status.success(), "{}", stderr(&out));
let stats = bin().env("ASC_CACHE_DIR", dir.path()).env("ASC_CACHE_MAX", "64M").args(["cache", "stats"]).output().unwrap();
let text = stdout(&stats);
assert!(text.starts_with(dir.path().to_str().unwrap()), "{text}");
assert!(text.contains("budget: 64.0 MiB (explicit)"), "{text}");
assert!(text.contains("L1 dex 1 APKs, 1 files"), "{text}");
}