From ac5cae15742fdd0dce4ad3e6b59df58c7ef21628 Mon Sep 17 00:00:00 2001 From: MG193_7 <2586364982@qq.com> Date: Wed, 23 Sep 2026 15:43:04 +0800 Subject: [PATCH 1/2] fix DEX.get_string count by bytes not by utf16 units issue --- droidasc/asc_client/gui/app.py | 2 + droidasc/asc_core/utils/tinydex.py | 43 ++++++++- pyproject.toml | 4 +- tests/test_mutf8_strings.py | 143 +++++++++++++++++++++++++++++ tests/test_source_edit.py | 34 +++++++ 5 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 tests/test_mutf8_strings.py diff --git a/droidasc/asc_client/gui/app.py b/droidasc/asc_client/gui/app.py index 0979f04..6ad8fe7 100644 --- a/droidasc/asc_client/gui/app.py +++ b/droidasc/asc_client/gui/app.py @@ -458,6 +458,7 @@ def _bind_editor_shortcuts(self): self.root.bind("", self._prev_tab) self.source_text.bind("", self._set_editor_cursor_from_click) self.source_text.bind("", self._open_member_from_click) + self.source_text.bind("", self._hide_member_links, add="+") self.root.bind("", self._show_member_links) self.root.bind("", self._show_member_links) self.root.bind("", self._hide_member_links) @@ -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) diff --git a/droidasc/asc_core/utils/tinydex.py b/droidasc/asc_core/utils/tinydex.py index e261be8..06aaca8 100644 --- a/droidasc/asc_core/utils/tinydex.py +++ b/droidasc/asc_core/utils/tinydex.py @@ -9,6 +9,18 @@ _STRUCT_HHI = struct.Struct(' 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) @@ -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 @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 9facea4..368b46c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -21,4 +21,4 @@ droidasc = "droidasc:main" include = ["droidasc", "droidasc.*"] [tool.setuptools.package-data] -"droidasc" = ["py.typed"] \ No newline at end of file +"droidasc" = ["py.typed"] diff --git a/tests/test_mutf8_strings.py b/tests/test_mutf8_strings.py new file mode 100644 index 0000000..4fe6b3c --- /dev/null +++ b/tests/test_mutf8_strings.py @@ -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(" Date: Wed, 23 Sep 2026 16:34:34 +0800 Subject: [PATCH 2/2] fix utf16 issue caused benchmark error --- tests/benchmark_compare.py | 17 +++++++++++++++-- tests/test_performance_compare.py | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/benchmark_compare.py b/tests/benchmark_compare.py index 4022586..b409118 100644 --- a/tests/benchmark_compare.py +++ b/tests/benchmark_compare.py @@ -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.""" @@ -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 diff --git a/tests/test_performance_compare.py b/tests/test_performance_compare.py index 70d5f28..90bfea4 100644 --- a/tests/test_performance_compare.py +++ b/tests/test_performance_compare.py @@ -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'])