#!/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`, a Windows bundle `.zip` and an unpacked build **directory** — it finds the Dart AOT snapshot inside each. A Windows `.exe` installer cannot be checked here and is refused rather than passed: Inno LZMA-compresses the payload, so the marker only exists in a form nothing can grep. That is why the release script gates the staged folder before packaging instead of gating the artifact afterwards. **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 os 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 if path.lower().endswith('.exe'): # Say why rather than throwing a BadZipFile, and fail rather than pass: # a checker that prints something reassuring about an artifact it never # read is worse than no checker. print(f'FAIL {path}: an Inno installer LZMA-compresses its payload, so ' 'the marker is not readable here.\n' ' The gate for a .exe is Assert-Flavor in ' 'amber-app/scripts/release_windows.ps1, which reads the staged\n' ' folder BEFORE packaging. Point this script at that folder, ' 'or at the .zip built beside the installer.') return 1 found = set() checked = [] if os.path.isdir(path): # The staged build folder, which is where the truth actually lives. for candidate in SNAPSHOTS: f = os.path.join(path, candidate.replace('/', os.sep)) if os.path.isfile(f): checked.append(candidate) with open(f, 'rb') as fh: found |= markers_in(fh.read()) else: 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]))