check-release-manifest.py

4.3 KB · raw

1#!/usr/bin/env python32"""Check the checked-in Android release manifest inventory against merged output."""34from pathlib import Path5import sys6import xml.etree.ElementTree as ET78ANDROID_NS = "{http://schemas.android.com/apk/res/android}"9ROOT = Path(__file__).resolve().parents[1]10DEFAULT_INVENTORY = ROOT / "docs" / "android-release-manifest-inventory.txt"11DEFAULT_MERGED = ROOT / "app" / "build" / "intermediates" / "merged_manifest" / "release" / "processReleaseMainManifest" / "AndroidManifest.xml"121314def attrs(node: ET.Element) -> str:15    """16    Every attribute on an element, canonicalised. Namespace prefixes are dropped17    (the merged manifest uses only `android:` plus a few tool markers), and the18    list is sorted so ordering can never look like drift.19    """20    items = sorted(21        (key.rsplit("}", 1)[-1], value) for key, value in node.attrib.items()22    )23    return ";".join(f"{key}={value}" for key, value in items)242526def inventory(path: Path) -> str:27    root = ET.parse(path).getroot()28    lines = []29    for permission in sorted(root.findall("uses-permission"), key=lambda node: node.get(ANDROID_NS + "name", "")):30        lines.append(31            f"permission|{permission.get(ANDROID_NS + 'name', '')}|{attrs(permission)}"32        )33    for feature in sorted(root.findall("uses-feature"), key=lambda node: node.get(ANDROID_NS + "name", "")):34        lines.append(35            f"uses-feature|{feature.get(ANDROID_NS + 'name', '')}|{attrs(feature)}"36        )37    for queries in root.findall("queries"):38        lines.append(f"queries|{attrs(queries)}")39        for child in sorted(queries, key=lambda node: (node.tag, node.get(ANDROID_NS + "name", ""))):40            lines.append(41                f"query|{child.tag}|{child.get(ANDROID_NS + 'name', '')}|{attrs(child)}"42            )43            for data in sorted(child.findall("data"), key=attrs):44                lines.append(f"query-data|{child.tag}|{attrs(data)}")45    application = root.find("application")46    if application is None:47        raise SystemExit("release manifest has no application")48    # fullBackupContent, networkSecurityConfig, usesCleartextTraffic, debuggable,49    # process, enabled — is exposure-bearing and was previously invisible here.50    # The manifest element's own versionName/versionCode are deliberately NOT51    # recorded: they change on every version bump, which is not the drift this52    # gate exists to catch (PB-26-133).53    lines.append(f"application|{attrs(application)}")54    for child in sorted(application, key=lambda node: (node.tag, node.get(ANDROID_NS + "name", ""))):55        name = child.get(ANDROID_NS + "name", "")56        # The full attribute set, so provider authorities, grantUriPermissions,57        # readPermission/writePermission, enabled, process, directBootAware and58        # every meta-data value are frozen too.59        lines.append(f"component|{child.tag}|{name}|{attrs(child)}")60        for intent_filter in child.findall("intent-filter"):61            actions = sorted(action.get(ANDROID_NS + "name", "") for action in intent_filter.findall("action"))62            categories = sorted(category.get(ANDROID_NS + "name", "") for category in intent_filter.findall("category"))63            lines.append(f"intent-filter|{child.tag}|{name}|actions={','.join(actions)}|categories={','.join(categories)}")64            for data in sorted(intent_filter.findall("data"), key=attrs):65                lines.append(f"intent-filter-data|{child.tag}|{name}|{attrs(data)}")66    return "\n".join(lines) + "\n"676869USAGE = f"usage: {Path(sys.argv[0]).name} check|write [merged-manifest] [inventory]"7071if len(sys.argv) not in (2, 3, 4):72    raise SystemExit(USAGE)73MERGED = Path(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_MERGED74INVENTORY = Path(sys.argv[3]) if len(sys.argv) > 3 else DEFAULT_INVENTORY7576if not MERGED.is_file():77    raise SystemExit(f"merged release manifest not found: {MERGED}")7879if sys.argv[1] == "write":80    INVENTORY.write_text(inventory(MERGED))81elif sys.argv[1] == "check":82    if not INVENTORY.is_file():83        raise SystemExit(f"checked-in inventory not found: {INVENTORY}")84    actual = inventory(MERGED)85    expected = INVENTORY.read_text()86    if actual != expected:87        raise SystemExit("merged release manifest differs from checked-in inventory")88else:89    raise SystemExit(f"unknown command: {sys.argv[1]}\n{USAGE}")