mirror of
https://github.com/MG1937/ASC.git
synced 2026-10-02 08:34:55 +08:00
Merge pull request #7 from ZacharyZcR/fix/reference-locator-and-runtime
Gate performance regressions and automate tested source releases
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
baseline: ${{ steps.version.outputs.baseline }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Validate version and development ancestry
|
||||
id: version
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
python scripts/build_release.py "$TAG" --output dist
|
||||
git merge-base --is-ancestor HEAD origin/dev-0.1.0
|
||||
echo "baseline=$(git rev-parse HEAD^)" >> "$GITHUB_OUTPUT"
|
||||
checks:
|
||||
needs: prepare
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
baseline_sha: ${{ needs.prepare.outputs.baseline }}
|
||||
publish:
|
||||
needs: checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: actions/setup-python@v7.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Build release assets
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: python scripts/build_release.py "$TAG" --output dist
|
||||
- name: Publish validated source release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
if gh release view "$TAG" --json isDraft > release.json; then
|
||||
python -c 'import json; assert json.load(open("release.json"))["isDraft"], "Refusing to overwrite published release"'
|
||||
else
|
||||
gh release create "$TAG" --verify-tag --draft --title "ASC $TAG" --generate-notes
|
||||
fi
|
||||
gh release upload "$TAG" dist/* --clobber
|
||||
if [[ "$TAG" == *-* ]]; then
|
||||
gh release edit "$TAG" --draft=false --prerelease
|
||||
else
|
||||
gh release edit "$TAG" --draft=false --prerelease=false
|
||||
fi
|
||||
@@ -1,5 +1,14 @@
|
||||
name: Tests
|
||||
on: [push, pull_request, workflow_dispatch]
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
baseline_sha:
|
||||
required: true
|
||||
type: string
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
@@ -12,14 +21,24 @@ jobs:
|
||||
python-version: ['3.11', '3.12']
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- name: Checkout performance baseline
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name || (github.event_name == 'push' && github.ref_name == 'dev-0.1.0' && github.repository) || 'MG1937/ASC' }}
|
||||
ref: ${{ inputs.baseline_sha || github.event.pull_request.base.sha || (github.event_name == 'push' && github.ref_name == 'dev-0.1.0' && github.event.before) || 'dev-0.1.0' }}
|
||||
path: .performance-base
|
||||
- uses: actions/setup-python@v7.0.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
- run: python -m pip install -r requirements.txt
|
||||
- run: python -m pip check
|
||||
- name: Run all regressions without dependency skips
|
||||
run: python tests/run_tests.py --require-decompiler
|
||||
- name: Unit tests (no skips)
|
||||
run: python tests/run_tests.py --require-decompiler --suite unit
|
||||
- name: Integration tests (no skips)
|
||||
run: python tests/run_tests.py --require-decompiler --suite integration
|
||||
- name: Reject performance regressions against the target branch
|
||||
run: python tests/benchmark_compare.py --baseline .performance-base --samples 31
|
||||
- name: Check cold-process startup with --debug
|
||||
run: python tests/benchmark_startup.py --samples 9 --max-total-ms 100 --max-wall-ms 250
|
||||
- name: Check supplied DEX benchmarks and 0.0880 s budget
|
||||
@@ -32,4 +51,15 @@ jobs:
|
||||
path: |
|
||||
artifacts/startup/
|
||||
artifacts/reference/
|
||||
artifacts/comparison/
|
||||
if-no-files-found: warn
|
||||
- name: Build source release preview
|
||||
if: matrix.python-version == '3.12'
|
||||
run: python scripts/build_release.py v0.1.0 --output dist
|
||||
- name: Upload source release preview
|
||||
if: matrix.python-version == '3.12'
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: source-release-preview
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -15,3 +15,7 @@ artifacts/startup/
|
||||
|
||||
!tests/fixtures/reference-workload.zip
|
||||
artifacts/reference/
|
||||
|
||||
.performance-base/
|
||||
artifacts/comparison/
|
||||
dist/
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Source releases
|
||||
|
||||
Python 3.11 or 3.12 is required. Extract `ASC-vX.Y.Z-source.zip`, enter the
|
||||
extracted directory, and run:
|
||||
|
||||
```sh
|
||||
python -m pip install -r requirements.txt
|
||||
python main.py --help
|
||||
python main.py getclass app.apk com.example.Main --debug
|
||||
python main.py findrefs app.apk string token
|
||||
python main.py app.apk --gui
|
||||
```
|
||||
|
||||
The GUI additionally needs Tk (on Debian/Ubuntu: `python3-tk`). Dependencies
|
||||
are installed separately; the archive is not a standalone executable.
|
||||
`SHA256SUMS` verifies the downloaded ZIP; it does not change DEX signatures.
|
||||
|
||||
## Maintainers
|
||||
|
||||
Merge the PR into `dev-0.1.0` first, then tag the intended commit with
|
||||
`vMAJOR.MINOR.PATCH` (or `-alpha.N`, `-beta.N`, `-rc.N`) and push that tag.
|
||||
The Release workflow rejects tags outside the development branch and compares
|
||||
performance against the tagged commit's first parent, by immutable SHA.
|
||||
All unit/integration tests, paired performance checks, and absolute budgets
|
||||
must pass on Python 3.11 and 3.12 before publication. A failed gate publishes
|
||||
nothing. Prerelease tags create GitHub prereleases.
|
||||
|
||||
PR CI also builds the source package and tests its CLI after extraction outside
|
||||
the checkout. Only tracked runtime files, requirements, README and docs enter
|
||||
the deterministic ZIP; test DEX/APK fixtures and local scripts are excluded.
|
||||
|
||||
Local packaging: `python scripts/build_release.py v0.1.0 --output dist`.
|
||||
The workflow creates a draft, uploads the ZIP and SHA256SUMS, then publishes it.
|
||||
If upload fails, only a draft remains; rerunning replaces draft assets.
|
||||
Published releases are never overwritten automatically.
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Build a source distribution from tracked runtime files."""
|
||||
import argparse
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def build(version, output):
|
||||
if not re.fullmatch(r'v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc)\.[1-9]\d*)?', version):
|
||||
raise ValueError('expected vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-rc.N (also alpha/beta)')
|
||||
paths = subprocess.check_output(
|
||||
['git', 'ls-files', '-z', '--', 'main.py', 'requirements.txt', 'README.md', 'LICENSE', 'src', 'docs'],
|
||||
cwd=ROOT).decode().split('\0')
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
archive = output / f'ASC-{version}-source.zip'
|
||||
with zipfile.ZipFile(archive, 'w', compression=zipfile.ZIP_DEFLATED) as package:
|
||||
for path in sorted(filter(None, paths)):
|
||||
info = zipfile.ZipInfo(f'ASC-{version}/{path}')
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
package.writestr(info, (ROOT / path).read_bytes())
|
||||
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
|
||||
(output / 'SHA256SUMS').write_text(f'{digest} {archive.name}\n', encoding='ascii')
|
||||
return archive
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('version')
|
||||
parser.add_argument('--output', type=Path, default=Path('dist'))
|
||||
args = parser.parse_args()
|
||||
print(build(args.version, args.output))
|
||||
@@ -175,7 +175,7 @@ class InsnLocator(BaseLocator):
|
||||
insn_maps = self.insn_maps
|
||||
|
||||
buf = self.buf
|
||||
method_bounds = self.method_bounds.copy()
|
||||
method_bounds = {}
|
||||
for off in offsets:
|
||||
midx = insn_maps.get(off >> 4)
|
||||
"""
|
||||
@@ -190,10 +190,10 @@ class InsnLocator(BaseLocator):
|
||||
# which means there is no possible to backtracking inside one method, so we can update method
|
||||
# start insn offset once we fullmatch an insn, avoid re-fullmatch from start of method
|
||||
# bugfix for insn mismatch issue 20260729
|
||||
if isinstance(midx, int) and InsnLocator.INSN_VERIFY.fullmatch(buf, method_bounds[midx], off):
|
||||
if isinstance(midx, int) and InsnLocator.INSN_VERIFY.fullmatch(buf, method_bounds.get(midx, self.method_bounds[midx]), off):
|
||||
ret_table.append(midx)
|
||||
method_bounds[midx] = off
|
||||
elif isinstance(midx, list) and InsnLocator.INSN_VERIFY.fullmatch(buf, method_bounds[midx[0]], off):
|
||||
elif isinstance(midx, list) and InsnLocator.INSN_VERIFY.fullmatch(buf, method_bounds.get(midx[0], self.method_bounds[midx[0]]), off):
|
||||
ret_table.append(midx)
|
||||
method_bounds[midx[0]] = off
|
||||
else:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from findrefs.locator.base_locator import BaseLocator
|
||||
import struct
|
||||
import sys
|
||||
from bisect import bisect_right
|
||||
from utils.leb128 import read_uleb128_len
|
||||
import re
|
||||
@@ -26,19 +27,21 @@ class StringLocator(BaseLocator):
|
||||
string_ids_off, string_ids_size = self.header.strings
|
||||
stridx_map = self.stridx_map
|
||||
buf = self.buf
|
||||
for idx in range(string_ids_size):
|
||||
data_offset = _STRUCT_I.unpack_from(buf, string_ids_off)[0]
|
||||
string_ids_off += 4
|
||||
stridx_map[data_offset] = idx
|
||||
self.string_offsets = sorted(stridx_map)
|
||||
if self.string_offsets:
|
||||
ids = buf[string_ids_off:string_ids_off + string_ids_size * 4]
|
||||
offsets = (ids.cast('I').tolist() if sys.byteorder == 'little' else
|
||||
[item[0] for item in _STRUCT_I.iter_unpack(ids)])
|
||||
stridx_map.update(zip(offsets, range(string_ids_size)))
|
||||
self.string_offsets = sorted(offsets)
|
||||
self.ids_in_physical_order = offsets == self.string_offsets
|
||||
if offsets:
|
||||
self.strdata_start = self.string_offsets[0]
|
||||
last = self.string_offsets[-1]
|
||||
self.strdata_end = buf.obj.find(b'\x00', last + read_uleb128_len(buf, last)) + 1
|
||||
stridx_map[self.strdata_end] = string_ids_size
|
||||
self.parsed = True
|
||||
self._debug_log("build_map", t_start, len(stridx_map))
|
||||
self._debug_log("build_map", t_start, string_ids_size)
|
||||
|
||||
def _match_string_offset(self, string : str):
|
||||
def _match_string_index(self, string : str):
|
||||
buf = self.buf
|
||||
string = string.encode('utf-8')
|
||||
strdata_start = self.strdata_start
|
||||
@@ -46,15 +49,30 @@ class StringLocator(BaseLocator):
|
||||
submem = buf[strdata_start: strdata_end]
|
||||
|
||||
mm = submem.obj
|
||||
pattern = re.compile(string)
|
||||
literal = bool(string) and b'\x00' not in string and re.escape(string) == string
|
||||
# One hit per string suffices for a set of string IDs. Consume the tail
|
||||
# in the C regex engine to avoid another search and duplicate hits.
|
||||
pattern = re.compile(string + b'[^\x00]*\x00' if literal else string)
|
||||
|
||||
for match in pattern.finditer(submem):
|
||||
start = strdata_start + match.start()
|
||||
item = self.string_offsets[bisect_right(self.string_offsets, start) - 1]
|
||||
content_start = item + read_uleb128_len(buf, item)
|
||||
content_end = mm.find(b'\x00', content_start, strdata_end)
|
||||
if start >= content_start and strdata_start + match.end() <= content_end:
|
||||
yield item
|
||||
content_end = strdata_start + match.end() - 1 if literal else mm.find(b'\x00', start, strdata_end)
|
||||
query_end = start + len(string) if literal else strdata_start + match.end()
|
||||
next_idx = self.stridx_map.get(content_end + 1)
|
||||
if self.ids_in_physical_order and next_idx is not None and next_idx > 0:
|
||||
idx = next_idx - 1
|
||||
item = self.string_offsets[idx]
|
||||
else:
|
||||
item = self.string_offsets[bisect_right(self.string_offsets, start) - 1]
|
||||
idx = self.stridx_map[item]
|
||||
content_start = item + (read_uleb128_len(buf, item) if buf[item] & 128 else 1)
|
||||
if mm.find(b'\x00', content_start, start) != -1:
|
||||
continue
|
||||
if literal and start < content_start:
|
||||
if mm.find(string, content_start, content_end) != -1:
|
||||
yield idx
|
||||
elif start >= content_start and query_end <= content_end:
|
||||
yield idx
|
||||
|
||||
def locate(self, string : str) -> set:
|
||||
t_start = time.perf_counter() if self.debug else None
|
||||
@@ -62,10 +80,7 @@ class StringLocator(BaseLocator):
|
||||
self._build_map()
|
||||
if not self.string_offsets:
|
||||
return set()
|
||||
stridx_map = self.stridx_map
|
||||
located_idx = set()
|
||||
for offset in self._match_string_offset(string):
|
||||
located_idx.add(stridx_map[offset])
|
||||
located_idx = set(self._match_string_index(string))
|
||||
# return set for O(1) lookup
|
||||
self._debug_log("locate", t_start, len(located_idx))
|
||||
return located_idx
|
||||
|
||||
@@ -33,3 +33,38 @@ These are fresh interpreter measurements with warm filesystem caches, not cold
|
||||
storage measurements or a guarantee on arbitrary hardware. CI runs both Python
|
||||
3.11 and 3.12, including real-DEX checks. Reports and stdout/stderr are uploaded
|
||||
from `artifacts/startup/` and `artifacts/reference/` even when a gate fails.
|
||||
|
||||
## Zero-regression comparison
|
||||
|
||||
```sh
|
||||
python tests/run_tests.py --require-decompiler --suite unit
|
||||
python tests/run_tests.py --require-decompiler --suite integration
|
||||
python tests/benchmark_compare.py --baseline /path/to/base-checkout --samples 31
|
||||
```
|
||||
|
||||
CI checks out the PR's exact base SHA. Direct pushes to `dev-0.1.0` compare
|
||||
against the pre-push SHA, so the candidate cannot become its own baseline.
|
||||
Other push/manual builds compare against `MG1937/ASC:dev-0.1.0`. A missing
|
||||
baseline fails the job. The same interpreter,
|
||||
runner, DEX, original scripts and query arguments exercise both revisions. On
|
||||
Linux the comparison and its child processes use one fixed available CPU; both
|
||||
sides use `PYTHONHASHSEED=0`. These controls are recorded in the report.
|
||||
|
||||
The comparison covers 20 module timings from `test_findrefs.py`, core decompilation
|
||||
from `test.py`, and real-APK CLI `getclass` / `findrefs`: **23 metrics** in total.
|
||||
Each workload records an explicit warmup pair, then 31 independent-process pairs
|
||||
in alternating base/candidate order. Warmups are retained in raw logs but excluded
|
||||
from statistics. Reference counts and decompiled/CLI outputs must match before
|
||||
any performance result can pass.
|
||||
|
||||
There is **no allowed percentage slowdown**. A one-sided exact paired sign test
|
||||
checks whether the candidate is consistently slower, with a 5% family-wise error
|
||||
budget divided across the metrics in that run (Bonferroni correction). A significant
|
||||
slowdown fails CI even if it is only 0.1%; balanced noise or one isolated scheduling
|
||||
outlier does not. Passing means no statistically significant regression was
|
||||
observed in these workloads, not proof of identical timing on every machine.
|
||||
|
||||
The comparison's unit tests exercise small/large regressions, improvements,
|
||||
identical timings, noise, outliers and incomplete/invalid measurements. The
|
||||
original absolute startup and 0.0880 s gates remain mandatory. Paired timings,
|
||||
base/candidate SHAs, p-values and raw logs are saved in `artifacts/comparison/`.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Compare module and CLI performance against a base checkout on one runner."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from performance_compare import compare_pairs
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / 'tests' / 'fixtures'
|
||||
|
||||
|
||||
def revision(root):
|
||||
return subprocess.check_output(['git', '-C', str(root), 'rev-parse', 'HEAD'], text=True).strip()
|
||||
|
||||
|
||||
def measure(root, directory, case, output, sample):
|
||||
env = dict(os.environ, PYTHONPATH=str(root / 'src' / 'asc_core'), PYTHONHASHSEED='0')
|
||||
if case in ('unit', 'core'):
|
||||
command = [sys.executable, 'test_findrefs.py' if case == 'unit' else 'test.py']
|
||||
else:
|
||||
command = [sys.executable, str(root / 'main.py')]
|
||||
if case == 'cli_getclass':
|
||||
command += ['getclass', 'fixture.apk', 'com.google.android.material.timepicker.ClockFaceView', '--threads', '1', '--debug']
|
||||
else:
|
||||
command += ['findrefs', 'fixture.apk', '--threads', '1', '--debug', 'string', 'create']
|
||||
result = subprocess.run(command, cwd=directory, env=env, capture_output=True, text=True, timeout=30)
|
||||
(output / f'{case}-{sample}.stdout.log').write_text(result.stdout, encoding='utf-8')
|
||||
(output / f'{case}-{sample}.stderr.log').write_text(result.stderr, encoding='utf-8')
|
||||
if result.returncode:
|
||||
raise ValueError(f'{case} exited {result.returncode}; see {output}')
|
||||
if case == 'unit':
|
||||
times = {key: float(value) for key, value in re.findall(r'\[DEBUG\] (\w+) Time: ([\d.]+) us', result.stdout)}
|
||||
answer = {key: int(value) for key, value in re.findall(r'\[DEBUG\] ([\w ]+): (\d+)\s*$', result.stdout, re.M)}
|
||||
elif case == 'core':
|
||||
match = re.search(r'Total Execution Time in test.py: ([\d.]+) s', result.stdout)
|
||||
if match is None:
|
||||
raise ValueError('missing core timing')
|
||||
times = {'decompile': float(match[1]) * 1e6}
|
||||
answer = result.stdout.split('[INFO]')[0].strip()
|
||||
else:
|
||||
match = re.search(r'\[DEBUG\] Total Execution Time: ([\d.]+) us', result.stdout)
|
||||
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))
|
||||
if not answer or (case in ('core', 'cli_getclass') and 'class ClockFaceView' not in answer):
|
||||
raise ValueError(f'{case}: missing result')
|
||||
return times, answer
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--baseline', type=Path, required=True)
|
||||
parser.add_argument('--candidate', type=Path, default=ROOT)
|
||||
parser.add_argument('--samples', type=int, default=31)
|
||||
parser.add_argument('--cases', nargs='+', choices=('unit', 'core', 'cli_getclass', 'cli_findrefs'),
|
||||
default=['unit', 'core', 'cli_getclass', 'cli_findrefs'])
|
||||
parser.add_argument('--output', type=Path, default=Path('artifacts/comparison'))
|
||||
args = parser.parse_args()
|
||||
if args.samples < 15:
|
||||
parser.error('at least 15 paired samples are required')
|
||||
roots = {'base': args.baseline.resolve(), 'candidate': args.candidate.resolve()}
|
||||
affinity = None
|
||||
if hasattr(os, 'sched_getaffinity'):
|
||||
affinity = [min(os.sched_getaffinity(0))]
|
||||
os.sched_setaffinity(0, affinity)
|
||||
report = {'python': sys.version, 'platform': platform.platform(),
|
||||
'cpu_affinity': affinity, 'pythonhashseed': '0', 'samples': {}, 'errors': []}
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
for side in roots:
|
||||
(args.output / side).mkdir(exist_ok=True)
|
||||
try:
|
||||
report['revisions'] = {side: revision(root) for side, root in roots.items()}
|
||||
contract = json.loads((FIXTURES / 'reference-baseline.json').read_text())
|
||||
archive = FIXTURES / 'reference-workload.zip'
|
||||
if hashlib.sha256(archive.read_bytes()).hexdigest() != contract['archive_sha256']:
|
||||
raise ValueError('reference archive identity mismatch')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with zipfile.ZipFile(archive) as source:
|
||||
if set(source.namelist()) != {'classes.dex', 'test.py', 'test_findrefs.py'}:
|
||||
raise ValueError('unexpected archive members')
|
||||
source.extractall(directory)
|
||||
with zipfile.ZipFile(Path(directory) / 'fixture.apk', 'w', compression=zipfile.ZIP_DEFLATED) as apk:
|
||||
apk.write(Path(directory) / 'classes.dex', 'classes.dex')
|
||||
for case in args.cases:
|
||||
expected_keys = set(contract['findrefs_times_us']) if case == 'unit' else {'decompile' if case == 'core' else case}
|
||||
samples = {key: {'base': [], 'candidate': []} for key in expected_keys}
|
||||
report['samples'][case] = samples
|
||||
for index in range(-1, args.samples):
|
||||
answers = {}
|
||||
order = ('base', 'candidate') if index % 2 == 0 else ('candidate', 'base')
|
||||
for side in order:
|
||||
times, answers[side] = measure(roots[side], directory, case, args.output / side, index)
|
||||
if times.keys() != expected_keys:
|
||||
raise ValueError(f'{case}: missing or unexpected timing metrics')
|
||||
if case == 'unit' and answers[side] != contract['findrefs_counts']:
|
||||
raise ValueError(f'{side}: reference count mismatch')
|
||||
if index >= 0:
|
||||
for key, value in times.items():
|
||||
samples[key][side].append(value)
|
||||
if answers['base'] != answers['candidate']:
|
||||
raise ValueError(f'{case}: base/candidate outputs differ')
|
||||
metric_count = sum(len(metrics) for metrics in report['samples'].values())
|
||||
report['comparisons'] = {}
|
||||
for case, metrics in report['samples'].items():
|
||||
for key, values in metrics.items():
|
||||
result = compare_pairs(values['base'], values['candidate'], metric_count)
|
||||
report['comparisons'][f'{case}/{key}'] = result
|
||||
if result['regression']:
|
||||
report['errors'].append(f'{case}/{key}: significant slowdown ({result["paired_median_change_percent"]:+.2f}%)')
|
||||
except (ValueError, OSError, subprocess.SubprocessError, zipfile.BadZipFile) as error:
|
||||
report['errors'].append(str(error))
|
||||
(args.output / 'report.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
|
||||
print(json.dumps({key: value for key, value in report.items() if key != 'samples'}, indent=2))
|
||||
return 1 if report['errors'] else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Paired zero-regression checks; no allowed percentage slowdown."""
|
||||
import math
|
||||
import statistics
|
||||
|
||||
|
||||
def compare_pairs(base, candidate, metric_count, alpha=0.05):
|
||||
if len(base) != len(candidate) or len(base) < 15:
|
||||
raise ValueError('at least 15 complete paired samples are required')
|
||||
if metric_count < 1 or not 0 < alpha < 1:
|
||||
raise ValueError('invalid comparison parameters')
|
||||
if any(not math.isfinite(value) or value <= 0 for value in base + candidate):
|
||||
raise ValueError('timings must be finite and positive')
|
||||
ratios = [new / old for old, new in zip(base, candidate)]
|
||||
slower = sum(value > 1 for value in ratios)
|
||||
faster = sum(value < 1 for value in ratios)
|
||||
n = slower + faster
|
||||
p_value = sum(math.comb(n, k) for k in range(slower, n + 1)) / 2 ** n
|
||||
threshold = alpha / metric_count
|
||||
return {'base_median': statistics.median(base),
|
||||
'candidate_median': statistics.median(candidate),
|
||||
'paired_median_change_percent': (statistics.median(ratios) - 1) * 100,
|
||||
'slower_pairs': slower, 'faster_pairs': faster,
|
||||
'p_value': p_value, 'threshold': threshold,
|
||||
'regression': p_value < threshold}
|
||||
@@ -11,9 +11,23 @@ sys.path.insert(0, str(ROOT))
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--require-decompiler', action='store_true')
|
||||
parser.add_argument('--suite', choices=('all', 'unit', 'integration'), default='all')
|
||||
args = parser.parse_args()
|
||||
if args.require_decompiler and importlib.util.find_spec('androguard') is None:
|
||||
parser.error('Androguard is required; install requirements.txt')
|
||||
suite = unittest.defaultTestLoader.discover(str(ROOT / 'tests'), pattern='test_*.py')
|
||||
if args.suite != 'all':
|
||||
def tests(items):
|
||||
for item in items:
|
||||
if isinstance(item, unittest.TestSuite):
|
||||
yield from tests(item)
|
||||
else:
|
||||
yield item
|
||||
suite = unittest.TestSuite(test for test in tests(suite)
|
||||
if ('test_decompiler.DecompilerTests.' in test.id()
|
||||
or test.id().endswith('test_archive_is_reproducible_and_runs_outside_checkout'))
|
||||
== (args.suite == 'integration'))
|
||||
if suite.countTestCases() == 0:
|
||||
parser.error('selected suite contains no tests')
|
||||
result = unittest.TextTestRunner(verbosity=2).run(suite)
|
||||
sys.exit(0 if result.wasSuccessful() and not (args.require_decompiler and result.skipped) else 1)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import unittest
|
||||
|
||||
from performance_compare import compare_pairs
|
||||
|
||||
|
||||
class PerformanceComparisonTests(unittest.TestCase):
|
||||
def test_consistent_small_regression_fails(self):
|
||||
self.assertTrue(compare_pairs([100.] * 31, [100.1] * 31, 26)['regression'])
|
||||
|
||||
def test_consistent_large_regression_fails(self):
|
||||
self.assertTrue(compare_pairs([100.] * 31, [200.] * 31, 26)['regression'])
|
||||
|
||||
def test_improvement_and_equal_times_pass(self):
|
||||
for value in (50., 100.):
|
||||
self.assertFalse(compare_pairs([100.] * 31, [value] * 31, 26)['regression'])
|
||||
|
||||
def test_balanced_noise_does_not_fail(self):
|
||||
values = [90., 110.] * 15 + [100.]
|
||||
self.assertFalse(compare_pairs([100.] * 31, values, 26)['regression'])
|
||||
|
||||
def test_single_outlier_does_not_fail(self):
|
||||
values = [100.] * 30 + [10000.]
|
||||
self.assertFalse(compare_pairs([100.] * 31, values, 26)['regression'])
|
||||
|
||||
def test_missing_or_invalid_measurements_fail(self):
|
||||
for values in ([], [100.] * 30, [float('nan')] * 31, [0.] * 31):
|
||||
with self.assertRaises(ValueError):
|
||||
compare_pairs([100.] * 31, values, 26)
|
||||
@@ -24,6 +24,17 @@ class ReferenceTests(unittest.TestCase):
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(set(results[0]), {0, 1})
|
||||
|
||||
def test_repeated_scans_preserve_method_bounds(self):
|
||||
locator = InsnLocator(DEX.parse(memoryview(make_dex()), 'fixture.dex'))
|
||||
locator.parse()
|
||||
original_bounds = dict(locator.method_bounds)
|
||||
scanner = CodeItemScanner(locator)
|
||||
for _ in range(2):
|
||||
query = {'string': {5}}
|
||||
scanner.scan(query)
|
||||
self.assertEqual(set(query['string'][0]), {0, 1})
|
||||
self.assertEqual(locator.method_bounds, original_bounds)
|
||||
|
||||
def test_fallback_without_class_data_map_entry(self):
|
||||
data = bytearray(make_dex())
|
||||
map_off = struct.unpack_from('<I', data, 52)[0]
|
||||
@@ -75,6 +86,37 @@ class StringTests(unittest.TestCase):
|
||||
self.assertEqual(self.locate(data, 'first'), {3})
|
||||
self.assertEqual(self.locate(data, 'Lexample/Test;'), {0})
|
||||
|
||||
def make_strings(self, values, gap=b''):
|
||||
from dex_fixture import uleb
|
||||
data = bytearray(make_dex())
|
||||
ids_off = struct.unpack_from('<I', data, 60)[0]
|
||||
struct.pack_into('<I', data, 56, len(values))
|
||||
for idx, value in enumerate(values):
|
||||
data.extend(gap)
|
||||
struct.pack_into('<I', data, ids_off + 4 * idx, len(data))
|
||||
data.extend(uleb(len(value)) + value + b'\0')
|
||||
return data
|
||||
|
||||
def test_literal_header_hit_does_not_hide_content_hit(self):
|
||||
self.assertEqual(self.locate(self.make_strings([b'A' * 65]), 'A'), {0})
|
||||
self.assertEqual(self.locate(self.make_strings([b'B' * 65]), 'A'), set())
|
||||
|
||||
def test_multibyte_length_and_repeated_hits(self):
|
||||
data = self.make_strings([b'A' * 200 + b'View', b'ViewView'])
|
||||
self.assertEqual(self.locate(data, 'View'), {0, 1})
|
||||
self.assertEqual(self.locate(data, 'V.ew'), {0, 1})
|
||||
self.assertEqual(self.locate(self.make_strings([b'A' * 128]), '\x01'), set())
|
||||
|
||||
def test_bytes_between_strings_are_not_search_results(self):
|
||||
data = self.make_strings([b'first', b'second', b'third'], gap=b'View\0')
|
||||
self.assertEqual(self.locate(data, 'View'), set())
|
||||
self.assertEqual(self.locate(data, 'second'), {1})
|
||||
|
||||
def test_regex_cannot_cross_string_terminators(self):
|
||||
data = self.make_strings([b'AAA', b'BBB'])
|
||||
self.assertEqual(self.locate(data, 'A.*B'), set())
|
||||
self.assertEqual(self.locate(data, 'A\x00\x03B'), set())
|
||||
|
||||
def test_empty_string_table(self):
|
||||
data = bytearray(make_dex())
|
||||
struct.pack_into('<II', data, 56, 0, 0)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
from dex_fixture import make_dex
|
||||
from scripts.build_release import build
|
||||
|
||||
|
||||
class ReleaseTests(unittest.TestCase):
|
||||
def test_invalid_version_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
for version in ('0.1.0', '../v0.1.0', 'v01.0.0', 'v0.1.0-rc.0'):
|
||||
with self.subTest(version=version), self.assertRaises(ValueError):
|
||||
build(version, Path(directory))
|
||||
|
||||
def test_archive_is_reproducible_and_runs_outside_checkout(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
archive = build('v0.1.0-rc.1', root)
|
||||
original = archive.read_bytes()
|
||||
self.assertEqual(build('v0.1.0-rc.1', root).read_bytes(), original)
|
||||
self.assertEqual((root / 'SHA256SUMS').read_text().split()[0],
|
||||
hashlib.sha256(original).hexdigest())
|
||||
with zipfile.ZipFile(archive) as package:
|
||||
names = package.namelist()
|
||||
self.assertTrue(any(name.endswith('/requirements.txt') for name in names))
|
||||
self.assertIn('ASC-v0.1.0-rc.1/LICENSE', names)
|
||||
self.assertFalse(any(name.endswith(('.dex', '.apk', '/test.py', '/test_findrefs.py'))
|
||||
or '/tests/' in name or '/.git/' in name for name in names))
|
||||
package.extractall(root)
|
||||
app = root / 'ASC-v0.1.0-rc.1'
|
||||
apk = root / 'fixture.apk'
|
||||
with zipfile.ZipFile(apk, 'w', compression=zipfile.ZIP_DEFLATED) as package:
|
||||
package.writestr('classes.dex', make_dex())
|
||||
for args, expected in ((['findrefs', str(apk), 'string', 'token'], 'token'),
|
||||
(['getclass', str(apk), 'example.Test'], 'class Test')):
|
||||
result = subprocess.run([sys.executable, str(app / 'main.py'), *args],
|
||||
cwd=root, capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn(expected, result.stdout)
|
||||
Reference in New Issue
Block a user