diff --git a/.github/workflows/android-termius-study.yml b/.github/workflows/android-termius-study.yml new file mode 100644 index 0000000000..9bdf91d44a --- /dev/null +++ b/.github/workflows/android-termius-study.yml @@ -0,0 +1,80 @@ +name: Isolated API36 Termius official-channel study +on: + pull_request: + branches: [candidate/android-mobile-key-enrollment-20261008] + types: [opened, synchronize, reopened] + paths: [docs/research/termius-android/**, .github/workflows/android-termius-study.yml] +permissions: + contents: read +concurrency: + group: termius-study-${{ github.head_ref }} + cancel-in-progress: true +jobs: + study: + if: github.repository == 'Mafei/tabby' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && github.head_ref == 'candidate/android-termius-runtime-study-20261009' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + # Existing explicit approval covers only this exact Android SDK agreement. + TABBY_ANDROID_SDK_LICENSE_APPROVED_SHA256: 1f8729233617b193fd619213792ae16a41b95d2bbbf525dfe66998252ba68b16 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + - name: Disposable tool and evidence directories + run: | + printf 'ANDROID_HOME=%s/termius-study-sdk\nANDROID_SDK_ROOT=%s/termius-study-sdk\nANDROID_USER_HOME=%s/termius-study-user\nANDROID_AVD_HOME=%s/termius-study-avd\n' "$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV" + mkdir -p study-artifacts + git rev-parse HEAD > study-artifacts/source-sha.txt + - name: Validate research consent and privacy guards + run: python3 -m unittest discover -s docs/research/termius-android -p 'test_*.py' -v + - name: Verify official Play image, ABI, approved agreements and install channel + id: preflight + run: python3 docs/research/termius-android/study.py preflight --output study-artifacts + - uses: actions/setup-java@v4 + if: steps.preflight.outputs.can_install == 'true' + with: + distribution: temurin + java-version: 21 + - uses: actions/setup-node@v4 + if: steps.preflight.outputs.can_install == 'true' + with: + node-version: 24.19.0 + - name: Install verified official SDK and Play image without accepting new terms + id: sdk + if: steps.preflight.outputs.can_install == 'true' + run: python3 docs/research/termius-android/study.py install --output study-artifacts + - name: Start only one disposable API36 emulator using existing KVM path + id: boot + if: steps.sdk.outcome == 'success' + run: | + trap 'python3 docs/research/termius-android/study.py diagnose --output study-artifacts' EXIT + node mobile/scripts/prepare-android-emulator.mjs --check-capacity + test -c /dev/kvm + study_runner_uid="$(id -u)" + sudo setfacl -m "u:${study_runner_uid}:rw" /dev/kvm + mkdir -p "$ANDROID_USER_HOME" "$ANDROID_AVD_HOME" + printf 'no\n' | "$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd --force --name termius-study-api36 --package 'system-images;android-36;google_apis_playstore;x86_64' --device pixel_7 + test -f "$ANDROID_AVD_HOME/termius-study-api36.avd/config.ini" + "$ANDROID_HOME/emulator/emulator" -no-window -no-audio -version + "$ANDROID_HOME/emulator/emulator" -avd termius-study-api36 -no-window -no-audio -no-snapshot -no-boot-anim -gpu software -accel on -memory 4096 -cores 4 -port 5554 -verbose > "$RUNNER_TEMP/termius-study-emulator.log" 2>&1 & + "$ANDROID_HOME/platform-tools/adb" start-server + node mobile/scripts/prepare-android-emulator.mjs --serial emulator-5554 --api 36 + - name: Observe official install entry and stop at login, terms or permissions + if: always() && steps.sdk.outcome == 'success' + run: python3 docs/research/termius-android/study.py observe --output study-artifacts --boot-ready '${{ steps.boot.outcome == 'success' }}' + - name: Stop only the disposable emulator + if: always() + run: | + if [ -x "$ANDROID_HOME/platform-tools/adb" ]; then + "$ANDROID_HOME/platform-tools/adb" -s emulator-5554 emu kill || true + fi + - name: Upload bounded public research evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: Termius-API36-Official-Study-${{ github.event.pull_request.head.sha }} + path: study-artifacts/ + if-no-files-found: error + retention-days: 14 diff --git a/docs/research/termius-android/README.md b/docs/research/termius-android/README.md new file mode 100644 index 0000000000..c791c16c5e --- /dev/null +++ b/docs/research/termius-android/README.md @@ -0,0 +1,74 @@ +# Isolated Termius Android runtime study + +This independent research candidate starts from sealed Android product +`5ebd250a499f6dff52f4713f84ebcbece09b81c7`. It changes no product source, +product acceptance gates, existing workflow, PR6, PR8 or desktop candidate. +The user authorized one API36 emulator study and its independent draft PR. +This does not authorize implementation of the proposed Termius-inspired UI. + +The new workflow runs only for this fork's exact research head targeting the +sealed key-enrollment branch. It uses `contents: read`, checks out the exact PR +head, has a separate concurrency group, and runs one Ubuntu 24.04 KVM job. +It does not trigger the product Android matrix or merge/release anything. + +## First run: verify official installation prerequisites + +The proposed image is exactly +`system-images;android-36;google_apis_playstore;x86_64`, with Pixel 7 geometry. +This is a research image choice, not a change to the non-Play product matrix. +Official SDK repository metadata must confirm its stable package, API and ABI. +The existing SDK agreement SHA256 +`1f8729233617b193fd619213792ae16a41b95d2bbbf525dfe66998252ba68b16` +is reused only after the exact published agreement text matches. The installer +has no `--licenses`, automatic agreement reply or alternative download route. +An added/changed agreement on a requested stable package is saved as public +license evidence and blocks SDK installation. The published catalogs also +contain unrelated preview packages; these are not selected or accepted, and +the report limits selected-package/permission evidence to channel-0 records. +SDK metadata/archives use Google's official repository and the +existing pinned command-line-tools checksum. + +The official [Termius installation documentation](https://docs.termius.com/getting-started/download-termius) +points Android users to [Google Play](https://play.google.com/store/apps/details?id=com.server.auditor.ssh.client). +Public pages are checked without cookies, credentials or login. No unofficial +APK, application-package redistribution or account export is used. + +If prerequisites pass, the job starts a fresh disposable API36 emulator using +the existing KVM/capacity/readiness path. It records API, ABI, screen size, +density and font scale, then opens only the official Play listing intent. +It never clicks Install, Sign in, Accept, purchase or a permission button. +The first run stops at the actual store/login/terms screen for review. A green +completed research job is not successful Termius installation or Android +product acceptance: consult `preflight.json` and `observation.json`. + +## Evidence and later observation scope + +CI artifacts bind the exact source SHA/tree, metadata hashes, selected +agreements and actual `adb screencap` capture times/dimensions/SHA256. Fresh AVD +data is never imported. A UI-tree privacy check rejects account-like text, +password fields and nonempty editable fields before storing a screenshot. +Screenshots receive pixel review after retrieval. No app logs, credential +inputs, account data, AVD snapshot or Termius APK are published. +The disposable emulator's startup diagnostic is limited to the last 160 lines +and 128 KiB, with authentication-related lines omitted. It collects no Android +logcat or application logs. This diagnostic does not relax the existing startup +deadline, real native focus checks or product acceptance requirements. + +When official installation is possible within authorized boundaries, further +research should record Termius version/installer/ABI and observe Hosts, session +switching, IME open/closed, expanded/scrolling keys, paste, local preedit/input +placement, application Home and system Home behavior. These are **not yet +observed** by this first-run prerequisite harness. Account creation, credentials, +new terms, paid features and unapproved permissions remain stop points. Real +SSH hosts and the user's phone are excluded. Chinese IME behavior requires an +actual available IME and cannot be inferred from synthetic InputConnection +tests, marketing screenshots or iOS documentation. + +The official [AVD documentation](https://developer.android.com/studio/run/managing-avds) +distinguishes Google APIs from Play Store images. The already completed +[official-image UI review](https://github.com/Mafei/tabby/pull/8) remains a design +artifact; Play listing images, actual emulator captures and our own design +screens must retain distinct labels. No absence of a visible editor in a static +image establishes how the app implements composition or input focus. + +AI disclosure: fully vibe coded by an AI coding agent. diff --git a/docs/research/termius-android/study.py b/docs/research/termius-android/study.py new file mode 100644 index 0000000000..56285266cb --- /dev/null +++ b/docs/research/termius-android/study.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Isolated API36 official-channel preflight. No account or consent automation.""" +import argparse +from datetime import datetime, timezone +import hashlib +from html.parser import HTMLParser +import importlib.util +import json +import os +from pathlib import Path +import re +import struct +import subprocess +import time +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET +import zipfile + +ROOT = Path(__file__).resolve().parents[3] +SPEC = importlib.util.spec_from_file_location('approved_sdk', ROOT / 'mobile/scripts/install-android-ci.py') +SDK = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SDK) +PACKAGE = 'system-images;android-36;google_apis_playstore;x86_64' +IMAGE = {'package': PACKAGE, 'platform': '36', 'tag': 'google_apis_playstore'} +CATALOG = 'https://dl.google.com/android/repository/sys-img/google_apis_playstore/sys-img2-3.xml' +INSTALL_PACKAGES = ['platform-tools', 'emulator', PACKAGE] +TERMIUS = 'com.server.auditor.ssh.client' +OFFICIAL_DOC = 'https://docs.termius.com/getting-started/download-termius' +PLAY = 'https://play.google.com/store/apps/details?id=' + TERMIUS + + +def utc(): + return datetime.now(timezone.utc).isoformat() + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def write_json(path, value): + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n') + + +def run(args, timeout=30, binary=False): + result = subprocess.run(args, capture_output=True, timeout=timeout, check=True) + return result.stdout if binary else result.stdout.decode('utf-8', errors='replace').strip() + + +def download(url, maximum=8 * 1024 * 1024): + # Normal official URLs only. No cookies, login, proxy changes or denied-route retry. + request = urllib.request.Request(url, headers={'User-Agent': 'Tabby-Android-UI-Research'}) + with urllib.request.urlopen(request, timeout=30) as response: + data = response.read(maximum + 1) + if len(data) > maximum: + raise ValueError('DOWNLOAD_SIZE_LIMIT') + return data + + +def packages(root, names): + return [p for p in SDK.children(root, 'remotePackage') if p.get('path') in names + and len(SDK.children(p, 'channelRef')) == 1 + and SDK.children(p, 'channelRef')[0].get('ref') == 'channel-0'] + + +def license_evidence(root, selected, output): + refs = sorted({x.get('ref') for p in packages(root, selected) for x in SDK.children(p, 'uses-license')}) + evidence = [] + for ref in refs: + if not ref or not re.fullmatch(r'[a-z0-9-]+', ref): + raise ValueError('LICENSE_ID_INVALID') + values = [x.text for x in SDK.children(root, 'license') if x.get('id') == ref] + item = {'id': ref, 'definitions': len(values), 'alreadyApproved': False} + if len(values) == 1 and isinstance(values[0], str): + data = values[0].encode() + item.update({'sha256': digest(data), 'bytes': len(data)}) + item['alreadyApproved'] = ref == 'android-sdk-license' and digest(data) == SDK.LICENSE_SHA256 + (output / ('license-' + ref + '.txt')).write_bytes(data) + evidence.append(item) + return evidence + + +class Links(HTMLParser): + def __init__(self): + super().__init__() + self.links = [] + + def handle_starttag(self, tag, attrs): + if tag == 'a': + value = dict(attrs).get('href', '') + if value.startswith('https://play.google.com/store/apps/details?') and ('id=' + TERMIUS) in value: + self.links.append(value) + + +def official_channel(): + result = {'officialDocumentation': OFFICIAL_DOC, 'playListing': PLAY, + 'appPackage': TERMIUS, 'apkDownloaded': False, 'versionVerified': False} + try: + text = download(OFFICIAL_DOC).decode('utf-8') + parser = Links() + parser.feed(text) + # GitBook may serve Markdown rather than HTML. + result['documentationLinksPlay'] = bool(parser.links) or bool(re.search( + r'https://play\.google\.com/store/apps/details\?id=com\.server\.auditor\.ssh\.client', text)) + result['documentationHTTP'] = 200 + except (urllib.error.URLError, ValueError) as error: + result['documentationError'] = type(error).__name__ + if isinstance(error, urllib.error.HTTPError): + result['documentationHTTP'] = error.code + try: + text = download(PLAY).decode('utf-8') + result['listingHTTP'] = 200 + result['listingNamesPublisher'] = 'Termius Corporation' in text + except (urllib.error.URLError, ValueError) as error: + result['listingError'] = type(error).__name__ + if isinstance(error, urllib.error.HTTPError): + result['listingHTTP'] = error.code + return result + + +def preflight(output): + report = {'createdUTC': utc(), 'sourceSHA': run(['git', 'rev-parse', 'HEAD']), + 'sourceTree': run(['git', 'rev-parse', 'HEAD^{tree}']), + 'package': PACKAGE, 'requestedRuntimeAPI': 36, 'requestedABI': 'x86_64', + 'metadataURLs': [SDK.REPOSITORY_URL, CATALOG], 'canInstall': False, + 'sdkInstalled': False, 'termiusInstalled': False, 'termiusRan': False, + 'evidenceScope': 'requested channel-0 records only; catalog previews not selected or accepted', + 'officialChannel': official_channel(), 'licenseEvidence': []} + try: + if os.environ.get('TABBY_ANDROID_SDK_LICENSE_APPROVED_SHA256') != SDK.LICENSE_SHA256: + raise ValueError('APPROVED_SDK_CONSENT_NOT_PRESENT') + selected = set() + for name, url, wanted, image in [ + ('repository.xml', SDK.REPOSITORY_URL, {'platform-tools', 'emulator', 'cmdline-tools;22.0'}, None), + ('play-images.xml', CATALOG, {PACKAGE}, IMAGE), + ]: + data = download(url) + (output / name).write_bytes(data) + root = ET.fromstring(data) + report.setdefault('catalogs', []).append({'file': name, 'sha256': digest(data)}) + if image: + stable = [p for p in packages(root, wanted) if len(SDK.children(p, 'channelRef')) == 1 + and SDK.children(p, 'channelRef')[0].get('ref') == 'channel-0'] + report['imageMetadata'] = {'exactStableRecords': len(stable)} + if len(stable) == 1: + details = SDK.children(stable[0], 'type-details') + if len(details) == 1: + report['imageMetadata'].update({ + 'apiValues': [x.text for x in SDK.children(details[0], 'api-level')], + 'abiValues': [x.text for x in SDK.children(details[0], 'abi')], + 'tagIDs': [x.text for t in SDK.children(details[0], 'tag') for x in SDK.children(t, 'id')]}) + report['licenseEvidence'].extend(license_evidence(root, wanted, output)) + report.setdefault('selectedPackages', []).extend( + {'path': p.get('path'), 'agreements': [x.get('ref') for x in SDK.children(p, 'uses-license')]} + for p in packages(root, wanted)) + selected.update(SDK.verified_packages(root, wanted, image)) + if selected != {'platform-tools', 'emulator', 'cmdline-tools;22.0', PACKAGE}: + raise ValueError('EXACT_STABLE_PACKAGE_NOT_FOUND') + report.update({'status': 'OFFICIAL_IMAGE_VERIFIED', 'canInstall': True}) + except (SystemExit, ValueError, ET.ParseError, urllib.error.URLError) as error: + code = str(error) if isinstance(error, (SystemExit, ValueError)) else type(error).__name__ + report.update({'status': 'PREFLIGHT_BLOCKED', 'blocker': code}) + if isinstance(error, urllib.error.HTTPError): + report['httpStatus'] = error.code + write_json(output / 'preflight.json', report) + if os.environ.get('GITHUB_OUTPUT'): + with open(os.environ['GITHUB_OUTPUT'], 'a') as target: + target.write('can_install=' + str(report['canInstall']).lower() + '\n') + if os.environ.get('GITHUB_STEP_SUMMARY'): + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as target: + target.write('## Termius Android research\n\n' + report['status'] + '\n\n') + target.write('Termius has not been installed or run. No account, consent or permission automation.\n') + if not report['canInstall']: + target.write('\nBlocker: `' + report['blocker'] + '`\n') + print(json.dumps(report, indent=2)) + return report + + +def install(output): + report = json.loads((output / 'preflight.json').read_text()) + if not report['canInstall'] or report['sourceSHA'] != run(['git', 'rev-parse', 'HEAD']): + raise ValueError('VERIFIED_PREFLIGHT_REQUIRED') + if os.environ.get('TABBY_ANDROID_SDK_LICENSE_APPROVED_SHA256') != SDK.LICENSE_SHA256: + raise ValueError('APPROVED_SDK_CONSENT_NOT_PRESENT') + for name, wanted, image in [('repository.xml', {'platform-tools', 'emulator', 'cmdline-tools;22.0'}, None), + ('play-images.xml', {PACKAGE}, IMAGE)]: + data = (output / name).read_bytes() + expected = next(x['sha256'] for x in report['catalogs'] if x['file'] == name) + if digest(data) != expected or SDK.verified_packages(ET.fromstring(data), wanted, image) != wanted: + raise ValueError('PREFLIGHT_BYTES_CHANGED') + sdk = Path(os.environ['ANDROID_HOME']).resolve() + if sdk.name != 'termius-study-sdk' or not sdk.is_relative_to(Path(os.environ['RUNNER_TEMP']).resolve()): + raise ValueError('DISPOSABLE_SDK_DIRECTORY_REQUIRED') + if sdk.exists() and any(sdk.iterdir()): + raise ValueError('EMPTY_DISPOSABLE_SDK_REQUIRED') + archive = download('https://dl.google.com/android/repository/commandlinetools-linux-15859902_latest.zip', 512 * 1024 * 1024) + if digest(archive) != SDK.CLI_SHA256: + raise ValueError('OFFICIAL_CLI_CHECKSUM_MISMATCH') + archive_path = sdk.parent / 'termius-study-cli.zip' + archive_path.write_bytes(archive) + sdk.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(archive_path) as zipped: + for item in zipped.infolist(): + if item.filename.startswith('/') or '..' in Path(item.filename).parts or (item.external_attr >> 16) & 0o170000 == 0o120000: + raise ValueError('UNSAFE_OFFICIAL_ARCHIVE') + zipped.extractall(sdk) + tools = sdk / 'cmdline-tools' + temporary = sdk / 'cli-unpacked' + tools.rename(temporary) + tools.mkdir() + temporary.rename(tools / 'latest') + for executable in (tools / 'latest/bin').iterdir(): + executable.chmod(executable.stat().st_mode | 0o111) + licenses = sdk / 'licenses' + licenses.mkdir() + (licenses / 'android-sdk-license').write_text('\n' + SDK.ACCEPTED_SDK_HASH + '\n') + # Only the already approved agreement. An unexpected prompt has no stdin reply. + subprocess.run([str(tools / 'latest/bin/sdkmanager'), '--channel=0', '--sdk_root=' + str(sdk), + *INSTALL_PACKAGES], stdin=subprocess.DEVNULL, check=True, timeout=900) + report['sdkInstalled'] = True + write_json(output / 'preflight.json', report) + + +def privacy_check(xml): + root = ET.fromstring(xml) + texts = [value for node in root.iter('node') for key in ['text', 'content-desc'] + if (value := node.get(key, ''))] + if any(re.search(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}', text) for text in texts): + raise ValueError('SCREEN_MAY_CONTAIN_PERSONAL_ACCOUNT') + for node in root.iter('node'): + if node.get('password') == 'true' or (node.get('class', '').endswith('EditText') and node.get('text', '')): + raise ValueError('SCREEN_CONTAINS_NONEMPTY_OR_PASSWORD_EDITOR') + return texts + + +def bounded_startup_log(data, temporary): + # Only this fresh emulator's bounded startup tail; never logcat/app data. + text = data[-128 * 1024:].decode('utf-8', errors='replace') + lines = text.splitlines()[-160:] + return '\n'.join('[emulator authentication detail omitted]' if re.search( + r'jwt|jwks|token|authorization|bearer|secret', line, re.I) else + line.replace(str(temporary), '[RUNNER_TEMP]') for line in lines) + '\n' + + +def diagnose(output): + temporary = Path(os.environ['RUNNER_TEMP']).resolve() + log = temporary / 'termius-study-emulator.log' + if log.is_file(): + with log.open('rb') as source: + source.seek(max(0, log.stat().st_size - 128 * 1024)) + text = bounded_startup_log(source.read(128 * 1024), temporary) + (output / 'emulator-startup.txt').write_text(text) + print(text) + adb = Path(os.environ['ANDROID_HOME']) / 'platform-tools/adb' + state = 'UNAVAILABLE' + try: + value = run([str(adb), '-s', 'emulator-5554', 'get-state'], timeout=5) + state = value if value in ['device', 'offline', 'bootloader'] else 'UNKNOWN' + except subprocess.SubprocessError: + pass + report = {'createdUTC': utc(), 'sourceSHA': run(['git', 'rev-parse', 'HEAD']), + 'serial': 'emulator-5554', 'adbState': state, + 'startupLogPresent': log.is_file(), 'appLogsCollected': False} + write_json(output / 'startup-diagnostic.json', report) + print(json.dumps(report, indent=2)) + + +def observe(output, ready): + adb = str(Path(os.environ['ANDROID_HOME']) / 'platform-tools/adb') + def command(*args, binary=False): + return run([adb, '-s', 'emulator-5554', *args], binary=binary) + report = {'createdUTC': utc(), 'sourceSHA': run(['git', 'rev-parse', 'HEAD']), + 'actualEmulator': False, 'actualTermiusUI': False, 'termiusInstalled': False, + 'bootReadinessPassed': ready, 'screenshots': [], + 'noUserInputEntered': True, 'noAccountImported': True, 'noPermissionsGranted': True, + 'notAndroidProductAcceptance': True} + try: + if command('get-state') != 'device': + raise ValueError('EMULATOR_UNAVAILABLE') + qemu = command('shell', 'getprop', 'ro.boot.qemu') or command('shell', 'getprop', 'ro.kernel.qemu') + api = command('shell', 'getprop', 'ro.build.version.sdk') + abi = command('shell', 'getprop', 'ro.product.cpu.abi') + if qemu != '1' or api != '36' or abi != 'x86_64': + raise ValueError('SINGLE_API36_CLOUD_EMULATOR_REQUIRED') + report.update({'actualEmulator': True, 'api': int(api), 'abi': abi, + 'resolution': command('shell', 'wm', 'size'), 'density': command('shell', 'wm', 'density'), + 'fontScale': command('shell', 'settings', 'get', 'system', 'font_scale'), + 'model': command('shell', 'getprop', 'ro.product.model')}) + def capture(name): + command('shell', 'uiautomator', 'dump', '/data/local/tmp/termius-study-ui.xml') + xml = command('shell', 'cat', '/data/local/tmp/termius-study-ui.xml') + texts = privacy_check(xml) + png = command('exec-out', 'screencap', '-p', binary=True) + if png[:8] != b'\x89PNG\r\n\x1a\n': + raise ValueError('SCREENSHOT_NOT_PNG') + (output / name).write_bytes(png) + report['screenshots'].append({'file': name, 'source': 'actual adb screencap; fresh disposable API36 emulator', + 'capturedUTC': utc(), 'bytes': len(png), 'sha256': digest(png), + 'dimensions': list(struct.unpack('>II', png[16:24])), + 'privacy': 'UI tree checked; no input, imported account or user data'}) + return texts + capture('01-emulator-startup.png') + installed = command('shell', 'pm', 'list', 'packages', TERMIUS) + report['termiusInstalled'] = ('package:' + TERMIUS) in installed.splitlines() + if not ready: + report['status'] = 'EMULATOR_STARTUP_BLOCKED' + elif 'package:com.android.vending' not in command('shell', 'pm', 'list', 'packages', 'com.android.vending').splitlines(): + report['status'] = 'OFFICIAL_PLAY_STORE_UNAVAILABLE' + else: + # Opens the official store only. Never clicks Install, Sign in, Accept or a permission. + command('shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', + 'market://details?id=' + TERMIUS, '-p', 'com.android.vending') + time.sleep(3) + texts = capture('02-official-play-entry.png') + screen = '\n'.join(texts).lower() + report['status'] = 'OFFICIAL_INSTALL_REQUIRES_USER_ACTION' + if re.search(r'sign in|sign into|log in', screen): + report['blocker'] = 'GOOGLE_PLAY_SIGN_IN_REQUIRED' + elif 'terms' in screen or 'agree' in screen: + report['blocker'] = 'OFFICIAL_STORE_TERMS_REQUIRE_REVIEW' + else: + report['blocker'] = 'STORE_SCREEN_REQUIRES_REVIEW_BEFORE_INSTALL' + report['observedStudyCoverage'] = 'official store entry only; Termius Hosts/terminal/IME/Home unobserved' + except (ValueError, ET.ParseError, subprocess.SubprocessError) as error: + report.update({'status': 'OBSERVATION_BLOCKED', 'blocker': str(error) if isinstance(error, ValueError) else type(error).__name__}) + write_json(output / 'observation.json', report) + if os.environ.get('GITHUB_STEP_SUMMARY'): + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as target: + target.write('\nObservation: **' + report['status'] + '**. Actual Termius UI observed: **false**.\n') + if 'blocker' in report: + target.write('\nBlocker: `' + report['blocker'] + '`\n') + print(json.dumps(report, indent=2)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('phase', choices=['preflight', 'install', 'diagnose', 'observe']) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--boot-ready', choices=['true', 'false'], default='false') + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + if args.phase == 'preflight': + preflight(args.output) + elif args.phase == 'install': + install(args.output) + elif args.phase == 'diagnose': + diagnose(args.output) + else: + observe(args.output, args.boot_ready == 'true') + + +if __name__ == '__main__': + main() diff --git a/docs/research/termius-android/test_study.py b/docs/research/termius-android/test_study.py new file mode 100644 index 0000000000..ba1ead6a57 --- /dev/null +++ b/docs/research/termius-android/test_study.py @@ -0,0 +1,140 @@ +"""Safety-path tests; synthetic metadata is not emulator or app acceptance.""" +import hashlib +from contextlib import redirect_stdout +import io +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import urllib.error +import xml.etree.ElementTree as ET + +SPEC = importlib.util.spec_from_file_location('termius_study', Path(__file__).with_name('study.py')) +study = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(study) +LICENSE = 'synthetic approved SDK agreement' +DIGEST = hashlib.sha256(LICENSE.encode()).hexdigest() + + +def catalog(image=False, agreement='android-sdk-license', abi='x86_64'): + root = ET.Element('repository') + ET.SubElement(root, 'license', id='android-sdk-license').text = LICENSE + if agreement != 'android-sdk-license': + ET.SubElement(root, 'license', id=agreement).text = 'unapproved agreement' + names = [study.PACKAGE] if image else ['platform-tools', 'emulator', 'cmdline-tools;22.0'] + for name in names: + p = ET.SubElement(root, 'remotePackage', path=name) + ET.SubElement(ET.SubElement(p, 'revision'), 'major').text = '1' + ET.SubElement(p, 'channelRef', ref='channel-0') + ET.SubElement(p, 'uses-license', ref=agreement) + if image: + detail = ET.SubElement(p, 'type-details') + ET.SubElement(detail, 'api-level').text = '36' + ET.SubElement(detail, 'abi').text = abi + ET.SubElement(ET.SubElement(detail, 'tag'), 'id').text = 'google_apis_playstore' + return ET.tostring(root) + + +class StudyGuards(unittest.TestCase): + def preflight(self, directory, image, repository=None): + def downloaded(url): + return (repository or catalog()) if url == study.SDK.REPOSITORY_URL else image + with patch.object(study.SDK, 'LICENSE_SHA256', DIGEST), \ + patch.dict(study.os.environ, {'TABBY_ANDROID_SDK_LICENSE_APPROVED_SHA256': DIGEST}, clear=True), \ + patch.object(study, 'run', return_value='synthetic-sha'), \ + patch.object(study, 'official_channel', return_value={'apkDownloaded': False}), \ + patch.object(study, 'download', side_effect=downloaded), redirect_stdout(io.StringIO()): + return study.preflight(directory) + + def test_approved_exact_image_can_progress_but_installs_nothing(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + r = self.preflight(directory, catalog(image=True)) + self.assertTrue(r['canInstall']) + self.assertFalse(r['sdkInstalled']) + self.assertFalse(r['termiusInstalled']) + self.assertEqual(r['package'], study.PACKAGE) + + def test_additional_agreement_is_saved_for_review_and_never_accepted(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + r = self.preflight(directory, catalog(image=True, agreement='android-sdk-arm-dbt-license')) + self.assertFalse(r['canInstall']) + self.assertEqual(r['blocker'], 'SDK_SELECTED_PACKAGE_ADDITIONAL_AGREEMENT') + self.assertFalse(r['licenseEvidence'][-1]['alreadyApproved']) + self.assertEqual(r['imageMetadata']['abiValues'], ['x86_64']) + self.assertEqual((directory / 'license-android-sdk-arm-dbt-license.txt').read_text(), 'unapproved agreement') + + def test_foreign_abi_is_not_a_successful_api36_image(self): + with tempfile.TemporaryDirectory() as temporary: + r = self.preflight(Path(temporary), catalog(image=True, abi='arm64-v8a')) + self.assertFalse(r['canInstall']) + self.assertEqual(r['blocker'], 'SDK_IMAGE_METADATA_INVALID') + + def test_unselected_preview_agreement_is_not_selected_or_accepted(self): + root = ET.fromstring(catalog()) + ET.SubElement(root, 'license', id='android-sdk-preview-license').text = 'unapproved preview' + preview = ET.SubElement(root, 'remotePackage', path='emulator') + ET.SubElement(preview, 'channelRef', ref='channel-1') + ET.SubElement(preview, 'uses-license', ref='android-sdk-preview-license') + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + r = self.preflight(directory, catalog(image=True), ET.tostring(root)) + self.assertTrue(r['canInstall']) + self.assertTrue(all(x['alreadyApproved'] for x in r['licenseEvidence'])) + self.assertTrue(all(x['agreements'] == ['android-sdk-license'] for x in r['selectedPackages'])) + self.assertFalse((directory / 'license-android-sdk-preview-license.txt').exists()) + self.assertFalse(r['sdkInstalled']) + + def test_http_denial_stops_without_alternate_download_or_sdk_install(self): + with tempfile.TemporaryDirectory() as temporary, \ + patch.dict(study.os.environ, {'TABBY_ANDROID_SDK_LICENSE_APPROVED_SHA256': study.SDK.LICENSE_SHA256}, clear=True), \ + patch.object(study, 'run', return_value='synthetic-sha'), \ + patch.object(study, 'official_channel', return_value={}), \ + patch.object(study, 'download', side_effect=urllib.error.HTTPError(study.SDK.REPOSITORY_URL, 403, 'denied', {}, None)) as download, \ + redirect_stdout(io.StringIO()): + r = study.preflight(Path(temporary)) + self.assertFalse(r['canInstall']) + self.assertEqual(r['httpStatus'], 403) + download.assert_called_once_with(study.SDK.REPOSITORY_URL) + + def test_install_rejects_a_blocked_report_before_network_or_license_files(self): + with tempfile.TemporaryDirectory() as temporary, patch.object(study, 'download') as download: + directory = Path(temporary) + (directory / 'preflight.json').write_text(json.dumps({'canInstall': False})) + with self.assertRaisesRegex(ValueError, 'VERIFIED_PREFLIGHT_REQUIRED'): + study.install(directory) + download.assert_not_called() + self.assertEqual([x.name for x in directory.iterdir()], ['preflight.json']) + + def test_capture_rejects_account_text_password_and_nonempty_editor(self): + for attributes in ["text='person@example.com'", "password='true'", "class='android.widget.EditText' text='typed-value'"]: + with self.subTest(attributes=attributes), self.assertRaises(ValueError): + study.privacy_check('') + self.assertEqual(study.privacy_check(''), ['Sign in']) + + def test_preflight_bytes_cannot_change_between_review_and_install(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + self.preflight(directory, catalog(image=True)) + (directory / 'repository.xml').write_bytes(b'') + with patch.object(study, 'run', return_value='synthetic-sha'), \ + patch.dict(study.os.environ, {'TABBY_ANDROID_SDK_LICENSE_APPROVED_SHA256': study.SDK.LICENSE_SHA256}, clear=True), \ + patch.object(study, 'download') as download: + with self.assertRaisesRegex(ValueError, 'PREFLIGHT_BYTES_CHANGED'): + study.install(directory) + download.assert_not_called() + + def test_startup_diagnostic_is_bounded_and_omits_authentication_details(self): + data = ('line\n' * 200 + '/temporary/emulator boot\nBearer sensitive\njwt sensitive\n').encode() + result = study.bounded_startup_log(data, '/temporary') + self.assertEqual(len(result.splitlines()), 160) + self.assertNotIn('sensitive', result) + self.assertNotIn('/temporary', result) + self.assertIn('[RUNNER_TEMP]/emulator boot', result) + + +if __name__ == '__main__': + unittest.main()