diff --git a/repo_contract/README.md b/repo_contract/README.md index ce1e837..3187e70 100644 --- a/repo_contract/README.md +++ b/repo_contract/README.md @@ -93,6 +93,14 @@ web-relevant file is staged (`web.DEFAULT_FILES`, overridable with degrades to a pass, and the gate defers to that workflow when run inside another GitHub Actions job. +## Flutter apps: no Google non-free sign-in + +A repo with a `pubspec.yaml` beside an `android/` dir (root or one level +down) also gets `scripts/check_no_gms_signin.sh` (a shim) and the always-run +`no-gms-signin` hook (`install_gms.py`). The shared gate fails on +google_sign_in / play-services-auth / googleid in pubspec.lock or gradle, or +in a built APK with `--apk`; apps use `google_account_token` instead. + ## Flutter apps: the launcher rules Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked diff --git a/repo_contract/install.py b/repo_contract/install.py index 1cad362..07e8765 100644 --- a/repo_contract/install.py +++ b/repo_contract/install.py @@ -25,6 +25,7 @@ from dep_freshness.install import NO_WORKFLOW_MARKER from repo_contract import bootstrap from repo_contract.check import violations from repo_contract.infer import propose +from repo_contract.install_gms import install_gms, plan_gms from repo_contract.install_web import install_web, plan_web from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook from repo_contract.stacks import detect_stacks @@ -64,6 +65,7 @@ def plan(repo: Path) -> list[str]: A website (detected or declared in repo-contract.toml) also gets the AgentReady gate: config, shim, workflow and hook (see install_web.py). + A Flutter Android app also gets the no-GMS-sign-in gate (install_gms.py). """ todo = [] if _needs_shim(repo): @@ -72,7 +74,7 @@ def plan(repo: Path) -> list[str]: todo.append(str(WORKFLOW)) if needs_hook(repo, HOOK_ID): todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)") - return todo + plan_web(repo) + return todo + plan_web(repo) + plan_gms(repo) def install(repo: Path) -> list[str]: @@ -92,7 +94,7 @@ def install(repo: Path) -> list[str]: if needs_hook(repo, HOOK_ID): write_hook(repo, _template("pre-commit-snippet.yaml")) done.append(f"{PRECOMMIT} ({HOOK_ID} hook)") - return done + install_web(repo) + return done + install_web(repo) + install_gms(repo) def bootstrap_commands(repo: Path) -> list[str]: diff --git a/repo_contract/install_gms.py b/repo_contract/install_gms.py new file mode 100644 index 0000000..f54b983 --- /dev/null +++ b/repo_contract/install_gms.py @@ -0,0 +1,62 @@ +"""The no-GMS-sign-in half of the installer: Flutter Android apps get the gate. + +Why it rides on install_repo_contract_gate.sh: that installer is the one step +every new repo gets after `git init`, so a new Flutter app cannot come out of +scaffolding able to re-add google_sign_in (13 apps were migrated off it onto +utils/google_account_token in November 2026). Pieces: the +scripts/check_no_gms_signin.sh delegate and an always-run pre-commit hook. +Only repos with a pubspec.yaml next to an android/ dir (at the root or one +level down, e.g. tutor/app) get it; utils itself holds the real gate. +""" + +from __future__ import annotations + +from pathlib import Path + +from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook + +TEMPLATES = Path(__file__).resolve().parent / "templates" +SHIM = Path("scripts/check_no_gms_signin.sh") +HOOK_ID = "no-gms-signin" +HOOK_PIECE = f"{PRECOMMIT} ({HOOK_ID} hook)" +SHIM_TEMPLATE = "check_no_gms_signin.shim.sh" + + +def _template(name: str) -> str: + return (TEMPLATES / name).read_text(encoding="utf-8") + + +def is_flutter_android(repo: Path) -> bool: + """A pubspec.yaml beside an android/ dir, at the root or one level down.""" + if (repo / "repo_contract" / "check.py").is_file(): + return False # utils: SHIM is the real gate, never a delegate + pubspecs = [repo / "pubspec.yaml", *repo.glob("*/pubspec.yaml")] + return any(p.is_file() and (p.parent / "android").is_dir() for p in pubspecs) + + +def plan_gms(repo: Path) -> list[str]: + """Which no-GMS-sign-in gate pieces are missing or drifted.""" + if not is_flutter_android(repo): + return [] + todo = [] + shim = repo / SHIM + if not shim.is_file() or shim.read_text(encoding="utf-8") != _template( + SHIM_TEMPLATE + ): + todo.append(str(SHIM)) + if needs_hook(repo, HOOK_ID): + todo.append(HOOK_PIECE) + return todo + + +def install_gms(repo: Path) -> list[str]: + """Write every missing or drifted no-GMS-sign-in piece. Returns them.""" + todo = plan_gms(repo) + if str(SHIM) in todo: + target = repo / SHIM + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(_template(SHIM_TEMPLATE), encoding="utf-8") + target.chmod(0o755) + if HOOK_PIECE in todo: + write_hook(repo, _template("no-gms-signin-hook.yaml")) + return todo diff --git a/repo_contract/templates/check_no_gms_signin.shim.sh b/repo_contract/templates/check_no_gms_signin.shim.sh new file mode 100644 index 0000000..5683f7f --- /dev/null +++ b/repo_contract/templates/check_no_gms_signin.shim.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# ============================================================================ +# Fail if this app links Google's non-free sign-in (google_sign_in, +# play-services-auth, googleid) instead of google_account_token. +# +# Thin delegate to the shared gate in ~/src/utils, which owns the package +# list and the fix message. Copy this file to scripts/check_no_gms_signin.sh +# in a repo; never copy the shared gate itself. +# ============================================================================ + +set -euo pipefail + +readonly SHARED_GATE="${UTILS_ROOT:-$HOME/src/utils}/scripts/check_no_gms_signin.sh" + +main() { + if [[ ! -x "$SHARED_GATE" ]]; then + echo "Error: shared no-gms-signin gate not found at $SHARED_GATE" >&2 + echo " Clone github.com/kuhyx/utils to ~/src/utils, or set" >&2 + echo " UTILS_ROOT to where it lives." >&2 + exit 1 + fi + + exec bash "$SHARED_GATE" "$@" +} + +main "$@" diff --git a/repo_contract/templates/no-gms-signin-hook.yaml b/repo_contract/templates/no-gms-signin-hook.yaml new file mode 100644 index 0000000..dc0a6eb --- /dev/null +++ b/repo_contract/templates/no-gms-signin-hook.yaml @@ -0,0 +1,12 @@ +# Added under `repos: - repo: local: hooks:` by install_repo_contract_gate.sh +# for any repo with a pubspec.yaml beside an android/ dir (a Flutter app). + # No Google non-free sign-in: google_sign_in links play-services-auth / + # googleid into the APK; apps use utils/google_account_token instead. + # always_run rather than `files:`: a poisoned pubspec.lock can arrive by + # pull or merge, and a staged-only trigger would never look again. + - id: no-gms-signin + name: no google_sign_in / GMS auth (use google_account_token) + entry: scripts/check_no_gms_signin.sh . + language: system + pass_filenames: false + always_run: true diff --git a/repo_contract/tests/test_install_gms.py b/repo_contract/tests/test_install_gms.py new file mode 100644 index 0000000..70d280a --- /dev/null +++ b/repo_contract/tests/test_install_gms.py @@ -0,0 +1,77 @@ +"""A Flutter repo with an Android target gets the no-GMS-sign-in gate.""" + +from __future__ import annotations + +import stat +import subprocess +from pathlib import Path + +from repo_contract import install as inst +from repo_contract import install_gms as gms + +from .conftest import write + + +def flutter_android(root: Path, sub: str = "") -> Path: + """A Flutter project at `root/sub` with an android/ dir.""" + base = f"{sub}/" if sub else "" + write(root, f"{base}pubspec.yaml", "name: x\n") + write(root, f"{base}android/app/build.gradle.kts", "plugins {}\n") + return root + + +def test_no_pubspec_means_no_gate(repo: Path) -> None: + assert gms.plan_gms(repo) == [] + assert gms.install_gms(repo) == [] + + +def test_pubspec_without_android_means_no_gate(repo: Path) -> None: + write(repo, "pubspec.yaml", "name: x\n") + assert gms.plan_gms(repo) == [] + + +def test_root_flutter_android_gets_shim_and_hook(repo: Path) -> None: + flutter_android(repo) + assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE] + assert inst.install(repo)[-2:] == [str(gms.SHIM), gms.HOOK_PIECE] + shim = repo / gms.SHIM + assert shim.stat().st_mode & stat.S_IXUSR + config = (repo / inst.PRECOMMIT).read_text(encoding="utf-8") + assert f"id: {gms.HOOK_ID}" in config + assert "always_run: true" in config + assert gms.plan_gms(repo) == [] + assert gms.install_gms(repo) == [] + + +def test_nested_app_dir_counts(repo: Path) -> None: + flutter_android(repo, "app") + assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE] + + +def test_drifted_shim_is_rewritten_hook_kept(repo: Path) -> None: + flutter_android(repo) + gms.install_gms(repo) + write(repo, gms.SHIM.as_posix(), "#!/bin/bash\nexit 0\n") + assert gms.install_gms(repo) == [str(gms.SHIM)] + + +def test_utils_itself_is_never_given_a_shim(repo: Path) -> None: + flutter_android(repo, "google_account_token") + write(repo, "repo_contract/check.py") + assert gms.plan_gms(repo) == [] + + +def test_installed_shim_delegates_to_the_shared_gate(repo: Path) -> None: + flutter_android(repo) + write(repo, "pubspec.lock", "packages:\n google_sign_in_android:\n") + gms.install_gms(repo) + utils_root = Path(__file__).resolve().parents[2] + proc = subprocess.run( + ["bash", str(repo / gms.SHIM), str(repo)], + capture_output=True, + text=True, + check=False, + env={"UTILS_ROOT": str(utils_root), "PATH": "/usr/bin:/bin"}, + ) + assert proc.returncode == 1 + assert "google_account_token" in proc.stderr diff --git a/scripts/check_no_gms_signin.sh b/scripts/check_no_gms_signin.sh new file mode 100755 index 0000000..67f4b41 --- /dev/null +++ b/scripts/check_no_gms_signin.sh @@ -0,0 +1,180 @@ +#!/bin/bash + +# ============================================================================ +# Fail if a Flutter/Android app links Google's non-free sign-in stack. +# +# Every app signs in through kuhy's FOSS plugin google_account_token +# (utils/google_account_token, Android AccountManager). google_sign_in pulls +# google_sign_in_android, which links play-services-auth / googleid into the +# APK. Nine apps were migrated off it on 2026-11-02 and four more on +# 2026-11-10; nothing else stops a later session from re-adding it. +# +# Lockfile mode (default) reads pubspec.lock package keys, plus the gradle +# files under each lock's sibling android/ dir. Web-only pieces +# (google_sign_in_web, google_sign_in_platform_interface and their transitive +# google_identity_services_web) are allowed while google_sign_in_android is +# absent: kartka's web build uses them for the GIS button and they never +# reach an APK. APK mode greps the built dex for the GMS auth classes, the +# ground truth when a dependency arrives some other way. +# +# Usage: check_no_gms_signin.sh [pubspec.lock | dir ...] +# check_no_gms_signin.sh --apk app.apk +# A dir is searched for pubspec.lock files (build/, .dart_tool/ pruned). +# With no arguments, checks ./pubspec.lock (passes if there is none). +# Exit: 0 clean | 1 non-free sign-in found | 2 usage error +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME +readonly BANNED=(google_sign_in google_sign_in_android google_sign_in_ios) +readonly WEB_ONLY=(google_sign_in_web google_sign_in_platform_interface + google_identity_services_web) +# Maven coordinates (and split-form catalog names) of the GMS sign-in stack. +# Anchored so com.google.gms:google-services (Firebase) and +# play-services-auth-api-phone do not trip it. +readonly GRADLE_PATTERN='com\.google\.android\.gms:play-services-(auth|identity-credentials)([^-[:alnum:]]|$)|com\.google\.android\.libraries\.identity\.googleid|androidx\.credentials:credentials-play-services-auth|name[[:space:]]*=[[:space:]]*"(play-services-auth|play-services-identity-credentials|credentials-play-services-auth|googleid)"' +readonly DEX_PATTERN='Lcom/google/android/(gms/auth|libraries/identity/googleid)/' +readonly PRUNE=(-name build -o -name .dart_tool -o -name .git -o -name .gradle -o -name .claude + -o -name node_modules) + +usage() { + echo "Usage: $SCRIPT_NAME [pubspec.lock | dir ...]" + echo " $SCRIPT_NAME --apk app.apk" + echo " Fails when an app links Google's non-free sign-in (GMS auth)." + exit 0 +} + +fix_hint() { + echo " Use google_account_token instead (FOSS, AccountManager):" >&2 + echo " google_account_token:" >&2 + echo " git:" >&2 + echo " url: https://github.com/kuhyx/utils" >&2 + echo " ref: google_account_token-v0.1.0" >&2 + echo " path: google_account_token" >&2 + echo " See utils/google_account_token/README.md." >&2 +} + +# Package keys of a pubspec.lock: the 2-space keys under `packages:`. +lock_packages() { + awk ' + /^packages:[[:space:]]*$/ { in_packages = 1; next } + /^[^[:space:]]/ { in_packages = 0; next } + in_packages && /^ [A-Za-z0-9_]+:[[:space:]]*$/ { + sub(/^ /, ""); sub(/:.*/, ""); print + } + ' "$1" +} + +# Prints the offending packages of one lock, one per line. +lock_offenders() { + local -A present=() + local name + while IFS= read -r name; do + present["$name"]=1 + done < <(lock_packages "$1") + for name in "${BANNED[@]}"; do + [[ -n "${present[$name]:-}" ]] && echo "$name" + done + if [[ -n "${present[google_sign_in_android]:-}" ]]; then + for name in "${WEB_ONLY[@]}"; do + [[ -n "${present[$name]:-}" ]] && echo "$name" + done + fi + return 0 +} + +# Prints file:line:text for every non-comment GMS sign-in coordinate under +# the lock's sibling android/ dir. +gradle_offenders() { + local android_dir="$1/android" file + [[ -d "$android_dir" ]] || return 0 + while IFS= read -r -d '' file; do + grep -nE "$GRADLE_PATTERN" "$file" \ + | grep -vE '^[0-9]+:[[:space:]]*(//|#|\*|/\*)' \ + | sed "s|^|$file:|" || true + done < <(find "$android_dir" \( "${PRUNE[@]}" \) -prune -o -type f \ + \( -name 'build.gradle*' -o -name '*.versions.toml' \) -print0) +} + +indent() { + local line + while IFS= read -r line; do + echo " $line" + done +} + +check_lock() { + local lock="$1" offenders gradle + offenders="$(lock_offenders "$lock")" + gradle="$(gradle_offenders "$(dirname "$lock")")" + [[ -z "$offenders" && -z "$gradle" ]] && return 0 + if [[ -n "$offenders" ]]; then + echo "Error: $lock links Google's non-free sign-in:" >&2 + indent <<<"$offenders" >&2 + fi + if [[ -n "$gradle" ]]; then + echo "Error: gradle names the GMS sign-in stack:" >&2 + indent <<<"$gradle" >&2 + fi + return 1 +} + +check_apk() { + local apk="$1" dex count + [[ -f "$apk" ]] || { echo "Error: no such APK: $apk" >&2; exit 2; } + count="$(for dex in $(unzip -Z1 "$apk" 'classes*.dex'); do + unzip -p "$apk" "$dex" + done | strings | grep -cE "$DEX_PATTERN" || true)" + if [[ "$count" -gt 0 ]]; then + echo "Error: $apk has $count GMS sign-in class references" >&2 + echo " (Lcom/google/android/gms/auth or .../identity/googleid)." >&2 + fix_hint + return 1 + fi +} + +# Expands the arguments into pubspec.lock paths, NUL-separated. +collect_locks() { + local arg + for arg in "$@"; do + if [[ -d "$arg" ]]; then + find "$arg" \( "${PRUNE[@]}" \) -prune -o -type f \ + -name pubspec.lock -print0 + else + printf '%s\0' "$arg" + fi + done +} + +main() { + case "${1:-}" in + -h|--help) usage ;; + --apk) + [[ $# -eq 2 ]] || { echo "Usage: $SCRIPT_NAME --apk app.apk" >&2; exit 2; } + check_apk "$2" + return + ;; + esac + if [[ $# -eq 0 ]]; then + [[ -f pubspec.lock ]] || return 0 + set -- pubspec.lock + fi + + local -a locks=() + local arg lock failed=0 + # Validated up front: collect_locks runs in a process substitution, whose + # exit status would be lost, and a typo'd path must not pass silently. + for arg in "$@"; do + [[ -e "$arg" ]] || { echo "Error: no such file or directory: $arg" >&2; exit 2; } + done + mapfile -d '' locks < <(collect_locks "$@") + for lock in "${locks[@]}"; do + check_lock "$lock" || failed=1 + done + [[ "$failed" -eq 0 ]] || fix_hint + return "$failed" +} + +main "$@" diff --git a/scripts/install_repo_contract_gate.sh b/scripts/install_repo_contract_gate.sh index f173aee..c2345ac 100755 --- a/scripts/install_repo_contract_gate.sh +++ b/scripts/install_repo_contract_gate.sh @@ -9,6 +9,7 @@ # when the repo has .dep-freshness-no-workflow), Commands section. A website # (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also # gets the AgentReady gate: config table, shim, hook, agent-ready workflow. +# A Flutter app with android/ also gets the no-GMS-sign-in gate (shim + hook). # # Usage: scripts/install_repo_contract_gate.sh [--check] [--no-bootstrap] # Exit: 0 contract satisfied | 1 violations left (e.g. ) | 2 not a git repo diff --git a/scripts/tests/test_check_no_gms_signin.py b/scripts/tests/test_check_no_gms_signin.py new file mode 100644 index 0000000..312f4c9 --- /dev/null +++ b/scripts/tests/test_check_no_gms_signin.py @@ -0,0 +1,158 @@ +"""check_no_gms_signin.sh: no app may link Google's non-free sign-in stack. + +Each test writes a throwaway Flutter-shaped project (pubspec.lock, optional +android/ gradle files) or a fake APK built with zipfile, runs the real +script, and asserts on the exit code and the message. +""" + +from __future__ import annotations + +import subprocess +import zipfile +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "check_no_gms_signin.sh" + +# The package keys of kuhy's real kartka lock (web-only GIS button). +KARTKA_KEYS = ( + "google_account_token", + "google_identity_services_web", + "google_sign_in_platform_interface", + "google_sign_in_web", +) + + +def _lock(root: Path, *packages: str) -> Path: + """Write a pubspec.lock under `root` with the given package keys.""" + root.mkdir(parents=True, exist_ok=True) + body = ["# Generated by pub", "packages:"] + for name in ("collection", *packages): + body += [ + f" {name}:", + ' dependency: "direct main"', + " description:", + f" name: {name}", + " source: hosted", + ' version: "1.0.0"', + ] + body += ["sdks:", ' dart: ">=3.0.0 <4.0.0"', ""] + lock = root / "pubspec.lock" + lock.write_text("\n".join(body), encoding="utf-8") + return lock + + +def _run(*args: str | Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(SCRIPT), *map(str, args)], + capture_output=True, + text=True, + check=False, + ) + + +def test_clean_lock_passes(tmp_path: Path) -> None: + lock = _lock(tmp_path, "google_account_token", "http") + assert _run(lock).returncode == 0 + + +def test_google_sign_in_android_fails_and_names_the_fix(tmp_path: Path) -> None: + lock = _lock(tmp_path, "google_sign_in_android") + proc = _run(lock) + assert proc.returncode == 1 + assert str(lock) in proc.stderr + assert "google_sign_in_android" in proc.stderr + assert "google_account_token-v0.1.0" in proc.stderr + assert "google_account_token/README.md" in proc.stderr + + +def test_app_facing_google_sign_in_fails(tmp_path: Path) -> None: + proc = _run(_lock(tmp_path, "google_sign_in")) + assert proc.returncode == 1 + assert "google_sign_in " in proc.stderr or "google_sign_in\n" in proc.stderr + + +def test_kartka_style_web_only_passes(tmp_path: Path) -> None: + assert _run(_lock(tmp_path, *KARTKA_KEYS)).returncode == 0 + + +def test_web_pieces_fail_once_android_is_linked(tmp_path: Path) -> None: + proc = _run(_lock(tmp_path, *KARTKA_KEYS, "google_sign_in_android")) + assert proc.returncode == 1 + assert "google_sign_in_web" in proc.stderr + + +def test_gradle_play_services_auth_fails(tmp_path: Path) -> None: + lock = _lock(tmp_path) + gradle = tmp_path / "android" / "app" / "build.gradle.kts" + gradle.parent.mkdir(parents=True) + gradle.write_text( + 'dependencies {\n implementation("com.google.gms:google-services:4")\n' + ' implementation("com.google.android.gms:play-services-auth:21.3.0")\n}\n', + encoding="utf-8", + ) + proc = _run(lock) + assert proc.returncode == 1 + assert str(gradle) in proc.stderr + assert "play-services-auth" in proc.stderr + + +def test_versions_toml_googleid_fails(tmp_path: Path) -> None: + lock = _lock(tmp_path) + toml = tmp_path / "android" / "gradle" / "libs.versions.toml" + toml.parent.mkdir(parents=True) + toml.write_text( + 'googleid = "com.google.android.libraries.identity.googleid:googleid:1.1"\n', + encoding="utf-8", + ) + assert _run(lock).returncode == 1 + + +def test_firebase_google_services_plugin_passes(tmp_path: Path) -> None: + lock = _lock(tmp_path) + gradle = tmp_path / "android" / "build.gradle" + gradle.parent.mkdir(parents=True) + gradle.write_text( + "// play-services-auth was removed 2026-11-02\n" + "classpath 'com.google.gms:google-services:4.4.2'\n", + encoding="utf-8", + ) + assert _run(lock).returncode == 0 + + +def test_directory_mode_finds_nested_locks_and_prunes_build(tmp_path: Path) -> None: + _lock(tmp_path / "app", "google_account_token") + _lock(tmp_path / "app" / "build" / "x", "google_sign_in_android") + _lock(tmp_path / ".dart_tool" / "y", "google_sign_in_android") + # Agent worktrees are other checkouts, judged in their own repo. + _lock(tmp_path / ".claude" / "worktrees" / "z", "google_sign_in_android") + assert _run(tmp_path).returncode == 0 + _lock(tmp_path / "other", "google_sign_in_ios") + proc = _run(tmp_path) + assert proc.returncode == 1 + assert "google_sign_in_ios" in proc.stderr + + +def test_missing_explicit_file_is_a_usage_error(tmp_path: Path) -> None: + assert _run(tmp_path / "nope.lock").returncode == 2 + + +def _apk(path: Path, dex: bytes) -> Path: + with zipfile.ZipFile(path, "w") as apk: + apk.writestr("AndroidManifest.xml", b"\x03\x00") + apk.writestr("classes.dex", b"dex\n035\x00" + dex) + apk.writestr("classes2.dex", b"dex\n035\x00Lkotlin/Unit;\x00") + return path + + +def test_apk_with_gms_auth_classes_fails(tmp_path: Path) -> None: + dex = b"\x00Lcom/google/android/gms/auth/api/signin/GoogleSignIn;\x00" * 2 + proc = _run("--apk", _apk(tmp_path / "bad.apk", dex)) + assert proc.returncode == 1 + assert "2" in proc.stderr + assert "google_account_token" in proc.stderr + + +def test_apk_without_gms_auth_classes_passes(tmp_path: Path) -> None: + dex = b"\x00Lcom/google/android/gms/common/api/Status;\x00" + proc = _run("--apk", _apk(tmp_path / "good.apk", dex)) + assert proc.returncode == 0, proc.stderr