ZapretKVN-android/scripts/verify-project.sh
loop-uh 4c3164c70f
Some checks failed
Android CI / build (push) Has been cancelled
Незаметность: без ICMP-пинга, IP по кнопке, DNS «Android» через узел
Решения владельца 2026-10-15: проверки не должны выдавать серверы провайдеру.

- ICMP-пинг серверов удалён целиком (IcmpPingProbe, ServerPingTargetResolver,
  runIcmp, поля icmp в моделях и кэше): задержка — только через ядро (Relay HTTPS).
  Старые записи кэша только с ICMP отбрасываются. Главная: строка
  «До сервера · ICMP» убрана.
- Внешний IP (ipify) больше не запрашивается при подключении/переключении:
  только по нажатию «Показать IP» на Главной, кэш до смены сервера или
  переподключения. verify-project сторожит единственные точки вызова.
- DNS «Android»: проксируемые имена — DNS узла через туннель (как Auto/Secure),
  direct-домены и имена самих VPN-серверов — системный DNS (без цикла при
  мёртвом туннеле); strict Private DNS и резерв Auto — по-старому. Health-хосты
  идут тем же путём, что трафик пользователя. Подпись режима обновлена.
- docs: DNS_ARCHITECTURE (параллельный вердикт, hedging, IP по кнопке), ARCHITECTURE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:48:56 +03:00

653 lines
32 KiB
Shell
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MANIFEST="$PROJECT_ROOT/app/src/main/AndroidManifest.xml"
[[ -f "$PROJECT_ROOT/settings.gradle.kts" ]]
[[ -f "$PROJECT_ROOT/app/build.gradle.kts" ]]
[[ -f "$MANIFEST" ]]
[[ -f "$PROJECT_ROOT/docs/NOTICE" ]]
[[ -f "$PROJECT_ROOT/docs/THIRD_PARTY_NOTICES.md" ]]
[[ -f "$PROJECT_ROOT/gradle/verification-metadata.xml" ]]
[[ -f "$PROJECT_ROOT/app/src/main/res/raw/sing_box_extended_license.txt" ]]
[[ -f "$PROJECT_ROOT/app/src/main/res/raw/sing_geoip_license.txt" ]]
[[ -f "$PROJECT_ROOT/audit/rule_set_performance_test.go" ]]
[[ -f "$PROJECT_ROOT/scripts/core-patchset.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/publish-local-stable.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/publish-forgejo-stable.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/test-publish-forgejo-stable.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/verify-release-bundle.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/derive-test-version.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/build-test-bundle.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/create-test-bundle.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/verify-test-bundle.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/publish-forgejo-test.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/check-libbox-fingerprint.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/ensure-libbox.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/test-libbox-fingerprint.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/generate-baseline-profile.sh" ]]
[[ -x "$PROJECT_ROOT/scripts/measure-startup-frames.sh" ]]
BASELINE_PROFILE="$PROJECT_ROOT/app/src/main/baseline-prof.txt"
[[ -s "$BASELINE_PROFILE" ]]
# Файл детерминирован: отсортирован, без повторов, только правила HRF (флаги HSP + дескриптор).
LC_ALL=C sort -c -u "$BASELINE_PROFILE"
if grep -Evq '^[HSP]*L[A-Za-z0-9_$/-]+;(->.+)?$' "$BASELINE_PROFILE"; then
echo "app/src/main/baseline-prof.txt contains a line that is not an HRF rule" >&2
exit 1
fi
(( $(grep -c -E '^[HSP]*Lio/github/zapretkvn/' "$BASELINE_PROFILE") >= 500 ))
# profileGen нужен только для записи профиля и не должен попадать в выпуск.
grep -Fq 'create("profileGen")' "$PROJECT_ROOT/app/build.gradle.kts"
if grep -Fq 'profileGen' "$PROJECT_ROOT/scripts/build-apk-matrix.sh" \
"$PROJECT_ROOT/scripts/create-release-bundle.sh" \
"$PROJECT_ROOT/scripts/publish-local-stable.sh"; then
echo "profileGen must not take part in release builds" >&2
exit 1
fi
grep -Fq 'scripts/ensure-libbox.sh' "$PROJECT_ROOT/app/build.gradle.kts"
[[ -f "$PROJECT_ROOT/release.properties" ]]
source "$PROJECT_ROOT/core.properties"
source "$PROJECT_ROOT/scripts/core-patchset.sh"
verify_core_patchset "$PROJECT_ROOT"
"$PROJECT_ROOT/scripts/test-libbox-fingerprint.sh"
[[ -f "$PROJECT_ROOT/scripts/gate8-performance-summary.jq" ]]
RELEASE_WORKFLOW="$PROJECT_ROOT/.forgejo/workflows/release-verify.yml"
grep -Fq 'workflow_dispatch:' "$RELEASE_WORKFLOW"
grep -Fq 'scripts/ci-build.sh' "$RELEASE_WORKFLOW"
grep -Fq 'scripts/verify-release-bundle.sh' "$RELEASE_WORKFLOW"
grep -Fq 'git.zapret.moe/api/v1/repos/' "$RELEASE_WORKFLOW"
if grep -Fq 'release create' "$RELEASE_WORKFLOW" ||
grep -Fq 'ANDROID_SIGNING_KEYSTORE_BASE64' "$RELEASE_WORKFLOW" ||
grep -Fq 'secrets.ANDROID_SIGNING' "$RELEASE_WORKFLOW"; then
echo "Background release verification must not publish or access the production key" >&2
exit 1
fi
if grep -Eq '^[[:space:]]+push:' "$RELEASE_WORKFLOW"; then
echo "Stable verification must be dispatched by the local publisher" >&2
exit 1
fi
LOCAL_PUBLISHER="$PROJECT_ROOT/scripts/publish-local-stable.sh"
grep -Fq -- '--final-gate-approved' "$LOCAL_PUBLISHER"
grep -Fq 'scripts/publish-forgejo-stable.sh' "$LOCAL_PUBLISHER"
grep -Fq 'actions/workflows/release-verify.yml/dispatches' "$LOCAL_PUBLISHER"
grep -Fq 'scripts/ci-build.sh' "$LOCAL_PUBLISHER"
grep -Fq 'scripts/verify-release-bundle.sh' "$LOCAL_PUBLISHER"
FORGEJO_PUBLISHER="$PROJECT_ROOT/scripts/publish-forgejo-stable.sh"
grep -Fq '/api/v1' "$FORGEJO_PUBLISHER"
grep -Fq 'draft:true,prerelease:false' "$FORGEJO_PUBLISHER"
grep -Fq 'draft:false,prerelease:false' "$FORGEJO_PUBLISHER"
grep -Fq 'sha256sum "$downloaded"' "$FORGEJO_PUBLISHER"
TEST_RELEASE_WORKFLOW="$PROJECT_ROOT/.forgejo/workflows/test-release-verify.yml"
grep -Fq 'workflow_dispatch:' "$TEST_RELEASE_WORKFLOW"
grep -Fq 'scripts/verify-test-bundle.sh' "$TEST_RELEASE_WORKFLOW"
grep -Eq '(^|[[:space:]])testDebugUnitTest' "$TEST_RELEASE_WORKFLOW"
TEST_PUBLISHER="$PROJECT_ROOT/scripts/publish-forgejo-test.sh"
grep -Fq 'draft:true,prerelease:true' "$TEST_PUBLISHER"
grep -Fq 'draft:false,prerelease:true' "$TEST_PUBLISHER"
grep -Fq 'sha256sum "$downloaded"' "$TEST_PUBLISHER"
grep -Fq 'actions/workflows/test-release-verify.yml/dispatches' "$TEST_PUBLISHER"
"$PROJECT_ROOT/scripts/test-publish-forgejo-stable.sh"
source "$PROJECT_ROOT/release.properties"
if [[ ! "$RELEASE_SIGNER_SHA256" =~ ^[0-9a-f]{64}$ ]]; then
echo "Invalid public production signing fingerprint" >&2
exit 1
fi
cmp -s "$PROJECT_ROOT/docs/LICENSE" "$PROJECT_ROOT/app/src/main/res/raw/license_gpl_3.txt"
cmp -s "$PROJECT_ROOT/docs/NOTICE" "$PROJECT_ROOT/app/src/main/res/raw/notice.txt"
cmp -s "$PROJECT_ROOT/docs/THIRD_PARTY_NOTICES.md" "$PROJECT_ROOT/app/src/main/res/raw/third_party_notices.txt"
WRAPPER_PROPERTIES="$PROJECT_ROOT/gradle/wrapper/gradle-wrapper.properties"
grep -Fqx 'distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip' "$WRAPPER_PROPERTIES"
grep -Fqx 'distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb' "$WRAPPER_PROPERTIES"
printf '%s %s\n' \
'55243ef57851f12b070ad14f7f5bb8302daceeebc5bce5ece5fa6edb23e1145c' \
"$PROJECT_ROOT/gradle/wrapper/gradle-wrapper.jar" \
| sha256sum -c - >/dev/null
INVALID_ACTIONS="$(
sed -n 's/^[[:space:]]*uses:[[:space:]]*//p' "$PROJECT_ROOT"/.forgejo/workflows/*.yml \
| grep -Ev '^https://data\.forgejo\.org/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[^[:space:]]+$' || true
)"
if [[ -n "$INVALID_ACTIONS" ]]; then
echo "Forgejo Actions must use explicit trusted action URLs:" >&2
printf '%s\n' "$INVALID_ACTIONS" >&2
exit 1
fi
mapfile -t ANDROID_MODULES < <(
find "$PROJECT_ROOT" -mindepth 2 -maxdepth 2 -name build.gradle.kts -printf '%h\n' \
| sed "s|$PROJECT_ROOT/||" \
| sort
)
EXPECTED_ANDROID_MODULES=(app app-updater network-bootstrap wireguard-import)
if [[ "${ANDROID_MODULES[*]}" != "${EXPECTED_ANDROID_MODULES[*]}" ]]; then
echo "Unexpected Android module set: ${ANDROID_MODULES[*]:-none}" >&2
exit 1
fi
if [[ "$(grep -l 'com.android.application' "$PROJECT_ROOT"/*/build.gradle.kts | wc -l)" -ne 1 ]] ||
! grep -Fq 'com.android.application' "$PROJECT_ROOT/app/build.gradle.kts"; then
echo "Exactly app must be the Android application module" >&2
exit 1
fi
if grep -Eq 'WAKE_LOCK|REQUEST_IGNORE_BATTERY_OPTIMIZATIONS|android:process=' "$MANIFEST"; then
echo "Manifest contains a forbidden permission/process" >&2
exit 1
fi
# Every production Kotlin source root: :app, :app-updater, the :core:* libraries and :feature:* modules.
SCAN_ROOTS=("$PROJECT_ROOT/app/src/main/java" "$PROJECT_ROOT/app-updater/src/main/java")
for module_dir in "$PROJECT_ROOT"/core/*/ "$PROJECT_ROOT"/feature/*/; do
[[ -d "${module_dir}src/main/java" ]] && SCAN_ROOTS+=("${module_dir}src/main/java")
done
# Module boundary: :feature:* hold screens and presenters only. They may use :core:* and androidx,
# never :app, the VPN runtime, ViewModels or the dependency containers (Route files in :app bind them).
for build_file in "$PROJECT_ROOT"/feature/*/build.gradle.kts "$PROJECT_ROOT"/core/*/build.gradle.kts; do
if grep -Eq 'project\(":(app|app-updater|network-bootstrap|wireguard-import|feature:[a-z]+)"\)' "$build_file"; then
echo "$build_file: :core:* and :feature:* modules must not depend on :app or on each other's features" >&2
exit 1
fi
done
for build_file in "$PROJECT_ROOT"/core/*/build.gradle.kts; do
if grep -Eq 'project\(":feature:' "$build_file"; then
echo "$build_file: :core:* modules must not depend on :feature:*" >&2
exit 1
fi
done
if [[ -n "$(find "$PROJECT_ROOT/feature" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -1)" ]]; then
if grep -R -n -E '^import io\.github\.zapretkvn\.android\.(vpn\.runtime|engines|platform|importer\.(ProfileImporter|SubscriptionClient|ImportParser)\b|profiles\.(ProfilesViewModel|ProfileStore)|[A-Za-z.]*ViewModel|ui\.(AppContainer|AppDependencies|AppNavigation|AppRoot|UiSettingsStore)|AppContainer|BuildConfig|R$)' \
"$PROJECT_ROOT"/feature/*/src; then
echo "Feature modules must not import the VPN runtime, ViewModels, AppContainer or AppDependencies" >&2
exit 1
fi
if grep -R -n -E '\b(AppDependencies|AppContainer|VpnController|[A-Za-z]+ViewModel)\b' --include=*.kt \
"$PROJECT_ROOT"/feature/*/src/main; then
echo "Feature modules must not reference ViewModels, VpnController or the dependency containers" >&2
exit 1
fi
fi
if grep -R -E 'import android\.util\.Log|\bLog\.[vdiewtf]\(|printStackTrace\(|System\.(out|err)' \
"${SCAN_ROOTS[@]}"; then
echo "Production source must not write credentials or runtime details to Logcat/stdout" >&2
exit 1
fi
if grep -R -E 'addPrimaryClipChangedListener|setPrimaryClip\(' \
"${SCAN_ROOTS[@]}"; then
echo "Application clipboard history/listeners are forbidden; clipboard import is read-on-action only" >&2
exit 1
fi
if grep -R -E 'android\.os\.PowerManager|android\.app\.AlarmManager|androidx\.work|ScheduledExecutorService|scheduleAtFixedRate|scheduleWithFixedDelay' \
"${SCAN_ROOTS[@]}"; then
echo "Production source contains an app-owned wake/alarm/work/periodic scheduler" >&2
exit 1
fi
# The only app-owned scheduler is the platform JobScheduler daily update check (no wakelock, no alarm,
# no WorkManager, not persisted across reboot because RECEIVE_BOOT_COMPLETED is not requested).
JOB_SCHEDULER_USERS="$(
grep -R -l 'android\.app\.job\.JobScheduler' "${SCAN_ROOTS[@]}" || true
)"
if [[ "$JOB_SCHEDULER_USERS" != "$PROJECT_ROOT/app/src/main/java/io/github/zapretkvn/android/updates/UpdateCheckJob.kt" ]]; then
echo "JobScheduler may only be used by the daily update check (updates/UpdateCheckJob.kt): $JOB_SCHEDULER_USERS" >&2
exit 1
fi
if grep -R -F 'CommandConnections' "$PROJECT_ROOT/app/src/main/java"; then
echo "Production runtime must not subscribe to per-connection polling" >&2
exit 1
fi
python3 - "$PROJECT_ROOT" <<'PY'
from pathlib import Path
import sys
root = Path(sys.argv[1])
source_root = root / "app/src/main/java"
module_roots = [
source_root,
*sorted(root.glob("core/*/src/main/java")),
*sorted(root.glob("feature/*/src/main/java")),
]
for module_root in module_roots:
for source in module_root.rglob("*.kt"):
declaration = next((line for line in source.read_text().splitlines() if line.startswith("package ")), "")
expected_package = ".".join(source.relative_to(module_root).parts[:-1])
if declaration != "package " + expected_package:
raise SystemExit(f"Kotlin package/path mismatch: {source.relative_to(root)}")
vpn_root = source_root / "io/github/zapretkvn/android/vpn"
if {path.name for path in vpn_root.glob("*.kt")} != {
"ZapretVpnService.kt", "ZapretQuickSettingsTileService.kt",
"VpnController.kt", "VpnRecoveryPolicy.kt",
"ConnectionDiagnosticsRecorder.kt", "LatencyPublisher.kt",
}:
raise SystemExit("Keep VPN lifecycle in vpn/; place adapters, probes and app scope in their packages")
delays = []
for source in (
root / "app/src/main/java",
root / "app-updater/src/main/java",
root / "network-bootstrap/src/main/java",
*sorted(root.glob("core/*/src/main/java")),
*sorted(root.glob("feature/*/src/main/java")),
):
for path in source.rglob("*.kt"):
count = path.read_text().count("delay(")
if count:
delays.append((str(path.relative_to(root)), count))
delays.sort()
expected = [
# Слушатель службы очереди обновлений: работает, только пока включён VPN и выбран канал Stable.
# Одна пауза pause(): после сбоя связи 60 → 180 с и 2 мин после разрешения или слишком быстрого
# ответа. Остальное время вопрос держит сама служба (до 4,5 мин), таймера на стороне приложения нет.
("app-updater/src/main/java/io/github/zapretkvn/android/updates/ReleaseWaitClient.kt", 1),
# Случайная пауза 0,15–1,2 с между разовыми DoH-запросами страны сервера внутри
# одной пачки (не больше 3 параллельно): запросы не идут залпом. Пачка стартует
# только после импорта/обновления профиля или при запуске; повторов и циклов нет.
("app/src/main/java/io/github/zapretkvn/android/network/geo/ServerCountryStore.kt", 1),
("app/src/main/java/io/github/zapretkvn/android/network/probes/HealthProbeRace.kt", 1),
# Проверка доступности сервисов: единственная пауза probeSleep(). Ею делаются случайный сдвиг
# старта запросов (0-400 мс), единственный повтор основного запроса через туннель (1,5-3 с)
# и разовая пауза автозапуска после подключения (4-12 с). Внутри одного запуска, без цикла и
# без периодического таймера; повторных запусков по времени нет.
("app/src/main/java/io/github/zapretkvn/android/network/probes/services/ServiceProbeRunner.kt", 1),
# Пауза после ввода в редакторе JSON перед разбором текста (selectors, серверы,
# совпадения поиска): один Job на редактор, предыдущий отменяется. Только UI.
("app/src/main/java/io/github/zapretkvn/android/profiles/ProfileEditorController.kt", 1),
# Прореживание перерисовки списка серверов (не чаще 4 раз в секунду) во
# время пачек замеров. Только UI: сетевых запросов и повторов не создаёт.
("app/src/main/java/io/github/zapretkvn/android/ui/servers/ServersRoute.kt", 1),
# Единый дебаунс смены сети (перезапуск, пауза и возобновление по правилам
# сети). Одновременно активен только один debounce Job; ожидание сети
# событийное и таймера не добавляет.
("app/src/main/java/io/github/zapretkvn/android/vpn/runtime/NetworkAutomationCoordinator.kt", 1),
# Ограниченный backoff восстановления после отказа попытки (VpnRecoveryPolicy:
# не больше MAX_ATTEMPTS подряд и MAX_TOTAL_ATTEMPTS на серию); ожидание сети
# событийное и таймера не добавляет.
("app/src/main/java/io/github/zapretkvn/android/vpn/runtime/RecoveryCoordinator.kt", 1),
# Видимое время сессии на Главной: тикает, только пока таймер на экране.
("feature/home/src/main/java/io/github/zapretkvn/android/ui/home/HomeScreen.kt", 1),
# Подпись «проверено 2 мин назад» на карточке сервисов обновляется раз в 30 с, пока карточка
# на экране. Только текст: сетевых запросов и запуска проверок не создаёт.
("feature/home/src/main/java/io/github/zapretkvn/android/ui/home/ServicesCard.kt", 1),
# Подстраховка одного DNS-вопроса: если ответа нет за hedgeAfterMillis (800 мс), уходит
# ровно один повтор того же запроса, первый ответ побеждает. Не больше двух запросов на
# вопрос, без цикла и без периодического таймера; спасает от 5-секундного тайм-аута netd
# при потере одного UDP-пакета.
("network-bootstrap/src/main/java/io/github/zapretkvn/networkbootstrap/HedgedQuery.kt", 1),
("network-bootstrap/src/main/java/io/github/zapretkvn/networkbootstrap/UnderlyingNetworkMonitor.kt", 1),
]
if delays != expected:
raise SystemExit(f"Unexpected production timer/retry surface: {delays!r}")
print(
"Production delay calls are limited to visible session time, server-list redraw throttle, probe stagger, "
"service-check pauses, card age label, update queue listener pauses, "
"network debounce, bounded recovery backoff and bootstrap settle."
)
PY
# Проверки не должны выдавать VPN-серверы провайдеру (решение владельца 15.10.2026):
# ни ICMP-пинга серверов напрямую, ни автоматического запроса внешнего IP.
python3 - "$PROJECT_ROOT" <<'PY'
from pathlib import Path
import re
import sys
root = Path(sys.argv[1])
sources = [
path
for base in (
root / "app/src/main/java",
root / "app-updater/src/main/java",
root / "network-bootstrap/src/main/java",
*sorted(root.glob("core/*/src/main/java")),
*sorted(root.glob("feature/*/src/main/java")),
)
for path in base.rglob("*.kt")
]
for path in sources:
text = path.read_text()
if re.search(r"IPPROTO_ICMP|IcmpPing|IcmpEcho|ServerPingTarget", text):
raise SystemExit(f"ICMP ping of VPN servers is forbidden: {path.relative_to(root)}")
ip_probe = "app/src/main/java/io/github/zapretkvn/android/network/probes/VpnExternalIpProbe.kt"
core_session = "app/src/main/java/io/github/zapretkvn/android/vpn/runtime/CoreSession.kt"
for path in sources:
relative = str(path.relative_to(root))
text = path.read_text()
if "ipify" in text and relative != ip_probe:
raise SystemExit(f"The external IP service is only called from VpnExternalIpProbe: {relative}")
if re.search(r"[eE]xternalIpProbe\.fetch\(", text) and relative != core_session:
raise SystemExit(f"External IP is fetched only on demand from CoreSession.requestExternalIp: {relative}")
session = (root / core_session).read_text()
if len(re.findall(r"[eE]xternalIpProbe\.fetch\(", session)) != 1:
raise SystemExit("CoreSession must fetch the external IP in exactly one place: requestExternalIp")
callers = sorted(
str(path.relative_to(root))
for path in sources
if any(
re.search(r"\brequestExternalIp\b", line)
for line in path.read_text().splitlines()
if not line.lstrip().startswith(("*", "//", "/*"))
)
)
expected_callers = [
"app/src/main/java/io/github/zapretkvn/android/ui/home/HomeRoute.kt",
"app/src/main/java/io/github/zapretkvn/android/vpn/VpnController.kt",
"app/src/main/java/io/github/zapretkvn/android/vpn/runtime/CoreSession.kt",
]
if callers != expected_callers:
raise SystemExit(f"Unexpected external IP request surface (user action only): {callers!r}")
print("VPN servers are never pinged over ICMP and the external IP is requested only by a user tap.")
PY
python3 - "$MANIFEST" "$PROJECT_ROOT/app/src/main/res/xml/diagnostic_file_paths.xml" <<'PY'
from pathlib import Path
import sys
import xml.etree.ElementTree as ET
android = "{http://schemas.android.com/apk/res/android}"
manifest_path, paths_path = map(Path, sys.argv[1:])
root = ET.parse(manifest_path).getroot()
application = root.find("application")
if application is None:
raise SystemExit("Manifest has no application")
if application.get(android + "usesCleartextTraffic") != "false":
raise SystemExit("Application must explicitly reject platform cleartext traffic")
source_permissions = {node.get(android + "name") for node in root.findall("uses-permission")}
expected_source_permissions = {
"android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.ACCESS_WIFI_STATE",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED",
"android.permission.POST_NOTIFICATIONS",
"android.permission.REQUEST_INSTALL_PACKAGES",
"android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION",
"android.permission.CAMERA",
"android.permission.QUERY_ALL_PACKAGES",
}
if source_permissions != expected_source_permissions:
raise SystemExit(
f"Source manifest permission allowlist mismatch: "
f"{sorted(source_permissions ^ expected_source_permissions)}"
)
queries = root.find("queries")
if queries is None:
raise SystemExit("Manifest must declare launcher package visibility fallback")
query_intents = set()
for intent in queries.findall("intent"):
actions = tuple(sorted(
node.get(android + "name") for node in intent.findall("action")
))
categories = tuple(sorted(
node.get(android + "name") for node in intent.findall("category")
))
query_intents.add((actions, categories))
expected_query_intents = {
(("android.intent.action.MAIN",), ("android.intent.category.LAUNCHER",)),
(("android.intent.action.MAIN",), ("android.intent.category.LEANBACK_LAUNCHER",)),
}
if query_intents != expected_query_intents:
raise SystemExit(
f"Launcher package visibility queries mismatch: "
f"{sorted(query_intents ^ expected_query_intents)}"
)
profileable = application.findall("profileable")
if len(profileable) != 1 or profileable[0].get(android + "shell") != "true":
raise SystemExit("Release-gate profiling requires exactly one shell-profileable declaration")
vpn_services = [
node for node in application.findall("service")
if node.get(android + "permission") == "android.permission.BIND_VPN_SERVICE"
]
if len(vpn_services) != 1:
raise SystemExit(f"Expected exactly one VPN service, found {len(vpn_services)}")
vpn_service = vpn_services[0]
if vpn_service.get(android + "exported") != "false":
raise SystemExit("VPN service must not be exported")
always_on_metadata = [
node for node in vpn_service.findall("meta-data")
if node.get(android + "name") == "android.net.VpnService.SUPPORTS_ALWAYS_ON"
]
if len(always_on_metadata) != 1 or always_on_metadata[0].get(android + "value") != "false":
raise SystemExit("VPN service must explicitly opt out of Always-on")
job_services = [
node for node in application.findall("service")
if node.get(android + "permission") == "android.permission.BIND_JOB_SERVICE"
]
if (
len(job_services) != 1
or job_services[0].get(android + "name") != ".updates.UpdateCheckJobService"
or job_services[0].get(android + "exported") != "false"
or job_services[0].findall("intent-filter")
):
raise SystemExit("Expected exactly one non-exported update-check JobService without intent filters")
receivers = application.findall("receiver")
if (
len(receivers) != 1
or receivers[0].get(android + "name") != ".updates.UpdateInstallReceiver"
or receivers[0].get(android + "exported") != "false"
or receivers[0].findall("intent-filter")
):
raise SystemExit("Expected exactly one non-exported install-result receiver without intent filters")
quick_settings_tiles = [
node for node in application.findall("service")
if node.get(android + "permission") == "android.permission.BIND_QUICK_SETTINGS_TILE"
]
if len(quick_settings_tiles) != 1:
raise SystemExit(
f"Expected exactly one Quick Settings tile service, found {len(quick_settings_tiles)}"
)
quick_settings_tile = quick_settings_tiles[0]
if (
quick_settings_tile.get(android + "name") != ".vpn.ZapretQuickSettingsTileService"
or quick_settings_tile.get(android + "exported") != "true"
):
raise SystemExit("Quick Settings tile must be the single exported system-bound tile service")
tile_actions = {
action.get(android + "name")
for intent_filter in quick_settings_tile.findall("intent-filter")
for action in intent_filter.findall("action")
}
if tile_actions != {"android.service.quicksettings.action.QS_TILE"}:
raise SystemExit(f"Quick Settings tile intent actions differ: {sorted(tile_actions)}")
providers = [
node for node in application.findall("provider")
if node.get(android + "name") == "androidx.core.content.FileProvider"
]
if len(providers) != 1:
raise SystemExit(f"Expected one FileProvider, found {len(providers)}")
provider = providers[0]
expected = {
android + "authorities": "${applicationId}.fileprovider",
android + "exported": "false",
android + "grantUriPermissions": "true",
}
for attribute, value in expected.items():
if provider.get(attribute) != value:
raise SystemExit(f"Unsafe FileProvider attribute {attribute}: {provider.get(attribute)!r}")
metadata = provider.findall("meta-data")
if len(metadata) != 1 or metadata[0].get(android + "resource") != "@xml/diagnostic_file_paths":
raise SystemExit("FileProvider must reference only diagnostic_file_paths")
paths = ET.parse(paths_path).getroot()
children = list(paths)
entries = {(child.tag, child.get("name"), child.get("path")) for child in children}
expected_paths = {
("cache-path", "diagnostics", "diagnostics/"),
}
if entries != expected_paths:
raise SystemExit(f"FileProvider paths differ from bounded cache paths: {entries!r}")
PY
python3 - "$PROJECT_ROOT" <<'PY'
from pathlib import Path
import hashlib
import re
import sys
root = Path(sys.argv[1])
docs_root = root / "docs"
docs = [docs_root / name for name in (
"ARCHITECTURE.md",
"DNS_ARCHITECTURE.md",
"ROUTING_ARCHITECTURE.md",
"IMPORT_FORMATS.md",
"IMPLEMENTATION_PLAN.md",
"README.md",
"LICENSING.md",
"SIGNING.md",
"GATE8_RESULTS.md",
"THIRD_PARTY_NOTICES.md",
)]
all_text = "\n".join(path.read_text() for path in docs)
fixture_root = root / "testdata"
manifest = {}
for line in (fixture_root / "SHA256SUMS").read_text().splitlines():
digest, relative = line.split(maxsplit=1)
manifest[relative] = digest
fixtures = sorted(fixture_root.rglob("*.json"))
fixture_names = {str(path.relative_to(fixture_root)) for path in fixtures}
if fixture_names != set(manifest):
raise SystemExit("Fixture set differs from testdata/SHA256SUMS")
for fixture in fixtures:
digest = hashlib.sha256(fixture.read_bytes()).hexdigest()
relative = str(fixture.relative_to(fixture_root))
if manifest[relative] != digest:
raise SystemExit(f"Fixture hash mismatch: {fixture}: {digest}")
if digest not in all_text:
raise SystemExit(f"Fixture hash is not documented: {fixture}: {digest}")
for doc in docs:
text = doc.read_text()
if len(re.findall(r"^```", text, re.MULTILINE)) % 2:
raise SystemExit(f"Unbalanced Markdown fences: {doc}")
for target in re.findall(r"\[[^\]]+\]\(([^)]+)\)", text):
if "://" in target or target.startswith("#"):
continue
local = target.split("#", 1)[0]
if local and not (doc.parent / local).exists():
raise SystemExit(f"Broken local link in {doc}: {target}")
print("Markdown, local links and fixture hashes are valid.")
PY
python3 "$PROJECT_ROOT/scripts/check-release-changes.py" "$PROJECT_ROOT/scripts/release-changes.txt"
python3 - "$PROJECT_ROOT" <<'PY'
from pathlib import Path
import sys
root = Path(sys.argv[1])
surfaces = {
"README": (root / "docs/README.md").read_text(),
"UI": (root / "feature/settings/src/main/java/io/github/zapretkvn/android/ui/settings/AboutPage.kt").read_text(),
"release notes generator": (root / "scripts/create-release-bundle.sh").read_text(),
}
required = (
"Известные ограничения MVP",
"arm64-v8a",
"Always-on/Lockdown",
"shared UID",
"DoH, DoT",
"FakeIP",
"Domain-only block",
"Clash YAML",
"Hysteria v1",
"silent install",
"plaintext DNS fallback",
)
for surface, text in surfaces.items():
missing = [value for value in required if value not in text]
if missing:
raise SystemExit(f"{surface} omits known limitations: {missing!r}")
print("Known limitations are synchronized across UI, README and release notes.")
PY
python3 - "$PROJECT_ROOT" <<'PY'
from pathlib import Path
import hashlib
import json
import sys
root = Path(sys.argv[1])
asset_root = root / "app/src/main/assets/rule-sets"
manifest = json.loads((asset_root / "manifest.json").read_text())
if manifest.get("generated_with_core") != "ff11f007ec798136a5de258f947a4f34011a37ea":
raise SystemExit("Rule-set manifest core revision mismatch")
expected = {"zapret-ru-domains", "zapret-ru-ip"}
entries = manifest.get("sets", [])
if {entry["tag"] for entry in entries} != expected:
raise SystemExit("Unexpected packaged rule-set tag set")
for entry in entries:
path = asset_root / entry["file"]
digest = hashlib.sha256(path.read_bytes()).hexdigest()
if digest != entry["sha256"]:
raise SystemExit(f"Rule-set asset hash mismatch: {path}: {digest}")
print("Packaged rule-set manifest and hashes are valid.")
PY
python3 - "$PROJECT_ROOT" <<'PY'
from pathlib import Path
import hashlib
import json
import re
import struct
import sys
root = Path(sys.argv[1])
asset_root = root / "app/src/main/assets/geoip"
manifest = json.loads((asset_root / "manifest.json").read_text())
blob = (asset_root / manifest["file"]).read_bytes()
if hashlib.sha256(blob).hexdigest() != manifest["sha256"] or len(blob) != manifest["bytes"]:
raise SystemExit("GeoIP country asset hash/size mismatch")
if blob[:4] != b"ZGC1" or manifest.get("format") != "ZGC1":
raise SystemExit("GeoIP country asset has an unexpected format")
if len(blob) > 3 * 1024 * 1024:
raise SystemExit("GeoIP country asset exceeds the 3 MiB budget")
if manifest.get("license") != "CC-BY-4.0" or manifest.get("attribution") != "IP Geolocation by DB-IP":
raise SystemExit("GeoIP manifest lost the DB-IP license/attribution")
if not re.fullmatch(r"\d{4}-\d{2}", manifest["month"]) or not manifest["source"].endswith(
f"dbip-country-lite-{manifest['month']}.csv.gz"
):
raise SystemExit("GeoIP manifest source/month mismatch")
month, count = struct.unpack_from(">IB", blob, 4)
if month != int(manifest["month"].replace("-", "")) or count != manifest["countries"]:
raise SystemExit("GeoIP asset header disagrees with its manifest")
notices = (root / "docs/THIRD_PARTY_NOTICES.md").read_text()
for required in ("IP Geolocation by DB-IP", "https://db-ip.com", "CC BY 4.0"):
if required not in notices:
raise SystemExit(f"Third-party notices omit the DB-IP attribution: {required!r}")
print("Packaged GeoIP country asset, manifest and attribution are valid.")
PY
PERFORMANCE_POLICY="$PROJECT_ROOT/scripts/gate8-performance-summary.jq"
POLICY_RESULT="$(
printf '%s\n' \
'{"scenario":"stack_current_mixed","cpu_ticks":100,"elapsed_nanos":1000000000,"pss_kb":100,"rss_kb":100,"tun_bytes":100,"throughput_mbps":100}' \
'{"scenario":"stack_system","cpu_ticks":104,"elapsed_nanos":1040000000,"pss_kb":104,"rss_kb":104,"tun_bytes":104,"throughput_mbps":104}' \
'{"scenario":"mtu_default_9000","cpu_ticks":100,"elapsed_nanos":1000000000,"pss_kb":100,"rss_kb":100,"tun_bytes":100,"throughput_mbps":100}' \
'{"scenario":"mtu_1500","cpu_ticks":106,"elapsed_nanos":1060000000,"pss_kb":106,"rss_kb":106,"tun_bytes":106,"throughput_mbps":106}' \
| jq -s --argjson threshold 5 -f "$PERFORMANCE_POLICY"
)"
BELOW_THRESHOLD_DECISION="$(
jq -r '.comparisons[] | select(.name == "mixed_vs_system") | .decision' <<<"$POLICY_RESULT"
)"
ABOVE_THRESHOLD_DECISION="$(
jq -r '.comparisons[] | select(.name == "mtu_default_vs_1500") | .decision' <<<"$POLICY_RESULT"
)"
if [[ "$BELOW_THRESHOLD_DECISION" != "NO_CHANGE_BELOW_5_PERCENT_OR_NO_SIGNAL" ]]; then
echo "Performance policy accepted a sub-5% change" >&2
exit 1
fi
if [[ "$ABOVE_THRESHOLD_DECISION" != "PHYSICAL_CONFIRMATION_REQUIRED" ]]; then
echo "Performance policy ignored a >=5% change" >&2
exit 1
fi
echo "Performance significance policy verified."
echo "Project structure verified."