ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
hil_privileged_helper_selftest.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Offline behavioral tests for the root-owned HIL helper."""
5
6from __future__ import annotations
7
8import argparse
9import importlib.util
10import os
11import resource
12import signal
13import sys
14import tempfile
15import time
16from collections.abc import Callable, Iterator
17from contextlib import contextmanager
18from pathlib import Path
19from types import ModuleType
20
21REPO_ROOT = Path(__file__).resolve().parents[2]
22HELPER = REPO_ROOT / "infra/ansible/roles/dev_box/files/ra8-hil-privileged.py"
23EXPECTED_NETWORK_MUTATIONS = 3
24
25
26def _member(module: ModuleType, name: str) -> Callable[..., object]:
27 """Return one deliberately private helper seam by its exact name."""
28 return vars(module)[name]
29
30
31def _load_helper() -> ModuleType:
32 """Load the checkout helper without invoking its CLI."""
33 spec = importlib.util.spec_from_file_location("ra8_hil_privileged", HELPER)
34 if spec is None or spec.loader is None:
35 message = "cannot load HIL privileged helper"
36 raise RuntimeError(message)
37 module = importlib.util.module_from_spec(spec)
38 sys.modules[spec.name] = module
39 spec.loader.exec_module(module)
40 return module
41
42
43def _policy(module: ModuleType) -> dict[str, object]:
44 """Return one internally authenticated physical-interface policy."""
45 policy: dict[str, object] = {
46 "board_iface": "eth0",
47 "mac": "02:00:00:00:00:09",
48 "phc_index": 0,
49 "sysfs_device": "/sys/devices/platform/bench-ethernet",
50 "version": 1,
51 }
52 policy["declaration_sha256"] = module.hashlib.sha256(
53 _member(module, "_canonical_policy")(policy)
54 ).hexdigest()
55 return policy
56
57
58def _rejects(module: ModuleType, callable_obj: object, *args: object, **kwargs: object) -> bool:
59 """Return whether one pure request is rejected by policy."""
60 try:
61 callable_obj(*args, **kwargs)
62 except module.PolicyError:
63 return True
64 return False
65
66
67@contextmanager
68def _patched(module: ModuleType, **replacements: object) -> Iterator[None]:
69 """Temporarily replace helper boundaries for an offline fake backend."""
70 originals = {name: getattr(module, name) for name in replacements}
71 try:
72 for name, value in replacements.items():
73 setattr(module, name, value)
74 yield
75 finally:
76 for name, value in originals.items():
77 setattr(module, name, value)
78
79
80class _FakeCycleOps:
81 """In-memory journal/device backend with one injectable failure point."""
82
83 def __init__(self, module: ModuleType, fail_at: str = "") -> None:
84 self.module = module
85 self.fail_at = fail_at
86 self.events: list[str] = []
87 self.pending = False
88
89 def _event(self, value: str) -> None:
90 self.events.append(value)
91 if self.fail_at == value:
92 message = f"injected {value}"
93 raise self.module.PolicyError(message)
94
95 def save(self, _path: Path, _state: dict[str, object]) -> None:
96 self.pending = True
97 self._event("save")
98
99 def apply(self, action: object) -> None:
100 value = "off" if action.args[-1] in {"off", "0"} else "on"
101 self._event(value)
102
103 def pause(self) -> None:
104 self._event("pause")
105
106 def clear(self, _path: Path) -> None:
107 self._event("clear")
108 self.pending = False
109
110
111class _ProcessCycleOps:
112 """File-backed backend used by one real signalled child process."""
113
114 def __init__(
115 self,
116 events: Path,
117 journal: Path,
118 state: Path,
119 fail_at: str,
120 inflight_off: bool,
121 ) -> None:
122 self.events = events
123 self.journal = journal
124 self.state = state
125 self.fail_at = fail_at
126 self.inflight_off = inflight_off
127
128 def _event(self, value: str) -> None:
129 with self.events.open("a", encoding="ascii") as stream:
130 stream.write(f"{value}\n")
131 stream.flush()
132 os.fsync(stream.fileno())
133 if self.fail_at == value:
134 message = f"injected {value}"
135 raise RuntimeError(message)
136
137 def save(self, _path: Path, _state: dict[str, object]) -> None:
138 self.journal.write_text("pending\n", encoding="ascii")
139 self._event("save")
140
141 def apply(self, action: object) -> None:
142 value = "off" if action.args[-1] in {"off", "0"} else "on"
143 if value == "off" and self.inflight_off:
144 self._delayed_off()
145 return
146 if self.fail_at == value:
147 self._event(value)
148 self.state.write_text(value, encoding="ascii")
149 self._event(value)
150
151 def _delayed_off(self) -> None:
152 """Model a spawned off child that must not outlive restoration."""
153 pid = os.fork()
154 if pid == 0:
155 time.sleep(0.2)
156 self.state.write_text("off", encoding="ascii")
157 self._event("off-child-completed")
158 os._exit(0)
159 self._event("off-child-spawned")
160 _, status = os.waitpid(pid, 0)
161 if os.waitstatus_to_exitcode(status) != 0:
162 message = "in-flight off child failed"
163 raise RuntimeError(message)
164
165 def pause(self) -> None:
166 self._event("pause")
167 time.sleep(0.3)
168
169 def clear(self, _path: Path) -> None:
170 self._event("clear")
171 self.journal.unlink()
172
173
174def _route_shape_checks(module: ModuleType) -> list[tuple[str, bool]]:
175 """Accept both iproute2 JSON spellings for one exact /32 route."""
176
177 def route_present(destination: str) -> bool:
178 payload = [{"dst": destination, "dev": "eth0", "prefsrc": "192.168.1.1"}]
179 with _patched(module, _json_command=lambda _argv: payload):
180 return bool(_member(module, "_route_present")("192.168.1.42", "eth0"))
181
182 return [
183 ("iproute host-form /32 route is recognized", route_present("192.168.1.42")),
184 (
185 "iproute explicit /32 route is recognized",
186 route_present("192.168.1.42/32"),
187 ),
188 ]
189
190
191def _request_checks(module: ModuleType) -> list[tuple[str, bool]]:
192 """Return exact topology and argument-injection tests."""
193 power_command = _member(module, "_usb_power_command")
194 validate_action = _member(module, "_validate_action")
195 validate_board_ip = _member(module, "_validate_board_ip")
196 validate_port = _member(module, "_validate_port")
197 expected = ["/usr/sbin/uhubctl", "-S", "-l", "2-1.3", "-p", "1", "-a", "off"]
198
199 checks = [
200 (
201 "nominal port argv is exact",
202 power_command("usb-port-power", ["1", "off"]) == expected,
203 ),
204 (
205 "legacy port 2 restore argv remains exact",
206 power_command("usb-port-power", ["2", "on"], restoring=True)
207 == ["/usr/sbin/uhubctl", "-S", "-l", "2-1.3", "-p", "2", "-a", "on"],
208 ),
209 (
210 "root power topology is fixed",
211 power_command("usb-root-power", ["on"])[3] == "2-1",
212 ),
213 (
214 "nominal board address accepted",
215 validate_board_ip("192.168.1.42") == "192.168.1.42",
216 ),
217 (
218 "J-Link control port accepted",
219 validate_port("3") == "3",
220 ),
221 ]
222 checks.extend(
223 (f"port {value!r} rejected", _rejects(module, validate_port, value))
224 for value in ("1;id", "1 2", "../1", "-1", "2")
225 )
226 checks.extend(
227 (f"action {value!r} rejected", _rejects(module, validate_action, value))
228 for value in ("off;id", "on\n", "--help", "cycle")
229 )
230 checks.extend(
231 (f"address {value!r} rejected", _rejects(module, validate_board_ip, value))
232 for value in (
233 "10.0.40.2",
234 "192.168.1.1",
235 "192.168.1.0",
236 "192.168.1.255",
237 "192.168.1.2;id",
238 )
239 )
240 checks.extend(_route_shape_checks(module))
241 return checks
242
243
244def _interface_checks(module: ModuleType) -> list[tuple[str, bool]]:
245 """Prove only the fleet-declared permanent physical identity is accepted."""
246 policy = _policy(module)
247 validate = _member(module, "_validate_iface_facts")
248 strict_policy = _member(module, "_strict_policy")
249 facts_type = _member(module, "_LiveIfaceFacts")
250
251 def facts(**changes: object) -> object:
252 values = {
253 "exists": True,
254 "uplinks": {"wlan0"},
255 "ipv4_addresses": set(),
256 "mac": policy["mac"],
257 "sysfs_device": policy["sysfs_device"],
258 "phc_index": policy["phc_index"],
259 **changes,
260 }
261 return facts_type(**values)
262
263 return [
264 *_interface_address_checks(module, policy, validate, facts),
265 (
266 "second valid-shaped interface cannot be selected",
267 _rejects(module, strict_policy, {**policy, "board_iface": "eth1"}),
268 ),
269 (
270 "dotted virtual interface cannot be selected",
271 _rejects(module, strict_policy, {**policy, "board_iface": "eth0.42"}),
272 ),
273 (
274 "permanent MAC drift rejected",
275 _rejects(module, validate, policy, facts(mac="02:00:00:00:00:10")),
276 ),
277 (
278 "canonical sysfs identity drift rejected",
279 _rejects(
280 module,
281 validate,
282 policy,
283 facts(sysfs_device="/sys/devices/virtual/net/eth0"),
284 ),
285 ),
286 (
287 "PHC identity drift rejected",
288 _rejects(module, validate, policy, facts(phc_index=1)),
289 ),
290 ]
291
292
293def _interface_address_checks(
294 module: ModuleType,
295 policy: dict[str, object],
296 validate: Callable[..., object],
297 facts: Callable[..., object],
298) -> list[tuple[str, bool]]:
299 """Prove uplink and foreign-address facts fail closed."""
300 return [
301 ("exact permanent interface accepted", validate(policy, facts()) == "eth0"),
302 (
303 "exact helper-owned address is allowed during recovery",
304 validate(
305 policy,
306 facts(ipv4_addresses={"192.168.1.1"}),
307 {"192.168.1.1"},
308 )
309 == "eth0",
310 ),
311 (
312 "foreign address beside helper ownership is rejected",
313 _rejects(
314 module,
315 validate,
316 policy,
317 facts(ipv4_addresses={"192.168.1.1", "192.168.1.99"}),
318 {"192.168.1.1"},
319 ),
320 ),
321 (
322 "IPv4 default uplink rejected",
323 _rejects(module, validate, policy, facts(uplinks={"eth0"})),
324 ),
325 (
326 "IPv6 default uplink rejected by same all-table census",
327 _rejects(module, validate, policy, facts(uplinks={"eth0"})),
328 ),
329 ]
330
331
332def _cycle_case(module: ModuleType, fail_at: str) -> tuple[list[str], bool, bool]:
333 """Run one rootless cycle with an injected boundary failure."""
334 state, off = _member(module, "_cycle_request")("usb-port-cycle", ["1"])
335 ops = _FakeCycleOps(module, fail_at)
336 failed = _rejects(module, _member(module, "_perform_cycle"), Path("unused"), state, off, ops)
337 return ops.events, ops.pending, failed
338
339
340def _interrupt_cycle_case(
341 module: ModuleType, interrupt_type: type[BaseException]
342) -> tuple[list[str], bool, bool]:
343 """Interrupt a cycle delay and record restoration before propagation."""
344 state, off = _member(module, "_cycle_request")("usb-port-cycle", ["1"])
345 ops = _FakeCycleOps(module)
346
347 def interrupt() -> None:
348 ops.events.append("pause")
349 raise interrupt_type
350
351 ops.pause = interrupt
352 propagated = False
353 try:
354 _member(module, "_perform_cycle")(Path("unused"), state, off, ops)
355 except interrupt_type:
356 propagated = True
357 return ops.events, ops.pending, propagated
358
359
360def _cycle_checks(module: ModuleType) -> list[tuple[str, bool]]:
361 """Prove restoration on off/delay faults and journaling on restore faults."""
362 nominal = _cycle_case(module, "")
363 off = _cycle_case(module, "off")
364 pause = _cycle_case(module, "pause")
365 on = _cycle_case(module, "on")
366 clear = _cycle_case(module, "clear")
367 keyboard_interrupted = _interrupt_cycle_case(module, KeyboardInterrupt)
368 system_exit_interrupted = _interrupt_cycle_case(module, SystemExit)
369 checks = [
370 (
371 "nominal cycle journals, restores, and clears",
372 nominal == (["save", "off", "pause", "on", "clear"], False, False),
373 ),
374 (
375 "off failure still restores before reporting",
376 off == (["save", "off", "on", "clear"], False, True),
377 ),
378 (
379 "delay failure still restores before reporting",
380 pause == (["save", "off", "pause", "on", "clear"], False, True),
381 ),
382 (
383 "on failure leaves pending restore journal",
384 on == (["save", "off", "pause", "on"], True, True),
385 ),
386 (
387 "clear failure leaves conservative restore journal",
388 clear == (["save", "off", "pause", "on", "clear"], True, True),
389 ),
390 (
391 "KeyboardInterrupt restores on before propagating",
392 keyboard_interrupted == (["save", "off", "pause", "on", "clear"], False, True),
393 ),
394 (
395 "SystemExit restores on before propagating",
396 system_exit_interrupted == (["save", "off", "pause", "on", "clear"], False, True),
397 ),
398 (
399 "persistent off remains explicit and separate",
400 _rejects(
401 module,
402 _member(module, "_cycle_request"),
403 "usb-port-cycle",
404 ["1", "off"],
405 ),
406 ),
407 ]
408 checks.append(("interrupted cycle is restored before a later mutation", _recovers(module)))
409 return checks
410
411
412def _recovers(module: ModuleType) -> bool:
413 """Return whether a later mutation restores one persisted legacy journal."""
414 recovery_ops = _FakeCycleOps(module)
415 recovery_state = {
416 "kind": "port-power",
417 "port": "2",
418 "restore": "on",
419 "version": 1,
420 }
421 with _patched(module, _load_restore=lambda _path: recovery_state):
422 _member(module, "_recover_restore")(Path("unused"), recovery_ops)
423 return recovery_ops.events == ["on", "clear"] and not recovery_ops.pending
424
425
426def _signal_child(
427 events: Path,
428 journal: Path,
429 device_state: Path,
430 fail_at: str,
431 inflight_off: bool,
432) -> int:
433 """Run the exact cycle boundary inside a signallable child process."""
434 module = _load_helper()
435 state, off = _member(module, "_cycle_request")("usb-port-cycle", ["1"])
436 try:
437 _member(module, "_perform_cycle")(
438 journal,
439 state,
440 off,
441 _ProcessCycleOps(events, journal, device_state, fail_at, inflight_off),
442 )
443 except RuntimeError:
444 return 7
445 except KeyboardInterrupt:
446 return 8 if signal.getsignal(signal.SIGINT) is signal.default_int_handler else 9
447 return 0
448
449
450def _signal_process_case(
451 signum: int,
452 fail_at: str = "",
453 *,
454 wait_for: str = "pause",
455 inflight_off: bool = False,
456) -> tuple[list[str], int, bool, str]:
457 """Send a real terminating signal after a child records transient off."""
458 with tempfile.TemporaryDirectory(prefix="ra8-hil-signal-") as raw:
459 root = Path(raw)
460 events = root / "events"
461 journal = root / "journal"
462 device_state = root / "device-state"
463 pid = os.fork()
464 if pid == 0:
465 signal.signal(signal.SIGHUP, signal.SIG_DFL)
466 signal.signal(signal.SIGINT, signal.default_int_handler)
467 signal.signal(signal.SIGQUIT, signal.SIG_DFL)
468 signal.signal(signal.SIGTERM, signal.SIG_DFL)
469 resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
470 os._exit(_signal_child(events, journal, device_state, fail_at, inflight_off))
471 deadline = time.monotonic() + 3
472 returncode: int | None = None
473 observed: list[str] = []
474 while time.monotonic() < deadline:
475 observed = events.read_text(encoding="ascii").splitlines() if events.exists() else []
476 waited, status = os.waitpid(pid, os.WNOHANG)
477 if waited:
478 returncode = os.waitstatus_to_exitcode(status)
479 break
480 if wait_for in observed:
481 break
482 time.sleep(0.01)
483 if returncode is None and wait_for in observed:
484 os.kill(pid, signum)
485 if returncode is None:
486 returncode = _wait_child(pid)
487 observed = events.read_text(encoding="ascii").splitlines() if events.exists() else []
488 final_state = device_state.read_text(encoding="ascii") if device_state.exists() else ""
489 return observed, returncode, journal.exists(), final_state
490
491
492def _wait_child(pid: int) -> int:
493 """Return one child's status, killing only a wedged selftest child."""
494 deadline = time.monotonic() + 3
495 while time.monotonic() < deadline:
496 waited, status = os.waitpid(pid, os.WNOHANG)
497 if waited:
498 return os.waitstatus_to_exitcode(status)
499 time.sleep(0.01)
500 os.kill(pid, signal.SIGKILL)
501 _, status = os.waitpid(pid, 0)
502 return os.waitstatus_to_exitcode(status)
503
504
505def _signal_cycle_checks() -> list[tuple[str, bool]]:
506 """Prove real signal deferral and conservative failure journaling."""
507 nominal = ["save", "off", "pause", "on", "clear"]
508 term = _signal_process_case(signal.SIGTERM)
509 hup = _signal_process_case(signal.SIGHUP)
510 inflight = ["save", "off-child-spawned", "off-child-completed", "pause", "on", "clear"]
511 interrupt = _signal_process_case(
512 signal.SIGINT,
513 wait_for="off-child-spawned",
514 inflight_off=True,
515 )
516 quit_signal = _signal_process_case(
517 signal.SIGQUIT,
518 wait_for="off-child-spawned",
519 inflight_off=True,
520 )
521 restore_failed = _signal_process_case(signal.SIGTERM, "on")
522 clear_failed = _signal_process_case(signal.SIGHUP, "clear")
523 return [
524 (
525 "SIGTERM restores then retains signal exit",
526 term == (nominal, -signal.SIGTERM, False, "on"),
527 ),
528 (
529 "SIGHUP restores then retains signal exit",
530 hup == (nominal, -signal.SIGHUP, False, "on"),
531 ),
532 (
533 "in-flight off completes before SIGINT propagation and final on",
534 interrupt == (inflight, 8, False, "on"),
535 ),
536 (
537 "in-flight off completes before SIGQUIT exit and final on",
538 quit_signal == (inflight, -signal.SIGQUIT, False, "on"),
539 ),
540 (
541 "restore failure surfaces before SIGTERM redelivery and keeps journal",
542 restore_failed == (["save", "off", "pause", "on"], 7, True, "off"),
543 ),
544 (
545 "clear failure surfaces before SIGHUP redelivery and keeps journal",
546 clear_failed == ([*nominal], 7, True, "on"),
547 ),
548 ]
549
550
551def _network_success(
552 module: ModuleType, fail_save: int = 0, fail_run: int = 0
553) -> tuple[list[str], bool]:
554 """Run prepare against injected state and ip boundaries."""
555 phases: list[str] = []
556 counters = {"save": 0, "run": 0, "route": 0}
557
558 def save(_path: Path, state: dict[str, object], _policy: dict[str, object]) -> None:
559 counters["save"] += 1
560 phases.append(f"{state['address_phase']}/{state['link_phase']}/{state['route_phase']}")
561 if counters["save"] == fail_save:
562 message = "injected save"
563 raise module.PolicyError(message)
564
565 def run(_argv: list[str], *, check: bool = True) -> object:
566 _ = check
567 counters["run"] += 1
568 if counters["run"] == fail_run:
569 message = "injected ip"
570 raise module.PolicyError(message)
571 return _member(module, "_CommandResult")(0, "", "")
572
573 def route(_board: str, _iface: str) -> bool:
574 counters["route"] += 1
575 return counters["route"] > 1
576
577 replacements = {
578 "_network_cleanup": lambda _path, _policy: None,
579 "_validate_live_iface": lambda _policy: ("eth0", False),
580 "_route_present": route,
581 "_address_present": lambda _iface: counters["run"] >= 1,
582 "_link_is_up": lambda _iface: counters["run"] >= EXPECTED_NETWORK_MUTATIONS - 1,
583 "_save_state": save,
584 "_run": run,
585 }
586 with _patched(module, **replacements):
587 failed = _rejects(
588 module,
589 _member(module, "_network_prepare"),
590 Path("unused"),
591 _policy(module),
592 "192.168.1.42",
593 )
594 return phases, failed
595
596
597def _network_checks(module: ModuleType) -> list[tuple[str, bool]]:
598 """Prove pending/applied ordering across every save and mutation boundary."""
599 phases, failed = _network_success(module)
600 expected = [
601 "absent/absent/absent",
602 "pending/absent/absent",
603 "applied/absent/absent",
604 "applied/pending/absent",
605 "applied/applied/absent",
606 "applied/applied/pending",
607 "applied/applied/applied",
608 ]
609 checks = [
610 (
611 "network success checkpoints pending then applied",
612 phases == expected and not failed,
613 )
614 ]
615 for boundary in range(1, len(expected) + 1):
616 saved, did_fail = _network_success(module, fail_save=boundary)
617 checks.append((f"save fault {boundary} fails closed", did_fail and len(saved) == boundary))
618 for boundary in range(1, 4):
619 saved, did_fail = _network_success(module, fail_run=boundary)
620 checks.append((f"ip mutation fault {boundary} leaves journal", did_fail and bool(saved)))
621 checks.extend(_network_cleanup_checks(module))
622 checks.extend(_network_identity_checks(module))
623 return checks
624
625
626def _network_cleanup_checks(module: ModuleType) -> list[tuple[str, bool]]:
627 """Prove ambiguous cleanup stops and partial cleanup is checkpointed."""
628 policy = _policy(module)
629 pending = _member(module, "_new_state")("eth0", "192.168.1.42")
630 pending["route_phase"] = "pending"
631 touched: list[str] = []
632 with _patched(
633 module,
634 _load_state=lambda _path, _policy: pending,
635 _cleanup_route=lambda *_args: touched.append("route"),
636 ):
637 pending_rejected = _rejects(
638 module, _member(module, "_network_cleanup"), Path("unused"), policy
639 )
640 applied = {**pending, "route_phase": "applied", "link_phase": "applied"}
641 snapshots: list[dict[str, object]] = []
642 runs = 0
643
644 def run(_argv: list[str], *, check: bool = True) -> object:
645 nonlocal runs
646 _ = check
647 runs += 1
648 if runs == EXPECTED_NETWORK_MUTATIONS - 1:
649 message = "injected later cleanup failure"
650 raise module.PolicyError(message)
651 return _member(module, "_CommandResult")(0, "", "")
652
653 routes = iter((True, False))
654 replacements = {
655 "_load_state": lambda _path, _policy: applied,
656 "_authenticate_cleanup_iface": lambda _policy, _state: ("eth0", True),
657 "_route_present": lambda _board, _iface: next(routes),
658 "_link_is_up": lambda _iface: True,
659 "_address_present": lambda _iface: False,
660 "_save_state": lambda _path, state, _policy: snapshots.append(dict(state)),
661 "_run": run,
662 }
663 with _patched(module, **replacements):
664 later_failed = _rejects(module, _member(module, "_network_cleanup"), Path("unused"), policy)
665 return [
666 (
667 "pending cleanup requires trusted recovery before mutation",
668 pending_rejected and not touched,
669 ),
670 (
671 "completed cleanup leg is checkpointed before a later fault",
672 later_failed and snapshots and snapshots[0]["route_phase"] == "absent",
673 ),
674 ]
675
676
677def _network_identity_checks(module: ModuleType) -> list[tuple[str, bool]]:
678 """Prove stale cleanup paths authenticate before any root mutation."""
679 policy = _policy(module)
680 state = _member(module, "_new_state")("eth0", "192.168.1.42")
681 state.update(address_phase="applied", link_phase="applied", route_phase="applied")
682 touched: list[str] = []
683
684 def reject_auth(_policy: object, _state: object) -> None:
685 message = "injected permanent identity drift"
686 raise module.PolicyError(message)
687
688 common = {
689 "_load_state": lambda _path, _policy: state,
690 "_authenticate_cleanup_iface": reject_auth,
691 "_run": lambda *_args, **_kwargs: touched.append("run"),
692 }
693 with _patched(module, **common, _cleanup_route=lambda *_args: touched.append("route")):
694 cleanup_rejected = _rejects(
695 module, _member(module, "_network_cleanup"), Path("unused"), policy
696 )
697 with _patched(module, **common):
698 neigh_rejected = _rejects(
699 module, _member(module, "_network_neigh_flush"), Path("unused"), policy
700 )
701 validated: list[str] = []
702
703 def reject_cleanup(_path: Path, _policy: object) -> None:
704 message = "stale state identity drift"
705 raise module.PolicyError(message)
706
707 with _patched(
708 module,
709 _network_cleanup=reject_cleanup,
710 _validate_live_iface=lambda _policy: validated.append("validated"),
711 ):
712 prepare_rejected = _rejects(
713 module,
714 _member(module, "_network_prepare"),
715 Path("unused"),
716 policy,
717 "192.168.1.42",
718 )
719 return [
720 ("cleanup rejects physical drift before mutation", cleanup_rejected and not touched),
721 ("neighbour flush rejects physical drift before mutation", neigh_rejected and not touched),
722 ("prepare cannot bypass stale-state authentication", prepare_rejected and not validated),
723 ]
724
725
726def _state_and_sysfs_checks(module: ModuleType) -> list[tuple[str, bool]]:
727 """Return policy/state parser and USB symlink escape tests."""
728 policy = _policy(module)
729 new_state = _member(module, "_new_state")
730 strict_policy = _member(module, "_strict_policy")
731 strict_state = _member(module, "_strict_state")
732 resolve_usb_device = _member(module, "_resolve_usb_device")
733 valid = new_state("eth0", "192.168.1.42")
734 checks = [
735 ("valid policy accepted", strict_policy(policy) == policy),
736 (
737 "stale policy digest rejected",
738 _rejects(module, strict_policy, {**policy, "declaration_sha256": "0" * 64}),
739 ),
740 ("valid phased state accepted", strict_state(valid, policy) == valid),
741 (
742 "missing state field rejected",
743 _rejects(module, strict_state, {"version": 1}, policy),
744 ),
745 (
746 "pending state is recognized",
747 strict_state({**valid, "route_phase": "pending"}, policy)["route_phase"] == "pending",
748 ),
749 ]
750 with tempfile.TemporaryDirectory(prefix="ra8-hil-sysfs-") as raw:
751 root = Path(raw)
752 devices = root / "sys/devices"
753 bus = root / "sys/bus/usb/devices"
754 valid_device = devices / "platform/usb2/2-1/2-1.3/2-1.3.1"
755 escaped = root / "outside/2-1.3.2"
756 jlink_device = devices / "platform/usb2/2-1/2-1.3/2-1.3.3"
757 valid_device.mkdir(parents=True)
758 jlink_device.mkdir(parents=True)
759 escaped.mkdir(parents=True)
760 bus.mkdir(parents=True)
761 (bus / "2-1.3.1").symlink_to(valid_device)
762 (bus / "2-1.3.3").symlink_to(jlink_device)
763 (bus / "2-1.3.2").symlink_to(escaped)
764 checks.append(
765 (
766 "kernel-style USB symlink accepted",
767 resolve_usb_device(bus, devices, "2-1.3.1") == valid_device.resolve(),
768 )
769 )
770 checks.append(
771 (
772 "current J-Link USB symlink accepted",
773 resolve_usb_device(bus, devices, "2-1.3.3") == jlink_device.resolve(),
774 )
775 )
776 checks.append(
777 (
778 "USB parent escape rejected",
779 _rejects(module, resolve_usb_device, bus, devices, "2-1.3.2"),
780 )
781 )
782 return checks
783
784
785def main(argv: list[str] | None = None) -> int:
786 """Run every offline helper policy and fault-injection check."""
787 args_list = sys.argv[1:] if argv is None else argv
788 parser = argparse.ArgumentParser(description=__doc__)
789 parser.add_argument("--selftest", action="store_true")
790 args = parser.parse_args(args_list)
791 if not args.selftest:
792 parser.error("--selftest is required")
793 module = _load_helper()
794 checks = (
795 _request_checks(module)
796 + _interface_checks(module)
797 + _cycle_checks(module)
798 + _signal_cycle_checks()
799 + _network_checks(module)
800 + _state_and_sysfs_checks(module)
801 )
802 for label, passed in checks:
803 print(f" [{'PASS' if passed else 'FAIL'}] {label}")
804 ok = all(passed for _, passed in checks)
805 print(f"hil_privileged_helper_selftest.py: {'PASS' if ok else 'FAIL'}")
806 return 0 if ok else 1
807
808
809if __name__ == "__main__":
810 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298