From 4bf6523ab3ca2ee2d2c97ce640b90725981e73e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 13:12:33 +0200 Subject: [PATCH] Add check-flavor.py: verify an artifact's flavour without installing it Reads the Dart AOT snapshot out of an .apk or a Linux/Windows bundle .zip and asserts exactly one AMBER_FLAVOR:: marker matching the expected variant. See the amber-app commit for why nothing cheaper distinguishes the two builds. Co-Authored-By: Claude Opus 5 --- scripts/check-flavor.py | 80 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100755 scripts/check-flavor.py diff --git a/scripts/check-flavor.py b/scripts/check-flavor.py new file mode 100755 index 0000000..31489c7 --- /dev/null +++ b/scripts/check-flavor.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Verify a built artifact's flavour without installing or launching it. + + check-flavor.py + +Exits non-zero unless the artifact contains exactly one `AMBER_FLAVOR::` marker +and it matches. Handles an `.apk`, a Linux bundle `.zip` and a Windows bundle +`.zip` — it finds the Dart AOT snapshot inside each. + +**Why this exists.** The clean flavour is what anonymous downloads and every +child profile receive, so publishing an adult build into a clean slot is the +worst mistake this release process can make — and until now the only way to tell +the two apart offline was to install the APK on a television and look at the tab +row. That made a content-safety gate depend on a TV being awake and on adb still +being authorised, which is exactly how it gets skipped "just this once". + +Nothing cheaper worked. Class names such as `AdultBrowse` survive in **both** +snapshots whatever the dart-define, and the `'adult'`/`'clean'` strings the +updater compares sit inside a function body, so both literals ship in both +builds. The two APKs are also routinely **byte-identical in size** (zip +alignment absorbs the difference), so size proves nothing either. `config.dart` +therefore compiles in a deliberate const-folded marker; see [kFlavorMarker]. +""" +import re +import sys +import zipfile + +MARKER = re.compile(rb'AMBER_FLAVOR::(adult|clean)') + +# Where the Dart AOT snapshot lives, per artifact kind. +SNAPSHOTS = ( + 'lib/arm64-v8a/libapp.so', # apk + 'lib/armeabi-v7a/libapp.so', # apk, 32-bit + 'lib/libapp.so', # linux bundle zip + 'data/app.so', # windows bundle zip +) + + +def markers_in(blob: bytes) -> set: + return {m.group(1).decode() for m in MARKER.finditer(blob)} + + +def main(path: str, expected: str) -> int: + if expected not in ('adult', 'clean'): + print(f'error: expected must be adult|clean, got {expected!r}') + return 2 + + found = set() + checked = [] + with zipfile.ZipFile(path) as z: + names = set(z.namelist()) + for candidate in SNAPSHOTS: + if candidate in names: + checked.append(candidate) + found |= markers_in(z.read(candidate)) + + if not checked: + # Better to fail loudly than to pass an artifact nothing was read from. + print(f'FAIL {path}: no Dart snapshot found (looked for {SNAPSHOTS})') + return 1 + if not found: + print(f'FAIL {path}: no AMBER_FLAVOR marker in {checked} — ' + 'built before the marker existed?') + return 1 + if len(found) > 1: + print(f'FAIL {path}: ambiguous, contains {sorted(found)}') + return 1 + + actual = found.pop() + if actual != expected: + print(f'FAIL {path}: is {actual!r}, expected {expected!r}') + return 1 + print(f'ok {path}: {actual} (from {", ".join(checked)})') + return 0 + + +if __name__ == '__main__': + if len(sys.argv) != 3: + raise SystemExit(__doc__) + raise SystemExit(main(sys.argv[1], sys.argv[2]))