ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
hil_convergence_safety_runtime_escape.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Two-sided escaped-descendant proofs for the image supervisor."""
4
5from __future__ import annotations
6
7import os
8import signal
9import stat
10import subprocess
11import time
12from pathlib import Path
13
14from hil_convergence_safety_runtime_mutations import (
15 POLL_SECONDS,
16 PRIVATE_MODE,
17 RESIDUE_TIMEOUT_SECONDS,
18 RuntimeMutationError,
19 _create_root,
20 _identity_text,
21 _no_residue,
22 _owned_root_scope,
23 _remove_root,
24 _run_supervisor,
25 _write_sources,
26)
27
28IDENTITY_FIELD_COUNT = 2
29PROCESS_GROUP_FIELD, SESSION_FIELD, START_TIME_FIELD = 2, 3, 19
30
31
32def _write_escape_fixture(root: Path) -> Path:
33 """Write a payload whose grandchild escapes its inherited session."""
34 payload = root / "escape-payload.py"
35 payload.write_text(
36 "import os, pathlib, sys, time\n"
37 "root = pathlib.Path(sys.argv[1])\n"
38 "child = os.fork()\n"
39 "if child == 0:\n"
40 " os.setsid()\n"
41 " os.chdir(root)\n"
42 " held = os.open(root, os.O_RDONLY | os.O_DIRECTORY)\n"
43 " os.close(1)\n"
44 " os.close(2)\n"
45 " process = os.getpid()\n"
46 " raw = pathlib.Path('/proc/self/stat').read_bytes()\n"
47 " fields = raw[raw.rfind(b')') + 2:].split()\n"
48 " if os.getpgrp() != process or os.getsid(0) != process:\n"
49 " os._exit(70)\n"
50 " identity = f'{process}:{int(fields[19])}\\n'.encode('ascii')\n"
51 " identity_fd = os.open(root / 'escape-child.identity', "
52 "os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)\n"
53 " os.write(identity_fd, identity)\n"
54 " os.close(identity_fd)\n"
55 " group_fd = os.open(root / 'escape-child.bound', "
56 "os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)\n"
57 " os.write(group_fd, f'{process}\\n'.encode('ascii'))\n"
58 " os.close(group_fd)\n"
59 " time.sleep(30)\n"
60 " os.close(held)\n"
61 " os._exit(0)\n"
62 "deadline = time.monotonic() + 5.0\n"
63 "while not (root / 'escape-child.bound').exists():\n"
64 " if time.monotonic() >= deadline:\n"
65 " os._exit(71)\n"
66 " time.sleep(0.01)\n"
67 "os._exit(1)\n",
68 encoding="ascii",
69 )
70 payload.chmod(stat.S_IRUSR | stat.S_IWUSR)
71 entry = root / "escape-entry.sh"
72 entry.write_text(
73 "#!/bin/bash\n"
74 '[[ "$1" == "--selftest" ]] || exit 64\n'
75 f'exec /usr/bin/python3 -B -I -S "{payload}" "{root}"\n',
76 encoding="ascii",
77 )
78 entry.chmod(PRIVATE_MODE)
79 return entry
80
81
82def _read_escape_identity(root: Path) -> tuple[int, int]:
83 """Read the exact PID/start-time identity published by the fixture."""
84 raw = (root / "escape-child.identity").read_bytes().strip().split(b":")
85 group = (root / "escape-child.bound").read_bytes().strip()
86 if (
87 len(raw) != IDENTITY_FIELD_COUNT
88 or not all(value.isdigit() for value in raw)
89 or not group.isdigit()
90 ):
91 message = "escaped-descendant identity receipt is malformed"
92 raise RuntimeMutationError(message)
93 identity = int(raw[0]), int(raw[1])
94 if identity[0] <= 0 or int(group) != identity[0]:
95 message = "escaped-descendant group receipt is inconsistent"
96 raise RuntimeMutationError(message)
97 return identity
98
99
100def _live_process_identity(process: int) -> tuple[int, int, int] | None:
101 """Return one live process's group, session, and start time."""
102 try:
103 raw = Path(f"/proc/{process}/stat").read_bytes()
104 except FileNotFoundError:
105 return None
106 except OSError as error:
107 message = "escaped-descendant identity is unreadable"
108 raise RuntimeMutationError(message) from error
109 closing = raw.rfind(b")")
110 fields = raw[closing + 2 :].split() if closing >= 0 else []
111 identity_fields = (PROCESS_GROUP_FIELD, SESSION_FIELD, START_TIME_FIELD)
112 if len(fields) <= START_TIME_FIELD or not all(
113 fields[index].isdigit() for index in identity_fields
114 ):
115 message = "escaped-descendant process identity is malformed"
116 raise RuntimeMutationError(message)
117 if fields[0] == b"Z":
118 return None
119 return tuple(int(fields[index]) for index in identity_fields)
120
121
122def _escape_is_live(identity: tuple[int, int]) -> bool:
123 """Prove the receipt still names its isolated escaped process."""
124 process, start_time = identity
125 return _live_process_identity(process) == (process, process, start_time)
126
127
128def _terminate_escape(identity: tuple[int, int]) -> bool:
129 """Kill only the exact still-live escaped group and wait for disappearance."""
130 process, _start_time = identity
131 if not _escape_is_live(identity):
132 return True
133 os.killpg(process, signal.SIGKILL)
134 deadline = time.monotonic() + RESIDUE_TIMEOUT_SECONDS
135 while time.monotonic() < deadline:
136 current = _live_process_identity(process)
137 if current is None:
138 return True
139 if current != (process, process, identity[1]):
140 message = "escaped-descendant PID identity changed during cleanup"
141 raise RuntimeMutationError(message)
142 time.sleep(POLL_SECONDS)
143 return False
144
145
146def _require_empty_diagnostics(stderr: bytes) -> None:
147 """Reject any unexpected diagnostic from the public supervisor."""
148 if stderr:
149 message = "escaped-descendant supervisor emitted diagnostics"
150 raise RuntimeMutationError(message)
151
152
153def _run_escape(
154 supervisor: str, process_source: str, cases_source: str
155) -> tuple[Path, tuple[int, int], tuple[int, int], int | None, bool]:
156 """Run the public supervisor against one isolated escaping payload."""
157 root, root_identity = _create_root()
158 escape_identity: tuple[int, int] | None = None
159 try:
160 main_path, process_path, cases_path = _write_sources(
161 root, supervisor, process_source, cases_source
162 )
163 entry = _write_escape_fixture(root)
164 bound = root / "escape-supervisor.bound"
165 bound.write_bytes(b"")
166 bound.chmod(stat.S_IRUSR | stat.S_IWUSR)
167 status, clean, stderr = _run_supervisor(
168 main_path,
169 process_path,
170 cases_path,
171 (
172 str(entry),
173 str(bound),
174 str(root / "escape-supervisor.outer"),
175 str(root / "escape-supervisor.status"),
176 "normal",
177 _identity_text(root),
178 ),
179 )
180 escape_identity = _read_escape_identity(root)
181 _require_empty_diagnostics(stderr)
182 except (OSError, RuntimeMutationError, subprocess.TimeoutExpired, ValueError):
183 identity_receipt = root / "escape-child.identity"
184 if escape_identity is None and identity_receipt.exists():
185 escape_identity = _read_escape_identity(root)
186 if (
187 escape_identity is not None
188 and _escape_is_live(escape_identity)
189 and not _terminate_escape(escape_identity)
190 ):
191 message = "escaped-descendant failure cleanup did not converge"
192 raise RuntimeMutationError(message) from None
193 _remove_root(root, root_identity)
194 raise
195 else:
196 return root, root_identity, escape_identity, status, clean
197
198
199def cases(inputs: dict[str, str]) -> list[tuple[str, bool]]:
200 """Prove subreaper cleanup and detect deleting its enablement."""
201 if not Path("/proc/self/stat").is_file():
202 return [("image supervisor escaped-descendant proof is Linux-only", True)]
203 supervisor = inputs["devcontainer_image_selftest_supervisor"]
204 process_source = inputs["devcontainer_image_selftest_process"]
205 supervisor_cases = inputs["devcontainer_image_selftest_supervisor_cases"]
206 enable = " self.subreaper = _enable_child_subreaper()\n"
207 if process_source.count(enable) != 1:
208 message = "subreaper runtime mutation authority is not unique"
209 raise RuntimeMutationError(message)
210 mutant_process = process_source.replace(
211 enable,
212 " self.subreaper = True # runtime mutation: kernel subreaper disabled\n",
213 1,
214 )
215 with _owned_root_scope():
216 roots: list[tuple[Path, tuple[int, int], tuple[int, int] | None]] = []
217 try:
218 base_root, base_root_identity, base_escape, base_status, base_clean = _run_escape(
219 supervisor, process_source, supervisor_cases
220 )
221 roots.append((base_root, base_root_identity, base_escape))
222 mutant_root, mutant_root_identity, mutant_escape, mutant_status, mutant_clean = (
223 _run_escape(supervisor, mutant_process, supervisor_cases)
224 )
225 roots.append((mutant_root, mutant_root_identity, mutant_escape))
226 base_absent = not _escape_is_live(base_escape) and _no_residue((base_root,))
227 mutant_live = _escape_is_live(mutant_escape) and not _no_residue((mutant_root,))
228 deletion_refused = False
229 try:
230 _remove_root(mutant_root, mutant_root_identity)
231 except RuntimeMutationError:
232 deletion_refused = mutant_root.is_dir()
233 mutant_recovered = _terminate_escape(mutant_escape) and _no_residue((mutant_root,))
234 return [
235 (
236 "supervisor subreaper removes a setsid root-owning descendant",
237 base_status == 1 and base_clean and base_absent,
238 ),
239 (
240 "subreaper deletion mutation preserves live root and then recovers exactly",
241 mutant_status == 1
242 and mutant_clean
243 and mutant_live
244 and deletion_refused
245 and mutant_recovered,
246 ),
247 ]
248 finally:
249 for root, root_identity, escape_identity in reversed(roots):
250 if (
251 escape_identity is not None
252 and _escape_is_live(escape_identity)
253 and not _terminate_escape(escape_identity)
254 ):
255 message = "escaped-descendant recovery did not converge"
256 raise RuntimeMutationError(message)
257 _remove_root(root, root_identity)