Files
Android/reproducible-builds/apkdiff/apkdiff.py
T

261 lines
8.4 KiB
Python
Executable File

#! /usr/bin/env python3
import difflib
import subprocess
import sys
import logging
from zipfile import ZipFile
from loguru import logger # ty:ignore[unresolved-import]
from lxml import etree # ty:ignore[unresolved-import]
from androguard.core.axml import AXMLPrinter # ty:ignore[unresolved-import]
logging.getLogger("apkdiff").setLevel(logging.ERROR)
logger.disable("androguard")
def android_attr(name: str) -> str:
return f"{{http://schemas.android.com/apk/res/android}}{name}"
# Related to app signing. Not expected to be present in unsigned builds. Doesn't affect app code.
IGNORE_FILES = [
"META-INF/MANIFEST.MF",
"META-INF/CERTIFIC.SF",
"META-INF/CERTIFIC.RSA",
"META-INF/TEXTSECU.SF",
"META-INF/TEXTSECU.RSA",
"META-INF/BNDLTOOL.SF",
"META-INF/BNDLTOOL.RSA",
"META-INF/code_transparency_signed.jwt",
"stamp-cert-sha256",
]
# Play Store may add or modify attributes as part of the bundle process. Doesn't affect app code.
MANIFEST_IGNORE_ATTRIBUTES: dict[str, set[str]] = {
"uses-sdk": {
android_attr("minSdkVersion"),
}
}
# Play Store may add or modify metadata as part of the bundle process. Doesn't affect app code.
MANIFEST_IGNORE_METADATA: list[str] = [
"com.android.stamp.source",
"com.android.stamp.type",
"com.android.vending.derived.apk.id",
]
def compare(apk1, apk2) -> bool:
print(f"Comparing: \n\t{apk1}\n\t{apk2}\n")
print("Unzipping...")
zip1 = ZipFile(apk1, "r")
zip2 = ZipFile(apk2, "r")
entry_names = compare_entry_names(zip1, zip2)
entry_contents = compare_entry_contents(zip1, zip2)
# Some splits (e.g. ABI config splits) contain no resource table. Compare when both APKs have one, treat both
# missing as a match, and fail if only one of them has it.
has_arsc_1 = "resources.arsc" in zip1.namelist()
has_arsc_2 = "resources.arsc" in zip2.namelist()
if has_arsc_1 and has_arsc_2:
resources = compare_resources_arsc(apk1, apk2)
elif has_arsc_1 != has_arsc_2:
print("resources.arsc is present in only one of the APKs!")
resources = False
else:
resources = True
return entry_names and entry_contents and resources
def compare_entry_names(zip1: ZipFile, zip2: ZipFile) -> bool:
print("Comparing zip entry names...")
name_list_sorted_1 = sorted(zip1.namelist())
name_list_sorted_2 = sorted(zip2.namelist())
for ignoreFile in IGNORE_FILES:
while ignoreFile in name_list_sorted_1:
name_list_sorted_1.remove(ignoreFile)
while ignoreFile in name_list_sorted_2:
name_list_sorted_2.remove(ignoreFile)
success = True
if len(name_list_sorted_1) != len(name_list_sorted_2):
print(
f"Manifest lengths differ! {len(name_list_sorted_1)} vs {len(name_list_sorted_2)}"
)
success = False
only_in_first = sorted(list(set(name_list_sorted_1) - set(name_list_sorted_2)))
only_in_second = sorted(list(set(name_list_sorted_2) - set(name_list_sorted_1)))
if only_in_first:
print(f"Files present only in {zip1.filename}:")
for name in only_in_first:
print(f" - {name}")
success = False
if only_in_second:
print(f"Files present only in {zip2.filename}:")
for name in only_in_second:
print(f" - {name}")
success = False
# If sets are identical but ordering differs, still report ordering mismatches
if success:
for entry_name_1, entry_name_2 in zip(name_list_sorted_1, name_list_sorted_2):
if entry_name_1 != entry_name_2:
print(f"Sorted manifests don't match: {entry_name_1} vs {entry_name_2}")
success = False
return success
def compare_entry_contents(zip1: ZipFile, zip2: ZipFile) -> bool:
print("Comparing zip entry contents...")
info_list_1 = list(
filter(lambda info: info.filename not in IGNORE_FILES, zip1.infolist())
)
info_list_2 = list(
filter(lambda info: info.filename not in IGNORE_FILES, zip2.infolist())
)
success = True
if len(info_list_1) != len(info_list_2):
print(
f"APK info lists of different length! {len(info_list_1)} vs {len(info_list_2)}"
)
success = False
for entry_info_1 in info_list_1:
for entry_info_2 in list(info_list_2):
if entry_info_1.filename == entry_info_2.filename:
entry_bytes_1 = zip1.read(entry_info_1.filename)
entry_bytes_2 = zip2.read(entry_info_2.filename)
if entry_bytes_1 != entry_bytes_2 and not handle_special_cases(
entry_info_1.filename, entry_bytes_1, entry_bytes_2
):
zip1.extract(entry_info_1, "mismatches/first")
zip2.extract(entry_info_2, "mismatches/second")
print(
f"APKs differ on file {entry_info_1.filename}! Files extracted to the mismatches/ directory."
)
success = False
info_list_2.remove(entry_info_2)
break
return success
def handle_special_cases(filename: str, bytes1: bytes, bytes2: bytes):
"""
There are some specific files that expect will not be byte-for-byte identical. We want to ensure that the files
are matching except these expected differences. The differences are all related to extra XML attributes that the
Play Store may add as part of the bundle process. These differences do not affect the behavior of the app and are
unfortunately unavoidable given the modern realities of the Play Store.
"""
if filename == "AndroidManifest.xml":
print("Comparing AndroidManifest.xml...")
return axml_diff(filename, bytes1, bytes2)
elif filename == "resources.arsc":
# we will compare resources.arsc separately with aapt2, so we can ignore any differences here
return True
return False
def compare_resources_arsc(apk1: str, apk2: str) -> bool:
print("Comparing resources.arsc...")
resources1 = dump_resources(apk1)
resources2 = dump_resources(apk2)
if resources1 == resources2:
return True
else:
print("resources.arsc files differ!")
diff = difflib.unified_diff(
resources1,
resources2,
fromfile=apk1,
tofile=apk2,
lineterm="",
)
for line in diff:
print(line)
return False
def dump_resources(apk):
try:
with subprocess.Popen(
["aapt2", "dump", "resources", apk],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
) as process:
stdout, stderr = process.communicate()
if process.returncode != 0:
raise RuntimeError(f"aapt2 failed with error: {stderr.strip()}")
except FileNotFoundError:
raise RuntimeError("aapt2 is not installed or not in the PATH.")
return stdout.strip().splitlines()
def filter_tree(root: etree._Element) -> etree._Element:
for element in root.findall(".//meta-data"):
name = element.get(android_attr("name"), "")
if any(name.startswith(prefix) for prefix in MANIFEST_IGNORE_METADATA):
element.getparent().remove(element)
for element in root.iter():
for attr in MANIFEST_IGNORE_ATTRIBUTES.get(element.tag, ()):
element.attrib.pop(attr, None)
return root
def axml_diff(filename: str, bytes1: bytes, bytes2: bytes) -> bool:
root_a = AXMLPrinter(bytes1).get_xml_obj()
root_b = AXMLPrinter(bytes2).get_xml_obj()
filtered_a = filter_tree(root_a)
filtered_b = filter_tree(root_b)
pretty_a = etree.tostring(filtered_a, pretty_print=True, encoding="unicode")
pretty_b = etree.tostring(filtered_b, pretty_print=True, encoding="unicode")
if pretty_a == pretty_b:
return True
else:
print(f"{filename} files differ!")
diff = difflib.unified_diff(
a=pretty_a.splitlines(keepends=True),
b=pretty_b.splitlines(keepends=True),
fromfile="a/" + filename,
tofile="b/" + filename,
)
sys.stdout.writelines(diff)
return False
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: apkdiff <pathToFirstApk> <pathToSecondApk>")
sys.exit(1)
if compare(sys.argv[1], sys.argv[2]):
print("APKs match!")
sys.exit(0)
else:
print("APKs don't match!")
sys.exit(1)