|
1 | 1 | #!/usr/bin/env python3 |
| 2 | +"""Regression guard: native runtime instrumentation entry points stay non-mutating.""" |
2 | 3 | from __future__ import annotations |
3 | 4 |
|
4 | | -import importlib.util |
| 5 | +import json |
5 | 6 | import subprocess |
6 | 7 | import sys |
7 | 8 | import tempfile |
8 | 9 | from pathlib import Path |
9 | 10 |
|
10 | 11 | ROOT = Path(__file__).resolve().parents[1] |
11 | | -MODULE = ROOT / "scripts/instrument_native_repository_http_evidence.py" |
12 | | -spec = importlib.util.spec_from_file_location("repository_http_evidence", MODULE) |
13 | | -assert spec and spec.loader |
14 | | -mod = importlib.util.module_from_spec(spec) |
15 | | -spec.loader.exec_module(mod) |
| 12 | +POLICY = json.loads((ROOT / "automation/native-human-ux-policy.json").read_text(encoding="utf-8")) |
| 13 | +CLIENT = ROOT / "scripts/instrument_native_client_evidence.py" |
| 14 | +REPOSITORY = ROOT / "scripts/instrument_native_repository_http_evidence.py" |
| 15 | +DESKTOP = ROOT / "scripts/instrument_native_desktop_evidence.py" |
16 | 16 |
|
| 17 | +assert "patch Nuvio source code for logging or instrumentation" in POLICY["forbidden_behaviors"] |
| 18 | +assert "patch Nuvio repository/network loaders to inject evidence interceptors" in POLICY["forbidden_behaviors"] |
17 | 19 |
|
18 | | -def write(root: Path, relative: str, content: str) -> Path: |
19 | | - target = root / relative |
20 | | - target.parent.mkdir(parents=True, exist_ok=True) |
21 | | - target.write_text(content, encoding="utf-8") |
22 | | - return target |
| 20 | +for path in (CLIENT, REPOSITORY, DESKTOP): |
| 21 | + text = path.read_text(encoding="utf-8") |
| 22 | + assert "disabled_by_human_ux_policy" in text, path.name |
| 23 | + assert "runtime_mutation=false" in text, path.name |
| 24 | + for forbidden in ( |
| 25 | + "write_text(", |
| 26 | + "write_bytes(", |
| 27 | + "replace_once(", |
| 28 | + "addInterceptor", |
| 29 | + "PluginRuntime.kt", |
| 30 | + "FetchBridge.kt", |
| 31 | + "AndroidManifest.xml", |
| 32 | + "android.permission.INTERNET", |
| 33 | + "usesCleartextTraffic", |
| 34 | + "networkSecurityConfig", |
| 35 | + ): |
| 36 | + assert forbidden not in text, f"{path.name}:{forbidden}" |
23 | 37 |
|
24 | 38 |
|
25 | | -with tempfile.TemporaryDirectory() as tmp_raw: |
26 | | - tmp = Path(tmp_raw) |
27 | | - |
28 | | - tv = tmp / "tv" |
29 | | - tv_path = write( |
30 | | - tv, |
31 | | - "app/src/full/java/com/nuvio/tv/core/plugin/PluginManager.kt", |
32 | | - "private val httpClient = OkHttpClient.Builder()\n" |
33 | | - " .dns(com.nuvio.tv.core.network.IPv4FirstDns())\n" |
34 | | - " .connectTimeout(30, TimeUnit.SECONDS)\n" |
35 | | - " .readTimeout(30, TimeUnit.SECONDS)\n" |
36 | | - " .build()\n", |
37 | | - ) |
38 | | - mod.instrument_tv(tv) |
39 | | - tv_out = tv_path.read_text(encoding="utf-8") |
40 | | - assert "FIELD_NATIVE_REPOSITORY_HTTP_REQUEST client=tv" in tv_out |
41 | | - assert ".connectTimeout(30, TimeUnit.SECONDS)" in tv_out |
42 | | - assert ".readTimeout(30, TimeUnit.SECONDS)" in tv_out |
43 | | - |
44 | | - mobile = tmp / "mobile" |
45 | | - mobile_path = write( |
46 | | - mobile, |
47 | | - "composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt", |
48 | | - "private fun client() = OkHttpClient.Builder()\n" |
49 | | - " .addInterceptor(SentryNetworkBreadcrumbInterceptor())\n" |
50 | | - " .proxy(Proxy.NO_PROXY)\n" |
51 | | - " .build()\n", |
| 39 | +def init_repo(path: Path) -> None: |
| 40 | + subprocess.run(["git", "init", "-q", str(path)], check=True) |
| 41 | + marker = path / "production-runtime.txt" |
| 42 | + marker.write_text("official runtime\n", encoding="utf-8") |
| 43 | + subprocess.run(["git", "-C", str(path), "add", "production-runtime.txt"], check=True) |
| 44 | + subprocess.run( |
| 45 | + [ |
| 46 | + "git", |
| 47 | + "-C", |
| 48 | + str(path), |
| 49 | + "-c", |
| 50 | + "user.email=native-lab@example.invalid", |
| 51 | + "-c", |
| 52 | + "user.name=Native Lab", |
| 53 | + "commit", |
| 54 | + "-qm", |
| 55 | + "baseline", |
| 56 | + ], |
| 57 | + check=True, |
52 | 58 | ) |
53 | | - mod.instrument_mobile(mobile) |
54 | | - mobile_out = mobile_path.read_text(encoding="utf-8") |
55 | | - assert "FIELD_NATIVE_REPOSITORY_HTTP_REQUEST client=mobile" in mobile_out |
56 | | - assert ".proxy(Proxy.NO_PROXY)" in mobile_out |
57 | 59 |
|
58 | | - desktop = tmp / "desktop" |
59 | | - desktop_path = write( |
60 | | - desktop, |
61 | | - "composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt", |
62 | | - "private val desktopHttpClient = OkHttpClient.Builder()\n" |
63 | | - " .followRedirects(true)\n" |
64 | | - " .followSslRedirects(true)\n" |
65 | | - " .build()\n", |
66 | | - ) |
67 | | - mod.instrument_desktop(desktop) |
68 | | - desktop_out = desktop_path.read_text(encoding="utf-8") |
69 | | - assert "FIELD_NATIVE_REPOSITORY_HTTP_REQUEST client=desktop" in desktop_out |
70 | | - assert "desktop-native-http-evidence.log" in desktop_out |
71 | | - assert "println(requestMessage)" not in desktop_out |
72 | | - assert "println(responseMessage)" not in desktop_out |
73 | | - assert "println(errorMessage)" not in desktop_out |
74 | | - |
75 | | - for client_out in (tv_out, mobile_out, desktop_out): |
76 | | - for required in ( |
77 | | - "rawGithubEvidence", |
78 | | - "loopbackEvidence", |
79 | | - 'setOf("127.0.0.1", "localhost", "10.0.2.2")', |
80 | | - 'Regex("/candidate-[0-9a-f]{32}/")', |
81 | | - "FIELD_NATIVE_REPOSITORY_HTTP_RESPONSE", |
82 | | - "FIELD_NATIVE_REPOSITORY_HTTP_ERROR", |
83 | | - "request_header_names=$requestHeaderNames", |
84 | | - "response_header_names=$responseHeaderNames", |
85 | | - "source=$cacheSource", |
86 | | - ): |
87 | | - assert required in client_out, required |
88 | | - # Evidence must not persist query strings, request/response bodies or |
89 | | - # credential/header values. Only endpoint sans query and header names exist. |
90 | | - assert "authorization=" not in client_out.lower() |
91 | | - assert "cookie=" not in client_out.lower() |
92 | | - assert "query=" not in client_out.lower() |
93 | 60 |
|
94 | | - # Execute the provider-runtime Desktop code generator against its exact anchors. |
95 | | - # This catches Python/Kotlin escaping regressions before macOS/Windows runners. |
96 | | - desktop_runtime = tmp / "desktop-runtime" |
97 | | - runtime_path = write( |
98 | | - desktop_runtime, |
99 | | - "composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt", |
100 | | - "fun install() { addModule(FetchBridge()) }\n", |
101 | | - ) |
102 | | - bridge_path = write( |
103 | | - desktop_runtime, |
104 | | - "composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt", |
105 | | - '''internal class FetchBridge : HostModule {\n''' |
106 | | - ''' fun fetch() {\n''' |
107 | | - ''' } catch (t: Throwable) {\n''' |
108 | | - ''' log.e(t) { "Fetch bridge error for $method $url" }\n''' |
109 | | - ''' val headers = parseHeaders(headersJson).toMutableMap()\n''' |
110 | | - ''' if (!headers.containsKey("User-Agent")) {\n''' |
111 | | - ''' headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"\n''' |
112 | | - ''' }\n\n''' |
113 | | - ''' val response = httpRequestRaw(\n''' |
114 | | - ''' val responseHeaders = response.headers.mapKeys { (key, _) -> key.lowercase() }\n''' |
115 | | - ''' .mapValues { (_, value) -> truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) }\n''' |
116 | | - ''' val result = JsonObject(\n''' |
117 | | - ''' }\n}\n''', |
118 | | - ) |
119 | | - generated = subprocess.run( |
120 | | - [sys.executable, str(ROOT / "scripts/instrument_native_desktop_evidence.py"), str(desktop_runtime)], |
121 | | - cwd=ROOT, |
| 61 | +def assert_clean(path: Path) -> None: |
| 62 | + status = subprocess.run( |
| 63 | + ["git", "-C", str(path), "status", "--porcelain=v1", "--untracked-files=all"], |
| 64 | + check=True, |
122 | 65 | text=True, |
123 | 66 | capture_output=True, |
124 | | - ) |
125 | | - assert generated.returncode == 0, generated.stdout + generated.stderr |
126 | | - runtime_out = runtime_path.read_text(encoding="utf-8") |
127 | | - bridge_out = bridge_path.read_text(encoding="utf-8") |
128 | | - assert "FetchBridge(scraperId, mediaType)" in runtime_out |
129 | | - assert "FIELD_NATIVE_HTTP_REQUEST client=desktop" in bridge_out |
130 | | - assert "FIELD_NATIVE_HTTP_RESPONSE client=desktop" in bridge_out |
131 | | - assert "FIELD_NATIVE_HTTP_ERROR client=desktop" in bridge_out |
132 | | - assert bridge_out.count("desktop-native-http-evidence.log") == 3 |
133 | | - assert 'log.i { "FIELD_NATIVE_HTTP_' not in bridge_out |
| 67 | + ).stdout |
| 68 | + assert status.strip() == "", status |
| 69 | + |
134 | 70 |
|
135 | | -# Provider-runtime Desktop evidence uses the same dedicated sanitized channel; |
136 | | -# never depend on Gradle's captured test stdout for Brain input. |
137 | | -desktop_provider_instrumenter = (ROOT / "scripts/instrument_native_desktop_evidence.py").read_text(encoding="utf-8") |
138 | | -desktop_suite = (ROOT / "scripts/run_native_corpus_desktop_suite.sh").read_text(encoding="utf-8") |
139 | | -assert "desktop-native-http-evidence.log" in desktop_provider_instrumenter |
140 | | -assert 'log.i { "FIELD_NATIVE_HTTP_' not in desktop_provider_instrumenter |
141 | | -assert 'HTTP_LOG="${WORKSPACE}/desktop-native-http-evidence.log"' in desktop_suite |
142 | | -assert 'cat "$HTTP_LOG" >> "$LOG"' in desktop_suite |
143 | | -assert 'rm -f "$HTTP_LOG" "$GRADLE_LOG"' in desktop_suite |
144 | | -assert "grep -E 'FIELD_NATIVE_(REPOSITORY_)?HTTP_" not in desktop_suite |
| 71 | +with tempfile.TemporaryDirectory() as raw: |
| 72 | + root = Path(raw) |
| 73 | + tv = root / "tv" |
| 74 | + mobile = root / "mobile" |
| 75 | + desktop = root / "desktop" |
| 76 | + for repo in (tv, mobile, desktop): |
| 77 | + repo.mkdir() |
| 78 | + init_repo(repo) |
| 79 | + |
| 80 | + calls = ( |
| 81 | + ([sys.executable, str(CLIENT), "tv", str(tv)], tv, "client=tv"), |
| 82 | + ([sys.executable, str(CLIENT), "mobile", str(mobile)], mobile, "client=mobile"), |
| 83 | + ([sys.executable, str(REPOSITORY), "tv", str(tv)], tv, "client=tv"), |
| 84 | + ([sys.executable, str(REPOSITORY), "mobile", str(mobile)], mobile, "client=mobile"), |
| 85 | + ([sys.executable, str(REPOSITORY), "desktop", str(desktop)], desktop, "client=desktop"), |
| 86 | + ([sys.executable, str(DESKTOP), str(desktop)], desktop, "client=desktop"), |
| 87 | + ) |
| 88 | + for command, repo, marker in calls: |
| 89 | + completed = subprocess.run(command, cwd=ROOT, text=True, capture_output=True) |
| 90 | + assert completed.returncode == 0, completed.stdout + completed.stderr |
| 91 | + assert "disabled_by_human_ux_policy" in completed.stdout |
| 92 | + assert marker in completed.stdout |
| 93 | + assert_clean(repo) |
145 | 94 |
|
146 | | -print("native repository HTTP instrumentation tests passed") |
| 95 | +print("native runtime instrumentation shims are policy-locked and non-mutating") |
0 commit comments