ZapretKVN-android/scripts/verify-project.sh
loop-uh 02e1f9eecb
Some checks failed
Android CI / build (push) Has been cancelled
Обновления: сами в фоне, только изменённые части APK, без всплывающих окон
- Маршрут: при включённом VPN проверка идёт наперегонки напрямую и через
  туннель, выигрывает первый ответ; без VPN — напрямую, затем временный
  VPN-маршрут. Промежуточных «Forgejo не отвечает» больше нет, ошибка —
  только если не сработал ни один маршрут. Удачный маршрут запоминается.
- Подключённый VPN больше не перезапускается ради обновления: правило
  «запросы приложения к Forgejo — через выбранный proxy» теперь постоянное
  (RuntimeConfigOptions.standingUpdaterPackageName; без proxy пропускается).
- Дельта: центральный каталог нового APK читается по Range, совпадающие
  записи копируются из установленного APK, остальное догружается; итог
  сверяется с опубликованным SHA-256, при любом сомнении — полная загрузка
  с докачкой. 0.5.0 → 0.5.1: 3,39 МБ вместо 117,8 МБ.
- Фон: проверка при запуске и раз в сутки (JobScheduler, без новых
  зависимостей); Stable скачивается сам (по мобильной сети — если ≤ 25 МБ).
- Установка через PackageInstaller: на Android 12+ после первой установки
  самим приложением — без подтверждения; иначе тихое уведомление и баннер,
  установщик открывается только по нажатию. Всплывающий диалог убран.

Проверено: 675 юнит-тестов, lint, verify-project.sh, 32/32 UI- и
updater-тестов на эмуляторе API 37 (тихая установка — 5 с без окна).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:31:42 +03:00

478 lines
22 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" ]]
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 -Fq ':app: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
if grep -R -E 'import android\.util\.Log|\bLog\.[vdiewtf]\(|printStackTrace\(|System\.(out|err)' \
"$PROJECT_ROOT/app/src/main/java" "$PROJECT_ROOT/app-updater/src/main/java"; then
echo "Production source must not write credentials or runtime details to Logcat/stdout" >&2
exit 1
fi
if grep -R -E 'addPrimaryClipChangedListener|setPrimaryClip\(' \
"$PROJECT_ROOT/app/src/main/java" "$PROJECT_ROOT/app-updater/src/main/java"; 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' \
"$PROJECT_ROOT/app/src/main/java" "$PROJECT_ROOT/app-updater/src/main/java"; 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' "$PROJECT_ROOT/app/src/main/java" "$PROJECT_ROOT/app-updater/src/main/java" || 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"
for source in source_root.rglob("*.kt"):
declaration = next((line for line in source.read_text().splitlines() if line.startswith("package ")), "")
expected_package = ".".join(source.relative_to(source_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", "VpnState.kt", "VpnRecoveryPolicy.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",
):
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 = [
("app/src/main/java/io/github/zapretkvn/android/network/probes/HealthProbeRace.kt", 1),
# Разовый случайный сдвиг старта ICMP-замера (ICMP_START_JITTER_MILLIS):
# воркеры не шлют эхо-запросы одним залпом. Не повтор и не цикл ожидания.
("app/src/main/java/io/github/zapretkvn/android/network/probes/LatencyProbeCoordinator.kt", 1),
# Видимое время сессии на Главной: тикает, только пока таймер на экране.
("app/src/main/java/io/github/zapretkvn/android/ui/home/HomeScreen.kt", 1),
# Прореживание перерисовки списка серверов (не чаще 4 раз в секунду) во
# время пачек замеров. Только UI: сетевых запросов и повторов не создаёт.
("app/src/main/java/io/github/zapretkvn/android/ui/servers/ServersRoute.kt", 1),
# Единый дебаунс смены сети (перезапуск, пауза и возобновление по правилам
# сети) плюс ограниченный backoff восстановления. Одновременно активен
# только один debounce Job; ожидание сети событийное и таймера не добавляет.
("app/src/main/java/io/github/zapretkvn/android/vpn/runtime/VpnRuntime.kt", 2),
("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, "
"network debounce, bounded recovery backoff and bootstrap settle."
)
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 / "app/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
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."