"""
Unit tests for APK code analysis components.
Tests APKCodeAnalyzer, LibraryFilter, SmaliParser, method enumeration,
library filtering, and method key matching. All external tools are mocked.
"""
import os
import tempfile
import textwrap
import pytest
from unittest.mock import MagicMock, patch, PropertyMock
pytestmark = [pytest.mark.unit, pytest.mark.apk, pytest.mark.decompile]
def _has_mmh3():
try:
import mmh3 # noqa: F401
return True
except ImportError:
return False
# ============================================================================
# LibraryFilter Tests
# ============================================================================
class TestLibraryFilter:
"""Tests for package-based library filtering."""
def test_default_prefixes_loaded(self):
from redb.extractors.decompiler.apk.library_filter import LibraryFilter
lf = LibraryFilter()
assert lf.is_library("android.app.Activity")
assert lf.is_library("androidx.core.app.NotificationCompat")
def test_user_class_not_filtered(self):
from redb.extractors.decompiler.apk.library_filter import LibraryFilter
lf = LibraryFilter()
assert not lf.is_library("com.example.myapp.MainActivity")
assert not lf.is_library("org.myorg.MyClass")
def test_dalvik_descriptor_format(self):
from redb.extractors.decompiler.apk.library_filter import LibraryFilter
lf = LibraryFilter()
assert lf.is_library("Landroid/app/Activity;")
assert lf.is_library("Landroidx/core/app/NotificationCompat;")
assert not lf.is_library("Lcom/example/myapp/MainActivity;")
def test_custom_prefixes(self):
from redb.extractors.decompiler.apk.library_filter import LibraryFilter
lf = LibraryFilter(prefixes=["com.custom."])
assert lf.is_library("com.custom.SomeClass")
assert not lf.is_library("android.app.Activity")
def test_env_var_override(self):
from redb.extractors.decompiler.apk.library_filter import LibraryFilter
with patch.dict(os.environ, {"APK_LIBRARY_PREFIXES": "my.lib.,other.lib."}):
lf = LibraryFilter()
assert lf.is_library("my.lib.SomeClass")
assert lf.is_library("other.lib.AnotherClass")
assert not lf.is_library("android.app.Activity")
def test_filter_stats(self):
from redb.extractors.decompiler.apk.library_filter import LibraryFilter
lf = LibraryFilter()
lf.is_library("android.app.Activity")
lf.is_library("com.example.MyClass")
lf.is_library("kotlin.Unit")
stats = lf.get_filter_stats()
assert stats["library"] == 2
assert stats["user"] == 1
def test_normalize_class_name(self):
from redb.extractors.decompiler.apk.library_filter import LibraryFilter
assert LibraryFilter._normalize_class_name("Lcom/example/Foo;") == "com.example.Foo"
assert LibraryFilter._normalize_class_name("com.example.Foo") == "com.example.Foo"
assert LibraryFilter._normalize_class_name("com/example/Foo") == "com.example.Foo"
def test_google_libraries_filtered(self):
from redb.extractors.decompiler.apk.library_filter import LibraryFilter
lf = LibraryFilter()
assert lf.is_library("com.google.android.gms.ads.AdView")
assert lf.is_library("com.google.firebase.messaging.FirebaseMessagingService")
assert lf.is_library("com.google.gson.Gson")
def test_kotlin_filtered(self):
from redb.extractors.decompiler.apk.library_filter import LibraryFilter
lf = LibraryFilter()
assert lf.is_library("kotlin.Unit")
assert lf.is_library("kotlinx.coroutines.CoroutineScope")
def test_third_party_libs_filtered(self):
from redb.extractors.decompiler.apk.library_filter import LibraryFilter
lf = LibraryFilter()
assert lf.is_library("com.squareup.okhttp3.OkHttpClient")
assert lf.is_library("io.reactivex.Observable")
assert lf.is_library("org.apache.commons.io.IOUtils")
def test_empty_class_name(self):
from redb.extractors.decompiler.apk.library_filter import LibraryFilter
lf = LibraryFilter()
assert not lf.is_library("")
# ============================================================================
# SmaliParser Tests
# ============================================================================
class TestSmaliParser:
"""Tests for smali file parsing."""
SAMPLE_SMALI = textwrap.dedent("""\
.class public Lcom/example/MyClass;
.super Ljava/lang/Object;
.method public constructor <init>()V
.registers 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
return-void
.end method
.method public onCreate(Landroid/os/Bundle;)V
.registers 4
.param p1, "savedInstanceState"
.line 10
invoke-super {p0, p1}, Landroid/app/Activity;->onCreate(Landroid/os/Bundle;)V
const/high16 v0, 0x7f090000
invoke-virtual {p0, v0}, Lcom/example/MyClass;->setContentView(I)V
const-string v1, "hello"
invoke-virtual {p0, v1}, Lcom/example/MyClass;->log(Ljava/lang/String;)V
return-void
.end method
.method public abstract doSomething()V
.end method
.method public native nativeMethod()V
.end method
""")
def test_parse_smali_content(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
methods = SmaliParser._parse_smali_content(self.SAMPLE_SMALI)
# abstract and native should be skipped
assert len(methods) == 2
def test_method_names_extracted(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
methods = SmaliParser._parse_smali_content(self.SAMPLE_SMALI)
names = {m.method_name for m in methods}
assert "<init>" in names
assert "onCreate" in names
def test_class_name_extracted(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
methods = SmaliParser._parse_smali_content(self.SAMPLE_SMALI)
for m in methods:
assert m.class_name == "Lcom/example/MyClass;"
def test_method_signature(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
methods = SmaliParser._parse_smali_content(self.SAMPLE_SMALI)
oncreate = [m for m in methods if m.method_name == "onCreate"][0]
assert oncreate.method_signature == "(Landroid/os/Bundle;)V"
def test_instruction_count(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
methods = SmaliParser._parse_smali_content(self.SAMPLE_SMALI)
oncreate = [m for m in methods if m.method_name == "onCreate"][0]
# invoke-super, const/high16, invoke-virtual, const-string, invoke-virtual, return-void
assert oncreate.instruction_count == 6
def test_register_count(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
methods = SmaliParser._parse_smali_content(self.SAMPLE_SMALI)
oncreate = [m for m in methods if m.method_name == "onCreate"][0]
assert oncreate.register_count == 4
def test_register_count_locals_directive(self):
"""apktool outputs .locals by default, not .registers."""
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
smali_with_locals = textwrap.dedent("""\
.class public Lcom/example/Locals;
.super Ljava/lang/Object;
.method public doWork()V
.locals 3
const/4 v0, 0x0
const/4 v1, 0x1
add-int v2, v0, v1
return-void
.end method
""")
methods = SmaliParser._parse_smali_content(smali_with_locals)
assert len(methods) == 1
assert methods[0].register_count == 3
def test_abstract_native_skipped(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
methods = SmaliParser._parse_smali_content(self.SAMPLE_SMALI)
names = {m.method_name for m in methods}
assert "doSomething" not in names
assert "nativeMethod" not in names
def test_normalize_smali_body(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
body = textwrap.dedent("""\
.registers 4
.line 10
# comment
invoke-super {p0, p1}, Landroid/app/Activity;->onCreate(Landroid/os/Bundle;)V
const-string v1, "hello"
""")
normalized = SmaliParser.normalize_smali_body(body)
assert ".line" not in normalized
assert "# comment" not in normalized
assert "invoke-super" in normalized
assert "const-string" in normalized
def test_count_instructions(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
body = textwrap.dedent("""\
.registers 4
.param p1, "x"
invoke-virtual {p0}, Lcom/example/Foo;->bar()V
const/4 v0, 0x0
:label_0
if-eqz v0, :label_1
return-void
:label_1
goto :label_0
""")
assert SmaliParser.count_instructions(body) == 5
def test_make_method_key(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
key = SmaliParser.make_method_key(
"Lcom/example/Foo;", "bar", "(I)V"
)
assert key == "Lcom/example/Foo;->bar(I)V"
def test_parse_smali_file(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
with tempfile.NamedTemporaryFile(
mode="w", suffix=".smali", delete=False
) as f:
f.write(self.SAMPLE_SMALI)
f.flush()
try:
methods = SmaliParser.parse_smali_file(f.name)
assert len(methods) == 2
finally:
os.unlink(f.name)
def test_parse_smali_directory(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
with tempfile.TemporaryDirectory() as tmpdir:
# Create nested dir structure
pkg_dir = os.path.join(tmpdir, "com", "example")
os.makedirs(pkg_dir)
with open(os.path.join(pkg_dir, "MyClass.smali"), "w") as f:
f.write(self.SAMPLE_SMALI)
result = SmaliParser.parse_smali_directory(tmpdir)
assert len(result) == 2
# Check keys are in expected format
for key in result:
assert "->" in key
def test_empty_smali_file(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
methods = SmaliParser._parse_smali_content("")
assert methods == []
def test_class_only_no_methods(self):
from redb.extractors.decompiler.apk.smali_parser import SmaliParser
content = ".class public Lcom/example/Empty;\n.super Ljava/lang/Object;\n"
methods = SmaliParser._parse_smali_content(content)
assert methods == []
# ============================================================================
# MethodExtractor Tests (Hashing, Obfuscation, Type Conversion)
# ============================================================================
class TestMethodExtractor:
"""Tests for method-level content extraction and hashing."""
def test_compute_sha256(self):
from redb.extractors.decompiler.apk.method_extractor import compute_sha256
h = compute_sha256("test content")
assert len(h) == 64
assert h == compute_sha256("test content") # deterministic
def test_compute_sha256_different_input(self):
from redb.extractors.decompiler.apk.method_extractor import compute_sha256
assert compute_sha256("a") != compute_sha256("b")
def test_compute_ssdeep_short_input(self):
from redb.extractors.decompiler.apk.method_extractor import compute_ssdeep
result = compute_ssdeep("short")
assert result is None
def test_compute_ssdeep_long_input(self):
from redb.extractors.decompiler.apk.method_extractor import compute_ssdeep
result = compute_ssdeep("x" * 200)
# May be None depending on entropy, but shouldn't crash
assert result is None or isinstance(result, str)
def test_compute_tlsh_short_input(self):
from redb.extractors.decompiler.apk.method_extractor import compute_tlsh
result = compute_tlsh("short")
assert result is None
def test_compute_tlsh_long_input(self):
from redb.extractors.decompiler.apk.method_extractor import compute_tlsh
data = "".join(chr(i % 256) for i in range(200))
result = compute_tlsh(data)
assert result is None or isinstance(result, str)
def test_compute_minhash_returns_none_without_mmh3(self):
from unittest.mock import patch
from redb.extractors.decompiler.apk.method_extractor import compute_minhash
with patch.dict("sys.modules", {"mmh3": None}):
# Force reimport to hit ImportError
import importlib
import redb.extractors.decompiler.apk.method_extractor as mod
importlib.reload(mod)
result = mod.compute_minhash("line1\nline2\nline3\nline4")
# mmh3 may or may not be available; just check it doesn't crash
assert result is None or isinstance(result, list)
importlib.reload(mod) # restore
def test_compute_minhash_too_few_lines(self):
from redb.extractors.decompiler.apk.method_extractor import compute_minhash
result = compute_minhash("invoke-direct {v0}, Lfoo;->bar()V")
# Only 1 instruction line, need at least 3 for 3-grams
assert result is None
@pytest.mark.skipif(
not _has_mmh3(), reason="mmh3 not installed"
)
def test_compute_minhash_deterministic(self):
from redb.extractors.decompiler.apk.method_extractor import compute_minhash
smali = (
"invoke-direct {v1}, Ljava/lang/Object;-><init>()V\n"
"const-string v0, \"hello\"\n"
"iput-object v0, v1, LA;->a:Ljava/lang/String;\n"
"return-void"
)
sig1 = compute_minhash(smali)
sig2 = compute_minhash(smali)
assert sig1 is not None
assert sig1 == sig2
@pytest.mark.skipif(
not _has_mmh3(), reason="mmh3 not installed"
)
def test_compute_minhash_signature_length(self):
from redb.extractors.decompiler.apk.method_extractor import compute_minhash
smali = (
"invoke-direct {v1}, Ljava/lang/Object;-><init>()V\n"
"const-string v0, \"hello\"\n"
"iput-object v0, v1, LA;->a:Ljava/lang/String;\n"
"return-void"
)
sig = compute_minhash(smali)
assert sig is not None
assert len(sig) == 64 # SIGNATURE_LENGTH
@pytest.mark.skipif(
not _has_mmh3(), reason="mmh3 not installed"
)
def test_compute_minhash_value_range(self):
from redb.extractors.decompiler.apk.method_extractor import compute_minhash
smali = (
"invoke-direct {v1}, Ljava/lang/Object;-><init>()V\n"
"const-string v0, \"hello\"\n"
"iput-object v0, v1, LA;->a:Ljava/lang/String;\n"
"return-void"
)
sig = compute_minhash(smali)
assert sig is not None
for val in sig:
assert 0 <= val < 256 # 8-bit values
@pytest.mark.skipif(
not _has_mmh3(), reason="mmh3 not installed"
)
def test_compute_minhash_different_inputs_differ(self):
from redb.extractors.decompiler.apk.method_extractor import compute_minhash
smali_a = (
"invoke-direct {v1}, Ljava/lang/Object;-><init>()V\n"
"const-string v0, \"hello\"\n"
"iput-object v0, v1, LA;->a:Ljava/lang/String;\n"
"return-void"
)
smali_b = (
"sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;\n"
"const-string v1, \"world\"\n"
"invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V\n"
"return-void"
)
sig_a = compute_minhash(smali_a)
sig_b = compute_minhash(smali_b)
assert sig_a is not None
assert sig_b is not None
assert sig_a != sig_b
@pytest.mark.skipif(
not _has_mmh3(), reason="mmh3 not installed"
)
def test_compute_minhash_skips_directives_and_labels(self):
from redb.extractors.decompiler.apk.method_extractor import compute_minhash
# Directives and labels should be filtered out
smali = (
".registers 3\n"
":start\n"
"invoke-direct {v1}, Ljava/lang/Object;-><init>()V\n"
".line 10\n"
"const-string v0, \"hello\"\n"
"iput-object v0, v1, LA;->a:Ljava/lang/String;\n"
"return-void"
)
smali_clean = (
"invoke-direct {v1}, Ljava/lang/Object;-><init>()V\n"
"const-string v0, \"hello\"\n"
"iput-object v0, v1, LA;->a:Ljava/lang/String;\n"
"return-void"
)
sig_with_directives = compute_minhash(smali)
sig_clean = compute_minhash(smali_clean)
assert sig_with_directives == sig_clean
def test_dalvik_to_java_class(self):
from redb.extractors.decompiler.apk.method_extractor import dalvik_to_java_class
assert dalvik_to_java_class("Lcom/example/Foo;") == "com.example.Foo"
assert dalvik_to_java_class("com.example.Foo") == "com.example.Foo"
def test_dalvik_type_to_java(self):
from redb.extractors.decompiler.apk.method_extractor import dalvik_type_to_java
assert dalvik_type_to_java("V") == "void"
assert dalvik_type_to_java("I") == "int"
assert dalvik_type_to_java("Z") == "boolean"
assert dalvik_type_to_java("Ljava/lang/String;") == "String"
assert dalvik_type_to_java("[I") == "int[]"
assert dalvik_type_to_java("[Ljava/lang/String;") == "String[]"
def test_dalvik_to_java_prototype(self):
from redb.extractors.decompiler.apk.method_extractor import dalvik_to_java_prototype
result = dalvik_to_java_prototype("onCreate", "(Landroid/os/Bundle;)V")
assert result == "void onCreate(Bundle)"
def test_dalvik_to_java_prototype_no_params(self):
from redb.extractors.decompiler.apk.method_extractor import dalvik_to_java_prototype
result = dalvik_to_java_prototype("toString", "()Ljava/lang/String;")
assert result == "String toString()"
def test_dalvik_to_java_prototype_multiple_params(self):
from redb.extractors.decompiler.apk.method_extractor import dalvik_to_java_prototype
result = dalvik_to_java_prototype("foo", "(ILjava/lang/String;Z)V")
assert result == "void foo(int, String, boolean)"
def test_detect_obfuscation_short_name(self):
from redb.extractors.decompiler.apk.method_extractor import detect_obfuscation_indicators
result = detect_obfuscation_indicators("a", "Lcom/example/Foo;", "", 10)
assert result["short_method_name"] is True
result = detect_obfuscation_indicators("onCreate", "Lcom/example/Foo;", "", 10)
assert result["short_method_name"] is False
def test_detect_obfuscation_short_class(self):
from redb.extractors.decompiler.apk.method_extractor import detect_obfuscation_indicators
result = detect_obfuscation_indicators("foo", "Lcom/example/a;", "", 10)
assert result["short_class_name"] is True
result = detect_obfuscation_indicators("foo", "Lcom/example/MainActivity;", "", 10)
assert result["short_class_name"] is False
def test_detect_reflection_calls(self):
from redb.extractors.decompiler.apk.method_extractor import detect_obfuscation_indicators
smali = "invoke-virtual {v0}, Ljava/lang/reflect/Method;->invoke(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;"
result = detect_obfuscation_indicators("foo", "Lcom/Foo;", smali, 10)
assert result["has_reflection_calls"] is True
def test_detect_no_reflection(self):
from redb.extractors.decompiler.apk.method_extractor import detect_obfuscation_indicators
smali = "invoke-virtual {p0}, Lcom/example/Foo;->bar()V"
result = detect_obfuscation_indicators("foo", "Lcom/Foo;", smali, 10)
assert result["has_reflection_calls"] is False
def test_detect_string_encryption(self):
from redb.extractors.decompiler.apk.method_extractor import detect_obfuscation_indicators
smali = 'const-string v0, "encrypted"\n invoke-static {v0}, Lcom/example/Crypto;->decrypt(Ljava/lang/String;)Ljava/lang/String;'
result = detect_obfuscation_indicators("foo", "Lcom/Foo;", smali, 10)
assert result["has_string_encryption"] is True
def test_detect_excessive_goto(self):
from redb.extractors.decompiler.apk.method_extractor import detect_obfuscation_indicators
smali = "\n".join(["goto :label_0"] * 20)
result = detect_obfuscation_indicators("foo", "Lcom/Foo;", smali, 30)
# threshold = max(5, 30*0.15) = 5, 20 > 5
assert result["excessive_goto_count"] is True
def test_detect_no_excessive_goto(self):
from redb.extractors.decompiler.apk.method_extractor import detect_obfuscation_indicators
smali = "goto :label_0\nreturn-void"
result = detect_obfuscation_indicators("foo", "Lcom/Foo;", smali, 100)
assert result["excessive_goto_count"] is False
def test_parse_dalvik_params_empty(self):
from redb.extractors.decompiler.apk.method_extractor import _parse_dalvik_params
assert _parse_dalvik_params("") == []
def test_parse_dalvik_params_primitives(self):
from redb.extractors.decompiler.apk.method_extractor import _parse_dalvik_params
result = _parse_dalvik_params("IZJ")
assert result == ["I", "Z", "J"]
def test_parse_dalvik_params_object(self):
from redb.extractors.decompiler.apk.method_extractor import _parse_dalvik_params
result = _parse_dalvik_params("Ljava/lang/String;")
assert result == ["Ljava/lang/String;"]
def test_parse_dalvik_params_mixed(self):
from redb.extractors.decompiler.apk.method_extractor import _parse_dalvik_params
result = _parse_dalvik_params("ILjava/lang/String;Z")
assert result == ["I", "Ljava/lang/String;", "Z"]
def test_parse_dalvik_params_array(self):
from redb.extractors.decompiler.apk.method_extractor import _parse_dalvik_params
result = _parse_dalvik_params("[I[Ljava/lang/String;")
assert result == ["[I", "[Ljava/lang/String;"]
# ============================================================================
# APKCodeAnalyzer Tests (mocked tools)
# ============================================================================
class TestAPKCodeAnalyzer:
"""Tests for the APKCodeAnalyzer orchestration."""
def test_init_defaults(self):
from redb.extractors.decompiler.apk.analyzer import APKCodeAnalyzer
analyzer = APKCodeAnalyzer("/fake/path.apk")
assert analyzer.filepath == "/fake/path.apk"
assert analyzer.timeout == 600
assert analyzer.min_instructions == 5
def test_init_custom_timeout(self):
from redb.extractors.decompiler.apk.analyzer import APKCodeAnalyzer
analyzer = APKCodeAnalyzer("/fake/path.apk", timeout=300)
assert analyzer.timeout == 300
def test_init_min_instructions_env(self):
from redb.extractors.decompiler.apk.analyzer import APKCodeAnalyzer
with patch.dict(os.environ, {"APK_MIN_METHOD_INSTRUCTIONS": "10"}):
analyzer = APKCodeAnalyzer("/fake/path.apk")
assert analyzer.min_instructions == 10
@patch("redb.extractors.decompiler.apk.analyzer.APKCodeAnalyzer._run_androguard")
@patch.object(
__import__("redb.extractors.decompiler.apk.apktool_wrapper", fromlist=["ApktoolDisassembler"]).ApktoolDisassembler,
"disassemble",
return_value=False,
)
@patch.object(
__import__("redb.extractors.decompiler.apk.jadx_wrapper", fromlist=["JADXDecompiler"]).JADXDecompiler,
"decompile",
return_value=False,
)
def test_extract_returns_dict_keys(self, mock_jadx, mock_apktool, mock_androguard):
from redb.extractors.decompiler.apk.analyzer import APKCodeAnalyzer
mock_analysis = MagicMock()
mock_analysis.get_methods.return_value = []
mock_androguard.return_value = (MagicMock(), [], mock_analysis)
analyzer = APKCodeAnalyzer("/fake/path.apk", log=MagicMock())
results = analyzer.extract()
assert "decompiled_content" in results
assert "decompiled_refs" in results
assert "smali_content" in results
assert "smali_refs" in results
assert "similarity_metrics" in results
assert "strings" in results
assert "analysis_errors" in results
analyzer.cleanup()
@patch("redb.extractors.decompiler.apk.analyzer.APKCodeAnalyzer._run_androguard")
def test_extract_androguard_failure(self, mock_androguard):
from redb.extractors.decompiler.apk.analyzer import APKCodeAnalyzer
mock_androguard.side_effect = Exception("Analysis failed")
analyzer = APKCodeAnalyzer("/fake/path.apk", log=MagicMock())
results = analyzer.extract()
assert len(results["analysis_errors"]) > 0
analyzer.cleanup()
def test_cleanup_removes_temp_dirs(self):
from redb.extractors.decompiler.apk.analyzer import APKCodeAnalyzer
analyzer = APKCodeAnalyzer("/fake/path.apk")
tmpdir = tempfile.mkdtemp()
analyzer._temp_dirs.append(tmpdir)
assert os.path.isdir(tmpdir)
analyzer.cleanup()
assert not os.path.isdir(tmpdir)
def test_normalize_java(self):
from redb.extractors.decompiler.apk.analyzer import _normalize_java
source = " public void foo() {\n int x = 1;\n }\n"
normalized = _normalize_java(source)
assert normalized == "public void foo() {\nint x = 1;\n}"
class TestNormalizeAndroguardOperands:
"""Tests for androguard-to-apktool output normalization."""
def test_invoke_adds_braces(self):
from redb.extractors.decompiler.apk.analyzer import _normalize_androguard_operands
result = _normalize_androguard_operands(
"invoke-direct",
"v1, Ljava/lang/Object;-><init>()V"
)
assert result == "{v1}, Ljava/lang/Object;-><init>()V"
def test_invoke_virtual_multiple_regs(self):
from redb.extractors.decompiler.apk.analyzer import _normalize_androguard_operands
result = _normalize_androguard_operands(
"invoke-virtual",
"v0, v1, Ljava/lang/String;->equals(Ljava/lang/Object;)Z"
)
assert result == "{v0, v1}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z"
def test_invoke_static_no_regs(self):
from redb.extractors.decompiler.apk.analyzer import _normalize_androguard_operands
# invoke-static with no register args (rare but possible)
result = _normalize_androguard_operands(
"invoke-static",
"Ljava/lang/System;->gc()V"
)
# First part starts with L, so no registers to wrap
assert result == "Ljava/lang/System;->gc()V"
def test_invoke_range(self):
from redb.extractors.decompiler.apk.analyzer import _normalize_androguard_operands
result = _normalize_androguard_operands(
"invoke-virtual/range",
"v3, v4, v5, Landroid/util/Log;->d(Ljava/lang/String; Ljava/lang/String;)I"
)
assert result == "{v3, v4, v5}, Landroid/util/Log;->d(Ljava/lang/String; Ljava/lang/String;)I"
def test_iget_field_colon(self):
from redb.extractors.decompiler.apk.analyzer import _normalize_androguard_operands
result = _normalize_androguard_operands(
"iget-object",
"v0, v4, LF;->a Ljava/lang/String;"
)
assert result == "v0, v4, LF;->a:Ljava/lang/String;"
def test_iput_field_colon(self):
from redb.extractors.decompiler.apk.analyzer import _normalize_androguard_operands
result = _normalize_androguard_operands(
"iput-object",
"v0, v1, LA;->b Ljava/lang/String;"
)
assert result == "v0, v1, LA;->b:Ljava/lang/String;"
def test_sget_primitive_field(self):
from redb.extractors.decompiler.apk.analyzer import _normalize_androguard_operands
result = _normalize_androguard_operands(
"sget",
"v0, Lcom/Foo;->count I"
)
assert result == "v0, Lcom/Foo;->count:I"
def test_sput_array_field(self):
from redb.extractors.decompiler.apk.analyzer import _normalize_androguard_operands
result = _normalize_androguard_operands(
"sput-object",
"v0, Lcom/Foo;->data [B"
)
assert result == "v0, Lcom/Foo;->data:[B"
def test_non_invoke_non_field_passthrough(self):
from redb.extractors.decompiler.apk.analyzer import _normalize_androguard_operands
result = _normalize_androguard_operands(
"const-string",
'v0, "hello world"'
)
assert result == 'v0, "hello world"'
def test_move_passthrough(self):
from redb.extractors.decompiler.apk.analyzer import _normalize_androguard_operands
result = _normalize_androguard_operands("move", "v0, v4")
assert result == "v0, v4"
def test_goto_passthrough(self):
from redb.extractors.decompiler.apk.analyzer import _normalize_androguard_operands
result = _normalize_androguard_operands("goto", "+005h")
assert result == "+005h"
class TestSmaliCFG:
"""Tests for smali CFG construction and metrics computation."""
def test_empty_body(self):
from redb.extractors.decompiler.apk.smali_cfg import compute_cfg_metrics
m = compute_cfg_metrics("")
assert m.block_count == 0
assert m.cyclomatic_complexity == 1
def test_linear_method_apktool(self):
"""Single basic block — no branches."""
from redb.extractors.decompiler.apk.smali_cfg import compute_cfg_metrics
smali = textwrap.dedent("""\
.registers 2
invoke-direct {v1}, Ljava/lang/Object;-><init>()V
const-string v0, "hello"
iput-object v0, v1, LA;->a:Ljava/lang/String;
return-void
""")
m = compute_cfg_metrics(smali)
assert m.block_count == 1
assert m.edge_count == 0
assert m.cyclomatic_complexity == 1
assert m.loop_count == 0
assert m.max_depth == 0
assert m.max_fan_out == 0
def test_linear_method_androguard(self):
"""Single basic block — androguard format (no labels/directives)."""
from redb.extractors.decompiler.apk.smali_cfg import compute_cfg_metrics
smali = (
" invoke-direct {v1}, Ljava/lang/Object;-><init>()V\n"
" const-string v0, \"hello\"\n"
" iput-object v0, v1, LA;->a:Ljava/lang/String;\n"
" return-void"
)
m = compute_cfg_metrics(smali)
assert m.block_count == 1
assert m.edge_count == 0
assert m.cyclomatic_complexity == 1
def test_if_branch_apktool(self):
"""Diamond pattern: if-else with two paths merging."""
from redb.extractors.decompiler.apk.smali_cfg import compute_cfg_metrics
smali = textwrap.dedent("""\
.registers 2
const/4 v0, 0x0
if-eqz v0, :cond_0
const/4 v0, 0x1
goto :goto_0
:cond_0
const/4 v0, 0x2
:goto_0
return v0
""")
m = compute_cfg_metrics(smali)
# Blocks: [const,if] [const,goto] [const] [return]
assert m.block_count == 4
assert m.cyclomatic_complexity == 2 # E - N + 2
assert m.loop_count == 0
assert m.max_fan_out == 2 # if-branch has 2 successors
def test_loop_apktool(self):
"""Simple loop: goto back to earlier label."""
from redb.extractors.decompiler.apk.smali_cfg import compute_cfg_metrics
smali = textwrap.dedent("""\
.registers 2
const/4 v0, 0x0
:loop_start
add-int/lit8 v0, v0, 0x1
const/16 v1, 0xa
if-lt v0, v1, :loop_start
return v0
""")
m = compute_cfg_metrics(smali)
assert m.loop_count == 1 # back edge from if-lt to loop_start
assert m.cyclomatic_complexity >= 2
def test_if_branch_androguard(self):
"""If-else in androguard offset format."""
from redb.extractors.decompiler.apk.smali_cfg import compute_cfg_metrics
smali = (
" const/4 v0, 0x0\n"
" if-eqz v0, +2h\n" # skip 2 instructions ahead
" const/4 v0, 0x1\n"
" goto +1h\n" # skip 1 ahead
" const/4 v0, 0x2\n"
" return v0"
)
m = compute_cfg_metrics(smali)
assert m.block_count >= 3
assert m.cyclomatic_complexity >= 2
def test_multiple_if_branches(self):
"""Multiple if-branches increase cyclomatic complexity."""
from redb.extractors.decompiler.apk.smali_cfg import compute_cfg_metrics
smali = textwrap.dedent("""\
.registers 3
if-eqz v0, :cond_0
if-eqz v1, :cond_1
const/4 v2, 0x0
goto :end
:cond_0
const/4 v2, 0x1
goto :end
:cond_1
const/4 v2, 0x2
:end
return v2
""")
m = compute_cfg_metrics(smali)
# Two if-branches: CC should be >= 3
assert m.cyclomatic_complexity >= 3
def test_return_terminates_block(self):
"""Return instruction terminates block with no successor."""
from redb.extractors.decompiler.apk.smali_cfg import compute_cfg_metrics
smali = textwrap.dedent("""\
.registers 1
const/4 v0, 0x0
return v0
""")
m = compute_cfg_metrics(smali)
assert m.block_count == 1
assert m.edge_count == 0
def test_throw_terminates_block(self):
"""Throw instruction terminates block with no successor."""
from redb.extractors.decompiler.apk.smali_cfg import compute_cfg_metrics
smali = textwrap.dedent("""\
.registers 1
new-instance v0, Ljava/lang/RuntimeException;
invoke-direct {v0}, Ljava/lang/RuntimeException;-><init>()V
throw v0
""")
m = compute_cfg_metrics(smali)
assert m.block_count == 1
assert m.edge_count == 0
def test_max_depth_linear_chain(self):
"""Chain of blocks gives increasing depth."""
from redb.extractors.decompiler.apk.smali_cfg import compute_cfg_metrics
# 3 blocks chained: entry -> if -> fall-through -> return
smali = textwrap.dedent("""\
.registers 2
const/4 v0, 0x0
if-nez v0, :cond_0
const/4 v0, 0x1
:cond_0
return v0
""")
m = compute_cfg_metrics(smali)
assert m.max_depth >= 1
def test_nested_loop_apktool(self):
"""Nested loop: two back edges."""
from redb.extractors.decompiler.apk.smali_cfg import compute_cfg_metrics
smali = textwrap.dedent("""\
.registers 3
const/4 v0, 0x0
:outer
const/4 v1, 0x0
:inner
add-int/lit8 v1, v1, 0x1
if-lt v1, v2, :inner
add-int/lit8 v0, v0, 0x1
if-lt v0, v2, :outer
return-void
""")
m = compute_cfg_metrics(smali)
assert m.loop_count == 2
def test_consistency_formula(self):
"""Verify E - N + 2 formula matches cyclomatic_complexity."""
from redb.extractors.decompiler.apk.smali_cfg import compute_cfg_metrics
smali = textwrap.dedent("""\
.registers 2
const/4 v0, 0x0
if-eqz v0, :cond_0
const/4 v0, 0x1
goto :goto_0
:cond_0
const/4 v0, 0x2
:goto_0
return v0
""")
m = compute_cfg_metrics(smali)
expected_cc = m.edge_count - m.block_count + 2
assert m.cyclomatic_complexity == max(1, expected_cc)