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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions droidasc/asc_client/gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ def _bind_editor_shortcuts(self):
self.root.bind("<Control-Shift-Tab>", self._prev_tab)
self.source_text.bind("<Button-1>", self._set_editor_cursor_from_click)
self.source_text.bind("<Control-Button-1>", self._open_member_from_click)
self.source_text.bind("<FocusOut>", self._hide_member_links, add="+")
self.root.bind("<KeyPress-Control_L>", self._show_member_links)
self.root.bind("<KeyPress-Control_R>", self._show_member_links)
self.root.bind("<KeyRelease-Control_L>", self._hide_member_links)
Expand Down Expand Up @@ -1318,6 +1319,7 @@ def apply_chunk(offset : int = 0):
apply_chunk()

def _show_editor_find(self, _event = None):
self._hide_member_links()
self.editor_find_frame.grid()
self.editor_find_entry.focus_set()
self.editor_find_entry.selection_range(0, tk.END)
Expand Down
43 changes: 41 additions & 2 deletions droidasc/asc_core/utils/tinydex.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@
_STRUCT_HHI = struct.Struct('<HHI')
_STRUCT_HHHHII = struct.Struct('<HHHHII')

def _decode_mutf8_fallback(raw : bytes) -> str:
raw = raw.replace(b'\xc0\x80', b'\x00')
try:
return (
raw.decode('utf-8', errors='surrogatepass')
.encode('utf-16-le', errors='surrogatepass')
.decode('utf-16-le', errors='replace')
)
except (UnicodeDecodeError, UnicodeEncodeError):
return raw.decode('utf-8', errors='replace')


class DEXHeader:
def __init__(self, buf):
# (off, size)
Expand Down Expand Up @@ -348,12 +360,31 @@ def get_string(self, str_idx):
return self._strings[str_idx]

str_idx_off = self.header.strings[0]
str_size = self.header.strings[1]
string_off = _STRUCT_I.unpack_from(self.buf, str_idx_off + str_idx * 4)[0]
utf16_size, c = read_uleb128_fast(self.buf, string_off)
data_start = string_off + c
end = data_start + utf16_size
s = bytes(self.buf[data_start:end]).decode('utf-8', errors='replace')
# ASCII keeps the old O(1) boundary calculation; MUTF-8 needs its terminator.
# refer to https://github.com/MG1937/ASC/issues/33
# 20260923 DEX.get_string slices utf16_size bytes out of a string_data_item, but that field counts UTF-16 code units
if end >= len(self.buf) or self.buf[end] != 0:
raw_buf = self.buf.obj
if hasattr(raw_buf, 'find') and len(raw_buf) == len(self.buf):
end = raw_buf.find(b'\x00', data_start, len(self.buf))
else:
end = data_start
while end < len(self.buf) and self.buf[end] != 0:
end += 1
if end == len(self.buf):
end = -1
if end < 0:
raise ValueError("unterminated string_data_item")

raw = bytes(self.buf[data_start:end])
try:
s = raw.decode('utf-8')
except UnicodeDecodeError:
s = _decode_mutf8_fallback(raw)
self._strings[str_idx] = s
return s

Expand Down Expand Up @@ -518,6 +549,14 @@ def get_class(self, fullname):
else:
right = mid - 1

if type_idx == -1:
# DEX sorts by UTF-16 code units, while Python compares code points.
for mid in range(type_ids_size):
desc_idx = _STRUCT_I.unpack_from(raw_bytes, type_ids_off + mid * 0x4)[0]
if self.get_string(desc_idx) == fullname:
type_idx = mid
break

if type_idx == -1:
return None

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "droidasc"
version = "0.1.1.post1"
version = "0.1.1.post2"
description = "ASC is a super FAST python Android Decompiler for Agents/Mobile Researchers"
readme = "README.md"
license = "Apache-2.0"
Expand All @@ -21,4 +21,4 @@ droidasc = "droidasc:main"
include = ["droidasc", "droidasc.*"]

[tool.setuptools.package-data]
"droidasc" = ["py.typed"]
"droidasc" = ["py.typed"]
17 changes: 15 additions & 2 deletions tests/benchmark_compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ def revision(root):
return subprocess.check_output(['git', '-C', str(root), 'rev-parse', 'HEAD'], text=True).strip()


def _stable_cli_findrefs_answer(text):
# String decoding fixes may intentionally change matched payload text while
# preserving the referenced DEX/method rows and their count.
return sorted(
line.split(' | matched=', 1)[0]
for line in text.splitlines()
if ' | ' in line
)


def _wrap_workload_test(root, directory, test_name):
"""Wrap a workload test file so old top-level imports (findrefs, utils, core, models)
are rewritten to droidasc.asc_core.*, matching the current package structure."""
Expand Down Expand Up @@ -95,8 +105,11 @@ def measure(root, directory, case, output, sample):
if match is None:
raise ValueError('missing CLI timing')
times = {case: float(match[1])}
answer = (result.stdout.split('-' * 50)[-1].strip() if case == 'cli_getclass' else
sorted(line for line in result.stdout.splitlines() if ' | ' in line))
answer = (
result.stdout.split('-' * 50)[-1].strip()
if case == 'cli_getclass'
else _stable_cli_findrefs_answer(result.stdout)
)
if not answer or (case in ('core', 'cli_getclass') and 'class ClockFaceView' not in answer):
raise ValueError(f'{case}: missing result')
return times, answer
Expand Down
143 changes: 143 additions & 0 deletions tests/test_mutf8_strings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import base64
import struct
import unittest

from droidasc.asc_core.utils.tinydex import DEX, _decode_mutf8_fallback


ISSUE_DEX = base64.b64decode(
"ZGV4CjAzNQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADtAAAAcAAAAHhWNBIAAAAAAAAA"
"AAAAAAADAAAAcAAAAAMAAAB8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAIgA"
"AAAAAAAAAAAAAMgAAADcAAAA5AAAAAAAAAABAAAAAgAAAAEAAAABAAAAAAAAAAAAAAD/"
"////AAAAAAAAAAAAAAAAAgAAAAEAAAAAAAAAAAAAAP////8AAAAAAAAAAAAAAAASTGph"
"dmEvbGFuZy9PYmplY3Q7AAVMbC/boTsABUxsL+GpuzsA"
)


def _uleb128(value):
out = bytearray()
while value > 0x7f:
out.append((value & 0x7f) | 0x80)
value >>= 7
out.append(value)
return out


def _encode_mutf8(text):
out = bytearray()
utf16 = text.encode("utf-16-be")
for offset in range(0, len(utf16), 2):
unit = int.from_bytes(utf16[offset:offset + 2], "big")
if unit == 0:
out.extend(b"\xc0\x80")
elif unit <= 0x7f:
out.append(unit)
elif unit <= 0x7ff:
out.extend((0xc0 | (unit >> 6), 0x80 | (unit & 0x3f)))
else:
out.extend((
0xe0 | (unit >> 12),
0x80 | ((unit >> 6) & 0x3f),
0x80 | (unit & 0x3f),
))
return bytes(out)


def _make_class_dex(names):
encoded = [_encode_mutf8(name) for name in names]
units = [len(name.encode("utf-16-le")) // 2 for name in names]
buf = bytearray(0x70)

string_ids_off = len(buf)
buf.extend(bytes(4 * len(names)))
type_ids_off = len(buf)
buf.extend(bytes(4 * len(names)))
class_defs_off = len(buf)
buf.extend(bytes(32 * len(names)))
data_off = len(buf)

for index, (raw, utf16_size) in enumerate(zip(encoded, units)):
struct.pack_into("<I", buf, string_ids_off + index * 4, len(buf))
struct.pack_into("<I", buf, type_ids_off + index * 4, index)
struct.pack_into(
"<IIIIIIII",
buf,
class_defs_off + index * 32,
index,
1,
0xffffffff,
0,
0xffffffff,
0,
0,
0,
)
buf.extend(_uleb128(utf16_size))
buf.extend(raw)
buf.append(0)

buf[:8] = b"dex\n035\0"
struct.pack_into("<IIIIII", buf, 0x20, len(buf), 0x70, 0x12345678, 0, 0, 0)
struct.pack_into(
"<IIIIIIIIIIIIII",
buf,
0x38,
len(names),
string_ids_off,
len(names),
type_ids_off,
0,
0,
0,
0,
0,
0,
len(names),
class_defs_off,
len(buf) - data_off,
data_off,
)
return bytes(buf)


class Mutf8StringTests(unittest.TestCase):
def test_issue_fixture_decodes_and_finds_non_ascii_classes(self):
dex = DEX.parse(ISSUE_DEX, "bug.dex")

self.assertEqual([dex.get_string(i) for i in range(3)], [
"Ljava/lang/Object;",
"Ll/\u06e1;",
"Ll/\u1a7b;",
])
self.assertIsNotNone(dex.get_class("Ll/\u06e1;"))
self.assertIsNotNone(dex.get_class("Ll/\u1a7b;"))

def test_mutf8_special_forms_decode(self):
self.assertEqual(_decode_mutf8_fallback(b"a\xc0\x80b"), "a\x00b")
self.assertEqual(
_decode_mutf8_fallback(b"\xed\xa0\xbd\xed\xb8\x80"),
"\U0001f600",
)

def test_malformed_mutf8_uses_replacement_character(self):
self.assertEqual(_decode_mutf8_fallback(b"a\xffb"), "a\ufffdb")

def test_get_class_falls_back_for_utf16_sort_order(self):
names = ["Ll/\U00010000;", "Ll/\ue000;"]
dex = DEX.parse(_make_class_dex(names), "utf16-order.dex")

for name in names:
with self.subTest(name=name):
self.assertIsNotNone(dex.get_class(name))

def test_unterminated_string_is_rejected(self):
data = bytearray(_make_class_dex(["Lexample/Test;"]))
data[-1] = 1
dex = DEX.parse(data, "unterminated.dex")

with self.assertRaisesRegex(ValueError, "unterminated string_data_item"):
dex.get_string(0)


if __name__ == "__main__":
unittest.main()
16 changes: 15 additions & 1 deletion tests/test_performance_compare.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import unittest

from performance_compare import compare_pairs
from benchmark_compare import should_gate
from benchmark_compare import _stable_cli_findrefs_answer, should_gate


class PerformanceComparisonTests(unittest.TestCase):
def test_cli_findrefs_answer_ignores_only_matched_payload_text(self):
base = 'classes.dex | Lfoo/Bar;->run | matched=(truncated)'
candidate = 'classes.dex | Lfoo/Bar;->run | matched=(full payload)'
self.assertEqual(
_stable_cli_findrefs_answer(base),
_stable_cli_findrefs_answer(candidate),
)
self.assertNotEqual(
_stable_cli_findrefs_answer(base),
_stable_cli_findrefs_answer(
'classes.dex | Lfoo/Baz;->run | matched=(full payload)'
),
)

def test_consistent_small_regression_is_reported_but_not_gated(self):
result = compare_pairs([100.] * 31, [100.1] * 31, 26)
self.assertTrue(result['statistically_significant'])
Expand Down
34 changes: 34 additions & 0 deletions tests/test_source_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ def tag_raise(self, name):
self.tags.append(("raise", name))


class _FindWidget:
def __init__(self):
self.calls = []

def grid(self):
self.calls.append("grid")

def focus_set(self):
self.calls.append("focus")

def selection_range(self, start, end):
self.calls.append(("selection", start, end))


def _text_index(text, offset):
line = text.count("\n", 0, offset) + 1
line_start = text.rfind("\n", 0, offset) + 1
Expand Down Expand Up @@ -88,6 +102,26 @@ def _make_app(source, references, offset):


class SourceEditTests(unittest.TestCase):
def test_ctrl_f_clears_member_underlines_before_moving_focus(self):
calls = []
frame = _FindWidget()
entry = _FindWidget()
app = SimpleNamespace(
_hide_member_links=lambda: calls.append("hide-links"),
editor_find_frame=frame,
editor_find_entry=entry,
editor_find_var=_Value("needle"),
_last_editor_find_text="",
_refresh_editor_find_marks=lambda reset_cursor: calls.append(("refresh", reset_cursor)),
)

result = AscGuiApp._show_editor_find(app)

self.assertEqual(result, "break")
self.assertEqual(calls[0], "hide-links")
self.assertEqual(frame.calls, ["grid"] )
self.assertEqual(entry.calls[0], "focus")

def test_rendered_member_ranges_follow_inserted_line_comments(self):
source = "void first() {}\nvoid second() {}\n"
first = source.index("first")
Expand Down
Loading