**Phase 03 `session_takeover` — final verification pass. All green.**
All four task files were already in `.agents/phases/complete/03_session_takeover/` with per-task reports, so this was the phase-level close, not new implementation.
**Completion criteria**
1. *2nd controller authenticates + takes the bridge; a 3rd is refused* — met host-side: `test_claim` 102 (whitelist matrix, `acceptsUpgrade` one-claim-at-a-time, both deadline clocks across `millis()` rollover, 64-byte cap through the real codec), `test_relayctl` 308 (`--take-over` = `T`,`X`,`H`; default refusal names the flag and sends no `X`), `test_page` 142 (claim→button→one click→`T` then `X` only). Refusal mechanism stated precisely: a second claim gets `503`/`E3`, a third *socket* gets the slot refusal (`slotrefused`).
2. *Handover releases then adopts, lands disarmed* — pinned: `closeLink("taken over")` before adoption (inverting it fails exactly 1 check, reverted); no `arm`/`setArmed`/`armed = true` in the `X` path (inserting one fails exactly 1 check, reverted); un-authenticated `X` admitted in `claim.h` fails `test_claim`+`test_relayctl` (reverted). Also verified by reading: `serveClaim` never writes `_lastInboundMs`/`_controllerSeen`, so a parked claim cannot hold the board armed past 8 s.
3. *Incumbent page says taken over, disarmed, no auto-reclaim* — `onE12()` + `test_page` (disarms, releases mouse, ends typing run, pill `taken over`, reconnect loop off).
4. *Three source pins fail on inversion* — re-demonstrated in this pass, all reverted (`src/claim.h`, `src/control_server.cpp` byte-identical to pre-inversion).
5. *Gate green + README/AGENTS say takeover cannot arm* — yes.
**Validation** — `bash .agents/validate.sh` → **exit 0**: 12 suites **5026 checks, 0 failures** (normal and `SANITIZE=1`), `bridge`/`console`/`rollback` all SUCCESS, page bytes 59 286, README/AGENTS claims OK, hygiene OK. Extra lint: `ruff --select F,E9` clean, `bash -n` clean, `py_compile` clean. Coverage on new/changed logic: `claim.h` 100 % (11 lines), `status.h` 100 % (29), `proto.h` 94.8 % (229), relayctl take-over lines 14/15 (the miss is a `def` continuation trace never records).
**Defects fixed (docs only — no code defect found)**: bug 6 claimed a 2nd `/ws` upgrade "is still refused"; Measured-behaviour samples lacked `taken=`/`busy`/`tkov`; footprint + TLS-cost rows quoted phase-02 numbers while claiming to be what the gate prints (now 99 744/1 257 933, 67 352/1 178 173, 93 568/1 157 609 → TLS = +6 176 B RAM/+100 324 B flash); page-delivery/handshake rows dated; `AGENTS.md` pure-module list was missing `claim.h`.
**Hardware**: no device run. The board still runs pre-phase-03 firmware, reflash/arming mutates a live device (the target is this machine), and both controllers need the operator's passphrase — guessing feeds the shared 5-fail lockout. The 3 blank rows (third-controller refusal, chord labels, nothing-outlives-the-link) are now recorded `skipped (no device run)` with the exact closing commands in `.agents/HW_VERIFICATION.md`.
**Tree left for the harness**: index untouched (0 staged), no history changes; `todo/03_session_takeover/00_phase.md` moved to `complete/` (matches the phase-02 rename pattern); untracked files the commit must include: `.agents/validate.sh`, `src/claim.h`, `tests/test_claim.cpp`, `.agents/phases/complete/03_session_takeover/`, `.agents/reports/03_session_takeover/`. Next phase: `todo/04_controller_page_responsive/`.
1178 lines
61 KiB
Python
1178 lines
61 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for the Python side: relayctl's WebSocket codec, and that its key table
|
|
has not drifted from the one in the controller page.
|
|
|
|
python3 tests/test_relayctl.py
|
|
|
|
No third-party dependencies, so it runs anywhere the tools do.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import pathlib
|
|
import re
|
|
import struct
|
|
import sys
|
|
|
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "tools"))
|
|
|
|
import relayctl # noqa: E402
|
|
|
|
failures = 0
|
|
checks = 0
|
|
|
|
|
|
def check(condition, label):
|
|
global checks, failures
|
|
checks += 1
|
|
if not condition:
|
|
failures += 1
|
|
print(f" FAIL {label}")
|
|
|
|
|
|
def check_eq(actual, expected, label):
|
|
global checks, failures
|
|
checks += 1
|
|
if actual != expected:
|
|
failures += 1
|
|
print(f" FAIL {label}: got {actual!r}, want {expected!r}")
|
|
|
|
|
|
print("handshake")
|
|
# RFC 6455 1.3's worked example -- the same vector the C++ suite asserts, so the
|
|
# two implementations are pinned to the same truth.
|
|
check_eq(relayctl.expect_accept("dGhlIHNhbXBsZSBub25jZQ=="),
|
|
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", "Sec-WebSocket-Accept of the RFC example")
|
|
check(relayctl.expect_accept("differentKey==") != relayctl.expect_accept("dGhlIQ=="),
|
|
"different keys give different accepts")
|
|
|
|
print("frame encoding")
|
|
frame = relayctl.encode_frame(relayctl.OP_TEXT, b"A1")
|
|
check_eq(len(frame), 2 + 4 + 2, "short masked frame length")
|
|
check_eq(frame[0], 0x81, "FIN + text opcode")
|
|
check_eq(frame[1] & 0x80, 0x80, "mask bit set (clients must mask)")
|
|
check_eq(frame[1] & 0x7F, 2, "payload length in the 7-bit field")
|
|
mask = frame[2:6]
|
|
check_eq(bytes(b ^ mask[i & 3] for i, b in enumerate(frame[6:])), b"A1",
|
|
"payload round-trips through the mask")
|
|
|
|
long_payload = b"x" * 300
|
|
frame = relayctl.encode_frame(relayctl.OP_TEXT, long_payload)
|
|
check_eq(frame[1] & 0x7F, 126, "126 signals the 16-bit length")
|
|
check_eq(struct.unpack(">H", frame[2:4])[0], 300, "16-bit length value")
|
|
mask = frame[4:8]
|
|
check_eq(relayctl.mask_payload(frame[8:], mask), long_payload,
|
|
"mask_payload is its own inverse")
|
|
check_eq(len(relayctl.encode_frame(relayctl.OP_TEXT, b"")), 6,
|
|
"empty frame still carries a mask key")
|
|
|
|
print("frame decoding")
|
|
receiver = relayctl.Receiver()
|
|
|
|
|
|
def server_frame(opcode, payload):
|
|
"""The unmasked frames the board sends."""
|
|
out = bytearray([0x80 | opcode, len(payload)])
|
|
out += payload
|
|
return bytes(out)
|
|
|
|
|
|
status = server_frame(relayctl.OP_TEXT, b'S{"armed":1,"usb":1}')
|
|
# Deliver it one byte at a time: the decoder must not invent a frame early.
|
|
emitted = []
|
|
for i in range(len(status)):
|
|
emitted += list(receiver.feed(status[i:i + 1]))
|
|
check_eq(len(emitted), 1, "exactly one frame from byte-at-a-time delivery")
|
|
check_eq(emitted[0][0], relayctl.OP_TEXT, "decoded opcode")
|
|
check_eq(emitted[0][1], b'S{"armed":1,"usb":1}',
|
|
"decoded payload, including the board's 'S' status prefix")
|
|
|
|
# Two frames in one read, plus a split across the boundary.
|
|
two = server_frame(relayctl.OP_TEXT, b"OK") + server_frame(relayctl.OP_PING, b"hi")
|
|
receiver = relayctl.Receiver()
|
|
got = list(receiver.feed(two))
|
|
check_eq(len(got), 2, "both frames decoded from one read")
|
|
check_eq(got[0][1], b"OK", "first frame payload")
|
|
check_eq(got[1][0], relayctl.OP_PING, "second frame opcode")
|
|
|
|
receiver = relayctl.Receiver()
|
|
got = list(receiver.feed(two[:5])) + list(receiver.feed(two[5:]))
|
|
check_eq(len(got), 2, "a frame split mid-payload is still decoded once")
|
|
|
|
# 16-bit length path, and the protocol violations the client refuses.
|
|
receiver = relayctl.Receiver()
|
|
big = bytearray([0x81, 126]) + struct.pack(">H", 130) + b"y" * 130
|
|
check_eq(list(receiver.feed(bytes(big)))[0][1], b"y" * 130, "16-bit length decoded")
|
|
|
|
receiver = relayctl.Receiver()
|
|
try:
|
|
list(receiver.feed(bytes([0x81, 0x82]) + b"\x00" * 6))
|
|
check(False, "a masked server frame must be rejected")
|
|
except relayctl.RelayError:
|
|
check(True, "masked server frame rejected")
|
|
|
|
receiver = relayctl.Receiver()
|
|
try:
|
|
list(receiver.feed(bytes([0x01, 0x01, b"a"[0]])))
|
|
check(False, "a fragmented frame must be rejected")
|
|
except relayctl.RelayError:
|
|
check(True, "fragmented server frame rejected")
|
|
|
|
print("key table")
|
|
check_eq(relayctl.resolve_key("Enter"), 0x28, "Enter is usage 0x28")
|
|
check_eq(relayctl.resolve_key("0x28"), 0x28, "numeric usage accepted")
|
|
check_eq(relayctl.resolve_key("Delete"), 0x4C, "Delete is Delete-Forward 0x4C")
|
|
for bad, why in [("0x00", "usage 0 is not a key"), ("0x1ff", "usage must fit a byte"),
|
|
("NotAKey", "unknown name")]:
|
|
try:
|
|
relayctl.resolve_key(bad)
|
|
check(False, why)
|
|
except (relayctl.RelayError, ValueError):
|
|
check(True, why)
|
|
|
|
# The page and this tool must agree, or `relayctl key Enter` and pressing Enter in
|
|
# the browser would be different keys. Compare the JS object in web_page.h.
|
|
page = (pathlib.Path(__file__).resolve().parent.parent / "src" / "web_page.h").read_text()
|
|
match = re.search(r"const CODE_TO_USAGE = \{(.*?)\};", page, re.S)
|
|
check(match is not None, "CODE_TO_USAGE found in src/web_page.h")
|
|
if match:
|
|
page_map = {}
|
|
for name, usage in re.findall(r"([A-Za-z][A-Za-z0-9]*)\s*:\s*(0x[0-9a-fA-F]+)",
|
|
match.group(1)):
|
|
page_map[name] = int(usage, 16)
|
|
check(len(page_map) > 80, f"page table parsed ({len(page_map)} entries)")
|
|
only_page = sorted(set(page_map) - set(relayctl.CODE_TO_USAGE))
|
|
only_cli = sorted(set(relayctl.CODE_TO_USAGE) - set(page_map))
|
|
check_eq(only_page, [], "keys the page knows but relayctl does not")
|
|
check_eq(only_cli, [], "keys relayctl knows but the page does not")
|
|
mismatched = sorted(k for k in set(page_map) & set(relayctl.CODE_TO_USAGE)
|
|
if page_map[k] != relayctl.CODE_TO_USAGE[k])
|
|
check_eq(mismatched, [], "same name, different usage id")
|
|
check_eq(page_map.get("Enter"), 0x28, "Enter agrees on 0x28")
|
|
check_eq(page_map.get("KeyA"), 0x04, "KeyA agrees on 0x04")
|
|
|
|
print("pasted text")
|
|
root = pathlib.Path(__file__).resolve().parent.parent
|
|
relay_src = (root / "tools" / "relayctl.py").read_text()
|
|
proto_src = (root / "src" / "proto.h").read_text()
|
|
text_src = (root / "src" / "text.h").read_text()
|
|
|
|
check_eq(relayctl.normalize_paste("a\r\nb"), "a\nb", "CRLF becomes one Enter")
|
|
check_eq(relayctl.normalize_paste("a\rb"), "a\nb", "a lone CR becomes Enter")
|
|
check_eq(relayctl.normalize_paste("a\nb"), "a\nb", "LF is left alone")
|
|
check_eq(relayctl.text_frame(0, "hi"), "P=0:6869", "the frame relayctl sends")
|
|
check_eq(relayctl.text_frame(250, "hi"), "P=250:6869", "the pace travels in decimal")
|
|
check_eq(relayctl.text_frame(0, "a"), "P=0:61", "one character")
|
|
|
|
# What the board's character map can type, read straight out of src/text.h: the
|
|
# switch spells out the punctuation and the ranges spell out the alphanumerics.
|
|
firmware = set()
|
|
for literal in re.findall(r"case '(\\.|[^'\\])':", text_src):
|
|
firmware.add(ord(literal.encode("latin-1").decode("unicode_escape")))
|
|
check("ch >= 'a' && ch <= 'z'" in text_src, "the lowercase range is still a range")
|
|
check("ch >= 'A' && ch <= 'Z'" in text_src, "the uppercase range is still a range")
|
|
check("ch >= '0' && ch <= '9'" in text_src, "the digit range is still a range")
|
|
firmware |= set(range(ord("a"), ord("z") + 1))
|
|
firmware |= set(range(ord("A"), ord("Z") + 1))
|
|
firmware |= set(range(ord("0"), ord("9") + 1))
|
|
|
|
expected = {9, 10, 13} | set(range(32, 127))
|
|
check_eq(sorted(firmware), sorted(expected),
|
|
"the board types exactly tab, newline, CR and printable ASCII")
|
|
|
|
# ... and the tool's validator must agree with it on every ASCII character, once the
|
|
# line endings are normalised. A character the tool waves through that the board then
|
|
# refuses would fail the whole paste at the board instead of at the terminal.
|
|
for code in range(128):
|
|
ch = chr(code)
|
|
tool_says_ok = not relayctl.untypeable(relayctl.normalize_paste(ch))
|
|
if tool_says_ok != (code in firmware):
|
|
check(False, f"code {code:#04x}: tool={tool_says_ok} board={code in firmware}")
|
|
check(True, "tool and board agree on all 128 ASCII codes")
|
|
|
|
for bad in ["\u2019", "\u2014", "\u00e9", "\u00a0", "\x00", "\x07", "\x7f"]:
|
|
check_eq(relayctl.untypeable("ok" + bad), [bad], f"refuses {bad!r}")
|
|
check_eq(relayctl.untypeable("line1\nline2\tcol\r"), ["\r"],
|
|
"CR is flagged before normalisation, and never sent")
|
|
|
|
# The three numbers have to be the board's numbers, or a paste dies on E8 halfway
|
|
# through because the tool split the text somewhere the firmware will not accept.
|
|
config_src = (root / "src" / "config.h").read_text()
|
|
|
|
|
|
def config_value(name):
|
|
# Decimal or hex: kMouseDeltaMax is 127 and kMouseButtonMask is 0x1F, and both are
|
|
# numbers a client has to agree with the firmware about.
|
|
found = re.search(rf"\b{name}\s*=\s*(0[xX][0-9a-fA-F]+|\d+)\s*;", config_src)
|
|
return int(found.group(1), 0) if found else None
|
|
|
|
|
|
check_eq(relayctl.TEXT_FRAME_MAX, config_value("kTextFrameMax"),
|
|
"relayctl TEXT_FRAME_MAX == kTextFrameMax")
|
|
check_eq(relayctl.MAX_PASTE_CHARS, config_value("kTextJobCap"),
|
|
"relayctl MAX_PASTE_CHARS == kTextJobCap")
|
|
check_eq(relayctl.DEFAULT_TEXT_PACE_MS, config_value("kTextPeriodMsDefault"),
|
|
"relayctl DEFAULT_TEXT_PACE_MS == kTextPeriodMsDefault")
|
|
page_src = (root / "src" / "web_page.h").read_text()
|
|
check_eq(int(re.search(r"const TEXT_FRAME_MAX = (\d+);", page_src).group(1)),
|
|
config_value("kTextFrameMax"), "page TEXT_FRAME_MAX == kTextFrameMax")
|
|
check_eq(int(re.search(r"const TEXT_JOB_MAX = (\d+);", page_src).group(1)),
|
|
config_value("kTextJobCap"), "page TEXT_JOB_MAX == kTextJobCap")
|
|
check(config_value("kTextFrameMax") * 2 + 7 <= config_value("kWsMessageCap"),
|
|
"the largest text frame fits one WebSocket message")
|
|
|
|
# _drain answered the board's ping with two methods that do not exist, which only
|
|
# surfaced on a session longer than the 15 s ping interval. A paste is such a session.
|
|
check("_send_frame" not in relay_src, "_drain no longer calls the missing _send_frame")
|
|
check("_check_error" not in relay_src, "_drain no longer calls the missing _check_error")
|
|
drained = relayctl.Relay._drain
|
|
src_lines = inspect.getsource(drained)
|
|
check("OP_PONG" in src_lines, "_drain answers a ping")
|
|
check("raise RelayError" in src_lines, "_drain still surfaces an error frame")
|
|
|
|
print("mouse")
|
|
check_eq(relayctl.mouse_frame(0x01, 30, -4), "M=01:30:-4:0:0",
|
|
"a mouse frame is the button byte then four movements")
|
|
check_eq(relayctl.mouse_frame(0), "M=00:0:0:0:0",
|
|
"and all five fields are always written -- the firmware parses exactly five")
|
|
check_eq(relayctl.mouse_frame(0x1f, -127, 127, -127, 127), "M=1f:-127:127:-127:127",
|
|
"the full signed range")
|
|
|
|
for bad, why in (((0x20, 0, 0, 0, 0), "a sixth button bit has nowhere to go"),
|
|
((0, 128, 0, 0, 0), "128 is more than one report"),
|
|
((0, -128, 0, 0, 0), "and so is -128"),
|
|
((0, 0, 0, 128, 0), "the wheel is one byte too")):
|
|
try:
|
|
relayctl.mouse_frame(*bad)
|
|
check(False, f"mouse_frame should refuse: {why}")
|
|
except relayctl.RelayError:
|
|
check(True, f"mouse_frame refuses: {why}")
|
|
|
|
# A movement bigger than one report is split here, exactly as the page splits it, and the
|
|
# split is exact -- clamping it would move the cursor half as far as was asked and say
|
|
# nothing about it.
|
|
for dx, dy in ((300, 0), (0, -260), (300, -260), (127, 127), (1, -1), (-500, 500)):
|
|
parts = relayctl.split_moves(dx, dy)
|
|
check_eq(sum(p[0] for p in parts), dx, f"the X split of {dx},{dy} adds up exactly")
|
|
check_eq(sum(p[1] for p in parts), dy, f"and the Y of {dx},{dy}")
|
|
check(all(abs(p[0]) <= relayctl.MOUSE_DELTA_MAX and abs(p[1]) <= relayctl.MOUSE_DELTA_MAX
|
|
for p in parts), f"and no part of {dx},{dy} exceeds one report")
|
|
|
|
check_eq(relayctl.mouse_frames("click", [], "left"),
|
|
[(0x01, 0, 0, 0, 0), (0, 0, 0, 0, 0)],
|
|
"a click is the button restated down then restated up")
|
|
check_eq(relayctl.mouse_frames("release", []), [(0, 0, 0, 0, 0)],
|
|
"release restates the whole button byte as nothing")
|
|
drag = relayctl.mouse_frames("drag", ["300", "0"], "right")
|
|
check_eq(drag[0], (0x02, 0, 0, 0, 0), "a drag presses first")
|
|
check(drag[-1] == (0, 0, 0, 0, 0), "and releases last")
|
|
check(all(f[0] == 0x02 for f in drag[1:-1]),
|
|
"and every movement in between carries the button, so it is a drag and not a move")
|
|
check_eq(sum(f[1] for f in drag), 300, "and the drag travels the whole distance")
|
|
check_eq(relayctl.mouse_frames("wheel", ["-3"]), [(0, 0, 0, -3, 0)],
|
|
"wheel notches go where the report's wheel byte is")
|
|
for bad_args, why in ((("move", ["1"], "left"), "move needs dx and dy"),
|
|
(("wheel", [], "left"), "wheel needs a count"),
|
|
(("move", ["1", "2"], "thumb"), "an unknown button is refused"),
|
|
(("wiggle", [], "left"), "an unknown action is refused")):
|
|
try:
|
|
relayctl.mouse_frames(*bad_args)
|
|
check(False, f"mouse_frames should refuse: {why}")
|
|
except (relayctl.RelayError, ValueError):
|
|
check(True, f"mouse_frames refuses: {why}")
|
|
|
|
# Every frame this tool can build must be one the firmware parses. The shape is asserted
|
|
# rather than a reimplementation of the parser, because the parser itself is tested
|
|
# against the same frames in tests/test_proto.cpp.
|
|
mframe = re.compile(r"^M=[0-9a-f]{2}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}$")
|
|
built = [relayctl.mouse_frame(*f)
|
|
for f in relayctl.mouse_frames("drag", ["500", "-500", "4", "-4"], "middle")]
|
|
check(all(mframe.match(f) for f in built), f"every built frame parses ({len(built)})")
|
|
check(all(len(f) <= config_value("kWsMessageCap") for f in built),
|
|
"and every one fits in one WebSocket message")
|
|
|
|
# The numbers are the board's. If they drift, the board starts answering E6 to frames
|
|
# this tool believed were legal.
|
|
check_eq(relayctl.MOUSE_DELTA_MAX, config_value("kMouseDeltaMax"),
|
|
"relayctl MOUSE_DELTA_MAX == kMouseDeltaMax")
|
|
check_eq(relayctl.MOUSE_BUTTON_MASK, config_value("kMouseButtonMask"),
|
|
"relayctl MOUSE_BUTTON_MASK == kMouseButtonMask")
|
|
check_eq(int(re.search(r"const MOUSE_DELTA_MAX = (\d+);", page_src).group(1)),
|
|
config_value("kMouseDeltaMax"), "page MOUSE_DELTA_MAX == kMouseDeltaMax")
|
|
check_eq(int(re.search(r"const MOUSE_BUTTON_MASK = (0x[0-9a-f]+);", page_src).group(1), 16),
|
|
config_value("kMouseButtonMask"), "page MOUSE_BUTTON_MASK == kMouseButtonMask")
|
|
check_eq(int(re.search(r"const MOUSE_FRAME_MS = (\d+);", page_src).group(1)),
|
|
relayctl.MOUSE_FRAME_MS, "the page and the CLI pace mouse frames identically")
|
|
|
|
# Button bits, agreed between the page and this tool even though the page keys them by
|
|
# DOM number and this keys them by name. Getting one wrong is a clean frame that is the
|
|
# wrong button -- the Alt+Tab failure again, one layer down.
|
|
check_eq({name: bits for name, bits in relayctl.MOUSE_BUTTONS.items()},
|
|
{"left": 0x01, "right": 0x02, "middle": 0x04, "back": 0x08, "forward": 0x10},
|
|
"the button bits are the report descriptor's order")
|
|
check("0: 0x01, 2: 0x02, 1: 0x04, 3: 0x08, 4: 0x10" in page_src,
|
|
"the page maps DOM button numbers to those same bits (DOM 1 is middle, 2 is right)")
|
|
|
|
print("protocol strings")
|
|
|
|
check_eq("S=%02x:%s" % (0x0A, "04,28"), "S=0a:04,28", "state frame format")
|
|
check_eq("Y=%02x:%s" % (0x05, "4c"), "Y=05:4c", "chord frame format")
|
|
check_eq("T" + "hunter2", "Thunter2", "auth frame format")
|
|
|
|
# The single-character commands relayctl emits must be ones proto.h accepts, and
|
|
# proto.h's own documented list must still mention them: a change on either side
|
|
# has to be a deliberate edit in two places.
|
|
for command in ("A1", "A0", "R", "H", "K"):
|
|
check(f'"{command}"' in relay_src, f'relayctl sends "{command}"')
|
|
for command in ("'A'", "'R'", "'H'", "'K'", "'T'", "'S'", "'Y'", "'C'", "'P'", "'M'"):
|
|
check(command in proto_src, f"proto.h parses {command}")
|
|
check("case 'M':" in proto_src, "proto.h routes M= to the mouse parser")
|
|
|
|
# The two mouse error codes have to be in the README's list. A client that cannot look up
|
|
# what E11 means cannot tell the operator why the pointer is not moving, and this suite is
|
|
# the only place that notices when the code and the document stop agreeing.
|
|
readme_src = (root / "README.md").read_text()
|
|
for code, where in (("10 the mouse interface is off", "proto.h"),
|
|
("11 the target asked for boot protocol", "proto.h")):
|
|
check(code in proto_src, f"proto.h's error list documents {code}")
|
|
check(code.split()[0] in readme_src,
|
|
f"README's error list has code {code.split()[0]}")
|
|
check("mouse interface is off" in readme_src and "boot protocol" in readme_src,
|
|
"README explains what both mouse refusals mean, not just their numbers")
|
|
check("`M=<btn>`" in readme_src or "M=<btn>" in readme_src or "`M=`" in readme_src,
|
|
"README's wire protocol table lists the M= command")
|
|
check("if (c == 'Y' && n == 0 && out.mods == 0)" in proto_src,
|
|
"proto.h still requires a chord to press something")
|
|
|
|
print("the fail-safe wiring")
|
|
# Source pins, and deliberately honest about being source pins: control_server.cpp and
|
|
# main.cpp need a socket and a board, so nothing here runs them. What they were caught
|
|
# doing is invisible to a compiler and to every behavioural test on the host, and each of
|
|
# these three is a sentence that a later edit can quietly undo.
|
|
main_src = (root / "src" / "main.cpp").read_text()
|
|
server_src = (root / "src" / "control_server.cpp").read_text()
|
|
|
|
# The backstop must not ask whether a controller is linked: a controller that has gone
|
|
# away is exactly the case where linked() is false, which made the silence timer
|
|
# uncallable in the one situation it exists for -- the board sat armed with a key held
|
|
# forever, and read-only /status traffic kept it that way.
|
|
check("if (armed && control.controllerSeen() &&" in main_src,
|
|
"loop()'s silence disarm runs off controllerSeen(), not linked()")
|
|
check("if (armed && control.linked()" not in main_src,
|
|
"nothing gates an armed-and-silent disarm on the link still being there")
|
|
|
|
# Accepting a TCP connection is not the operator being alive.
|
|
accept = server_src[server_src.index("void ControlServer::acceptNew"):
|
|
server_src.index("size_t ControlServer::readSome")]
|
|
# Code lines only -- the function carries a comment explaining why it does NOT stamp the
|
|
# clock, and a check that flagged the comment would be worse than no check at all.
|
|
accept_code = "\n".join(l for l in accept.splitlines()
|
|
if not l.strip().startswith(("//", "*", "/*")))
|
|
check("_lastInboundMs" not in accept_code,
|
|
"acceptNew() does not refresh the controller-silence clock")
|
|
|
|
# And closing a read-only /status request must not refresh it either -- the first fix
|
|
# stamped it on every close, which held a dead controller's armed state open exactly as
|
|
# effectively as the original bug did.
|
|
close_fn = server_src[server_src.index("void ControlServer::closeLink"):
|
|
server_src.index("}", server_src.index("_onLinkChange(_ctx, false)"))]
|
|
check("if (wasLinked) _lastInboundMs = millis();" in close_fn,
|
|
"closeLink() stamps the silence clock only when a controller link is what closed")
|
|
check("const bool wasLinked = _phase == Phase::WebSocket;" in close_fn,
|
|
"closeLink() treats anything that reached the WebSocket phase as able to leave "
|
|
"keys down, not only what is still marked authenticated")
|
|
|
|
print("the bridge handover (phase 03)")
|
|
# `X` moves the keyboard from one socket to the other, and the safety sentences about it
|
|
# are ordering facts in control_server.cpp that no host-runnable test can observe -- they
|
|
# need two real sockets and a board. Same trade as bug 10's pins above: these are source
|
|
# pins, deliberately, for the three sentences a later edit can undo silently.
|
|
server_hdr = (root / "src" / "control_server.h").read_text()
|
|
claim_src = (root / "src" / "claim.h").read_text()
|
|
|
|
hand_at = server_src.index("void ControlServer::handOverClaim")
|
|
hand = server_src[hand_at:server_src.index("\n}", hand_at)]
|
|
# Code lines only -- the function's comment is full of the words it forbids itself, and
|
|
# a check that flagged the comment would be worse than no check at all.
|
|
hand_code = "\n".join(l for l in hand.splitlines()
|
|
if not l.strip().startswith(("//", "*", "/*")))
|
|
|
|
# (a) The funnel runs BEFORE the newcomer is adopted. closeLink() is the release funnel
|
|
# (it disarms and releases for anything that reached the WebSocket phase -- bug 10's
|
|
# fix -- and runs _onLinkChange(false), which in main.cpp is disarm("controller
|
|
# disconnected") -> releaseEverything()); adopting the claimant is `_authed = true`.
|
|
# If those two ever swap order, the newcomer inherits a held key and an armed
|
|
# bridge, and nothing on a host would ever notice.
|
|
check("closeLink(\"taken over\")" in hand_code,
|
|
"the handover runs the existing release funnel, not a copy of it")
|
|
check("_authed = true" in hand_code,
|
|
"and it is the code that adopts the claimant as the controller")
|
|
check(hand_code.index("closeLink(\"taken over\")") < hand_code.index("_authed = true"),
|
|
"the funnel runs BEFORE the claimant is adopted -- a takeover can never inherit "
|
|
"a held key or an armed bridge")
|
|
check("if (!linked) disarm(\"controller disconnected\");" in main_src,
|
|
"and the funnel's link change is the one main.cpp disarms (releases every key "
|
|
"and every mouse button) on")
|
|
|
|
# (b) No arm is reachable from the `X` handler. The board lands DISARMED by design; the
|
|
# newcomer arms with an ordinary authenticated A1 or never. `arm(`/`setArmed`/"armed =
|
|
# true" must not appear in the handover's code lines, nor in the controller-side `X`
|
|
# case in main.cpp (which is a no-op answered OK).
|
|
for tok in ("setArmed", "armed = true", "arm("):
|
|
check(tok not in hand_code,
|
|
f"the handover path contains no {tok!r} -- a takeover cannot produce an "
|
|
f"armed board")
|
|
mt_at = main_src.index("case proto::Cmd::Takeover:")
|
|
mt_case_raw = main_src[mt_at:main_src.index("return;", mt_at)]
|
|
# Comment lines stripped for the same reason as hand_code: the case's own comment
|
|
# explains what it must not do, in words that contain the words being searched for.
|
|
mt_case = "\n".join(l for l in mt_case_raw.splitlines()
|
|
if not l.strip().startswith(("//", "*", "/*")))
|
|
check("control.sendOk();" in mt_case,
|
|
"`X` from the incumbent controller is a no-op answered OK, not E6")
|
|
check("setArmed" not in mt_case and "arm(" not in mt_case,
|
|
"and it cannot arm either -- you already hold the bridge, that is all this says")
|
|
|
|
# (c) A claim cannot be promoted without `_claimAuthed`. Two halves: the whitelist is
|
|
# consulted before the Takeover branch in handleClaimFrame, and claim.h admits `X`
|
|
# only in the authenticated branch, so the ordering has something correct to check.
|
|
hcf_at = server_src.index("bool ControlServer::handleClaimFrame")
|
|
hcf = server_src[hcf_at:server_src.index("\n}", hcf_at)]
|
|
check("claim::honours(_viewerRole, _claimAuthed, cmd)" in hcf,
|
|
"handleClaimFrame asks the one whitelist, with _claimAuthed in the question")
|
|
check(hcf.index("claim::honours(") < hcf.index("proto::Cmd::Takeover"),
|
|
"and it asks BEFORE the Takeover branch -- `X` cannot reach the promotion "
|
|
"unauthenticated")
|
|
check_eq(server_src.count("handOverClaim(nowMs);"), 1,
|
|
"handOverClaim is called from exactly that one place, behind the whitelist")
|
|
hon_at = claim_src.index("inline bool honours(")
|
|
hon = claim_src[hon_at:claim_src.index("\n}", hon_at)]
|
|
check("if (!authed) return cmd == proto::Cmd::Auth;" in hon,
|
|
"claim.h admits only T before authentication -- an unauthenticated `X` is refused")
|
|
check(hon.index("if (!authed)") < hon.index("case proto::Cmd::Takeover"),
|
|
"and `X` lives in the authenticated branch only, after that refusal")
|
|
|
|
# E12 is its own sentence: "you were dropped", not E3's "you were refused". The named
|
|
# code and the incumbent-side send are both pinned so the two can never merge back.
|
|
check("kErrTakenOver = 12" in server_hdr,
|
|
"the error code is named at 12, the number the incumbent's page will read")
|
|
check('sendError(kErrTakenOver, "session taken over")' in hand_code,
|
|
"and it is that named code the incumbent is told with, before the funnel closes "
|
|
"its link")
|
|
|
|
print("one routing path, two transports")
|
|
# Phase 02 task 04 moved the TLS session underneath the routing instead of beside it. The
|
|
# claims are structural, so they are pinned as source sentences: what they defend is a
|
|
# second path that can disagree, and nothing that runs on a host can observe two paths
|
|
# agreeing until one of them is wrong.
|
|
conn_src = (root / "src" / "conn.h").read_text()
|
|
ws_src = (root / "src" / "ws_codec.h").read_text()
|
|
|
|
# readSome()'s rule, carried over from the TLS task and now the only clock-stamping read:
|
|
# bytes count as "the operator is alive" only from an authenticated controller on the
|
|
# WebSocket. Over TLS a handshake, a /status poll and a viewer refresh all look identical
|
|
# to the transport, so if this condition ever loosens to "bytes arrived", anything on the
|
|
# network can hold a dead controller's armed state open -- bug 10's shape again.
|
|
read_fn = server_src[server_src.index("size_t ControlServer::readSome"):
|
|
server_src.index("\n}", server_src.index("size_t ControlServer::readSome"))]
|
|
check("if (_phase == Phase::WebSocket && _authed) _lastInboundMs = millis();" in read_fn,
|
|
"readSome() stamps the silence clock only for an authenticated controller on the "
|
|
"WebSocket, whatever transport the bytes came over")
|
|
check("_lastInboundMs" in read_fn, "readSome() is still the code that notices inbound bytes")
|
|
# ... and it is the ONLY inbound path that may. Every writer of that clock is a route
|
|
# that can keep the board armed, so the count is pinned: readSome, the authentication,
|
|
# closeLink stamping on the way out, and the bridge handover ADOPTING an already-
|
|
# authenticated claim (phase 03 -- the claim was checked against the same passphrase,
|
|
# so from that instant a controller exists and is speaking; the adoption stamps exactly
|
|
# the stamp an auth would). A fifth writer needs its sentence written here first.
|
|
stamps = [l.strip() for l in server_src.splitlines() if "_lastInboundMs = millis()" in l]
|
|
check_eq(len(stamps), 4,
|
|
"exactly four writers of the controller-silence clock: auth, readSome, "
|
|
"closeLink, handover adoption")
|
|
check(any("the controller exists from here" in l for l in stamps),
|
|
"one of them is the authentication itself")
|
|
# The spare slot must not be able to reach that clock even by accident: it reads the Conn
|
|
# directly, never through readSome().
|
|
viewer_fn = server_src[server_src.index("void ControlServer::serveViewer"):
|
|
server_src.index("void ControlServer::stopViewer")]
|
|
# Code lines only: the function carries a comment explaining why it does NOT go through
|
|
# readSome(), and a check that flagged the comment would be worse than no check at all.
|
|
viewer_code = "\n".join(l for l in viewer_fn.splitlines()
|
|
if not l.strip().startswith(("//", "*", "/*")))
|
|
check("readSome" not in viewer_code,
|
|
"the read-only slot reads its Conn directly, so it cannot refresh the controller clock")
|
|
|
|
# One HTTP response writer. The task's completion criterion is a grep that comes up empty,
|
|
# so this is that grep, as a check: exactly one formatter in src/, and the only literal
|
|
# response status line outside it is the 101 protocol switch (which is not a response).
|
|
writers = sorted(p.name for p in (root / "src").glob("*")
|
|
if '"HTTP/1.1 ' in p.read_text(errors="replace"))
|
|
check_eq(writers, ["conn.h", "control_server.cpp"],
|
|
"only conn.h's formatter and the 101 upgrade line write an HTTP status line")
|
|
check_eq(server_src.count('"HTTP/1.1 '), 1,
|
|
"control_server.cpp holds exactly one, the WebSocket 101 -- not a second response writer")
|
|
check("respondAndClose(rsp)" in server_src and "respondViewer(rsp)" in server_src,
|
|
"both slots queue a built response through the same pair of writers")
|
|
# serveReadonly is the single read-only answer, reachable from both listeners/slots.
|
|
check_eq(server_src.count("serveReadonly(req, rsp,"), 2,
|
|
"the page/status routing is reached from the controller slot and the spare, once each")
|
|
check("void ControlServer::serveReadonly" in server_src,
|
|
"and it is defined once")
|
|
check_eq(server_src.count("plaintextRoute("), 1,
|
|
"the :80 answer table is consulted in exactly one place, inside serveReadonly")
|
|
# Both slots go through the transport view; nothing in the routing consults the port.
|
|
check("_ctl.read(" in conn_src or "_ctl.read(" in server_src or "_plain->read" in conn_src,
|
|
"the controller slot reads through kr::Conn")
|
|
check("_view.read(" in server_src, "the spare slot reads through kr::Conn too")
|
|
check("_server.hasClient()" in server_src and "::accept(_tlsListen" in server_src,
|
|
"both listeners still feed one accept decision")
|
|
|
|
# The :80 policy itself is pure (kr::plaintextRoute, walked in tests/test_conn.cpp); these
|
|
# pins keep the firmware from re-deciding it locally.
|
|
check("PlainRoute::UpgradeRequired" in conn_src and "PlainRoute::Redirect" in conn_src,
|
|
"conn.h spells out the redirect and the 426 as data, not as scattered ifs")
|
|
check("namespace kr" in conn_src, "conn.h is in namespace kr, like every other module")
|
|
check("token=" in conn_src and "The value is never copied" in conn_src,
|
|
"the token-in-URL test looks for the key without copying the passphrase")
|
|
# The Host header had to be parsed to build a Location that keeps the operator's address.
|
|
check('ciEquals(ls, "host:", 5)' in ws_src, "ws_codec parses Host: for the redirect")
|
|
check("char host[" in ws_src, "and carries it on Request")
|
|
|
|
# What the terminal client says when the board wants TLS. A refusal that reads like a
|
|
# passphrase problem sends the operator re-typing a correct secret until the board locks it.
|
|
check("board refuses plaintext" in relayctl.handshake_refusal("HTTP/1.1 426 Upgrade Required"),
|
|
"426 is named as a TLS refusal")
|
|
check("passphrase" not in relayctl.handshake_refusal("HTTP/1.1 426 Upgrade Required").lower(),
|
|
"and the 426 wording does not blame the passphrase")
|
|
check("passphrase" in relayctl.handshake_refusal("HTTP/1.1 401 Unauthorized"),
|
|
"401 still says what it always said")
|
|
check(relayctl.handshake_refusal("HTTP/1.1 503 Service Unavailable")
|
|
!= relayctl.handshake_refusal("HTTP/1.1 426 Upgrade Required"),
|
|
"'busy' and 'this board wants TLS' are never the same sentence")
|
|
check("https" in relayctl.handshake_refusal("HTTP/1.1 301 Moved Permanently"),
|
|
"a redirect on :80 is reported as the port policy it is")
|
|
for line, want in (("HTTP/1.1 418 Teapot", "refused"), ("garbage", "refused")):
|
|
check_eq(relayctl.handshake_refusal(line), want, f"unknown status {line!r}")
|
|
|
|
# The page the transport sends: the byte count tests/test_conn.cpp drains through the
|
|
# chunk arithmetic has to be the page that is actually in the tree. The page is a
|
|
# chain of raw-string chunks joined by one #if KR_TLS build marker (task 05), so
|
|
# "the page" is the chunks plus exactly one marker branch -- and both branches are
|
|
# asserted to be the same length, which is what keeps every pinned count (this one,
|
|
# the gate's, the device's `wc -c`) build-independent. That is a test, not a hope.
|
|
def served_page(src, tls=True):
|
|
"""The bytes one build serves: the raw-string chunks concatenated around the
|
|
marker branch that build takes. None when the layout is not chunks joined by
|
|
markers, so a restructured page fails loudly instead of counting half of it."""
|
|
chunks = re.findall(r'R"HTML\((.*?)\)HTML"', src, re.S)
|
|
marks = re.findall(r'#if KR_TLS\n\s*"([^"]*)"\n#else\n\s*"([^"]*)"\n#endif', src)
|
|
if len(chunks) < 1 or len(marks) != len(chunks) - 1:
|
|
return None
|
|
out = ""
|
|
for i, chunk in enumerate(chunks):
|
|
out += chunk
|
|
if i < len(marks):
|
|
out += marks[i][0] if tls else marks[i][1]
|
|
return out
|
|
|
|
|
|
page_tls = served_page(page_src, True)
|
|
page_plain = served_page(page_src, False)
|
|
check(page_tls is not None and page_plain is not None,
|
|
"every join between the page's raw-string chunks is one #if KR_TLS marker")
|
|
check(all(len(a) == len(b) for a, b in
|
|
re.findall(r'#if KR_TLS\n\s*"([^"]*)"\n#else\n\s*"([^"]*)"\n#endif',
|
|
page_src)),
|
|
"both branches of every build marker are the same length, so the served page "
|
|
"is the same size whichever build flashes")
|
|
check(page_tls is not None and "var KR_TLS_BUILD=1;" in page_tls,
|
|
"the TLS build's page carries its marker")
|
|
check("var KR_TLS_BUILD=0;" in page_plain,
|
|
"and the -D KR_TLS=0 rollback page carries its own")
|
|
check_eq(len(page_tls.encode("utf-8")), len(page_plain.encode("utf-8")),
|
|
"the two builds serve byte-count-equal pages")
|
|
pinned_page_bytes = int(re.search(r"kServedPageBytes = (\d+)",
|
|
(root / "tests" / "test_conn.cpp").read_text()).group(1))
|
|
check_eq(len(page_tls.encode("utf-8")), pinned_page_bytes,
|
|
"the page the chunk arithmetic is tested against is the page in web_page.h")
|
|
|
|
# The page's only scheme choice is its own origin; a TLS board never has an http: origin
|
|
# because :80 answers a redirect (pinned above, walked in test_conn).
|
|
check(page_src.count('"wss://"') == 1 and page_src.count('"ws://"') == 1,
|
|
"the page derives the socket scheme from location.protocol, in exactly one place")
|
|
check('location.protocol === "https:" ? "wss://" : "ws://"' in page_src,
|
|
"https: origin gives wss://, and ws:// is reachable only on a KR_TLS=0 build")
|
|
# The guard the other way round (task 05): a TLS build's page on an http: origin
|
|
# refuses to connect and refuses the passphrase. The behaviour is walked in
|
|
# tests/test_page.js; these pins keep the refusal from being edited into a warning.
|
|
check("function servedInClear()" in page_src and "if (servedInClear())" in page_src,
|
|
"the page asks whether it arrived in the clear, in connect() and at load")
|
|
check("this page arrived in the clear; reload over https" in page_src,
|
|
"and it says why, in words the operator can act on")
|
|
check('$("token").disabled = true' in page_src,
|
|
"a clear-served page will not even take passphrase input")
|
|
check("paintChannel(s)" in page_src and 'id="certFp"' in page_src
|
|
and 'id="pTls"' in page_src,
|
|
"the status frame's tls/fp pair is rendered: a pill and a certificate line")
|
|
|
|
print("the client side of pinning")
|
|
# The half of phase 02 the operator types. None of this may guess: no TLS argument
|
|
# means no connection, a wrong fingerprint means nothing was sent, and the only
|
|
# path to the plaintext port is the flag that says it knows what it is doing.
|
|
import contextlib # noqa: E402
|
|
import hashlib # noqa: E402
|
|
import io # noqa: E402
|
|
import os # noqa: E402
|
|
import socket as socketmod # noqa: E402
|
|
|
|
# --- the decision functions, walked directly ---------------------------------
|
|
check_eq(relayctl.select_transport("a" * 64, "", False, False), "pin",
|
|
"a fingerprint pins")
|
|
check_eq(relayctl.select_transport("", "/tmp/ca.pem", False, False), "ca",
|
|
"a CA file verifies a chain")
|
|
check_eq(relayctl.select_transport("", "", True, False), "insecure",
|
|
"--insecure is a decision, not a default")
|
|
check_eq(relayctl.select_transport("", "", False, True), "plain",
|
|
"--plain is the rollback path")
|
|
for bad, why in ((("", "", False, False), "no trust argument at all"),
|
|
(("a" * 64, "/tmp/ca.pem", False, False), "a pin and a CA at once"),
|
|
(("", "", True, True), "--insecure together with --plain"),
|
|
(("a" * 64, "", False, True), "a pin on the rollback path")):
|
|
try:
|
|
relayctl.select_transport(*bad)
|
|
check(False, f"select_transport should refuse: {why}")
|
|
except relayctl.RelayError as exc:
|
|
check(True, f"select_transport refuses: {why}")
|
|
if why == "no trust argument at all":
|
|
for option in ("--fingerprint", "--ca", "--insecure", "--plain"):
|
|
check(option in str(exc),
|
|
f"the refusal names {option} as a way forward")
|
|
|
|
check_eq(relayctl.resolve_port("pin", None), 443, "a pinned run defaults to :443")
|
|
check_eq(relayctl.resolve_port("ca", None), 443, "a verified run defaults to :443")
|
|
check_eq(relayctl.resolve_port("insecure", None), 443,
|
|
"even an unverified TLS run defaults to the TLS port")
|
|
check_eq(relayctl.resolve_port("plain", None), 80,
|
|
"--plain is the only path to :80")
|
|
check_eq(relayctl.resolve_port("pin", 8443), 8443, "an explicit --port wins")
|
|
|
|
check_eq(relayctl.normalize_fingerprint("A" * 64), "a" * 64,
|
|
"a bare hex fingerprint normalises to lowercase")
|
|
check_eq(relayctl.normalize_fingerprint(":".join("a1" for _ in range(32))), "a1" * 32,
|
|
"OpenSSL's colon pairs are accepted")
|
|
check_eq(relayctl.normalize_fingerprint(" " + "ab" * 32 + "\n"), "ab" * 32,
|
|
"and so is the whitespace a serial console wraps in")
|
|
for bad in ("", "deadbeef", "zz" * 32, "ab" * 31, "ab" * 32 + "0"):
|
|
try:
|
|
relayctl.normalize_fingerprint(bad)
|
|
check(False, f"a malformed fingerprint is refused: {bad[:12]!r}")
|
|
except relayctl.RelayError:
|
|
check(True, f"malformed fingerprint refused: {bad[:12]!r}")
|
|
|
|
# --- the connection itself, against a fake board ------------------------------
|
|
FAKE_DER = b"-----BEGIN FAKE CERTIFICATE----- der-bytes pinning never sees"
|
|
|
|
|
|
# The status an adopted controller gets back to back with its `OK` -- the frame
|
|
# whose ABSENCE after `OK` is how a pending claim learns the board is busy. The
|
|
# values are the honest post-handover ones: disarmed, busy (with itself), one
|
|
# completed takeover.
|
|
FAKE_STATUS = b'S{"v":"1.0.0","armed":0,"usb":1,"typing":0,"busy":1,"tkov":1}'
|
|
|
|
|
|
class FakeBoardSock:
|
|
"""Records every byte this side sends; answers the upgrade and `T<token>`.
|
|
|
|
The recorded frame list is the point: "the pin aborted before T<token>" can
|
|
only be shown by what did NOT go out.
|
|
|
|
`claim_slot=True` makes the board a BUSY one: `T` authenticates into the spare
|
|
slot as a pending claim (`OK`, and deliberately no adoption status -- the status
|
|
frame belongs to the controller alone), and the board adopts the client only
|
|
when it sends the one deliberate `X`. That asymmetry is the whole of phase 03
|
|
from this side of the socket, so the fake spells it out instead of collapsing
|
|
both answers into "OK".
|
|
"""
|
|
|
|
def __init__(self, host, port, close_early=False, bad_accept=False, flood=False,
|
|
claim_slot=False):
|
|
self.host, self.port = host, port
|
|
self.bad_accept = bad_accept # answer the upgrade with a wrong accept hash
|
|
self.flood = flood # answer with headers that never terminate
|
|
self.claim_slot = claim_slot # busy board: park the newcomer as a claim
|
|
self.sent = [] # every sendall payload, in order
|
|
self.inbox = bytearray()
|
|
self.upgraded = False
|
|
self.post = bytearray() # bytes after the 101 (client WS frames)
|
|
self.closed = False
|
|
self.timeout = None
|
|
self.close_early = close_early # answer the upgrade with a clean EOF
|
|
|
|
def settimeout(self, t):
|
|
self.timeout = t
|
|
|
|
def sendall(self, data):
|
|
data = bytes(data)
|
|
self.sent.append(data)
|
|
if self.close_early:
|
|
return # received, but the board answers nothing
|
|
if not self.upgraded:
|
|
blob = b"".join(self.sent)
|
|
if b"\r\n\r\n" in blob:
|
|
m = re.search(rb"Sec-WebSocket-Key: (\S+)", blob)
|
|
if self.flood:
|
|
self.inbox += b"HTTP/1.1 101 " + b"x" * 20000
|
|
self.upgraded = True
|
|
return
|
|
accept = (b"not-the-accept" if self.bad_accept
|
|
else relayctl.expect_accept(m.group(1).decode()).encode())
|
|
self.inbox += (b"HTTP/1.1 101 Switching Protocols\r\n"
|
|
b"Sec-WebSocket-Accept: " + accept + b"\r\n\r\n")
|
|
self.upgraded = True
|
|
return
|
|
self.post += data
|
|
while len(self.post) >= 2:
|
|
length = self.post[1] & 0x7F
|
|
key = self.post[2:6]
|
|
payload = bytes(b ^ key[i & 3] for i, b in enumerate(self.post[6:6 + length]))
|
|
del self.post[:6 + length]
|
|
if payload.startswith(b"T"):
|
|
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, b"OK")
|
|
if not self.claim_slot:
|
|
# The adoption status a real controller gets back to back with
|
|
# the OK -- the auth path sends both on one write.
|
|
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
|
|
elif payload == b"X":
|
|
# Only a claim has anything to take; this fake only ever sees X
|
|
# from one. The handover lands disarmed, and the adopted session
|
|
# gets exactly the greeting a fresh authentication gets.
|
|
if self.claim_slot:
|
|
self.claim_slot = False
|
|
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, b"OK")
|
|
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
|
|
elif payload == b"H":
|
|
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
|
|
|
|
def recv(self, n):
|
|
if self.inbox:
|
|
data = bytes(self.inbox[:n])
|
|
del self.inbox[:n]
|
|
return data
|
|
if self.close_early:
|
|
return b"" # clean EOF: the board hung up mid-handshake
|
|
raise socketmod.timeout("fake board: no more bytes right now")
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
def relayctl_server_frame(opcode, payload):
|
|
out = bytearray([0x80 | opcode, len(payload)])
|
|
out += payload
|
|
return bytes(out)
|
|
|
|
|
|
class FakeSsl:
|
|
"""Stand-in for the ssl module: real policy code runs against these knobs, and
|
|
wrap_socket returns the same socket with a getpeercert of our choosing."""
|
|
|
|
PROTOCOL_TLS_CLIENT = "tls-client"
|
|
CERT_NONE = 0
|
|
CERT_REQUIRED = 2
|
|
|
|
class SSLError(Exception):
|
|
pass
|
|
|
|
def __init__(self, der=FAKE_DER, wrap_error=None, empty_der=False):
|
|
self.der = der
|
|
self.wrap_error = wrap_error # raised by wrap_socket, simulating a failed TLS
|
|
self.empty_der = empty_der # peer completes TLS but shows no certificate
|
|
self.contexts = [] # (kind, ctx) for every context built
|
|
|
|
def SSLContext(self, protocol):
|
|
ctx = FakeSsl.Ctx(self, protocol)
|
|
self.contexts.append(("manual", ctx))
|
|
return ctx
|
|
|
|
def create_default_context(self, cafile=None):
|
|
ctx = FakeSsl.Ctx(self, "default")
|
|
ctx.cafile = cafile
|
|
self.contexts.append(("default", ctx))
|
|
return ctx
|
|
|
|
class Ctx:
|
|
def __init__(self, owner, protocol):
|
|
self.owner, self.protocol = owner, protocol
|
|
self.check_hostname = None
|
|
self.verify_mode = None
|
|
self.cafile = None
|
|
self.server_hostname = None
|
|
|
|
def wrap_socket(self, sock, server_hostname=None):
|
|
self.server_hostname = server_hostname
|
|
if self.owner.wrap_error is not None:
|
|
raise self.owner.wrap_error
|
|
der = b"" if self.owner.empty_der else self.owner.der
|
|
sock.getpeercert = lambda binary_form=False: der if binary_form else {}
|
|
return sock
|
|
|
|
|
|
def connect_with(fingerprint="", ca="", insecure=False, plain=False, port=None,
|
|
der=FAKE_DER, token="sekrit", wrap_error=None, close_early=False,
|
|
empty_der=False, bad_accept=False, flood=False, claim_slot=False,
|
|
take_over=False,
|
|
real_create=socketmod.create_connection, real_ssl=relayctl.ssl):
|
|
fake = FakeSsl(der, wrap_error=wrap_error, empty_der=empty_der)
|
|
socks = []
|
|
|
|
def fake_create(addr, timeout):
|
|
s = FakeBoardSock(addr[0], addr[1], close_early=close_early,
|
|
bad_accept=bad_accept, flood=flood, claim_slot=claim_slot)
|
|
socks.append(s)
|
|
return s
|
|
|
|
relayctl.ssl = fake
|
|
relayctl.socket.create_connection = fake_create
|
|
stderr = io.StringIO()
|
|
relay, err = None, None
|
|
try:
|
|
with contextlib.redirect_stderr(stderr):
|
|
relay = relayctl.Relay("keyrelay.local", port, token, 1.0,
|
|
fingerprint=fingerprint, ca=ca,
|
|
insecure=insecure, plain=plain,
|
|
take_over=take_over)
|
|
except relayctl.RelayError as exc:
|
|
err = exc
|
|
finally:
|
|
relayctl.ssl = real_ssl
|
|
relayctl.socket.create_connection = real_create
|
|
return relay, err, socks, fake, stderr.getvalue()
|
|
|
|
|
|
def client_frame_payloads(sock):
|
|
"""Decode the masked client frames this side sent, skipping the HTTP upgrade."""
|
|
blob = b"".join(sock.sent)
|
|
tail = blob.split(b"\r\n\r\n", 1)[1] if b"\r\n\r\n" in blob else b""
|
|
out = []
|
|
while len(tail) >= 2:
|
|
length = tail[1] & 0x7F
|
|
key = tail[2:6]
|
|
out.append(bytes(b ^ key[i & 3] for i, b in enumerate(tail[6:6 + length])))
|
|
tail = tail[6 + length:]
|
|
return out
|
|
|
|
# A wrong fingerprint aborts BEFORE a single frame -- above all before T<token>.
|
|
_wrong = "0" * 64
|
|
relay, err, socks, fake, warns = connect_with(fingerprint=_wrong)
|
|
check(isinstance(err, relayctl.RelayError), "a wrong fingerprint raises")
|
|
check(err is not None and "fingerprint mismatch" in str(err),
|
|
"and says exactly what mismatched")
|
|
check(socks and socks[0].sent == [],
|
|
"nothing at all went out -- not the upgrade, and certainly not T<token>")
|
|
|
|
# The right fingerprint (in any sane spelling) connects, and the passphrase frame
|
|
# is the first thing on the encrypted wire.
|
|
_right = hashlib.sha256(FAKE_DER).hexdigest()
|
|
relay, err, socks, fake, warns = connect_with(fingerprint=_right)
|
|
check(err is None and relay is not None, f"the pinned fingerprint connects ({err})")
|
|
check(relay is not None and relay.mode == "pin", "the mode is the pin")
|
|
if socks:
|
|
frames = client_frame_payloads(socks[0])
|
|
check(frames and frames[0] == b"Tsekrit",
|
|
"the first frame on a pinned session is the authentication")
|
|
check(b"Tsekrit" not in b"".join(socks[0].sent),
|
|
"the token never travels as plaintext bytes -- it is only in masked frames")
|
|
check(socks and socks[0].port == 443, "pinning dials :443")
|
|
relay2, err2, socks2, _, _ = connect_with(
|
|
fingerprint=":".join(_right[i:i + 2] for i in range(0, 64, 2)).upper())
|
|
check(err2 is None, "a colon-separated uppercase pin is the same pin")
|
|
|
|
# TLS failures, in the two shapes the operator can actually act on.
|
|
relay, err, socks, fake, warns = connect_with(
|
|
fingerprint=_right, wrap_error=FakeSsl.SSLError("handshake refused"))
|
|
check(err is not None and "TLS handshake with keyrelay.local failed" in str(err),
|
|
"a failed TLS handshake is named as that")
|
|
check(err is not None and "426" in str(err),
|
|
"and it names the plaintext-port mistake without ever proposing to make it")
|
|
check(socks and socks[0].closed, "and the socket is closed on the way out")
|
|
|
|
relay, err, socks, fake, warns = connect_with(
|
|
ca="/tmp/ca.pem", wrap_error=FakeSsl.SSLError("certificate verify failed"))
|
|
check(err is not None and "failed verification" in str(err),
|
|
"a --ca verification failure is not collapsed into 'handshake failed'")
|
|
check(err is not None and "nvs erase" in str(err),
|
|
"and it says the two things a verify failure can mean")
|
|
|
|
relay, err, socks, fake, warns = connect_with(fingerprint=_right, empty_der=True)
|
|
check(err is not None and "no certificate" in str(err),
|
|
"a TLS peer that shows no certificate is refused, not hashed as empty")
|
|
check(socks and socks[0].closed, "and that socket is closed too")
|
|
|
|
# --plain against a board that hangs up instead of speaking WebSocket.
|
|
relay, err, socks, fake, warns = connect_with(plain=True, close_early=True)
|
|
check(err is not None and "server closed during the handshake" in str(err),
|
|
"a closed handshake is reported for what it is")
|
|
|
|
relay, err, socks, fake, warns = connect_with(plain=True, bad_accept=True)
|
|
check(err is not None and "Sec-WebSocket-Accept" in str(err),
|
|
"a wrong accept hash refuses -- the RFC 6455 check is not decoration")
|
|
|
|
relay, err, socks, fake, warns = connect_with(plain=True, flood=True)
|
|
check(err is not None and "handshake response too large" in str(err),
|
|
"and a header block that never ends is bounded, not buffered forever")
|
|
|
|
# --ca verifies with OpenSSL's own machinery: hostname checking ON, chain REQUIRED.
|
|
relay, err, socks, fake, warns = connect_with(ca="/tmp/board-ca.pem")
|
|
check(err is None, f"--ca connects ({err})")
|
|
kinds = [k for k, _ in fake.contexts]
|
|
check(kinds == ["default"], "--ca builds its context with create_default_context")
|
|
ctx = fake.contexts[0][1]
|
|
check_eq(ctx.cafile, "/tmp/board-ca.pem", "and loads the file the operator named")
|
|
check_eq(ctx.check_hostname, True, "--ca keeps hostname checking on")
|
|
check_eq(ctx.verify_mode, 2, "and requires the chain")
|
|
check(ctx.server_hostname == "keyrelay.local",
|
|
"the hostname travels -- SNI and the check share one value")
|
|
|
|
# --insecure: connects, warns, and warns on EVERY run (it is stderr, not a memory).
|
|
relay, err, socks, fake, warns = connect_with(insecure=True)
|
|
check(err is None, "--insecure connects")
|
|
check("--insecure" in warns and "not verifying" in warns,
|
|
"--insecure is loud about what it skipped")
|
|
check_eq(socks[0].port, 443, "and still uses the TLS port")
|
|
_, _, _, fake_p, warns_p = connect_with(plain=True)
|
|
check(fake_p.contexts == [], "--plain builds no TLS context at all")
|
|
check(warns_p == "", "--plain warns about nothing it did not skip")
|
|
|
|
# --plain is the only way to :80, and it is explicit every time.
|
|
relay, err, socks, fake, warns = connect_with(plain=True)
|
|
check(err is None and socks[0].port == 80, "--plain reaches :80")
|
|
frames = client_frame_payloads(socks[0])
|
|
check(frames and frames[0] == b"Tsekrit",
|
|
"the rollback session authenticates the same way")
|
|
|
|
print("the busy board and --take-over")
|
|
# Phase 03, from the terminal side. A busy board authenticates this tool into its
|
|
# one spare slot as a pending claim -- `OK` and then the silence that means `busy`,
|
|
# because the status frame belongs to the controller alone. The default is a
|
|
# refusal that names the flag; the flag is one deliberate `X`; a free board is a
|
|
# no-op. The frames that DO go out are asserted, in order, because "takeover can
|
|
# never arm" is a sentence about what a session looks like at the moment it takes
|
|
# over: T, X, and nothing that types.
|
|
|
|
# Default against a busy board: refuse, name --take-over, send no X, free the slot.
|
|
relay, err, socks, fake, warns = connect_with(plain=True, claim_slot=True)
|
|
check(isinstance(err, relayctl.RelayError), "a busy board refuses by default")
|
|
check(err is not None and "--take-over" in str(err), "and the refusal names the flag")
|
|
check(err is not None and "DISARMED" in str(err),
|
|
"and says what the handover lands on, before anyone takes it")
|
|
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit"],
|
|
"the refusal sends no X -- the act needs the flag, not just the passphrase")
|
|
check(socks and socks[0].closed, "and the claim slot is freed rather than parked on")
|
|
|
|
# With the flag: the claim's one deliberate frame, in the right order, alone.
|
|
relay, err, socks, fake, warns = connect_with(plain=True, claim_slot=True,
|
|
take_over=True)
|
|
check(err is None and relay is not None, f"--take-over takes a busy board ({err})")
|
|
check(relay is not None and relay.took_over, "and the session knows it took the bridge")
|
|
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit", b"X"],
|
|
"T then X, in that order, and nothing else -- one intent, no arm, no state")
|
|
|
|
# --take-over against a free board is a no-op, not an error, and never wastes an X
|
|
# on a board that has nothing to take.
|
|
relay, err, socks, fake, warns = connect_with(plain=True, take_over=True)
|
|
check(err is None and relay is not None, f"--take-over on a free board succeeds ({err})")
|
|
check(relay is not None and relay.busy is False and not relay.took_over,
|
|
"and it reports the board was free")
|
|
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit"],
|
|
"a free board is never sent an X it did not need")
|
|
|
|
# --- main(): the refusals that need no board -----------------------------------
|
|
|
|
|
|
def run_main(argv, fp_env=None, token_env="hunter2secret", claim_slot=False,
|
|
socks_out=None):
|
|
"""Run main() with the transport faked: create_connection records the address
|
|
instead of dialing, and ssl is the fake from above. Any credential that
|
|
reaches the returned text is a test failure, so the caller never has to
|
|
trust this helper to scrub it."""
|
|
calls = []
|
|
|
|
def record_create(addr, timeout):
|
|
calls.append(addr)
|
|
s = FakeBoardSock(addr[0], addr[1], claim_slot=claim_slot)
|
|
if socks_out is not None:
|
|
socks_out.append(s)
|
|
return s
|
|
|
|
old_argv = sys.argv
|
|
old_env = os.environ.get("KEYRELAY_TLS_FP"), os.environ.get("KEYRELAY_TOKEN")
|
|
sys.argv = ["relayctl.py"] + argv
|
|
if fp_env is None:
|
|
os.environ.pop("KEYRELAY_TLS_FP", None)
|
|
else:
|
|
os.environ["KEYRELAY_TLS_FP"] = fp_env
|
|
if token_env is None:
|
|
os.environ.pop("KEYRELAY_TOKEN", None)
|
|
else:
|
|
os.environ["KEYRELAY_TOKEN"] = token_env
|
|
real_ssl_main = relayctl.ssl
|
|
relayctl.ssl = FakeSsl()
|
|
relayctl.socket.create_connection = record_create
|
|
out, err_stream = io.StringIO(), io.StringIO()
|
|
code, exc = None, None
|
|
try:
|
|
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err_stream):
|
|
code = relayctl.main()
|
|
except BaseException as e: # noqa: BLE001 -- reported, then re-raised below
|
|
exc = e
|
|
finally:
|
|
sys.argv = old_argv
|
|
for name, value in zip(("KEYRELAY_TLS_FP", "KEYRELAY_TOKEN"), old_env):
|
|
if value is None:
|
|
os.environ.pop(name, None)
|
|
else:
|
|
os.environ[name] = value
|
|
relayctl.socket.create_connection = real_create_main
|
|
relayctl.ssl = real_ssl_main
|
|
if exc is not None:
|
|
raise exc
|
|
return code, out.getvalue(), err_stream.getvalue(), calls
|
|
|
|
|
|
real_create_main = socketmod.create_connection
|
|
|
|
code, out, err_text, calls = run_main(["--host", "keyrelay.local", "status"])
|
|
check_eq(code, 2, "no trust argument: main refuses with a usage error")
|
|
check(calls == [], "and refuses without opening a socket")
|
|
for option in ("--fingerprint", "--ca", "--insecure", "--plain"):
|
|
check(option in err_text, f"the refusal names {option}")
|
|
check("hunter2secret" not in err_text and "hunter2secret" not in out,
|
|
"the refusal prints no credential")
|
|
|
|
code, out, err_text, calls = run_main(["--fingerprint", "nothex", "status"])
|
|
check_eq(code, 2, "a malformed pin is refused before the network too")
|
|
check(calls == [] and "SHA-256" in err_text, "with the shape of what a pin must be")
|
|
|
|
# $KEYRELAY_TLS_FP is honoured like $KEYRELAY_TOKEN: the gate passes and the
|
|
# socket that follows is the board's. The fake's DER does not match the pin,
|
|
# so the run ends at the mismatch -- proof the pin is wired through main, and
|
|
# the token (never echoed) stays in the terminal.
|
|
code, out, err_text, calls = run_main(["--timeout", "0.1", "status"],
|
|
fp_env="dead" * 16)
|
|
check(calls == [("keyrelay.local", 443)],
|
|
"$KEYRELAY_TLS_FP satisfied the gate and selected :443")
|
|
check(code == 1 and "fingerprint mismatch" in err_text,
|
|
"and a pin that does not match still aborts the run, through main()")
|
|
check("hunter2secret" not in err_text and "hunter2secret" not in out,
|
|
"the passphrase is not printed on the abort path")
|
|
|
|
code, out, err_text, calls = run_main(["paste"], fp_env="ab" * 32, token_env=None)
|
|
check_eq(code, 2, "the token pre-flight still runs before anything else")
|
|
check("passphrase" in err_text and calls == [],
|
|
"no token: refused in the terminal, exactly as before TLS")
|
|
code, out, err_text, calls = run_main(["paste"], fp_env="ab" * 32)
|
|
check_eq(code, 4, "paste still needs --yes before any connection")
|
|
check(calls == [], "and that check still does not require a reachable board")
|
|
|
|
# The whole takeover gesture through main(): take the held bridge, then read the
|
|
# board. T, X, H -- claim, handover, status -- with the terminal told which path
|
|
# ran, and no credential in anything it prints.
|
|
board = []
|
|
code, out, err_text, calls = run_main(["--plain", "--take-over", "status"],
|
|
token_env="sekrit", claim_slot=True,
|
|
socks_out=board)
|
|
check_eq(code, 0, f"--take-over status against a busy board runs ({err_text})")
|
|
session = client_frame_payloads(board[0]) if board else []
|
|
check_eq([f for f in session if f in (b"Tsekrit", b"X", b"H")],
|
|
[b"Tsekrit", b"X", b"H"],
|
|
"the busy session's frames are T, then X, then H -- claim, handover, status")
|
|
check("took the bridge" in out and "DISARMED" in out,
|
|
"and the terminal is told what the handover did before anything else runs")
|
|
check("sekrit" not in out and "sekrit" not in err_text,
|
|
"and the passphrase stays out of the terminal")
|
|
|
|
board = []
|
|
code, out, err_text, calls = run_main(["--plain", "--take-over", "status"],
|
|
token_env="sekrit", socks_out=board)
|
|
check_eq(code, 0, f"--take-over against a free board still runs ({err_text})")
|
|
check("nothing to take" in out, "and says it was a no-op, in those words")
|
|
check_eq([f for f in (client_frame_payloads(board[0]) if board else []) if f == b"X"],
|
|
[], "with no X on the wire at all")
|
|
|
|
# The default against a busy board, through main(): exit 1, the refusal names
|
|
# --take-over, and the wire never carried an X.
|
|
code, out, err_text, calls = run_main(["--plain", "status"], token_env="sekrit",
|
|
claim_slot=True, socks_out=board)
|
|
check_eq(code, 1, "a busy board is a refusal through main() too")
|
|
check("--take-over" in err_text, "and the refusal names the flag in the terminal")
|
|
check_eq([f for f in (client_frame_payloads(board[-1]) if board else [])
|
|
if f == b"X"], [], "and refused means refused: no X went out anyway")
|
|
|
|
print("the target-side verifier")
|
|
# Twice now the verifier itself was the bug -- mislabelled LEDs, unnamed key codes that
|
|
# looked like lost keystrokes -- so the thing that decides what "it arrived" means gets
|
|
# tested too, and the mouse half of it especially: it is new, and a watcher that misread
|
|
# REL_X would either cry wolf or miss a mouse that worked.
|
|
import watch_target as wt # noqa: E402
|
|
|
|
# EV_REL is what makes a node a pointing device. Nothing here trusts a device name.
|
|
check_eq(wt.classify(1 << wt.EV_REL | 1 << wt.EV_KEY), "mouse", "EV_REL means mouse")
|
|
check_eq(wt.classify(1 << wt.EV_KEY | 1 << wt.EV_LED), "keyboard",
|
|
"EV_KEY without EV_REL means keyboard")
|
|
check_eq(wt.classify(1 << wt.EV_ABS), None,
|
|
"an absolute-only device is neither, and is not watched as a mouse")
|
|
check_eq(wt.capability_bits("/nonexistent/does/not/exist"), 0,
|
|
"an unreadable sysfs file is 0 rather than an exception")
|
|
|
|
m = wt.MouseWatch(False)
|
|
check(m.event(0, 0, 20) is None and m.event(0, 1, -6) is None,
|
|
"a raw axis event is not a line -- one report is one line")
|
|
check(m.event(0, 0, 15) is None, "and more movement in the same report accumulates")
|
|
line = m.flush(0.1)
|
|
check(line is not None and "+35" in line and "-6" in line,
|
|
f"the report comes out as the sum of its axes ({line!r})")
|
|
check(m.flush(0.2) is None, "a report with no movement prints nothing")
|
|
check_eq(m.x, 35, "net X is the exact sum of what the target received")
|
|
check_eq(m.y, -6, "and so is net Y")
|
|
check_eq(m.travel, 41, "and the path length counts both, not the diagonal")
|
|
|
|
# REL_WHEEL_HI_RES is 1/240 of a notch. Counting it as a notch would report 240x the
|
|
# scroll that happened, which is the same class of error as the mislabelled LEDs.
|
|
# The axis numbers, pinned to the kernel's, not to the HID report's byte order. Getting
|
|
# this wrong made a working wheel look dead -- see the comment on REL_NAMES.
|
|
check_eq((wt.REL_X, wt.REL_Y, wt.REL_WHEEL, wt.REL_HWHEEL), (0, 1, 8, 6),
|
|
"axis codes are the kernel's: REL_WHEEL is 8, not 2")
|
|
check_eq((wt.REL_WHEEL_HI_RES, wt.REL_HWHEEL_HI_RES), (11, 12),
|
|
"and the hi-res wheel codes are 11/12, not 6/7")
|
|
|
|
m2 = wt.MouseWatch(False)
|
|
check(m2.event(0, wt.REL_WHEEL, -3) is not None,
|
|
"a wheel notch is printed as it happens, not coalesced with movement")
|
|
m2.event(0, wt.REL_WHEEL_HI_RES, 720) # the same notches again, finer
|
|
check_eq(m2.wheel, -3, "hi-res wheel events are not counted as extra notches")
|
|
check("REL_WHEEL" in "\n".join(m2.report()),
|
|
"and the summary names the axis it saw, so a wheel that arrived is visible")
|
|
m3 = wt.MouseWatch(False)
|
|
m3.event(0, 40, 5) # an axis this tool does not know
|
|
check(any("rel40" in l for l in m3.report()),
|
|
"an unnamed axis code is counted and shown, never dropped in silence")
|
|
m2.event(0, 272, 1)
|
|
check("still held" in "\n".join(m2.report()),
|
|
"a button the target still believes is down is called out, in those words")
|
|
m2.event(0, 272, 0)
|
|
check("still held" not in "\n".join(m2.report()),
|
|
"and the warning goes away when it is released")
|
|
|
|
k = wt.KeyboardWatch(False)
|
|
k.event(0, 30, 1)
|
|
k.event(0, 30, 0)
|
|
check("a" in "".join(k.typed), "the keyboard watcher still reassembles typed text")
|
|
kc = wt.KeyboardWatch(False)
|
|
for code in (29, 56, 111):
|
|
kc.event(0, code, 1)
|
|
check(any("Ctrl+Alt+Del" in l for l in kc.report()),
|
|
"and still names Ctrl+Alt+Del as one combination, not three keys")
|
|
|
|
print(f"relayctl {checks} checks, {failures} failures")
|
|
sys.exit(1 if failures else 0)
|